fix find post

This commit is contained in:
王性驊 2026-06-25 17:34:28 +08:00
parent d0da1a1103
commit b7125abeac
103 changed files with 3002 additions and 1036 deletions

View File

@ -1 +1 @@
26382
51557

View File

@ -1,43 +1,248 @@
2026/06/25 16:19:34 job scheduler started: holder=wangxinghuadeMac-mini.local-scheduler interval=1m0s
2026/06/25 16:19:34 job reaper started: interval=30s
2026/06/25 16:19:34 job worker started: id=wangxinghuadeMac-mini.local-go-worker type=go
2026/06/25 17:30:42 job scheduler started: holder=wangxinghuadeMac-mini.local-scheduler interval=1m0s
2026/06/25 17:30:42 job worker started: id=wangxinghuadeMac-mini.local-go-worker type=go
2026/06/25 17:30:42 job reaper started: interval=30s
Starting backend backend at 0.0.0.0:8890...
{"@timestamp":"2026-06-25T16:19:35.055+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:60604 - curl/8.7.1","duration":"0.3ms","level":"info","span":"6415334576486240","trace":"950fc5b3ba616a4912d51241eb2b3a48"}
{"@timestamp":"2026-06-25T16:19:35.063+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:60605 - curl/8.7.1","duration":"0.1ms","level":"info","span":"32b367b9757aa4ce","trace":"b8d6e2b9b9f6991f39ca52286514f8d0"}
{"@timestamp":"2026-06-25T16:19:37.414+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 401 - GET /api/v1/members/me - 127.0.0.1:60617 - 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.3ms","level":"info","span":"0a0ab6f5c14d5d26","trace":"a092f127556e39b4d17762ca139e6068"}
{"@timestamp":"2026-06-25T16:19:37.416+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 401 - GET /api/v1/members/me - 127.0.0.1:60618 - 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.1ms","level":"info","span":"79c81d67629142ba","trace":"d819499213c89d08de3b5b5d2f35b7e6"}
{"@timestamp":"2026-06-25T16:19:37.419+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/refresh - 127.0.0.1:60619 - 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":"028c5080eabb0c20","trace":"91f42acca9e2bd90c32b9bd5425f5243"}
{"@timestamp":"2026-06-25T16:19:37.444+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:60623 - 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.7ms","level":"info","span":"e505d50d87cf5e3f","trace":"a1dc33e9536bba807f03d824b68d11ae"}
{"@timestamp":"2026-06-25T16:19:37.446+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:60624 - 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":"dc5a2ac116d2793f","trace":"bf0c219c6522b3f0026595075c847f2a"}
{"@timestamp":"2026-06-25T16:19:37.472+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:60632 - 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":"13.7ms","level":"info","span":"3986579b07eb7cc6","trace":"62e9f8ae8907e878c693ed5cc909bacd"}
{"@timestamp":"2026-06-25T16:19:37.480+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60633 - 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":"21.1ms","level":"info","span":"b106b93beea0b78b","trace":"26cc88f8ff2121b4df8f38b43d7304c0"}
{"@timestamp":"2026-06-25T16:19:37.481+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:60646 - 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":"0ac7545e219b276a","trace":"9801452fce8d5260fd59282be8d94535"}
{"@timestamp":"2026-06-25T16:19:37.483+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60648 - 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":"514548e176d35c49","trace":"3ea5c3db304edb00642910eb331cad11"}
{"@timestamp":"2026-06-25T16:19:37.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:60630 - 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":"27.3ms","level":"info","span":"846dd3bd919fabfc","trace":"bc5287a47b8d1e9aff648aa6f1801b30"}
{"@timestamp":"2026-06-25T16:19:37.487+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:60636 - 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":"27.5ms","level":"info","span":"4274ae675b087aa5","trace":"c4e5d877a268cd28ed5560216c49fbb3"}
{"@timestamp":"2026-06-25T16:19:37.487+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:60650 - 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":"d95aa13b6583ddbd","trace":"1b963cd6f167ad24b1b5a7f425b1382e"}
{"@timestamp":"2026-06-25T16:19:37.489+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-missions - 127.0.0.1:60631 - 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":"31.0ms","level":"info","span":"74793b560680c5b3","trace":"759db809c9ccaaee2681541c1ac9e4c0"}
{"@timestamp":"2026-06-25T16:19:37.489+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:60654 - 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":"7ecde433bfd09aa3","trace":"34f4e4ff4c1efc22ad6c2b71434e5327"}
{"@timestamp":"2026-06-25T16:19:37.489+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:60652 - 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":"33138517c2669bfd","trace":"d7bb1ef130cd1fc4c0ea13d5857eb43a"}
{"@timestamp":"2026-06-25T16:19:37.490+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/ai-settings - 127.0.0.1:60635 - 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":"31.3ms","level":"info","span":"832aa7d686494971","trace":"3a88a027e63eccbd06bec130f801209a"}
{"@timestamp":"2026-06-25T16:19:37.492+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:60658 - 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.6ms","level":"info","span":"c27fd79c0ce6aa3b","trace":"2102a91a76c1610f1622df77402f0993"}
{"@timestamp":"2026-06-25T16:19:37.492+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/ai-settings - 127.0.0.1:60660 - 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":"41eac6809cffa5fc","trace":"acd5544f67f670d2614da16744372bfc"}
{"@timestamp":"2026-06-25T16:19:37.492+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-missions - 127.0.0.1:60659 - 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.9ms","level":"info","span":"edc1fd6192317f35","trace":"0389ec83da5ac17ae72d4fc745389dc3"}
{"@timestamp":"2026-06-25T16:19:37.493+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:60662 - 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.4ms","level":"info","span":"7eb2e6972f3e44c9","trace":"5af3f0bad4af7c49455e5756f60cdb07"}
{"@timestamp":"2026-06-25T16:19:37.527+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:60664 - 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":"ade7a29f73a7c288","trace":"5ab83b6cd21393e1de22b5f87cf3da1b"}
{"@timestamp":"2026-06-25T16:19:37.533+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:60666 - 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":"2cbde8636870326f","trace":"dfeeff52daffd47979d1bf27132ff1fe"}
{"@timestamp":"2026-06-25T16:19:37.554+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:60668 - 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":"21.5ms","level":"info","span":"e99768c1e5a60f19","trace":"ed1142224257855380d4c7d82ef30d7d"}
{"@timestamp":"2026-06-25T16:19:37.562+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:60670 - 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":"682d76eaf77ba854","trace":"2debafb3df297667243d29080aaf46f3"}
{"@timestamp":"2026-06-25T16:19:37.567+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:60672 - 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":"ede6f51d147113cc","trace":"ed9e3a6e7d3dce0809583ad7069428a6"}
{"@timestamp":"2026-06-25T16:19:38.079+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:60606 - node - slowcall(2059.6ms)","duration":"2059.6ms","level":"slow","span":"154f34f3c1f12155","trace":"f8ed314cbff91e799bccd0404c1e8d10"}
{"@timestamp":"2026-06-25T16:19:38.079+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:60606 - node","duration":"2059.6ms","level":"info","span":"154f34f3c1f12155","trace":"f8ed314cbff91e799bccd0404c1e8d10"}
{"@timestamp":"2026-06-25T16:19:39.468+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60674 - 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":"3aa1e079de1afc33","trace":"c83e8c4576b691e3c046fcf69afab8ba"}
{"@timestamp":"2026-06-25T16:19:42.157+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60676 - 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":"49f9c0136a1bb10e","trace":"1af1b6eda97a33a254a83ef3528a232f"}
{"@timestamp":"2026-06-25T16:19:43.159+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:60606 - node - slowcall(2065.1ms)","duration":"2065.1ms","level":"slow","span":"b0466c9c7c11a34b","trace":"6c3157a0083e8935e5d727cefa561016"}
{"@timestamp":"2026-06-25T16:19:43.159+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:60606 - node","duration":"2065.1ms","level":"info","span":"b0466c9c7c11a34b","trace":"6c3157a0083e8935e5d727cefa561016"}
{"@timestamp":"2026-06-25T16:19:44.154+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60678 - 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":"0b8ef70e28a0b93c","trace":"fa02be6dc695e628d454c08e32b04602"}
{"@timestamp":"2026-06-25T16:19:46.152+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60682 - 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":"1de3a57a2b185f05","trace":"113b14351258aa182b42f764d9fc3d78"}
{"@timestamp":"2026-06-25T16:19:48.151+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60684 - 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":"6bfcce92975c0546","trace":"154518eadb36033595c2b0608bbff0d1"}
{"@timestamp":"2026-06-25T16:19:48.248+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:60606 - node - slowcall(2084.2ms)","duration":"2084.2ms","level":"slow","span":"efaa36b9549d8d3d","trace":"fed1ba361d31f1dca61f215e56dcb2ca"}
{"@timestamp":"2026-06-25T16:19:48.248+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:60606 - node","duration":"2084.2ms","level":"info","span":"efaa36b9549d8d3d","trace":"fed1ba361d31f1dca61f215e56dcb2ca"}
{"@timestamp":"2026-06-25T16:19:50.152+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:60689 - 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":"d34bb8e081297bc5","trace":"36e0c61a91be1e5075ac51dbe563ff7a"}
{"@timestamp":"2026-06-25T17:30:42.961+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:55160 - curl/8.7.1","duration":"0.5ms","level":"info","span":"045ba34c000a4d69","trace":"162a6986337c530f2a88d15d9486f01b"}
{"@timestamp":"2026-06-25T17:30:42.975+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:55161 - curl/8.7.1","duration":"0.1ms","level":"info","span":"53345b0a3c07f4f5","trace":"e3dfecd1de18e183f93bc45d19581b53"}
{"@timestamp":"2026-06-25T17:30:45.843+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2044.6ms)","duration":"2044.6ms","level":"slow","span":"9aca8d10547dac0f","trace":"b886a38ebf0791d14e081ecb6d7ed106"}
{"@timestamp":"2026-06-25T17:30:45.843+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2044.6ms","level":"info","span":"9aca8d10547dac0f","trace":"b886a38ebf0791d14e081ecb6d7ed106"}
{"@timestamp":"2026-06-25T17:30:50.602+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/login - 127.0.0.1:55190 - 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":"103.3ms","level":"info","span":"c9d9bb386da27ea9","trace":"836f3f3c7831c91ec30eb7b3fc8ca0c4"}
{"@timestamp":"2026-06-25T17:30:50.615+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55192 - 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":"70e96ee134e675e3","trace":"9dc9ea0636288a4f404fd0e5811988d0"}
{"@timestamp":"2026-06-25T17:30:50.633+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/job/schedules?page=1&pageSize=100 - 127.0.0.1:55195 - 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.7ms","level":"info","span":"efb917d662253464","trace":"08f52016878848d8d8ca6f442d0ba7f3"}
{"@timestamp":"2026-06-25T17:30:50.636+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/job/schedules?page=1&pageSize=100 - 127.0.0.1:55208 - 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":"0818d78ae2e89a7f","trace":"4dffd229432d026d33abea4323b5198c"}
{"@timestamp":"2026-06-25T17:30:50.636+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=50 - 127.0.0.1:55193 - 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":"10.6ms","level":"info","span":"1046ad31eb987738","trace":"24c8e91f7fbe024108c2a3f9db90dde8"}
{"@timestamp":"2026-06-25T17:30:50.638+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55196 - 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.3ms","level":"info","span":"3aa47488bcdd1604","trace":"884c0060f20b5e484741198dc4ca3152"}
{"@timestamp":"2026-06-25T17:30:50.639+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=50 - 127.0.0.1:55210 - 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":"7609b8075ef2eee7","trace":"18a169caf39dcc4345c12a3cdbaa2d9b"}
{"@timestamp":"2026-06-25T17:30:50.641+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55197 - 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":"15.0ms","level":"info","span":"cfbcfeb0821e857b","trace":"0a479a0de3a42fe1ed30a5eacbe01ff3"}
{"@timestamp":"2026-06-25T17:30:50.641+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55212 - 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":"c60cfa9cdee18f32","trace":"d4663d1888e3e42bc566969a35bc5184"}
{"@timestamp":"2026-06-25T17:30:50.643+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55201 - 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":"16.8ms","level":"info","span":"87a95c55afefe871","trace":"d5c9aab073e7c0b07713a310013f8ac6"}
{"@timestamp":"2026-06-25T17:30:50.645+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55214 - 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":"9b34d1828c68edff","trace":"fc03532d8b9e854a866115f0daf1bde0"}
{"@timestamp":"2026-06-25T17:30:50.648+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55216 - 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":"85ec75935d353dd6","trace":"3e50d1982ee1a69fa8aea7329449225c"}
{"@timestamp":"2026-06-25T17:30:50.648+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55218 - 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":"b215edceb1f23e97","trace":"a6cf1b0662541ef1a31543861d85cfd6"}
{"@timestamp":"2026-06-25T17:30:50.657+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55220 - 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.9ms","level":"info","span":"45df893595b47cc0","trace":"3951796ab3abbe515d7f773bddac0ba2"}
{"@timestamp":"2026-06-25T17:30:50.673+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55223 - 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":"ee3bfef0ea16861c","trace":"5d69ba637d00496e147e94f4ee06fa25"}
{"@timestamp":"2026-06-25T17:30:50.675+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55224 - 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":"0b01b7ac3436d854","trace":"16a644b25a2d990c0e17080b00c975ef"}
{"@timestamp":"2026-06-25T17:30:50.678+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55226 - 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":"c2e07676a4032951","trace":"59b892589ae8dd18cf94b9d6d8fa4ad4"}
{"@timestamp":"2026-06-25T17:30:50.927+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2071.2ms)","duration":"2071.2ms","level":"slow","span":"bc8917b8a7e369d1","trace":"9ee3d32d8264e74da8337a6c7d73d37a"}
{"@timestamp":"2026-06-25T17:30:50.927+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2071.2ms","level":"info","span":"bc8917b8a7e369d1","trace":"9ee3d32d8264e74da8337a6c7d73d37a"}
{"@timestamp":"2026-06-25T17:30:51.901+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55230 - 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":"7ad2d1a8325fa704","trace":"3abd6632e7c80697176e734cf753a5af"}
{"@timestamp":"2026-06-25T17:30:51.902+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-missions - 127.0.0.1:55231 - 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":"d058165b1246ef75","trace":"485546586d2d29478fbaf67de46cf79d"}
{"@timestamp":"2026-06-25T17:30:51.902+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:55232 - 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":"caab402214329961","trace":"8ce648cae59691a7cc0343b5a5b88458"}
{"@timestamp":"2026-06-25T17:30:51.903+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55234 - 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":"af6416f906ac1a54","trace":"470ebfa460168763428cbe5e0efecefd"}
{"@timestamp":"2026-06-25T17:30:51.905+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-missions - 127.0.0.1:55237 - 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.9ms","level":"info","span":"102df3903a0141a5","trace":"f00e87c07bbf62c3ead92f4e8c252f8b"}
{"@timestamp":"2026-06-25T17:30:51.905+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:55239 - 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.7ms","level":"info","span":"0092616cd44b40d3","trace":"790f9d14191133a97cc9e247b6814345"}
{"@timestamp":"2026-06-25T17:30:51.905+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55240 - 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":"a309118e34ed5a0f","trace":"ea7eb9e26f977c5db6a4319983a2eca0"}
{"@timestamp":"2026-06-25T17:30:51.909+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8 - 127.0.0.1:55244 - 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.2ms","level":"info","span":"ebac4f23af901a17","trace":"dfae73fa78d7ed5d0dc789bf4607df80"}
{"@timestamp":"2026-06-25T17:30:51.911+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55247 - 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":"e870871cf77b2547","trace":"f76091fcd5bfd6b5531737793226c789"}
{"@timestamp":"2026-06-25T17:30:51.913+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55250 - 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":"af6699a87fe4ac3c","trace":"9563aacda92c6243ab744e13458879b8"}
{"@timestamp":"2026-06-25T17:30:51.914+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55248 - 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":"12de4c48e88ed5b6","trace":"bf1fae11ed90857242f6c622f30f2ba8"}
{"@timestamp":"2026-06-25T17:30:51.914+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55243 - 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":"0aacc80c57455da4","trace":"33d7d6b3b0001d61905ca344584ba5da"}
{"@timestamp":"2026-06-25T17:30:51.917+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55252 - 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":"1b3b6aca6dc53382","trace":"ed1bf91d8e0328c40dc9f7d5b4f2192a"}
{"@timestamp":"2026-06-25T17:30:51.920+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55254 - 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":"88ee7b8163e1e59e","trace":"72706beab614f76decaad9832a35cacd"}
{"@timestamp":"2026-06-25T17:30:52.631+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55257 - 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":"f8f9c1b34a503b8c","trace":"d99aa9424ef6164a3245e984b25dcf13"}
{"@timestamp":"2026-06-25T17:30:54.633+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55259 - 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":"0ea6ec0bbb6a4bc6","trace":"57e282aa4117d5ac1606b786c7b138bc"}
{"@timestamp":"2026-06-25T17:30:55.970+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2038.7ms)","duration":"2038.7ms","level":"slow","span":"08539fb389748e1a","trace":"c9fb51ce83c0be9e6489261fc5a428ed"}
{"@timestamp":"2026-06-25T17:30:55.970+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2038.7ms","level":"info","span":"08539fb389748e1a","trace":"c9fb51ce83c0be9e6489261fc5a428ed"}
{"@timestamp":"2026-06-25T17:30:56.630+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55262 - 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":"8083aeacc63cca50","trace":"d6eade15023e75f4b280079447013bd7"}
{"@timestamp":"2026-06-25T17:30:57.926+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55272 - 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":"aa89e894abd169f5","trace":"2d64a34f896f4e5151f42251dc070e42"}
{"@timestamp":"2026-06-25T17:30:57.929+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55274 - 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":"1254fab35f182638","trace":"aebdfaaf3752c01fdc1976c3de194882"}
{"@timestamp":"2026-06-25T17:30:57.935+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55275 - 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":"6ec5292bee49323e","trace":"33a347e6566102f39f02984c2dea7813"}
{"@timestamp":"2026-06-25T17:30:57.936+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8 - 127.0.0.1:55278 - 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":"b2d34d812726e84a","trace":"eca7ad3c5e369376a172c8dfbff67e59"}
{"@timestamp":"2026-06-25T17:30:57.936+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55277 - 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":"771c64fe2b4c0239","trace":"c3fa86ff681184f804898da637c9c45b"}
{"@timestamp":"2026-06-25T17:30:57.936+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55279 - 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":"0a285bb192ade3a0","trace":"f91633612e1569268b5f436d1560f0d2"}
{"@timestamp":"2026-06-25T17:30:57.937+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55280 - 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":"0d25b78969ddb628","trace":"736282ea74cf471d142e8f1eca4126f7"}
{"@timestamp":"2026-06-25T17:30:57.939+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55285 - 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":"abb2e9e78a60b447","trace":"996f7cd66cfb407dea41143e80ebea55"}
{"@timestamp":"2026-06-25T17:30:57.940+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55287 - 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":"cd8ce20de364a2ee","trace":"0dd73756ab2857d517af7c63d0a58d58"}
{"@timestamp":"2026-06-25T17:30:57.940+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8 - 127.0.0.1:55289 - 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":"05e73404ad0815f6","trace":"a1d282ffeebb9512ec086b51c76ad0b9"}
{"@timestamp":"2026-06-25T17:30:57.941+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55291 - 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":"020f06a8d967d281","trace":"fce59e05edd3d9220068d4dc90d95516"}
{"@timestamp":"2026-06-25T17:30:57.941+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55290 - 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":"7809c4b92f9f91c9","trace":"3d29ed43e55f3533f9d39ebc0f90ae31"}
{"@timestamp":"2026-06-25T17:30:57.943+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55294 - 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":"20688e0d89c3ddbe","trace":"7b00b63a2e64ae10aa0933913af5b251"}
{"@timestamp":"2026-06-25T17:30:57.944+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55295 - 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":"91bb232409b87bb8","trace":"6b906e48b510477cd45ed33310c4d29c"}
{"@timestamp":"2026-06-25T17:30:57.946+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55296 - 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":"ac581a6a34288c03","trace":"2a9b86259eac4d1347448f04e3c14f20"}
{"@timestamp":"2026-06-25T17:30:57.975+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55298 - 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":"b82ee1ef135412db","trace":"c6a76cf7ea27a813a4a60d925d20742e"}
{"@timestamp":"2026-06-25T17:30:57.981+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55300 - 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":"70c68dc7b205af38","trace":"dccb0fd715ebff61cbbaa992c3cb01a3"}
{"@timestamp":"2026-06-25T17:30:57.984+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55302 - 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":"7ba4f89461bc3bc6","trace":"4071dc284ec847524b095aac1578e4b1"}
{"@timestamp":"2026-06-25T17:30:57.987+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55304 - 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":"8a5325643dfa5d22","trace":"c3b6fd6bfed495c3306b8e1ab40e810c"}
{"@timestamp":"2026-06-25T17:30:57.994+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55306 - 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":"db3308545e2198d3","trace":"eca7614390d72379a2a20e00207d42e4"}
{"@timestamp":"2026-06-25T17:30:59.946+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55309 - 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.2ms","level":"info","span":"3a12f6b9d681fed7","trace":"e3ac5f81f3a84be398d60bd89af93365"}
{"@timestamp":"2026-06-25T17:31:01.046+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2070.2ms)","duration":"2070.2ms","level":"slow","span":"11b5f89014e104e1","trace":"eda812904869cf93bb175f8431f19278"}
{"@timestamp":"2026-06-25T17:31:01.046+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2070.2ms","level":"info","span":"11b5f89014e104e1","trace":"eda812904869cf93bb175f8431f19278"}
{"@timestamp":"2026-06-25T17:31:01.942+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55311 - 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.9ms","level":"info","span":"cd14d0819298e6f5","trace":"fb41d4703fe398a0e93d2e36542596b7"}
{"@timestamp":"2026-06-25T17:31:02.451+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55319 - 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":"f590d82d383b8975","trace":"682f1840c98d1c7ce975fc7f009c638f"}
{"@timestamp":"2026-06-25T17:31:02.453+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55320 - 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":"260d074990c5a8b6","trace":"3c7a7656ad9b70ae62fb4734a56112b9"}
{"@timestamp":"2026-06-25T17:31:02.459+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55321 - 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":"d6ccd931430440be","trace":"26a6a1551b1be8dffa08f16a2bc5a544"}
{"@timestamp":"2026-06-25T17:31:02.460+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8 - 127.0.0.1:55324 - 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":"299b095e60854722","trace":"2e19508624657ccca9e5f71fcc2fe1e7"}
{"@timestamp":"2026-06-25T17:31:02.460+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55323 - 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":"eb4fe2790f17a9c6","trace":"8fcc1419576f5a783804f57878c34857"}
{"@timestamp":"2026-06-25T17:31:02.460+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55326 - 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":"786557eaddeaf5d2","trace":"1f8eeda14829f13fe6498a94b02d10ee"}
{"@timestamp":"2026-06-25T17:31:02.461+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55325 - 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":"def6394282635fc7","trace":"80b043112914c34d6f1442579ade5613"}
{"@timestamp":"2026-06-25T17:31:02.463+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55333 - 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":"c36b05b6840c3656","trace":"074245184bb2b94c9f45d3740650e441"}
{"@timestamp":"2026-06-25T17:31:02.463+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8 - 127.0.0.1:55334 - 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":"9ae2637e676f1999","trace":"7bbee3bc35d4387f67b7dcdced9ce6a6"}
{"@timestamp":"2026-06-25T17:31:02.464+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55335 - 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":"cd5599243bac82b5","trace":"85d6616e236bce9a9da9ac3f13d3e710"}
{"@timestamp":"2026-06-25T17:31:02.464+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55336 - 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":"d572a72809faad57","trace":"bf691da31b1c75406a4fcc15f4e121cc"}
{"@timestamp":"2026-06-25T17:31:02.465+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55338 - 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.6ms","level":"info","span":"7848a1a7397fbdea","trace":"0cda585155368929a9a43952c96a303f"}
{"@timestamp":"2026-06-25T17:31:02.465+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55337 - 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":"86520d0e8cc8438e","trace":"797c4cbcacfed0981188100ecc5a1653"}
{"@timestamp":"2026-06-25T17:31:02.468+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55340 - 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":"5c1fbc2e0ab14685","trace":"8ed837a6e5953763948bd8cae9905301"}
{"@timestamp":"2026-06-25T17:31:02.471+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55342 - 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":"a7860be5759e30e6","trace":"cc4cd12c25607433a490cef57515e18f"}
{"@timestamp":"2026-06-25T17:31:02.501+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55344 - 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":"804076bea5a1f541","trace":"c04e6a00f0d3c68e5b9e94ba6803d136"}
{"@timestamp":"2026-06-25T17:31:02.508+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55348 - 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":"68023896a9bf8545","trace":"6a068ddf94de0313ecfffd2c83ffca99"}
{"@timestamp":"2026-06-25T17:31:02.509+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55346 - 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":"0dc3c275a4672e17","trace":"51987cd7f911ce0a17caa223bb72577f"}
{"@timestamp":"2026-06-25T17:31:02.515+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55350 - 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":"bf13884fa827bed1","trace":"8e4411e78f82926d11c26357d4f9789a"}
{"@timestamp":"2026-06-25T17:31:02.522+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55352 - 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":"81a01a3eba4a754b","trace":"a7d10ab7dc922150c3a3fe82713cd664"}
{"@timestamp":"2026-06-25T17:31:04.468+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55354 - 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.6ms","level":"info","span":"3575c4ac7e2c1da7","trace":"50831760b2d840ce3e5083f6a0a4af37"}
{"@timestamp":"2026-06-25T17:31:05.701+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55361 - 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":"2ca12b5ee6e1df92","trace":"a5003bcc30c894f9ac57d70d9fb87c5d"}
{"@timestamp":"2026-06-25T17:31:05.704+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:55360 - 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.2ms","level":"info","span":"06a14815c58e5951","trace":"4c2a945ce414d9c257aa3952e8603dac"}
{"@timestamp":"2026-06-25T17:31:05.709+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:55365 - 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":"6cd5323379666c0d","trace":"cef5a557e47ba0a9090c155570507710"}
{"@timestamp":"2026-06-25T17:31:05.710+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55363 - 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":"3398337a0bde5c87","trace":"0a3e7a18112c41f1ca97ab700d4962ab"}
{"@timestamp":"2026-06-25T17:31:05.715+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:55359 - 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":"17.8ms","level":"info","span":"cee65f36538d0eff","trace":"927e60670d29f47b89148d1750e05ef8"}
{"@timestamp":"2026-06-25T17:31:05.723+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:55367 - 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.7ms","level":"info","span":"fddf9358b7b6bb30","trace":"a96c5f8debc97727f81d5b89a3d1f98c"}
{"@timestamp":"2026-06-25T17:31:06.110+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2060.1ms)","duration":"2060.1ms","level":"slow","span":"da165bfebe449f26","trace":"c54a81f0c5673c639a774b12db9b0b88"}
{"@timestamp":"2026-06-25T17:31:06.110+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2060.1ms","level":"info","span":"da165bfebe449f26","trace":"c54a81f0c5673c639a774b12db9b0b88"}
{"@timestamp":"2026-06-25T17:31:06.464+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55380 - 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":"2c9273fe735bd60c","trace":"73ba788e5c133779ff7fee945c1ec01b"}
{"@timestamp":"2026-06-25T17:31:06.787+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55388 - 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.0ms","level":"info","span":"b28a988bad4fa8c5","trace":"b4d5b47524a770309ddd50a8f0f95991"}
{"@timestamp":"2026-06-25T17:31:06.790+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55389 - 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":"056038562bd58aa6","trace":"a639448cb9ae36e53a9bac43c0328413"}
{"@timestamp":"2026-06-25T17:31:06.798+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:55392 - 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":"62901cf042a6c15a","trace":"a7e5e615be32313dc32518099fcccabd"}
{"@timestamp":"2026-06-25T17:31:06.799+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55394 - 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":"9cd703d25683b9fc","trace":"2a4afca6c0c5d93ac953b1cc5b64e594"}
{"@timestamp":"2026-06-25T17:31:06.799+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55393 - 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":"305b9bbe16be35e9","trace":"f8f9be45343402570a145bfe11c13f2c"}
{"@timestamp":"2026-06-25T17:31:06.799+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55395 - 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":"89f18bfb6eebfdb0","trace":"bd4f44b3d6daedb7763c0696997fbad8"}
{"@timestamp":"2026-06-25T17:31:06.800+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:55391 - 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":"88a87954dec11ce1","trace":"23f0a49a7258868c88f6586f92b31bf2"}
{"@timestamp":"2026-06-25T17:31:06.804+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:55402 - 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":"2e3f33a921286e13","trace":"8891e84abef9bd8062f5cc28358a0f00"}
{"@timestamp":"2026-06-25T17:31:06.805+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55403 - 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":"e7a0017f02cc6a8f","trace":"53d0cb527d7ae27ea85a8b9d7eadc6c7"}
{"@timestamp":"2026-06-25T17:31:06.807+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:55406 - 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":"1e169418721e50fe","trace":"4691f8943301de04f39a19f058c492d4"}
{"@timestamp":"2026-06-25T17:31:06.807+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55405 - 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":"fb87ead57d815315","trace":"113a2d9bcf5cb0910a9d1b10998133f3"}
{"@timestamp":"2026-06-25T17:31:06.807+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55404 - 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":"a800b51bc9ccdd05","trace":"882c6acf043f066791ecb91d129f2c9a"}
{"@timestamp":"2026-06-25T17:31:06.810+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55407 - 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":"a4b80e2720bb300c","trace":"f087b6816f30239a33c6c0930b2b4ec5"}
{"@timestamp":"2026-06-25T17:31:06.838+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55409 - 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":"1f2009151093c23a","trace":"23feced1a871fd42af22c7ea21628b3b"}
{"@timestamp":"2026-06-25T17:31:06.838+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55411 - 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":"8f376760945ea213","trace":"f615ff626e75098105d969f8c64ba337"}
{"@timestamp":"2026-06-25T17:31:06.844+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55413 - 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":"cd4dccf233bb29d5","trace":"941f9f90bf3d44db6ad3a563727d50a9"}
{"@timestamp":"2026-06-25T17:31:06.851+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55415 - 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":"d9a672ab54258f17","trace":"2b7177c3cad783a4304f23db9d291eef"}
{"@timestamp":"2026-06-25T17:31:06.855+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/a76918ed-69a7-4f45-8ea2-c984d8a85409/connection - 127.0.0.1:55417 - 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":"33be1ab5c1770cc8","trace":"6db71228a0988ff7e507196a6cab0a26"}
{"@timestamp":"2026-06-25T17:31:08.801+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55420 - 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":"b05cf50f858dd528","trace":"df0163829a024e24c95f36e2746a76ac"}
{"@timestamp":"2026-06-25T17:31:09.479+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/logout - 127.0.0.1:55422 - 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":"6511056c48eb6664","trace":"cf3c0886fdf1265b3eecaa92634d266d"}
{"@timestamp":"2026-06-25T17:31:11.207+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2094.3ms)","duration":"2094.3ms","level":"slow","span":"c69ffc592e5e910b","trace":"c4f65c6e7add84a1ebc61cd242e25883"}
{"@timestamp":"2026-06-25T17:31:11.208+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2094.3ms","level":"info","span":"c69ffc592e5e910b","trace":"c4f65c6e7add84a1ebc61cd242e25883"}
{"@timestamp":"2026-06-25T17:31:16.292+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2078.8ms)","duration":"2078.8ms","level":"slow","span":"654f6d8d44cdceba","trace":"80abdae74376b55c046de09eec4eaaf3"}
{"@timestamp":"2026-06-25T17:31:16.292+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2078.8ms","level":"info","span":"654f6d8d44cdceba","trace":"80abdae74376b55c046de09eec4eaaf3"}
{"@timestamp":"2026-06-25T17:31:21.398+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2098.0ms)","duration":"2098.0ms","level":"slow","span":"04c66ed148f5aea8","trace":"a2254c41f02b5ea7387120779b02cfbc"}
{"@timestamp":"2026-06-25T17:31:21.398+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2098.0ms","level":"info","span":"04c66ed148f5aea8","trace":"a2254c41f02b5ea7387120779b02cfbc"}
{"@timestamp":"2026-06-25T17:31:26.484+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2079.5ms)","duration":"2079.5ms","level":"slow","span":"2c5bcf866c9a0807","trace":"31fa976dc0be92e5ca985a3b8f7e4d12"}
{"@timestamp":"2026-06-25T17:31:26.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2079.5ms","level":"info","span":"2c5bcf866c9a0807","trace":"31fa976dc0be92e5ca985a3b8f7e4d12"}
{"@timestamp":"2026-06-25T17:31:31.588+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2097.6ms)","duration":"2097.6ms","level":"slow","span":"a95aa5c0c8e39189","trace":"39a5e176b834d5c1f8b59f687344f12f"}
{"@timestamp":"2026-06-25T17:31:31.589+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2097.6ms","level":"info","span":"a95aa5c0c8e39189","trace":"39a5e176b834d5c1f8b59f687344f12f"}
{"@timestamp":"2026-06-25T17:31:36.695+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2099.9ms)","duration":"2099.9ms","level":"slow","span":"c45c88f25aa37028","trace":"4644b074b0e7eb5eb03b22b914b40d44"}
{"@timestamp":"2026-06-25T17:31:36.695+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2099.9ms","level":"info","span":"c45c88f25aa37028","trace":"4644b074b0e7eb5eb03b22b914b40d44"}
{"@timestamp":"2026-06-25T17:31:41.784+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2085.0ms)","duration":"2085.0ms","level":"slow","span":"ad09cf5e01b779fb","trace":"cdd16a4dac610045e5fea7b1c3ddef3d"}
{"@timestamp":"2026-06-25T17:31:41.785+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2085.0ms","level":"info","span":"ad09cf5e01b779fb","trace":"cdd16a4dac610045e5fea7b1c3ddef3d"}
{"@timestamp":"2026-06-25T17:31:42.550+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=10.8Mi, Sys=22.8Mi, NumGC=8","level":"stat"}
{"@timestamp":"2026-06-25T17:31:42.582+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 118, pass: 118, drop: 0","level":"stat"}
{"@timestamp":"2026-06-25T17:31:42.963+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 2.0/s, drops: 0, avg time: 214.8ms, med: 2.6ms, 90th: 2044.6ms, 99th: 2099.8ms, 99.9th: 2099.8ms","level":"stat"}
{"@timestamp":"2026-06-25T17:31:46.867+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2077.7ms)","duration":"2077.7ms","level":"slow","span":"6ab8d4ee8a0087bf","trace":"3cac9f4fd18a0d0301c09bc511a4191e"}
{"@timestamp":"2026-06-25T17:31:46.867+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2077.7ms","level":"info","span":"6ab8d4ee8a0087bf","trace":"3cac9f4fd18a0d0301c09bc511a4191e"}
{"@timestamp":"2026-06-25T17:31:51.957+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2087.0ms)","duration":"2087.0ms","level":"slow","span":"3ce71abc1ab25c02","trace":"3fdab0afb5165445531bb339273d225f"}
{"@timestamp":"2026-06-25T17:31:51.957+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2087.0ms","level":"info","span":"3ce71abc1ab25c02","trace":"3fdab0afb5165445531bb339273d225f"}
{"@timestamp":"2026-06-25T17:31:57.052+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2091.3ms)","duration":"2091.3ms","level":"slow","span":"27627a4eac5a334f","trace":"57fa85ee4d69821697646a59d5a39f13"}
{"@timestamp":"2026-06-25T17:31:57.053+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2091.3ms","level":"info","span":"27627a4eac5a334f","trace":"57fa85ee4d69821697646a59d5a39f13"}
{"@timestamp":"2026-06-25T17:32:02.155+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2098.2ms)","duration":"2098.2ms","level":"slow","span":"f5780756d4a5c68c","trace":"b7f04f30dc65c57b0bf550e066bee273"}
{"@timestamp":"2026-06-25T17:32:02.156+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2098.2ms","level":"info","span":"f5780756d4a5c68c","trace":"b7f04f30dc65c57b0bf550e066bee273"}
{"@timestamp":"2026-06-25T17:32:07.225+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2066.1ms)","duration":"2066.1ms","level":"slow","span":"663c65e67c709bef","trace":"7de4c2867d569f88ad80c275d9772991"}
{"@timestamp":"2026-06-25T17:32:07.226+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2066.1ms","level":"info","span":"663c65e67c709bef","trace":"7de4c2867d569f88ad80c275d9772991"}
{"@timestamp":"2026-06-25T17:32:12.242+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2013.0ms)","duration":"2013.0ms","level":"slow","span":"0d7b4a7560c09816","trace":"e8bfe2682b4e73a4b43263aaf038da2c"}
{"@timestamp":"2026-06-25T17:32:12.242+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2013.0ms","level":"info","span":"0d7b4a7560c09816","trace":"e8bfe2682b4e73a4b43263aaf038da2c"}
{"@timestamp":"2026-06-25T17:32:17.344+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2096.7ms)","duration":"2096.7ms","level":"slow","span":"ead4d4433e6a7b3b","trace":"a2fb614f0dd6d6dd23287e7c831cb5b9"}
{"@timestamp":"2026-06-25T17:32:17.344+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2096.7ms","level":"info","span":"ead4d4433e6a7b3b","trace":"a2fb614f0dd6d6dd23287e7c831cb5b9"}
{"@timestamp":"2026-06-25T17:32:22.429+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2080.3ms)","duration":"2080.3ms","level":"slow","span":"db06ee273e5a445d","trace":"09d612fbb44ab144261d8484c2be206e"}
{"@timestamp":"2026-06-25T17:32:22.429+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2080.3ms","level":"info","span":"db06ee273e5a445d","trace":"09d612fbb44ab144261d8484c2be206e"}
{"@timestamp":"2026-06-25T17:32:27.516+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2082.5ms)","duration":"2082.5ms","level":"slow","span":"12e7d0db835a4559","trace":"1683ffea836ab1cf3ccedede55f846ce"}
{"@timestamp":"2026-06-25T17:32:27.517+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2082.5ms","level":"info","span":"12e7d0db835a4559","trace":"1683ffea836ab1cf3ccedede55f846ce"}
{"@timestamp":"2026-06-25T17:32:32.529+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2008.3ms)","duration":"2008.3ms","level":"slow","span":"71633f5376ecfa93","trace":"c2114cba9ffe336425ff66da96f4c70d"}
{"@timestamp":"2026-06-25T17:32:32.529+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2008.3ms","level":"info","span":"71633f5376ecfa93","trace":"c2114cba9ffe336425ff66da96f4c70d"}
{"@timestamp":"2026-06-25T17:32:37.619+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2085.1ms)","duration":"2085.1ms","level":"slow","span":"ec573737c77d8639","trace":"e3d884c37a7c8bdb0eb7044a12d42399"}
{"@timestamp":"2026-06-25T17:32:37.619+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2085.1ms","level":"info","span":"ec573737c77d8639","trace":"e3d884c37a7c8bdb0eb7044a12d42399"}
{"@timestamp":"2026-06-25T17:32:42.551+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=11.0Mi, Sys=22.8Mi, NumGC=8","level":"stat"}
{"@timestamp":"2026-06-25T17:32:42.582+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-25T17:32:42.709+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2084.8ms)","duration":"2084.8ms","level":"slow","span":"6dd76653010f793b","trace":"c261af1cf03adfe1ff293788c26738f8"}
{"@timestamp":"2026-06-25T17:32:42.709+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2084.8ms","level":"info","span":"6dd76653010f793b","trace":"c261af1cf03adfe1ff293788c26738f8"}
{"@timestamp":"2026-06-25T17:32:42.964+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2072.4ms, med: 2084.7ms, 90th: 2098.1ms, 99th: 2098.1ms, 99.9th: 2098.1ms","level":"stat"}
{"@timestamp":"2026-06-25T17:32:47.786+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2073.1ms)","duration":"2073.1ms","level":"slow","span":"c66a6d108d49e2ba","trace":"75cf868767690979b2a9b2752ba29e91"}
{"@timestamp":"2026-06-25T17:32:47.786+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2073.1ms","level":"info","span":"c66a6d108d49e2ba","trace":"75cf868767690979b2a9b2752ba29e91"}
{"@timestamp":"2026-06-25T17:32:52.890+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2100.5ms)","duration":"2100.5ms","level":"slow","span":"76d74728bd1fba11","trace":"5ff817f5c11917d5eebf4fadbcc52faa"}
{"@timestamp":"2026-06-25T17:32:52.890+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2100.5ms","level":"info","span":"76d74728bd1fba11","trace":"5ff817f5c11917d5eebf4fadbcc52faa"}
{"@timestamp":"2026-06-25T17:32:57.986+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2091.4ms)","duration":"2091.4ms","level":"slow","span":"ae40024330d59976","trace":"3bb8ad99579eaf978378032663ff25fe"}
{"@timestamp":"2026-06-25T17:32:57.986+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2091.4ms","level":"info","span":"ae40024330d59976","trace":"3bb8ad99579eaf978378032663ff25fe"}
{"@timestamp":"2026-06-25T17:33:03.021+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2031.2ms)","duration":"2031.2ms","level":"slow","span":"8f7609830dfbc72a","trace":"502c18fca2d055ca54d17fa40ba52a34"}
{"@timestamp":"2026-06-25T17:33:03.022+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2031.2ms","level":"info","span":"8f7609830dfbc72a","trace":"502c18fca2d055ca54d17fa40ba52a34"}
{"@timestamp":"2026-06-25T17:33:08.029+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2003.4ms)","duration":"2003.4ms","level":"slow","span":"adcb55997fe2d556","trace":"6020e9b6ef12d81c8e62b85392ed6f60"}
{"@timestamp":"2026-06-25T17:33:08.029+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2003.4ms","level":"info","span":"adcb55997fe2d556","trace":"6020e9b6ef12d81c8e62b85392ed6f60"}
{"@timestamp":"2026-06-25T17:33:13.034+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2002.2ms)","duration":"2002.2ms","level":"slow","span":"a768e89de63fcfb2","trace":"5ca4e3870e866ac9a06c700523adedf4"}
{"@timestamp":"2026-06-25T17:33:13.034+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2002.2ms","level":"info","span":"a768e89de63fcfb2","trace":"5ca4e3870e866ac9a06c700523adedf4"}
{"@timestamp":"2026-06-25T17:33:18.092+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2054.1ms)","duration":"2054.1ms","level":"slow","span":"80d74a43a2d943e2","trace":"cc8b48dde391f96686e22641aae76364"}
{"@timestamp":"2026-06-25T17:33:18.092+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2054.1ms","level":"info","span":"80d74a43a2d943e2","trace":"cc8b48dde391f96686e22641aae76364"}
{"@timestamp":"2026-06-25T17:33:23.106+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2010.1ms)","duration":"2010.1ms","level":"slow","span":"1e6c0bad83977082","trace":"e20cf6f7eb7a72a0e1fd541c70ec5a3e"}
{"@timestamp":"2026-06-25T17:33:23.107+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2010.1ms","level":"info","span":"1e6c0bad83977082","trace":"e20cf6f7eb7a72a0e1fd541c70ec5a3e"}
{"@timestamp":"2026-06-25T17:33:28.166+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2054.8ms)","duration":"2054.8ms","level":"slow","span":"dd8e290d37298772","trace":"c65be74d49540b646ca4f2a28bdfa4ff"}
{"@timestamp":"2026-06-25T17:33:28.166+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2054.8ms","level":"info","span":"dd8e290d37298772","trace":"c65be74d49540b646ca4f2a28bdfa4ff"}
{"@timestamp":"2026-06-25T17:33:33.206+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2036.3ms)","duration":"2036.3ms","level":"slow","span":"a3b150258e687634","trace":"3cda63e235415958488ed77ad31ef94f"}
{"@timestamp":"2026-06-25T17:33:33.206+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2036.3ms","level":"info","span":"a3b150258e687634","trace":"3cda63e235415958488ed77ad31ef94f"}
{"@timestamp":"2026-06-25T17:33:38.220+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2009.6ms)","duration":"2009.6ms","level":"slow","span":"50b2976cce40ffca","trace":"c78f86732e57bf74dd28919535aba491"}
{"@timestamp":"2026-06-25T17:33:38.220+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2009.6ms","level":"info","span":"50b2976cce40ffca","trace":"c78f86732e57bf74dd28919535aba491"}
{"@timestamp":"2026-06-25T17:33:42.551+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=11.8Mi, Sys=22.8Mi, NumGC=9","level":"stat"}
{"@timestamp":"2026-06-25T17:33:42.583+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-25T17:33:42.964+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2042.3ms, med: 2054.0ms, 90th: 2100.4ms, 99th: 2100.4ms, 99.9th: 2100.4ms","level":"stat"}
{"@timestamp":"2026-06-25T17:33:43.284+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2058.7ms)","duration":"2058.7ms","level":"slow","span":"d9af7376685e531b","trace":"8f8c36e4db268ccbc125037b980ca964"}
{"@timestamp":"2026-06-25T17:33:43.284+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2058.7ms","level":"info","span":"d9af7376685e531b","trace":"8f8c36e4db268ccbc125037b980ca964"}
{"@timestamp":"2026-06-25T17:33:48.319+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2031.8ms)","duration":"2031.8ms","level":"slow","span":"5286d37a79fc5cae","trace":"d0c98a2998a5b7a229bbbe6bf9572f0b"}
{"@timestamp":"2026-06-25T17:33:48.319+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2031.8ms","level":"info","span":"5286d37a79fc5cae","trace":"d0c98a2998a5b7a229bbbe6bf9572f0b"}
{"@timestamp":"2026-06-25T17:33:53.331+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2009.5ms)","duration":"2009.5ms","level":"slow","span":"628998740afc0158","trace":"eed13a92127ae10bd5b1248314445789"}
{"@timestamp":"2026-06-25T17:33:53.332+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2009.5ms","level":"info","span":"628998740afc0158","trace":"eed13a92127ae10bd5b1248314445789"}
{"@timestamp":"2026-06-25T17:33:58.410+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2075.2ms)","duration":"2075.2ms","level":"slow","span":"6f069a1ef9563546","trace":"be08467974b3d47491178c99318c3c5e"}
{"@timestamp":"2026-06-25T17:33:58.411+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2075.2ms","level":"info","span":"6f069a1ef9563546","trace":"be08467974b3d47491178c99318c3c5e"}
{"@timestamp":"2026-06-25T17:34:03.446+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2029.9ms)","duration":"2029.9ms","level":"slow","span":"f592b115b639fd49","trace":"508156e8fe0fdc6a2fff56f68bc313f3"}
{"@timestamp":"2026-06-25T17:34:03.446+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2029.9ms","level":"info","span":"f592b115b639fd49","trace":"508156e8fe0fdc6a2fff56f68bc313f3"}
{"@timestamp":"2026-06-25T17:34:08.498+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2048.2ms)","duration":"2048.2ms","level":"slow","span":"34eb427912169013","trace":"2a3caed7bf8273c9069e03bbb1aed951"}
{"@timestamp":"2026-06-25T17:34:08.499+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2048.2ms","level":"info","span":"34eb427912169013","trace":"2a3caed7bf8273c9069e03bbb1aed951"}
{"@timestamp":"2026-06-25T17:34:13.226+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/login - 127.0.0.1:55487 - 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":"83.2ms","level":"info","span":"de0b846b0d0b03c4","trace":"c660d10bd6c44b171a644f0dc70b971a"}
{"@timestamp":"2026-06-25T17:34:13.236+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:55489 - 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":"6c957466489aab06","trace":"1e8c4908971c451df4ee47cb9160791e"}
{"@timestamp":"2026-06-25T17:34:13.257+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/job/schedules?page=1&pageSize=100 - 127.0.0.1:55496 - 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":"bad2dcc4e3bfb2f8","trace":"2e36e44a4834890ebea0623ca074b016"}
{"@timestamp":"2026-06-25T17:34:13.257+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55498 - 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":"4a1922242b423c1b","trace":"7637c5d70cd66c2a5697c04b812d2d24"}
{"@timestamp":"2026-06-25T17:34:13.258+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55497 - 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":"16aab11bd1b4c035","trace":"7c784b566db4eccfa21b9945240b6fc2"}
{"@timestamp":"2026-06-25T17:34:13.259+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=50 - 127.0.0.1:55495 - 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.9ms","level":"info","span":"70c19f25af237fab","trace":"32c92af096f8607fdb05fbf9c5a7c960"}
{"@timestamp":"2026-06-25T17:34:13.259+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55499 - 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":"603161ac3d384acf","trace":"7a74b059192f4518f01e048714e357c5"}
{"@timestamp":"2026-06-25T17:34:13.261+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/job/schedules?page=1&pageSize=100 - 127.0.0.1:55504 - 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":"177a9042c244ce1d","trace":"f743752bbb395d9e72700e8b6bb8ec64"}
{"@timestamp":"2026-06-25T17:34:13.261+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55506 - 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":"693d09517c51272a","trace":"73ae172771c01b130d1f4b689ef92d6a"}
{"@timestamp":"2026-06-25T17:34:13.262+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:55509 - 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":"dc5a6a542b42a416","trace":"27eda16b5954803e005e706333a12fb6"}
{"@timestamp":"2026-06-25T17:34:13.262+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=50 - 127.0.0.1:55508 - 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.0ms","level":"info","span":"fa57482c8b72b6f9","trace":"8ec65300c6fa37e09447c2d8e37035d2"}
{"@timestamp":"2026-06-25T17:34:13.263+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55511 - 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.6ms","level":"info","span":"466f6152cdfb187c","trace":"2693043b7f13a296f0c6542e470098e8"}
{"@timestamp":"2026-06-25T17:34:13.263+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55507 - 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":"580d9d44dc13a16a","trace":"63db48c572bdcc3eeb9aa09b6590de09"}
{"@timestamp":"2026-06-25T17:34:13.280+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55513 - 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.2ms","level":"info","span":"9276cb12d5979c5b","trace":"62455819c00df613fcc8f3f1a287de16"}
{"@timestamp":"2026-06-25T17:34:13.287+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:55515 - 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":"973d3515d269fbff","trace":"c3c8d27a51dc0f9aaad906f67f10739d"}
{"@timestamp":"2026-06-25T17:34:13.538+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"b00c0db209e4a57c","trace":"e905c3b542cc69d904c58d440f2bdfbe"}
{"@timestamp":"2026-06-25T17:34:13.538+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2034.5ms","level":"info","span":"b00c0db209e4a57c","trace":"e905c3b542cc69d904c58d440f2bdfbe"}
{"@timestamp":"2026-06-25T17:34:15.264+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55517 - 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":"15046a0c56108d0d","trace":"ed13c4023911ef36a7c3ab00b8eb601b"}
{"@timestamp":"2026-06-25T17:34:16.227+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55523 - 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":"63bd15555e2ad4f6","trace":"20805b5a6a194019c762c96fdf77481c"}
{"@timestamp":"2026-06-25T17:34:16.230+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:55522 - 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.9ms","level":"info","span":"193a63f058a5a407","trace":"0b3add8c84563b9d8a1cc76d7e923c75"}
{"@timestamp":"2026-06-25T17:34:16.236+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:55527 - 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":"0d6f0e38bcc544dd","trace":"6e531fba3a04700c3d41dafb0d85de00"}
{"@timestamp":"2026-06-25T17:34:16.237+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:55526 - 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":"9ade351ba23e98b1","trace":"55f3065710880297345462d524e4d22a"}
{"@timestamp":"2026-06-25T17:34:16.243+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:55521 - 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":"18.0ms","level":"info","span":"f159c0c225117347","trace":"0637774e9aedef60f5f35dc1b95991fb"}
{"@timestamp":"2026-06-25T17:34:16.250+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:55529 - 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":"1d41091e46c55919","trace":"bae55464a7aa3a4560a023e14bca3a99"}
{"@timestamp":"2026-06-25T17:34:17.022+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8 - 127.0.0.1:55535 - 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":"c9295ac3ec07382e","trace":"18a8409ade0c78bd800a280b03bc8bda"}
{"@timestamp":"2026-06-25T17:34:17.022+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55533 - 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":"40baa4e6a7825420","trace":"215a3fefe6bd654286ac03130b39eee8"}
{"@timestamp":"2026-06-25T17:34:17.022+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55534 - 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":"89b56c79ceb6d6ca","trace":"ed516227a2db16352b36d3bfe686f560"}
{"@timestamp":"2026-06-25T17:34:17.026+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8 - 127.0.0.1:55539 - 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":"008a0d02d1227d76","trace":"2053d1fa6f5e0b35954fc3443a2136c5"}
{"@timestamp":"2026-06-25T17:34:17.027+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 404 - GET /api/v1/personas/d539b02a-ea8e-4d6e-8ebb-3d94a1f31cb8/copy-missions - 127.0.0.1:55541 - 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":"7268f09956de0acf","trace":"4e62ffe935e050a7a0a95ffbf10f6d97"}
{"@timestamp":"2026-06-25T17:34:17.027+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55540 - 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":"803b24364aedcfc6","trace":"ebdb018aec2678b6c74733974e030e5f"}
{"@timestamp":"2026-06-25T17:34:17.031+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55543 - 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":"adeca56146be8aaf","trace":"8f1cc00206ee54dd00b6834049923778"}
{"@timestamp":"2026-06-25T17:34:17.034+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:55548 - 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":"32049a924f5ae43d","trace":"504423223c86cf78724f872097d80d5a"}
{"@timestamp":"2026-06-25T17:34:17.035+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55551 - 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.9ms","level":"info","span":"55c16eef394ba771","trace":"6e9f7fd8bdab24c5a6ade3343550a2e6"}
{"@timestamp":"2026-06-25T17:34:17.037+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-missions - 127.0.0.1:55546 - 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":"231f87d8841e869c","trace":"5eb008225ced180fc32ce5e585876b6e"}
{"@timestamp":"2026-06-25T17:34:17.037+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:55553 - 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":"58276bde115780a9","trace":"43b7b943cbc8fca865d4674204d21779"}
{"@timestamp":"2026-06-25T17:34:17.038+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:55550 - 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":"b4e924e714587574","trace":"fc5701e6ccaefe8f4e5d4f43395a79e4"}
{"@timestamp":"2026-06-25T17:34:17.042+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-missions - 127.0.0.1:55556 - 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":"e7df1756a3e8db26","trace":"a9e55bf54acd83a7f916e9c3b40c9d00"}
{"@timestamp":"2026-06-25T17:34:17.048+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-missions - 127.0.0.1:55558 - 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":"09ef1c864b6ca639","trace":"5bcfdf427812c467f8e1df7fd7ef459d"}
{"@timestamp":"2026-06-25T17:34:17.261+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55560 - 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.0ms","level":"info","span":"126f4dbdeec6f9ef","trace":"3d743f615986dcbe18559139486ef4b3"}
{"@timestamp":"2026-06-25T17:34:18.582+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2040.3ms)","duration":"2040.3ms","level":"slow","span":"2d9b54c5b0b86800","trace":"a0bb3d0b3f12e06400af2833e441739c"}
{"@timestamp":"2026-06-25T17:34:18.582+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2040.3ms","level":"info","span":"2d9b54c5b0b86800","trace":"a0bb3d0b3f12e06400af2833e441739c"}
{"@timestamp":"2026-06-25T17:34:20.207+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55562 - 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.2ms","level":"info","span":"c3b1673f60408246","trace":"4e2eaf72dd42b52b9613344d48ee9f5d"}
{"@timestamp":"2026-06-25T17:34:22.204+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55564 - 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.9ms","level":"info","span":"8d6b9a718d300d64","trace":"7966693ed54d53352b4e60acbb2f7df9"}
{"@timestamp":"2026-06-25T17:34:23.614+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node - slowcall(2028.4ms)","duration":"2028.4ms","level":"slow","span":"7d9ad1fced3ee7ce","trace":"53f8a448da91d69a57eabef7359056f7"}
{"@timestamp":"2026-06-25T17:34:23.614+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:55162 - node","duration":"2028.4ms","level":"info","span":"7d9ad1fced3ee7ce","trace":"53f8a448da91d69a57eabef7359056f7"}
{"@timestamp":"2026-06-25T17:34:24.203+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55566 - 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":"91d37732e5e293da","trace":"de4e83de346a4a7ca03ad1afc9b0d2f1"}
{"@timestamp":"2026-06-25T17:34:26.200+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:55568 - 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":"c1ce92f78e053dce","trace":"aef13880fae066abcbc1c0c4f8c6c720"}

View File

@ -3,7 +3,7 @@
> vite
VITE v6.4.3 ready in 174 ms
VITE v6.4.3 ready in 130 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose

View File

@ -2,4 +2,4 @@
> 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-26520 api=http://127.0.0.1:8890
[8d-worker] started id=local-style-8d-node-51650 api=http://127.0.0.1:8890

View File

@ -1 +1 @@
26440
51572

View File

@ -1 +1 @@
26441
51573

View File

@ -9,11 +9,17 @@ type (
}
CopySimilarAccountData {
Username string `json:"username"`
Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"`
Confidence string `json:"confidence,omitempty"`
ProfileUrl string `json:"profile_url,omitempty"`
Username string `json:"username"`
Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"`
Confidence string `json:"confidence,omitempty"`
ProfileUrl string `json:"profile_url,omitempty"`
AuthorVerified bool `json:"author_verified,omitempty"`
FollowerCount int `json:"follower_count,omitempty"`
EngagementScore int `json:"engagement_score,omitempty"`
LikeCount int `json:"like_count,omitempty"`
ReplyCount int `json:"reply_count,omitempty"`
PostCount int `json:"post_count,omitempty"`
}
CopyMissionResearchMapData {

View File

@ -242,4 +242,7 @@ service gateway {
@handler disableJobSchedule
post /job/schedules/:id/disable (JobScheduleIDPath) returns (JobScheduleData)
@handler deleteJobSchedule
delete /job/schedules/:id (JobScheduleIDPath)
}

View File

@ -88,6 +88,8 @@ type (
SearchTag string `json:"search_tag"`
Permalink string `json:"permalink"`
Author string `json:"author"`
AuthorVerified bool `json:"author_verified,omitempty"`
FollowerCount int `json:"follower_count,omitempty"`
Text string `json:"text"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`

View File

@ -27,4 +27,4 @@ func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
data, err := l.InspireCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}
}

View File

@ -27,4 +27,4 @@ func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.Handle
data, err := l.StartCopyMissionCopyDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}
}

View File

@ -27,4 +27,4 @@ func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFu
data, err := l.StartCopyMissionMatrixJob(&req)
response.Write(r.Context(), w, data, err)
}
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.JobScheduleIDPath
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 := job.NewDeleteJobScheduleLogic(r.Context(), svcCtx)
err := l.DeleteJobSchedule(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -226,16 +226,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/:personaId/copy-missions",
Handler: copy_mission.ListCopyMissionsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-mission-inspiration",
Handler: copy_mission.InspireCopyMissionHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:personaId/copy-missions",
Handler: copy_mission.ListCopyMissionsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions",
@ -261,6 +261,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:personaId/copy-missions/:id/analyze-jobs",
Handler: copy_mission.StartCopyMissionAnalyzeJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/copy-draft-jobs",
Handler: copy_mission.StartCopyMissionCopyDraftJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:personaId/copy-missions/:id/copy-drafts",
@ -276,11 +281,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:personaId/copy-missions/:id/matrix-jobs",
Handler: copy_mission.StartCopyMissionMatrixJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/copy-draft-jobs",
Handler: copy_mission.StartCopyMissionCopyDraftJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/scan-jobs",
@ -306,85 +306,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/personas"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/job/schedules",
Handler: job.ListJobSchedulesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules",
Handler: job.CreateJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/job/schedules/:id",
Handler: job.UpdateJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules/:id/disable",
Handler: job.DisableJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules/:id/enable",
Handler: job.EnableJobScheduleHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/job/templates",
Handler: job.ListJobTemplatesHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/job/templates/:type",
Handler: job.GetJobTemplateHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/job/templates/:type",
Handler: job.UpsertJobTemplateHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs",
Handler: job.CreateJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs",
Handler: job.ListJobsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs/:id",
Handler: job.GetJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs/:id/cancel",
Handler: job.CancelJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs/:id/events",
Handler: job.ListJobEventsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs/:id/retry",
Handler: job.RetryJobHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.WorkerSecret},
@ -444,6 +365,90 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/internal"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/job/schedules",
Handler: job.ListJobSchedulesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules",
Handler: job.CreateJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/job/schedules/:id",
Handler: job.UpdateJobScheduleHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/job/schedules/:id",
Handler: job.DeleteJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules/:id/disable",
Handler: job.DisableJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules/:id/enable",
Handler: job.EnableJobScheduleHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/job/templates",
Handler: job.ListJobTemplatesHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/job/templates/:type",
Handler: job.GetJobTemplateHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/job/templates/:type",
Handler: job.UpsertJobTemplateHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs",
Handler: job.CreateJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs",
Handler: job.ListJobsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs/:id",
Handler: job.GetJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs/:id/cancel",
Handler: job.CancelJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs/:id/events",
Handler: job.ListJobEventsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs/:id/retry",
Handler: job.RetryJobHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},

View File

@ -52,4 +52,4 @@ func FormatViralSamples(posts []ViralPostSample) string {
}
}
return strings.TrimSpace(b.String())
}
}

View File

@ -0,0 +1,52 @@
package permmatch
import (
"strings"
)
// MethodAllowed reports whether method is covered by a pipe-separated methods string.
func MethodAllowed(methods, method string) bool {
method = strings.ToUpper(strings.TrimSpace(method))
if method == "" {
return false
}
for _, item := range strings.Split(methods, "|") {
if strings.ToUpper(strings.TrimSpace(item)) == method {
return true
}
}
return false
}
// PathAllowed reports whether requestPath matches pattern (exact or trailing * wildcard).
func PathAllowed(pattern, requestPath string) bool {
pattern = strings.TrimSpace(pattern)
requestPath = strings.TrimSpace(requestPath)
if pattern == "" || requestPath == "" {
return false
}
if pattern == requestPath {
return true
}
if strings.HasSuffix(pattern, "*") {
prefix := strings.TrimRight(strings.TrimSuffix(pattern, "*"), "/")
if requestPath == prefix {
return true
}
return strings.HasPrefix(requestPath, prefix+"/")
}
return false
}
// RequestAllowed checks member permission map against an HTTP request.
func RequestAllowed(permissions map[string]string, method, requestPath string) bool {
if len(permissions) == 0 {
return false
}
for pattern, methods := range permissions {
if PathAllowed(pattern, requestPath) && MethodAllowed(methods, method) {
return true
}
}
return false
}

View File

@ -0,0 +1,52 @@
package permmatch
import "testing"
func TestMethodAllowed(t *testing.T) {
if !MethodAllowed("GET|POST", "get") {
t.Fatal("expected GET")
}
if MethodAllowed("GET|POST", "PATCH") {
t.Fatal("expected no PATCH")
}
}
func TestPathAllowed(t *testing.T) {
if !PathAllowed("/api/v1/jobs/*", "/api/v1/jobs/abc/cancel") {
t.Fatal("expected wildcard match")
}
if PathAllowed("/api/v1/jobs/*", "/api/v1/job/schedules") {
t.Fatal("expected no match")
}
if !PathAllowed("/api/v1/members/me", "/api/v1/members/me") {
t.Fatal("expected exact match")
}
}
func TestRequestAllowed(t *testing.T) {
perms := map[string]string{
"/api/v1/members/me": "GET|PATCH",
"/api/v1/jobs/*": "GET|POST",
}
if !RequestAllowed(perms, "GET", "/api/v1/members/me") {
t.Fatal("expected member me")
}
if RequestAllowed(perms, "DELETE", "/api/v1/members/me") {
t.Fatal("expected deny delete")
}
if !RequestAllowed(perms, "POST", "/api/v1/jobs/x/cancel") {
t.Fatal("expected job cancel")
}
}
func TestPathAllowedListRoot(t *testing.T) {
for _, path := range []string{"/api/v1/personas", "/api/v1/jobs", "/api/v1/brands"} {
pattern := path + "/*"
if !PathAllowed(pattern, path) {
t.Fatalf("expected list root match for %s", path)
}
}
if PathAllowed("/api/v1/jobs/*", "/api/v1/job/schedules") {
t.Fatal("expected schedules path to stay blocked")
}
}

View File

@ -22,14 +22,14 @@ type execCrawlerInput struct {
}
type execCrawlerPost struct {
Text string `json:"text"`
Permalink string `json:"permalink"`
ExternalID string `json:"externalId"`
AuthorName string `json:"authorName"`
LikeCount int `json:"likeCount"`
ReplyCount int `json:"replyCount"`
AuthorVerified bool `json:"authorVerified"`
FollowerCount int `json:"followerCount"`
Text string `json:"text"`
Permalink string `json:"permalink"`
ExternalID string `json:"externalId"`
AuthorName string `json:"authorName"`
LikeCount int `json:"likeCount"`
ReplyCount int `json:"replyCount"`
AuthorVerified bool `json:"authorVerified"`
FollowerCount int `json:"followerCount"`
}
type execCrawlerOutput struct {

View File

@ -106,4 +106,4 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
}
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
}
}

View File

@ -110,4 +110,4 @@ func runCrawlerDiscover(ctx context.Context, req DiscoverRequest) ([]DiscoverPos
return nil, fmt.Errorf("crawler keyword is empty")
}
return req.Crawler(ctx, req.Member, keyword, req.Limit)
}
}

View File

@ -4,11 +4,11 @@ import "testing"
func TestShouldTryCrawlerFirst_mixedWithBrowser(t *testing.T) {
m := MemberContext{
AllowsCrawler: true,
BrowserConnected: true,
SearchSourceMode: SearchSourceMixed,
AllowsThreadsAPI: true,
ApiConnected: true,
AllowsCrawler: true,
BrowserConnected: true,
SearchSourceMode: SearchSourceMixed,
AllowsThreadsAPI: true,
ApiConnected: true,
}
if !ShouldTryCrawlerFirst(m) {
t.Fatal("mixed + browser should try crawler first to save API")
@ -38,4 +38,4 @@ func TestBuildMemberContextFormalModeKeepsCrawlerMode(t *testing.T) {
if !ctx.AllowsThreadsAPI {
t.Fatal("threads_crawler should still allow API fallback")
}
}
}

View File

@ -13,4 +13,4 @@ func EffectiveExpandStrategy(research ResearchSettings) libkg.ExpandStrategy {
func WebSearchAvailable(research ResearchSettings) bool {
return !MissingWebSearchKey(research)
}
}

View File

@ -35,4 +35,4 @@ func TestWebSearchAvailable(t *testing.T) {
if !WebSearchAvailable(ResearchSettings{WebSearchProvider: "brave", BraveAPIKey: "k"}) {
t.Fatal("expected available with brave key")
}
}
}

View File

@ -101,4 +101,4 @@ func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string {
parts = append(parts, block)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
}

View File

@ -23,4 +23,4 @@ func TestHasReady8DFromAnalysisOnly(t *testing.T) {
if !HasReady8D("", raw) {
t.Fatal("expected ready from analysis summary")
}
}
}

View File

@ -21,12 +21,12 @@ const ViralSoftWarn = 300
type LengthBand string
const (
BandEmpty LengthBand = "empty"
BandTooShort LengthBand = "too_short"
BandSweet LengthBand = "sweet"
BandLong LengthBand = "long"
BandOverSoft LengthBand = "over_soft"
BandOverHard LengthBand = "over_hard"
BandEmpty LengthBand = "empty"
BandTooShort LengthBand = "too_short"
BandSweet LengthBand = "sweet"
BandLong LengthBand = "long"
BandOverSoft LengthBand = "over_soft"
BandOverHard LengthBand = "over_hard"
)
func RuneLen(text string) int {
@ -97,4 +97,4 @@ func PublishHint(text string) string {
default:
return ""
}
}
}

View File

@ -46,4 +46,4 @@ func TestPublishBand(t *testing.T) {
t.Fatalf("len %d: got %s want %s", c.len, got, c.want)
}
}
}
}

View File

@ -230,4 +230,4 @@ func sortByEngagement(items []placement.ScanCandidate) {
}
}
}
}
}

View File

@ -14,4 +14,4 @@ func TestCountMissionQuality(t *testing.T) {
if got := countMissionQuality(merged); got != 1 {
t.Fatalf("expected 1 quality post, got %d", got)
}
}
}

View File

@ -69,4 +69,4 @@ func TestRunDiscover_missionRelaxedFallbackWithoutVerified(t *testing.T) {
if out[0].AuthorVerified {
t.Fatal("verified should remain false when API omits it")
}
}
}

View File

@ -138,4 +138,4 @@ func pickRawMessage(root map[string]json.RawMessage, keys ...string) json.RawMes
}
}
return nil
}
}

View File

@ -7,19 +7,19 @@ import (
)
type MissionInspireInput struct {
PersonaDisplayName string
PersonaBrief string
PersonaBlock string
StyleBenchmark string
PersonaAudience string
PersonaContentGoal string
PersonaQuestions []string
PersonaPillars []string
RecentMissionLabels []string
RecentSeedQueries []string
TrendSnippets []MissionInspireTrendSnippet
WebSearchProvider string
LLMOnly bool
PersonaDisplayName string
PersonaBrief string
PersonaBlock string
StyleBenchmark string
PersonaAudience string
PersonaContentGoal string
PersonaQuestions []string
PersonaPillars []string
RecentMissionLabels []string
RecentSeedQueries []string
TrendSnippets []MissionInspireTrendSnippet
WebSearchProvider string
LLMOnly bool
}
type MissionInspireTrendSnippet struct {
@ -30,11 +30,11 @@ type MissionInspireTrendSnippet struct {
}
type MissionInspireOutput struct {
Label string
SeedQuery string
Brief string
TrendReason string
TrendKeywords []string
Label string
SeedQuery string
Brief string
TrendReason string
TrendKeywords []string
}
func BuildMissionInspireSystemPrompt() string {
@ -175,4 +175,4 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
}
}
return out, nil
}
}

View File

@ -63,4 +63,4 @@ func CollectMissionInspireTrends(
}
}
return out
}
}

View File

@ -22,8 +22,8 @@ func TestParseMissionInspireOutput(t *testing.T) {
func TestBuildMissionInspireUserPromptLLMOnly(t *testing.T) {
prompt := BuildMissionInspireUserPrompt(MissionInspireInput{
PersonaDisplayName: "測試人設",
PersonaBrief: "職場焦慮",
LLMOnly: true,
PersonaBrief: "職場焦慮",
LLMOnly: true,
})
if !strings.Contains(prompt, "未設定 Web Search API key") {
t.Fatalf("expected llm-only hint in prompt: %s", prompt)
@ -31,4 +31,4 @@ func TestBuildMissionInspireUserPromptLLMOnly(t *testing.T) {
if !strings.Contains(prompt, "無外部趨勢結果") {
t.Fatalf("expected empty trend fallback in prompt: %s", prompt)
}
}
}

View File

@ -52,4 +52,4 @@ func TestParseMissionResearchMapOutput_StringSuggestedTags(t *testing.T) {
if len(out.SuggestedTags) != 4 {
t.Fatalf("expected 4 tags, got %#v", out.SuggestedTags)
}
}
}

View File

@ -4,10 +4,10 @@ import "haixun-backend/internal/library/placement"
// Mission-quality gates for copy-mission viral patrol (stricter than generic patrol).
const (
MissionQualityMinLikes = 18
MissionQualityMinEngagement = 50
MissionVerifiedMinLikes = 10
MissionVerifiedMinEngagement = 35
MissionQualityMinLikes = 18
MissionQualityMinEngagement = 50
MissionVerifiedMinLikes = 10
MissionVerifiedMinEngagement = 35
MissionInfluencerMinFollowers = 5000
)
@ -36,4 +36,4 @@ func MergeAuthorSignals(prev, next placement.ScanCandidate) placement.ScanCandid
prev.FollowerCount = next.FollowerCount
}
return prev
}
}

View File

@ -15,4 +15,4 @@ func TestPassesMissionQualityCandidate_unverifiedStricter(t *testing.T) {
if !PassesMissionQualityCandidate("轉職面試技巧分享心得", 25, 4, 65, false, 0, nil) {
t.Fatal("unverified author should pass with strong engagement")
}
}
}

View File

@ -198,4 +198,4 @@ func formatReferenceReason(item referenceAuthorAgg) string {
return fmt.Sprintf("標籤「%s」高互動作者", item.sampleSearchTag)
}
return "本次海巡高互動作者"
}
}

View File

@ -21,4 +21,4 @@ func TestBuildReferenceAccountsFromScan_relaxedFallback(t *testing.T) {
if got[0].AuthorVerified {
t.Fatal("verified must stay false when not provided")
}
}
}

View File

@ -55,4 +55,4 @@ func TestBuildReferenceAccountsFromScan_requiresTopicMatch(t *testing.T) {
if len(got) != 0 {
t.Fatalf("expected no accounts for off-topic post, got %d", len(got))
}
}
}

View File

@ -18,15 +18,15 @@ type CopyResearchMap struct {
}
type CopyResearchMapInput struct {
Label string
SeedQuery string
Brief string
Persona string
StyleBenchmark string
Label string
SeedQuery string
Brief string
Persona string
StyleBenchmark string
PersonaAudienceSummary string
PersonaContentGoal string
PersonaQuestions []string
PersonaPillars []string
PersonaContentGoal string
PersonaQuestions []string
PersonaPillars []string
}
var copyMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")

View File

@ -0,0 +1,27 @@
package authz
import (
"context"
"haixun-backend/internal/library/authctx"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/svc"
)
func RequireAdmin(ctx context.Context, svcCtx *svc.ServiceContext) error {
actor, ok := authctx.ActorFromContext(ctx)
if !ok {
return app.For(code.Auth).AuthUnauthorized("missing actor")
}
member, err := svcCtx.Member.GetByUID(ctx, actor.TenantID, actor.UID)
if err != nil {
return err
}
for _, role := range member.Roles {
if role == "admin" {
return nil
}
}
return app.For(code.Auth).AuthForbidden("admin role required")
}

View File

@ -151,4 +151,4 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
WebSearchUsed: webSearchUsed,
Message: message,
}, nil
}
}

View File

@ -74,4 +74,4 @@ func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(
Status: string(run.Status),
Message: "深仿寫已在背景執行",
}, nil
}
}

View File

@ -61,8 +61,8 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
payload := map[string]any{
"persona_id": personaID,
"copy_mission_id": missionID,
"count": count,
"copy_mission_id": missionID,
"count": count,
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
TemplateType: "generate-copy-matrix",
@ -80,4 +80,4 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
Status: string(run.Status),
Message: "內容矩陣產出已在背景執行",
}, nil
}
}

View File

@ -6,6 +6,8 @@ import (
"haixun-backend/internal/library/authctx"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/logic/authz"
"haixun-backend/internal/svc"
)
func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
@ -15,3 +17,7 @@ func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
}
return actor.TenantID, actor.UID, nil
}
func requireAdmin(ctx context.Context, svcCtx *svc.ServiceContext) error {
return authz.RequireAdmin(ctx, svcCtx)
}

View File

@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteJobScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteJobScheduleLogic {
return &DeleteJobScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteJobScheduleLogic) DeleteJobSchedule(req *types.JobScheduleIDPath) error {
return l.svcCtx.Job.DeleteSchedule(l.ctx, req.ID)
}

View File

@ -17,6 +17,9 @@ func NewGetJobTemplateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ge
}
func (l *GetJobTemplateLogic) GetJobTemplate(req *types.JobTemplatePath) (*types.JobTemplateData, error) {
if err := requireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
template, err := l.svcCtx.Job.GetTemplate(l.ctx, req.Type)
if err != nil {
return nil, err

View File

@ -17,6 +17,9 @@ func NewListJobTemplatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *
}
func (l *ListJobTemplatesLogic) ListJobTemplates() (*types.JobTemplateListData, error) {
if err := requireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
templates, err := l.svcCtx.Job.ListTemplates(l.ctx)
if err != nil {
return nil, err

View File

@ -19,6 +19,9 @@ func NewUpsertJobTemplateLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
func (l *UpsertJobTemplateLogic) UpsertJobTemplate(req *types.UpsertJobTemplateReq) (*types.JobTemplateData, error) {
if err := requireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
template, err := l.svcCtx.Job.UpsertTemplate(l.ctx, domusecase.UpsertTemplateRequest{
Type: req.Type,
Version: req.Version,

View File

@ -3,6 +3,7 @@ package permission
import (
"context"
"haixun-backend/internal/logic/authz"
domusecase "haixun-backend/internal/model/permission/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -18,6 +19,9 @@ func NewGetPermissionCatalogLogic(ctx context.Context, svcCtx *svc.ServiceContex
}
func (l *GetPermissionCatalogLogic) GetPermissionCatalog(req *types.PermissionCatalogQuery) (*types.PermissionCatalogData, error) {
if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
tree, list, err := l.svcCtx.Permission.Catalog(l.ctx, domusecase.CatalogRequest{
Status: req.Status,
Type: req.Type,

View File

@ -0,0 +1,59 @@
package middleware
import (
"net/http"
"haixun-backend/internal/library/authctx"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/permmatch"
memberdomain "haixun-backend/internal/model/member/domain/usecase"
permissiondomain "haixun-backend/internal/model/permission/domain/usecase"
"haixun-backend/internal/response"
)
// PermissionRBACMiddleware enforces catalog permissions for authenticated members.
// Mount after AuthJWT or MemberAuth so actor is present in context.
type PermissionRBACMiddleware struct {
members memberdomain.UseCase
permissions permissiondomain.UseCase
}
func NewPermissionRBACMiddleware(
members memberdomain.UseCase,
permissions permissiondomain.UseCase,
) *PermissionRBACMiddleware {
return &PermissionRBACMiddleware{members: members, permissions: permissions}
}
func (m *PermissionRBACMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := authctx.ActorFromContext(r.Context())
if !ok {
response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("missing actor"))
return
}
if m.members == nil || m.permissions == nil {
response.Write(r.Context(), w, nil, app.For(code.Permission).SysNotImplemented("permission rbac is not configured"))
return
}
member, err := m.members.GetByUID(r.Context(), actor.TenantID, actor.UID)
if err != nil {
response.Write(r.Context(), w, nil, err)
return
}
me, err := m.permissions.Me(r.Context(), member, false)
if err != nil {
response.Write(r.Context(), w, nil, err)
return
}
if !permmatch.RequestAllowed(me.Permissions, r.Method, r.URL.Path) {
response.Write(r.Context(), w, nil, app.For(code.Permission).AuthForbidden("permission denied"))
return
}
next(w, r)
}
}

View File

@ -53,13 +53,13 @@ type MissionSummary struct {
}
type CreateRequest struct {
TenantID string
OwnerUID string
PersonaID string
Label string
SeedQuery string
Brief string
InitialResearchMap *entity.ResearchMap
TenantID string
OwnerUID string
PersonaID string
Label string
SeedQuery string
Brief string
InitialResearchMap *entity.ResearchMap
InitialSelectedTags []string
}

View File

@ -16,6 +16,7 @@ type ScheduleRepository interface {
Create(ctx context.Context, schedule *entity.Schedule) (*entity.Schedule, error)
Update(ctx context.Context, schedule *entity.Schedule) (*entity.Schedule, error)
FindByID(ctx context.Context, id string) (*entity.Schedule, error)
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter ScheduleListFilter, offset, limit int64) ([]*entity.Schedule, int64, error)
FindDue(ctx context.Context, now int64, limit int64) ([]*entity.Schedule, error)
}

View File

@ -120,6 +120,7 @@ type UseCase interface {
UpdateSchedule(ctx context.Context, req UpdateScheduleRequest) (*entity.Schedule, error)
EnableSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error)
DisableSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error)
DeleteSchedule(ctx context.Context, scheduleID string) error
RunSchedulerTick(ctx context.Context, holder string) (int, error)
RunMaintenance(ctx context.Context) (MaintenanceResult, error)

View File

@ -72,6 +72,24 @@ func (r *mongoScheduleRepository) Update(ctx context.Context, schedule *entity.S
return &out, nil
}
func (r *mongoScheduleRepository) Delete(ctx context.Context, id string) error {
if r.collection == nil {
return app.For(code.Job).DBUnavailable("Mongo is not configured")
}
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return app.For(code.Job).InputInvalidFormat("invalid schedule id")
}
res, err := r.collection.DeleteOne(ctx, bson.M{"_id": objectID})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return app.For(code.Job).ResNotFound("job schedule not found")
}
return nil
}
func (r *mongoScheduleRepository) FindByID(ctx context.Context, id string) (*entity.Schedule, error) {
if r.collection == nil {
return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")

View File

@ -125,6 +125,16 @@ func (u *jobUseCase) DisableSchedule(ctx context.Context, scheduleID string) (*e
return u.schedules.Update(ctx, schedule)
}
func (u *jobUseCase) DeleteSchedule(ctx context.Context, scheduleID string) error {
if strings.TrimSpace(scheduleID) == "" {
return app.For(code.Job).InputMissingRequired("schedule id is required")
}
if _, err := u.schedules.FindByID(ctx, scheduleID); err != nil {
return err
}
return u.schedules.Delete(ctx, scheduleID)
}
func (u *jobUseCase) RunSchedulerTick(ctx context.Context, holder string) (int, error) {
ok, err := u.queue.TrySchedulerLock(ctx, holder, 55)
if err != nil {

View File

@ -217,6 +217,21 @@ func (m *memoryScheduleRepo) FindByID(_ context.Context, id string) (*entity.Sch
}
return nil, nil
}
func (m *memoryScheduleRepo) Delete(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return err
}
for i, item := range m.schedules {
if item.ID == objectID {
m.schedules = append(m.schedules[:i], m.schedules[i+1:]...)
return nil
}
}
return nil
}
func (m *memoryScheduleRepo) List(_ context.Context, _ domrepo.ScheduleListFilter, _, _ int64) ([]*entity.Schedule, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -3,6 +3,7 @@ package usecase
import (
"context"
"sort"
"strings"
memberentity "haixun-backend/internal/model/member/domain/entity"
"haixun-backend/internal/model/permission/domain/entity"
@ -111,9 +112,14 @@ func (u *permissionUseCase) Me(ctx context.Context, member *memberentity.Member,
Permissions: map[string]string{},
}
for _, node := range nodes {
if node.HTTPPath != "" && node.HTTPMethods != "" {
result.Permissions[node.HTTPPath] = node.HTTPMethods
if node.HTTPPath == "" || node.HTTPMethods == "" {
continue
}
if existing, ok := result.Permissions[node.HTTPPath]; ok {
result.Permissions[node.HTTPPath] = mergeHTTPMethods(existing, node.HTTPMethods)
continue
}
result.Permissions[node.HTTPPath] = node.HTTPMethods
}
if includeTree {
result.Tree = buildTree(nodes)
@ -121,6 +127,25 @@ func (u *permissionUseCase) Me(ctx context.Context, member *memberentity.Member,
return result, nil
}
func mergeHTTPMethods(existing, next string) string {
seen := map[string]struct{}{}
out := make([]string, 0, 8)
for _, block := range []string{existing, next} {
for _, item := range strings.Split(block, "|") {
method := strings.ToUpper(strings.TrimSpace(item))
if method == "" {
continue
}
if _, ok := seen[method]; ok {
continue
}
seen[method] = struct{}{}
out = append(out, method)
}
}
return strings.Join(out, "|")
}
func hasRole(roles []string, role string) bool {
for _, item := range roles {
if item == role {
@ -146,10 +171,20 @@ func filterPermissionsByName(items []*entity.Permission, names []string) []*enti
func defaultUserPermissionNames() []string {
return []string{
"auth.logout",
"member.me.read",
"member.me.update",
"member.placement_settings.read",
"member.placement_settings.update",
"permission.me.read",
"setting.manage",
"ai.use",
"brand.manage",
"persona.manage",
"placement_topic.manage",
"threads_account.manage",
"job.manage",
"job.schedule.manage",
}
}
@ -196,14 +231,28 @@ func buildTree(items []domusecase.PermissionNode) []domusecase.PermissionNode {
}
func defaultPermissions() []*entity.Permission {
allMethods := "GET|POST|PUT|PATCH|DELETE"
return []*entity.Permission{
{Name: "auth.logout", HTTPMethods: "POST", HTTPPath: "/api/v1/auth/logout", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.me.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.placement_settings.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.placement_settings.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "permission.catalog.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/catalog", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "permission.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "setting.manage", HTTPMethods: "GET|PUT|DELETE", HTTPPath: "/api/v1/settings/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "ai.use", HTTPMethods: "GET|POST", HTTPPath: "/api/v1/ai/*", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "job.manage", HTTPMethods: "GET|POST|PUT", HTTPPath: "/api/v1/jobs/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{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: "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},
{Name: "job.template.manage", HTTPMethods: "GET|PUT", HTTPPath: "/api/v1/job/templates/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
}
}

View File

@ -0,0 +1,14 @@
package usecase
import "testing"
func TestMergeHTTPMethods(t *testing.T) {
got := mergeHTTPMethods("GET", "PATCH")
if got != "GET|PATCH" {
t.Fatalf("got %q", got)
}
got = mergeHTTPMethods("GET|POST", "POST|PATCH")
if got != "GET|POST|PATCH" {
t.Fatalf("got %q", got)
}
}

View File

@ -135,6 +135,9 @@ func NewServiceContext(c config.Config) *ServiceContext {
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
panic(err)
}
if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, "default"); err != nil {
panic(err)
}
jobTemplateRepository := jobrepo.NewMongoTemplateRepository(mongoClient.Database())
jobRunRepository := jobrepo.NewMongoRunRepository(mongoClient.Database())

View File

@ -230,18 +230,6 @@ type CopyMissionData struct {
UpdateAt int64 `json:"update_at"`
}
type CopyMissionPath struct {
PersonaID string `path:"personaId" validate:"required"`
ID string `path:"id" validate:"required"`
}
type CopyMissionInspirationSourceData struct {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
URL string `json:"url,omitempty"`
}
type CopyMissionInspirationData struct {
Label string `json:"label"`
SeedQuery string `json:"seed_query"`
@ -253,6 +241,18 @@ type CopyMissionInspirationData struct {
Message string `json:"message"`
}
type CopyMissionInspirationSourceData struct {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
URL string `json:"url,omitempty"`
}
type CopyMissionPath struct {
PersonaID string `path:"personaId" validate:"required"`
ID string `path:"id" validate:"required"`
}
type CopyMissionResearchMapData struct {
AudienceSummary string `json:"audience_summary,omitempty"`
ContentGoal string `json:"content_goal,omitempty"`
@ -1084,19 +1084,19 @@ type StartCopyMissionAnalyzeJobData struct {
Message string `json:"message"`
}
type StartCopyMissionScanJobData struct {
type StartCopyMissionCopyDraftJobData struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
type StartCopyMissionMatrixJobReq struct {
Count int `json:"count,optional"`
type StartCopyMissionCopyDraftJobHandlerReq struct {
CopyMissionPath
StartCopyMissionCopyDraftJobReq
}
type StartCopyMissionMatrixJobHandlerReq struct {
CopyMissionPath
StartCopyMissionMatrixJobReq
type StartCopyMissionCopyDraftJobReq struct {
ScanPostID string `json:"scan_post_id" validate:"required"`
}
type StartCopyMissionMatrixJobData struct {
@ -1105,16 +1105,16 @@ type StartCopyMissionMatrixJobData struct {
Message string `json:"message"`
}
type StartCopyMissionCopyDraftJobReq struct {
ScanPostID string `json:"scan_post_id" validate:"required"`
}
type StartCopyMissionCopyDraftJobHandlerReq struct {
type StartCopyMissionMatrixJobHandlerReq struct {
CopyMissionPath
StartCopyMissionCopyDraftJobReq
StartCopyMissionMatrixJobReq
}
type StartCopyMissionCopyDraftJobData struct {
type StartCopyMissionMatrixJobReq struct {
Count int `json:"count,optional"`
}
type StartCopyMissionScanJobData struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`

View File

@ -207,4 +207,4 @@ func runGenerateCopyDraft(ctx context.Context, step StepContext, deps GenerateCo
},
})
return err
}
}

View File

@ -243,4 +243,4 @@ func intField(payload map[string]any, key string) int {
default:
return 0
}
}
}

View File

@ -11,8 +11,8 @@ import (
libviral "haixun-backend/internal/library/viral"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
copydraftusecase "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"
jobdom "haixun-backend/internal/model/job/domain/usecase"
personaentity "haixun-backend/internal/model/persona/domain/entity"

View File

@ -1,15 +1,16 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthContext'
import { ThemeProvider } from './theme/ThemeContext'
import { AdminRoute } from './components/AdminRoute'
import { Layout } from './components/Layout'
import { ProtectedRoute } from './components/ProtectedRoute'
import { AiPage } from './pages/AiPage'
import { DashboardPage } from './pages/DashboardPage'
import { JobDetailPage } from './pages/JobDetailPage'
import { JobSchedulesPage } from './pages/JobSchedulesPage'
import { JobTemplatesPage } from './pages/JobTemplatesPage'
import { JobsPage } from './pages/JobsPage'
import { LoginPage } from './pages/LoginPage'
import { EasterEggsPage } from './pages/EasterEggsPage'
import { PermissionsPage } from './pages/PermissionsPage'
import { LegacyBrandRouteRedirect } from './components/LegacyBrandRouteRedirect'
import { BrandDetailPage } from './pages/BrandDetailPage'
@ -59,12 +60,14 @@ export default function App() {
<Route path="/personas/:id/matrix" element={<LegacyBrandRouteRedirect to="/matrix" />} />
<Route path="/personas/:id" element={<PersonaDetailPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/permissions" element={<PermissionsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ai" element={<AiPage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/jobs/:id" element={<JobDetailPage />} />
<Route path="/job-templates" element={<JobTemplatesPage />} />
<Route element={<AdminRoute />}>
<Route path="/job-templates" element={<JobTemplatesPage />} />
<Route path="/permissions" element={<PermissionsPage />} />
<Route path="/easter-eggs" element={<EasterEggsPage />} />
</Route>
<Route path="/job-schedules" element={<JobSchedulesPage />} />
<Route path="/threads/:id" element={<ThreadsAccountWorkspace />}>
<Route index element={<Navigate to="publish" replace />} />

View File

@ -221,22 +221,3 @@ export async function streamIslanderChat(
await consumeAIEventStream(res, onDelta, onDone, onError)
}
export async function streamAIChat(
body: Record<string, unknown>,
onDelta: (text: string) => void,
onDone: (finishReason?: string) => void,
onError: (msg: string) => void,
) {
const memberToken = storage.getAccessToken()
const providerToken = storage.getAiProviderToken()
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (memberToken) headers['X-Member-Authorization'] = `Bearer ${memberToken}`
if (providerToken) headers.Authorization = `Bearer ${providerToken}`
const res = await fetch('/api/v1/ai/chat/stream', {
method: 'POST',
headers,
body: JSON.stringify(body),
})
await consumeAIEventStream(res, onDelta, onDone, onError)
}

View File

@ -3,73 +3,95 @@ import type { AcAppKey } from '../lib/acAssets'
const stroke = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeWidth: 1.85,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
}
const paths: Record<AcAppKey, React.ReactNode> = {
home: <path {...stroke} d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1v-9.5Z" />,
home: (
<>
<path {...stroke} d="M4.5 10.8 12 5.2l7.5 5.6V19a1.2 1.2 0 0 1-1.2 1.2H14.8v-5.4H9.2v5.4H5.7A1.2 1.2 0 0 1 4.5 19v-8.2Z" />
<path {...stroke} d="M10 19.2v-4.6h4v4.6" />
</>
),
persona: (
<>
<circle {...stroke} cx="12" cy="8" r="3.5" />
<path {...stroke} d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
<path {...stroke} d="M17 5l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2Z" />
<circle {...stroke} cx="12" cy="8.2" r="3.2" />
<path {...stroke} d="M5.5 19.5c0-3.4 2.9-5.8 6.5-5.8s6.5 2.4 6.5 5.8" />
<path {...stroke} d="M17.2 6.2 18.6 8l2.2.4-1.6 1.4-.4 2.2-1.8-1.1-1.8 1.1.4-2.2-1.6-1.4 2.2-.4 1.4-1.8Z" />
</>
),
jobs: (
<>
<path {...stroke} d="M7.5 5.5h9a1.5 1.5 0 0 1 1.5 1.5v11.8a1.5 1.5 0 0 1-1.5 1.5h-9a1.5 1.5 0 0 1-1.5-1.5V7a1.5 1.5 0 0 1 1.5-1.5Z" />
<path {...stroke} d="M9.2 9.8h7.6M9.2 12.6h5.4M9.2 15.4h6.8" />
<circle fill="currentColor" stroke="none" cx="17.4" cy="12.6" r="1.1" />
</>
),
jobs: <path {...stroke} d="M6 4h12a1 1 0 0 1 1 1v14l-7-3.5L5 19V5a1 1 0 0 1 1-1Z" />,
schedule: (
<>
<path {...stroke} d="M8 2v4M16 2v4M4 8h16M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" />
<path {...stroke} d="M8 12h4M8 16h8" />
</>
),
ai: (
<>
<rect {...stroke} x="5" y="7" width="14" height="10" rx="2" />
<path {...stroke} d="M9 11h.01M15 11h.01M10 15h4" />
<path {...stroke} d="M12 4v2" />
<path {...stroke} d="M8 3.8v2.4M16 3.8v2.4M5.8 8.6h12.4M7.2 6.2h9.6a1.8 1.8 0 0 1 1.8 1.8v10.2a1.8 1.8 0 0 1-1.8 1.8H7.2a1.8 1.8 0 0 1-1.8-1.8V8a1.8 1.8 0 0 1 1.8-1.8Z" />
<circle fill="currentColor" stroke="none" cx="12" cy="14.8" r="1.65" />
<path {...stroke} d="M9.2 11.8h2.2" />
</>
),
template: (
<>
<path {...stroke} d="M8 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" />
<path {...stroke} d="M10 9h8M10 13h8M10 17h5" />
<rect {...stroke} x="6.8" y="5.5" width="10.4" height="13" rx="1.6" />
<path {...stroke} d="M9.4 9.2h7.2M9.4 12h7.2M9.4 14.8h4.6" />
<path {...stroke} d="M15.8 5.5V4.3a1.2 1.2 0 0 1 1.2-1.2h2.8a1.2 1.2 0 0 1 1.2 1.2v12.4a1.2 1.2 0 0 1-1.2 1.2h-2.8a1.2 1.2 0 0 1-1.2-1.2v-1" />
</>
),
settings: (
<>
<circle {...stroke} cx="12" cy="12" r="3" />
<path
{...stroke}
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
/>
<path {...stroke} d="M6.5 9.2v5.6M12 7v10M17.5 10.4v3.2" />
<circle {...stroke} cx="6.5" cy="9.2" r="2" />
<circle {...stroke} cx="12" cy="7" r="2" />
<circle {...stroke} cx="17.5" cy="10.4" r="2" />
</>
),
profile: (
<>
<circle {...stroke} cx="12" cy="8" r="3.5" />
<path {...stroke} d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
<circle {...stroke} cx="12" cy="12" r="8.2" />
<circle {...stroke} cx="12" cy="9.4" r="2.8" />
<path {...stroke} d="M6.8 17.8c1.2-2.6 3.2-3.8 5.2-3.8s4 1.2 5.2 3.8" />
</>
),
permissions: (
<>
<path {...stroke} d="M8 11V8a4 4 0 1 1 8 0v3" />
<rect {...stroke} x="6" y="11" width="12" height="9" rx="2" />
<path {...stroke} d="M12 4.2 17.2 6.4v5.1c0 3.1-2.2 5.2-5.2 6.3-3-.9-5.2-3.2-5.2-6.3V6.4L12 4.2Z" />
<path {...stroke} d="m9.4 12.2 1.8 1.8 3.6-3.8" />
</>
),
easter: (
<>
<path
{...stroke}
d="M12 5.2c-3.8 0-6.8 2.4-6.8 5.8 0 2.6 1.6 4.8 4 5.8"
/>
<path
{...stroke}
d="M12 5.2c3.8 0 6.8 2.4 6.8 5.8 0 2.6-1.6 4.8-4 5.8"
/>
<path {...stroke} d="M12 16.8v2.2M9.4 19.8h5.2" />
<path {...stroke} d="M10.2 9.8c1.4 1 3.2 1 4.8 0" opacity="0.65" />
</>
),
threads: (
<>
<circle {...stroke} cx="12" cy="8" r="3.5" />
<path {...stroke} d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
<path {...stroke} d="M16 6.5c2 0 3.5 1.2 3.5 2.8S18 12 16 12" />
<circle {...stroke} cx="9" cy="9.2" r="2.6" />
<circle {...stroke} cx="15.8" cy="14.8" r="2.6" />
<path {...stroke} d="M11.2 10.8c2.2 1.2 2.8 2.2 2.8 3.2" />
<path {...stroke} d="M5.5 19.5c0-3.2 2.4-5.2 5.2-5.2" opacity="0.55" />
</>
),
more: (
<>
<circle fill="currentColor" cx="7" cy="12" r="1.5" stroke="none" />
<circle fill="currentColor" cx="12" cy="12" r="1.5" stroke="none" />
<circle fill="currentColor" cx="17" cy="12" r="1.5" stroke="none" />
<rect fill="currentColor" stroke="none" x="5.5" y="5.5" width="5.2" height="5.2" rx="1.3" />
<rect fill="currentColor" stroke="none" x="13.3" y="5.5" width="5.2" height="5.2" rx="1.3" />
<rect fill="currentColor" stroke="none" x="5.5" y="13.3" width="5.2" height="5.2" rx="1.3" />
<rect fill="currentColor" stroke="none" x="13.3" y="13.3" width="5.2" height="5.2" rx="1.3" />
</>
),
}
@ -84,7 +106,7 @@ export function AcIcon({
className?: string
}) {
const px = size === 'sm' ? 'h-9 w-9' : size === 'lg' ? 'h-12 w-12' : 'h-10 w-10'
const iconPx = size === 'sm' ? 'h-4 w-4' : size === 'lg' ? 'h-6 w-6' : 'h-5 w-5'
const iconPx = size === 'sm' ? 'h-[18px] w-[18px]' : size === 'lg' ? 'h-6 w-6' : 'h-5 w-5'
return (
<span className={`ac-app-icon-svg ${px} ${className}`}>
<svg aria-hidden className={iconPx} viewBox="0 0 24 24">

View File

@ -4,6 +4,8 @@ import {
DEFAULT_AI_CREDENTIALS,
getApiKeyStatus,
isMaskedKey,
normalizeProviderSettings,
normalizeSupportedProvider,
PROVIDER_KEY_LABELS,
PROVIDER_OPTIONS,
PROVIDER_ORDER,
@ -47,7 +49,15 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
setError('')
try {
const data = await api.get<ThreadsAccountAiSettingsData>(aiSettingsPath(accountId), { auth: true })
setSettings(data)
setSettings({
...data,
...normalizeProviderSettings({
provider: data.provider,
model: data.model,
research_provider: data.research_provider,
research_model: data.research_model,
}),
})
setConfigured(parseConfigured(data.api_keys_configured))
setKeyInputs(data.api_keys ?? {})
} catch (e) {
@ -68,23 +78,37 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
setMessage('')
try {
const apiKeys: ProviderApiKeys = {}
for (const [provider, value] of Object.entries(keyInputs) as [ProviderId, string][]) {
const trimmed = value?.trim()
for (const provider of PROVIDER_ORDER) {
const trimmed = keyInputs[provider]?.trim()
if (!trimmed || isMaskedKey(trimmed)) continue
apiKeys[provider] = trimmed
}
const normalized = normalizeProviderSettings({
provider: settings.provider,
model: settings.model,
research_provider: settings.research_provider,
research_model: settings.research_model,
})
const data = await api.put<ThreadsAccountAiSettingsData>(
aiSettingsPath(accountId),
{
provider: settings.provider,
model: settings.model,
research_provider: settings.research_provider,
research_model: settings.research_model,
provider: normalized.provider,
model: normalized.model,
research_provider: normalized.research_provider,
research_model: normalized.research_model,
api_keys: apiKeys,
},
{ auth: true },
)
setSettings(data)
setSettings({
...data,
...normalizeProviderSettings({
provider: data.provider,
model: data.model,
research_provider: data.research_provider,
research_model: data.research_model,
}),
})
setConfigured(parseConfigured(data.api_keys_configured))
setKeyInputs(data.api_keys ?? {})
setMessage('AI 設定已儲存')
@ -95,9 +119,8 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
}
}
const provider = (settings?.provider as ProviderId) || DEFAULT_AI_CREDENTIALS.provider
const researchProvider =
(settings?.research_provider as ProviderId) || provider
const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider
const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider)
const providerOption = PROVIDER_OPTIONS.find((item) => item.value === provider)
const researchOption = PROVIDER_OPTIONS.find((item) => item.value === researchProvider)
const currentProviderConfigured = configured[provider]

View File

@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { isAdminMember } from '../lib/memberRole'
export function AdminRoute() {
const { member, loading } = useAuth()
if (loading) {
return (
<div className="flex min-h-[12rem] items-center justify-center text-muted">
</div>
)
}
if (!isAdminMember(member)) {
return <Navigate to="/" replace />
}
return <Outlet />
}

View File

@ -0,0 +1,37 @@
import { Link } from 'react-router-dom'
import { AuthTicketIcon } from './AuthDecor'
type Props = {
variant?: 'header' | 'sidebar'
className?: string
}
export function AppBrandLink({ variant = 'header', className = '' }: Props) {
const isSidebar = variant === 'sidebar'
return (
<Link
to="/"
className={`ac-brand-link ${isSidebar ? 'ac-sidebar-brand' : 'ac-app-header-brand'} ${className}`.trim()}
aria-label="回到總覽"
>
<AuthTicketIcon
className={isSidebar ? 'ac-sidebar-brand-icon' : 'ac-app-header-icon'}
/>
<div className="min-w-0">
<p className="display-en text-[10px] font-semibold tracking-[0.16em] text-accent uppercase">
Haixun Patrol
</p>
<p
className={
isSidebar
? 'truncate text-base font-bold leading-snug text-ink'
: 'truncate text-lg font-bold leading-snug text-ink'
}
>
</p>
</div>
</Link>
)
}

View File

@ -1,5 +1,7 @@
import { useMemo } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { isAdminMember } from '../lib/memberRole'
import { navGroupsForOnboarding, onboardingGlowClass, shouldGlowNav } from '../lib/onboarding'
import { coreWorkflowNavGroup, type AcAppKey } from '../lib/acAssets'
import { getActiveTopicId } from '../lib/brandContext'
@ -53,14 +55,16 @@ function SidebarNavItem({
export function AppSidebar() {
const { pathname } = useLocation()
const { member } = useAuth()
const { isComplete, nextStep } = useOnboarding()
const isAdmin = isAdminMember(member)
const groups = useMemo(() => {
const base = navGroupsForOnboarding(isComplete)
const base = navGroupsForOnboarding(isComplete, isAdmin)
if (!isComplete) return base
const flowGroup = coreWorkflowNavGroup()
if (base.length === 0) return [flowGroup]
return [flowGroup, ...base]
}, [isComplete])
}, [isComplete, isAdmin])
return (
<aside className="ac-sidebar hidden lg:flex" aria-label="側欄導覽">

View File

@ -58,23 +58,59 @@ export function SceneDecor() {
/** @deprecated 使用 SceneDecor */
export const AuthSceneDecor = SceneDecor
/** 品牌徽章:巡檢視窗 + 燈塔信標,幾何俐落、適合小尺寸 */
export function AuthTicketIcon({ className = '' }: { className?: string }) {
return (
<span className={`auth-ticket-icon ${className}`.trim()}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M5 11.5 12 6l7 5.5V19a1.5 1.5 0 0 1-1.5 1.5H14v-5.5h-4V20.5H6.5A1.5 1.5 0 0 1 5 19v-7.5Z"
<svg viewBox="0 0 40 40" fill="none" aria-hidden className="auth-ticket-icon__svg">
<defs>
<linearGradient id="hx-mark-body" x1="8" y1="10" x2="30" y2="32" gradientUnits="userSpaceOnUse">
<stop stopColor="var(--hx-brand)" />
<stop offset="1" stopColor="var(--hx-brand-hover)" />
</linearGradient>
<linearGradient id="hx-mark-beam" x1="22" y1="11" x2="34" y2="18" gradientUnits="userSpaceOnUse">
<stop stopColor="var(--hx-brand)" stopOpacity="0.85" />
<stop offset="1" stopColor="var(--hx-brand)" stopOpacity="0" />
</linearGradient>
</defs>
<rect
x="7.5"
y="7.5"
width="25"
height="25"
rx="7.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
strokeWidth="1.35"
opacity="0.28"
/>
<path
d="M17 4.5c1.2.8 2 2.2 2 3.8 0 2.8-2.2 4.2-4 5.2"
d="M11 13.5h5.2M23.8 13.5h5.2M11 26.5h5.2M23.8 26.5h5.2"
stroke="currentColor"
strokeWidth="1.5"
strokeWidth="1.45"
strokeLinecap="round"
opacity="0.42"
/>
<path
d="M14.2 28.4h11.6a1.8 1.8 0 0 0 1.8-1.8V18.2H14.2v8.4Z"
fill="url(#hx-mark-body)"
/>
<path d="M14.2 18.2 20 12.4l5.8 5.8H14.2Z" fill="currentColor" opacity="0.92" />
<path d="M22.2 12.8 28 18.6" stroke="var(--hx-surface)" strokeWidth="0.9" strokeLinecap="round" opacity="0.55" />
<path d="M22 18.2v8.4a1.35 1.35 0 0 1-2.7 0v-8.4" fill="var(--hx-surface)" opacity="0.96" />
<circle cx="20" cy="10.6" r="2.15" fill="var(--hx-surface)" />
<circle cx="20" cy="10.6" r="1.05" fill="currentColor" />
<path
d="M21.4 10.6h11.5"
stroke="url(#hx-mark-beam)"
strokeWidth="2.4"
strokeLinecap="round"
/>
<circle cx="18.5" cy="5.5" r="1.25" fill="currentColor" />
<ellipse cx="20" cy="31.8" rx="10.5" ry="1.7" fill="currentColor" opacity="0.1" />
</svg>
</span>
)

View File

@ -3,8 +3,8 @@ import { Button, Card } from './ui'
export function CopyFlowPipeline() {
return (
<section className="ac-persona-pipeline" aria-label="拷貝忍者工作流入口">
<div className="mb-4 space-y-1">
<section className="ac-persona-pipeline flex h-full flex-col" aria-label="拷貝忍者工作流入口">
<div className="mb-4 flex-1 space-y-1 lg:min-h-[8.5rem]">
<p className="display-en text-[10px] font-bold tracking-[0.16em] text-brand uppercase">
</p>
@ -17,7 +17,7 @@ export function CopyFlowPipeline() {
仿
</p>
</div>
<Card className="flex flex-wrap items-center justify-between gap-4 p-5">
<Card className="flex min-h-[5.5rem] flex-wrap items-center justify-between gap-4 p-5">
<div>
<p className="font-bold text-ink"></p>
<p className="mt-1 text-sm text-ink-secondary">稿</p>

View File

@ -13,12 +13,7 @@ export type CopyMissionScanScheduleData = {
last_run_at?: number
}
const CRON_PRESETS: Array<{ label: string; value: string }> = [
{ label: '每天 09:00', value: '0 9 * * *' },
{ label: '每週一 09:00', value: '0 9 * * 1' },
{ label: '每 6 小時', value: '0 */6 * * *' },
{ label: '每 12 小時', value: '0 */12 * * *' },
]
import { CRON_PRESETS } from '../lib/scheduleCatalog'
type Props = {
personaId: string

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { Link } from 'react-router-dom'
import { api } from '../api/client'
import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
import { jobTemplateLabel } from '../lib/jobTemplate'
import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow'
import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph'
import { setPlacementHandoff } from '../lib/islander/handoffStore'
@ -57,13 +58,7 @@ function recentEnough(job: JobData) {
}
function shortJobTitle(job: JobData) {
if (job.template_type === 'style-8d') return '8D 風格分析'
if (job.template_type === 'expand-graph') return '知識圖譜擴展'
if (job.template_type === 'placement-scan') return '雙軌海巡'
if (job.template_type === 'scan-viral') return '爆款掃描'
if (job.template_type === 'analyze-copy-mission') return '拷貝研究地圖'
if (job.template_type === 'demo_long_task') return 'Demo 任務'
return job.template_type
return jobTemplateLabel(job.template_type)
}
function phaseHint(job: JobData) {

View File

@ -1,11 +1,13 @@
import { Outlet } from 'react-router-dom'
import { AuthTicketIcon, SceneDecor } from './AuthDecor'
import { SceneDecor } from './AuthDecor'
import { AppBrandLink } from './AppBrandLink'
import { AppSidebar } from './AppSidebar'
import { MobileBottomNav } from './MobileBottomNav'
import { AccountSwitcher } from './AccountSwitcher'
import { MemberMenu } from './MemberMenu'
import { JobMonitor } from './JobMonitor'
import { IslanderCompanion } from './islander/IslanderCompanion'
import { useIslanderUnlock } from '../hooks/useIslanderUnlock'
import { IslanderPageProvider } from '../lib/islander'
import { OnboardingBanner } from './OnboardingBanner'
import { OnboardingRouteGuard } from './OnboardingRouteGuard'
@ -13,6 +15,8 @@ import { OnboardingProvider } from '../onboarding/OnboardingContext'
import { ThreadsAccountProvider } from '../threads/ThreadsAccountContext'
export function Layout() {
const islanderUnlocked = useIslanderUnlock()
return (
<ThreadsAccountProvider>
<OnboardingProvider>
@ -24,16 +28,8 @@ export function Layout() {
<div className="ac-workspace-column flex min-w-0 flex-1 flex-col">
<header className="ac-app-chrome">
<div className="ac-app-header-brand lg:hidden">
<AuthTicketIcon className="ac-app-header-icon" />
<div className="min-w-0">
<p className="display-en text-[10px] font-semibold tracking-[0.16em] text-accent uppercase">
Haixun Patrol
</p>
<h1 className="truncate text-lg font-bold leading-snug text-ink"></h1>
</div>
</div>
<div className="ac-app-chrome-spacer hidden min-w-0 flex-1 lg:block" aria-hidden />
<AppBrandLink />
<div className="ac-app-chrome-spacer min-w-0 flex-1" aria-hidden />
<div className="ac-app-header-actions flex shrink-0 items-center gap-2">
<AccountSwitcher />
<MemberMenu />
@ -55,7 +51,7 @@ export function Layout() {
<MobileBottomNav />
<JobMonitor />
<IslanderCompanion />
{islanderUnlocked ? <IslanderCompanion /> : null}
</div>
</OnboardingProvider>
</ThreadsAccountProvider>

View File

@ -1,13 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { formatMemberRoles, memberAvatarGlyph } from '../lib/memberRole'
import { ThemeToggle } from './ThemeToggle'
function memberInitial(email?: string, displayName?: string) {
const source = displayName?.trim() || email?.trim() || '?'
return source.charAt(0).toUpperCase()
}
export function MemberMenu() {
const navigate = useNavigate()
const { member, logout } = useAuth()
@ -17,8 +13,9 @@ export function MemberMenu() {
const email = member?.email ?? ''
const displayName = member?.display_name?.trim()
const roleLabel = member?.roles?.join(', ') || 'member'
const shortLabel = displayName || email.split('@')[0] || '會員'
const roleLabel = formatMemberRoles(member?.roles)
const avatarGlyph = memberAvatarGlyph(member)
const shortLabel = displayName || '會員'
useEffect(() => {
if (!open) return
@ -58,7 +55,7 @@ export function MemberMenu() {
aria-label={`會員:${email || shortLabel}`}
>
<span className="ac-member-avatar" aria-hidden>
{memberInitial(email, displayName)}
{avatarGlyph}
</span>
<span className="ac-member-trigger-text hidden min-w-0 max-w-[6.5rem] truncate sm:inline">
{shortLabel}
@ -73,7 +70,7 @@ export function MemberMenu() {
<p className="ac-account-menu-title"></p>
<div className="ac-member-menu-body">
<div className="ac-member-menu-hero">
<span className="ac-member-avatar ac-member-avatar--lg">{memberInitial(email, displayName)}</span>
<span className="ac-member-avatar ac-member-avatar--lg">{avatarGlyph}</span>
<div className="min-w-0 flex-1">
{displayName ? (
<p className="truncate text-sm font-bold text-ink">{displayName}</p>
@ -88,7 +85,7 @@ export function MemberMenu() {
className="ac-member-menu-link"
onClick={() => setOpen(false)}
>
Profile
</Link>
<div className="ac-member-menu-theme">
<span className="text-sm font-semibold text-ink-secondary"></span>

View File

@ -3,6 +3,8 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { PLACEMENT_FLOW, isPlacementFlowPath, placementFlowPath, topicIdFromSearch } from '../lib/placementFlow'
import { getActiveTopicId } from '../lib/brandContext'
import { onboardingGlowClass, onboardingNavApps, shouldGlowNav } from '../lib/onboarding'
import { useAuth } from '../auth/AuthContext'
import { isAdminMember } from '../lib/memberRole'
import { useOnboarding } from '../onboarding/OnboardingContext'
import { AcIcon } from './AcIcon'
@ -21,12 +23,14 @@ function isMoreActive(pathname: string, routes: { to: string }[]) {
}
export function MobileBottomNav() {
const { member } = useAuth()
const { isComplete, nextStep } = useOnboarding()
const [moreOpen, setMoreOpen] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const isAdmin = isAdminMember(member)
const mobileTabs = isComplete ? fullMobileTabs : onboardingMobileTabs
const navApps = onboardingNavApps(isComplete)
const navApps = onboardingNavApps(isComplete, isAdmin)
const moreRoutes = navApps.filter((n) => !mobileTabs.some((t) => t.to === n.to))
const activeTopicId = topicIdFromSearch(location.search) || getActiveTopicId()
const resolveTo = (to: string) =>

View File

@ -12,12 +12,7 @@ export type BrandScanScheduleData = {
last_run_at?: number
}
const CRON_PRESETS: Array<{ label: string; value: string }> = [
{ label: '每天 09:00', value: '0 9 * * *' },
{ label: '每週一 09:00', value: '0 9 * * 1' },
{ label: '每 6 小時', value: '0 */6 * * *' },
{ label: '每 12 小時', value: '0 */12 * * *' },
]
import { CRON_PRESETS } from '../lib/scheduleCatalog'
type Props = {
brandId: string

View File

@ -1,43 +1,27 @@
import { Link } from 'react-router-dom'
import { PLACEMENT_FLOW } from '../lib/placementFlow'
import { Button, Card } from './ui'
type Props = {
highlight?: 'research' | 'outreach'
}
export function PlacementFlowPipeline({ highlight }: Props) {
export function PlacementFlowPipeline() {
return (
<section className="ac-persona-pipeline" aria-label="找 TA 工作流入口">
<div className="mb-4 space-y-1">
<section className="ac-persona-pipeline flex h-full flex-col" aria-label="找 TA 工作流入口">
<div className="mb-4 flex-1 space-y-1 lg:min-h-[8.5rem]">
<p className="display-en text-[10px] font-bold tracking-[0.16em] text-brand uppercase">
TA
</p>
<h2 className="text-lg font-black text-ink"> TA</h2>
<p className="max-w-2xl text-sm leading-relaxed text-ink-secondary">
<Link to="/placement/topics" className="mx-1 font-semibold text-brand hover:underline">
TA
</Link>
TA
TA TA
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
{PLACEMENT_FLOW.map((step) => {
const isHighlight = highlight === step.key
return (
<Link
key={step.key}
to={step.path}
className={`ac-persona-pipeline__card ${isHighlight ? 'ac-persona-pipeline__card--highlight' : ''}`}
>
<span className="ac-persona-pipeline__step">{step.step}</span>
<span className="ac-persona-pipeline__title">{step.label}</span>
<span className="ac-persona-pipeline__desc">{step.desc}</span>
<span className="ac-persona-pipeline__cta"> </span>
</Link>
)
})}
</div>
<Card className="flex min-h-[5.5rem] flex-wrap items-center justify-between gap-4 p-5">
<div>
<p className="font-bold text-ink"> TA</p>
<p className="mt-1 text-sm text-ink-secondary"></p>
</div>
<Link to="/placement/topics">
<Button> TA</Button>
</Link>
</Card>
</section>
)
}
}

View File

@ -0,0 +1,113 @@
import { Select } from './ui'
import {
HOUR_OPTIONS,
INTERVAL_HOUR_OPTIONS,
MINUTE_OPTIONS,
WEEKDAY_OPTIONS,
type ScheduleFrequency,
type ScheduleRepeatMode,
} from '../lib/scheduleCron'
const REPEAT_OPTIONS: Array<{ value: ScheduleRepeatMode; label: string }> = [
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每週' },
{ value: 'interval', label: '固定間隔' },
]
type Props = {
value: ScheduleFrequency
onChange: (next: ScheduleFrequency) => void
compact?: boolean
}
function timeSelectClass(compact?: boolean) {
return compact ? 'px-2 py-1.5 text-sm' : ''
}
export function ScheduleFrequencyPicker({ value, onChange, compact = false }: Props) {
const update = (patch: Partial<ScheduleFrequency>) => onChange({ ...value, ...patch })
return (
<div className={compact ? 'flex flex-wrap items-center gap-2' : 'space-y-3'}>
<label className={compact ? 'flex items-center gap-2 text-sm text-ink' : 'block text-base'}>
{!compact ? <span className="mb-2 block font-bold text-ink"></span> : null}
<Select
value={value.mode}
onChange={(e) => update({ mode: e.target.value as ScheduleRepeatMode })}
className={timeSelectClass(compact)}
>
{REPEAT_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</label>
{value.mode === 'weekly' ? (
<label className={compact ? 'flex items-center gap-2 text-sm text-ink' : 'block text-base'}>
{!compact ? <span className="mb-2 block font-bold text-ink"></span> : null}
<Select
value={String(value.weekday)}
onChange={(e) => update({ weekday: Number.parseInt(e.target.value, 10) })}
className={timeSelectClass(compact)}
>
{WEEKDAY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</label>
) : null}
{value.mode === 'interval' ? (
<label className={compact ? 'flex items-center gap-2 text-sm text-ink' : 'block text-base'}>
{!compact ? <span className="mb-2 block font-bold text-ink"></span> : null}
<Select
value={String(value.intervalHours)}
onChange={(e) => update({ intervalHours: Number.parseInt(e.target.value, 10) })}
className={timeSelectClass(compact)}
>
{INTERVAL_HOUR_OPTIONS.map((hours) => (
<option key={hours} value={hours}>
{hours}
</option>
))}
</Select>
</label>
) : (
<div className={compact ? 'flex items-center gap-2 text-sm text-ink' : 'block text-base'}>
{!compact ? <span className="mb-2 block font-bold text-ink"></span> : null}
<div className="flex items-center gap-2">
<Select
value={String(value.hour)}
onChange={(e) => update({ hour: Number.parseInt(e.target.value, 10) })}
className={timeSelectClass(compact)}
aria-label="小時"
>
{HOUR_OPTIONS.map((hour) => (
<option key={hour} value={hour}>
{String(hour).padStart(2, '0')}
</option>
))}
</Select>
<span className="text-muted">:</span>
<Select
value={String(value.minute)}
onChange={(e) => update({ minute: Number.parseInt(e.target.value, 10) })}
className={timeSelectClass(compact)}
aria-label="分鐘"
>
{MINUTE_OPTIONS.map((minute) => (
<option key={minute} value={minute}>
{String(minute).padStart(2, '0')}
</option>
))}
</Select>
</div>
</div>
)}
</div>
)
}

View File

@ -198,15 +198,23 @@ export function Notice({
)
}
export function CopyableId({ label, value }: { label: string; value: string }) {
export function CopyableId({
label,
value,
className = '',
}: {
label: string
value: string
className?: string
}) {
const copy = async () => {
if (!value) return
await navigator.clipboard.writeText(value)
}
return (
<div className="ac-slot px-4 py-3">
<div className={`ac-slot px-5 py-5 ${className}`.trim()}>
<div className="text-sm font-bold text-ink-secondary">{label}</div>
<div className="mt-1 flex items-start justify-between gap-2">
<div className="mt-3 flex items-start justify-between gap-3">
<code className="break-all text-base font-en text-ink">{value || '—'}</code>
{value ? (
<button type="button" onClick={copy} className="ac-btn-secondary shrink-0 px-3 py-1 text-sm">
@ -357,4 +365,25 @@ export function ProgressBar({ value, className = '' }: { value: number; classNam
<div className="ac-progress__fill" style={{ width: `${pct}%` }} />
</div>
)
}
export function HoverTip({
label,
children,
placement = 'top',
className = '',
}: {
label: string
children: ReactNode
placement?: 'top' | 'bottom'
className?: string
}) {
return (
<span className={`ac-hover-tip ac-hover-tip--${placement} ${className}`.trim()}>
{children}
<span className="ac-hover-tip__panel" role="tooltip">
{label}
</span>
</span>
)
}

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import {
appendIslanderUnlockKey,
ISLANDER_UNLOCK_EVENT,
readIslanderUnlocked,
toggleIslanderUnlocked,
} from '../lib/islander/unlock'
function shouldIgnoreKeydown(event: KeyboardEvent): boolean {
if (event.isComposing) return true
if (event.ctrlKey || event.metaKey || event.altKey) return true
const el = event.target
if (!(el instanceof HTMLElement)) return false
if (el.isContentEditable) return true
if (el instanceof HTMLInputElement && el.type === 'password') return true
return false
}
export function useIslanderUnlock() {
const [unlocked, setUnlocked] = useState(readIslanderUnlocked)
useEffect(() => {
const onUnlockChange = (event: Event) => {
const detail = (event as CustomEvent<{ unlocked?: boolean }>).detail
setUnlocked(typeof detail?.unlocked === 'boolean' ? detail.unlocked : readIslanderUnlocked())
}
window.addEventListener(ISLANDER_UNLOCK_EVENT, onUnlockChange)
return () => window.removeEventListener(ISLANDER_UNLOCK_EVENT, onUnlockChange)
}, [])
useEffect(() => {
let buffer = ''
let toggling = false
const onKeyDown = (event: KeyboardEvent) => {
if (shouldIgnoreKeydown(event)) return
const { buffer: nextBuffer, matched } = appendIslanderUnlockKey(buffer, event.key)
buffer = nextBuffer
if (!matched || toggling) return
toggling = true
const next = toggleIslanderUnlocked()
setUnlocked(next)
buffer = ''
window.setTimeout(() => {
toggling = false
}, 200)
}
document.addEventListener('keydown', onKeyDown, true)
return () => document.removeEventListener('keydown', onKeyDown, true)
}, [])
return unlocked
}

View File

@ -2474,6 +2474,16 @@ th {
}
}
.ac-brand-link {
color: inherit;
text-decoration: none;
transition: background 0.12s ease, color 0.12s ease;
}
.ac-brand-link:hover {
color: var(--hx-brand);
}
.ac-sidebar-brand {
display: flex;
flex-shrink: 0;
@ -2483,12 +2493,30 @@ th {
padding: 1rem 1rem 0.9rem;
}
.ac-brand-link.ac-app-header-brand {
border-radius: var(--radius-md);
padding: 0.15rem 0.35rem 0.15rem 0;
}
.ac-brand-link.ac-app-header-brand:hover {
background: var(--hx-brand-soft);
}
.ac-brand-link.ac-sidebar-brand:hover {
background: color-mix(in srgb, var(--hx-brand-soft) 55%, transparent 45%);
}
.ac-sidebar-brand-icon.auth-ticket-icon {
height: 2.5rem;
width: 2.5rem;
flex-shrink: 0;
}
.ac-sidebar-brand-icon.auth-ticket-icon .auth-ticket-icon__svg {
height: 1.55rem;
width: 1.55rem;
}
.ac-sidebar-nav {
min-height: 0;
flex: 1;
@ -2863,15 +2891,30 @@ th {
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 2px solid var(--hx-line);
border-radius: 1rem;
background: var(--hx-brand-soft);
border: 1px solid color-mix(in srgb, var(--hx-brand) 34%, var(--hx-line) 66%);
border-radius: 1.2rem;
background:
radial-gradient(
120% 90% at 18% 12%,
color-mix(in srgb, var(--hx-brand-soft) 88%, var(--hx-surface) 12%),
transparent 58%
),
linear-gradient(
165deg,
color-mix(in srgb, var(--hx-surface) 90%, var(--hx-brand-soft) 10%),
color-mix(in srgb, var(--hx-brand-soft) 42%, var(--hx-surface) 58%)
);
color: var(--hx-brand);
box-shadow:
0 1px 0 color-mix(in srgb, var(--hx-surface) 88%, transparent 12%) inset,
0 0 0 3px color-mix(in srgb, var(--hx-brand) 7%, transparent 93%),
0 8px 20px color-mix(in srgb, var(--hx-brand) 14%, transparent 86%);
}
.auth-ticket-icon svg {
height: 1.65rem;
width: 1.65rem;
.auth-ticket-icon__svg {
height: 2.05rem;
width: 2.05rem;
filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--hx-brand-shadow) 10%, transparent 90%));
}
.auth-shell-title.ac-title-bar {
@ -2947,11 +2990,21 @@ th {
width: 2.75rem;
}
.ac-app-header-icon.auth-ticket-icon .auth-ticket-icon__svg {
height: 1.75rem;
width: 1.75rem;
}
@media (min-width: 640px) {
.ac-app-header-icon.auth-ticket-icon {
height: 3rem;
width: 3rem;
}
.ac-app-header-icon.auth-ticket-icon .auth-ticket-icon__svg {
height: 1.9rem;
width: 1.9rem;
}
}
.ac-app-main-panel {
@ -3171,17 +3224,36 @@ th {
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.75rem;
border: 2px solid var(--hx-line);
background: var(--hx-surface);
border-radius: 0.9rem;
border: 1px solid color-mix(in srgb, var(--hx-line) 82%, var(--hx-brand) 18%);
background:
radial-gradient(
95% 85% at 22% 14%,
color-mix(in srgb, var(--hx-surface) 92%, var(--hx-brand-soft) 8%),
var(--hx-surface) 72%
);
color: var(--hx-ink-secondary);
box-shadow: 0 1px 0 color-mix(in srgb, var(--hx-surface) 90%, transparent 10%) inset;
transition:
border-color 0.16s ease,
color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease;
}
.ac-app-tile--active .ac-app-icon-svg,
.ac-app-tile:hover .ac-app-icon-svg {
border-color: var(--hx-brand);
border-color: color-mix(in srgb, var(--hx-brand) 48%, var(--hx-line) 52%);
color: var(--hx-brand);
background: var(--hx-surface);
background:
radial-gradient(
95% 85% at 22% 14%,
color-mix(in srgb, var(--hx-brand-soft) 72%, var(--hx-surface) 28%),
var(--hx-surface) 70%
);
box-shadow:
0 1px 0 color-mix(in srgb, var(--hx-surface) 90%, transparent 10%) inset,
0 0 0 2px color-mix(in srgb, var(--hx-brand) 10%, transparent 90%);
}
.ac-btn-primary {
@ -4950,6 +5022,209 @@ th {
color: var(--hx-muted);
}
.ac-job-history-row {
padding: 1.15rem 1.35rem;
}
@media (min-width: 640px) {
.ac-job-history-row {
padding: 1.25rem 1.6rem;
}
}
.ac-schedule-row {
padding: 0.7rem 0.9rem;
}
@media (min-width: 640px) {
.ac-schedule-row {
padding: 0.75rem 1rem;
}
}
.ac-schedule-row__main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem 1rem;
}
.ac-schedule-row__info {
min-width: 0;
flex: 1 1 12rem;
}
.ac-schedule-row__controls {
flex: 2 1 16rem;
}
.ac-schedule-row__actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-left: auto;
}
.ac-job-history-grid {
display: grid;
gap: 0.75rem 1rem;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
'meta'
'status'
'progress'
'summary';
}
.ac-job-history-grid__meta {
grid-area: meta;
min-width: 0;
}
.ac-job-history-grid__title {
font-size: 0.9375rem;
font-weight: 800;
line-height: 1.45;
color: var(--hx-ink);
}
.ac-job-history-grid__time {
margin-top: 0.2rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--hx-muted);
}
.ac-job-history-grid__status {
grid-area: status;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.65rem 1rem;
}
.ac-job-history-grid__status-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.ac-job-history-grid__percent {
font-size: 0.875rem;
font-weight: 800;
color: var(--hx-ink-secondary);
white-space: nowrap;
}
.ac-job-history-grid__progress {
grid-area: progress;
}
.ac-job-history-grid__summary {
grid-area: summary;
font-size: 0.875rem;
line-height: 1.55;
color: var(--hx-ink-secondary);
}
@media (min-width: 768px) {
.ac-job-history-grid {
gap: 0.65rem 1.5rem;
grid-template-columns: minmax(8.5rem, 30%) minmax(0, 1fr);
grid-template-areas:
'meta status'
'meta progress'
'summary summary';
align-items: center;
}
.ac-job-history-grid__meta {
align-self: start;
}
.ac-job-history-grid__title {
font-size: 1rem;
}
}
.ac-hover-tip {
position: relative;
display: inline-flex;
max-width: 100%;
}
.ac-job-error-btn {
display: inline-flex;
align-items: center;
border: 2px solid color-mix(in srgb, var(--hx-danger) 35%, var(--hx-line) 65%);
border-radius: var(--radius-pill);
background: var(--hx-danger-soft);
padding: 0.2rem 0.7rem;
font-size: 0.75rem;
font-weight: 800;
line-height: 1.4;
color: var(--hx-danger);
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.ac-job-error-btn:hover,
.ac-job-error-btn:focus-visible {
border-color: var(--hx-danger);
background: color-mix(in srgb, var(--hx-danger-soft) 70%, var(--hx-surface) 30%);
color: var(--hx-danger);
outline: none;
}
.ac-hover-tip__panel {
position: absolute;
z-index: 40;
left: 50%;
width: max-content;
max-width: min(22rem, calc(100vw - 2rem));
transform: translateX(-50%) translateY(0.35rem);
border: 2px solid var(--hx-line);
border-radius: var(--radius-md);
background: var(--hx-surface);
padding: 0.65rem 0.85rem;
box-shadow: var(--hx-shadow-card);
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.55;
color: var(--hx-danger);
white-space: pre-wrap;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease;
}
.ac-hover-tip--top .ac-hover-tip__panel {
bottom: calc(100% + 0.35rem);
transform: translateX(-50%) translateY(-0.35rem);
}
.ac-hover-tip--bottom .ac-hover-tip__panel {
top: calc(100% + 0.35rem);
}
.ac-hover-tip:hover .ac-hover-tip__panel,
.ac-hover-tip:focus-visible .ac-hover-tip__panel {
opacity: 1;
visibility: visible;
}
.ac-hover-tip--top:hover .ac-hover-tip__panel,
.ac-hover-tip--top:focus-visible .ac-hover-tip__panel {
transform: translateX(-50%) translateY(0);
}
.ac-hover-tip--bottom:hover .ac-hover-tip__panel,
.ac-hover-tip--bottom:focus-visible .ac-hover-tip__panel {
transform: translateX(-50%) translateY(0);
}
.ac-table-action {
display: inline-flex;
align-items: center;

View File

@ -3,20 +3,21 @@ export type AcAppKey =
| 'persona'
| 'jobs'
| 'schedule'
| 'ai'
| 'template'
| 'settings'
| 'profile'
| 'permissions'
| 'easter'
| 'threads'
| 'more'
type NavItem = {
export type NavItem = {
to: string
label: string
icon: AcAppKey
end?: boolean
matchPrefix?: string
adminOnly?: boolean
}
/** 外層雙軌:流程 A 拷貝忍者 + 流程 B 找 TA主題列表 + 子步驟) */
@ -45,11 +46,11 @@ export const navGroups: { label: string; items: NavItem[] }[] = [
{
label: '系統',
items: [
{ to: '/ai', label: 'AI', icon: 'ai' },
{ to: '/job-templates', label: '模板', icon: 'template' },
{ to: '/job-templates', label: '模板', icon: 'template', adminOnly: true },
{ to: '/settings', label: '設定', icon: 'settings' },
{ to: '/profile', label: '會員', icon: 'profile' },
{ to: '/permissions', label: '權限', icon: 'permissions' },
{ to: '/permissions', label: '權限', icon: 'permissions', adminOnly: true },
{ to: '/easter-eggs', label: '彩蛋手冊', icon: 'easter', adminOnly: true },
],
},
]

View File

@ -1,9 +1,11 @@
export type ProviderId = 'xai' | 'openai' | 'anthropic' | 'google' | 'opencode-go'
export type ProviderId = 'xai' | 'opencode-go'
export type ProviderApiKeys = Partial<Record<ProviderId, string>>
export const AI_CREDENTIALS_KEY = 'ai.credentials'
export const SUPPORTED_PROVIDERS: ProviderId[] = ['opencode-go', 'xai']
export const PROVIDER_KEY_LABELS: Record<
ProviderId,
{ label: string; hint: string; docsUrl?: string }
@ -14,18 +16,9 @@ export const PROVIDER_KEY_LABELS: Record<
docsUrl: 'https://opencode.ai/docs/go/',
},
xai: { label: 'Grok (xAI)', hint: 'xAI Console API key' },
openai: { label: 'OpenAI', hint: 'platform.openai.com API key' },
anthropic: { label: 'Anthropic', hint: 'console.anthropic.com API key' },
google: { label: 'Google Gemini', hint: 'Google AI Studio API key' },
}
export const PROVIDER_ORDER: ProviderId[] = [
'opencode-go',
'xai',
'openai',
'anthropic',
'google',
]
export const PROVIDER_ORDER: ProviderId[] = [...SUPPORTED_PROVIDERS]
export const PROVIDER_OPTIONS = [
{
@ -41,13 +34,6 @@ export const PROVIDER_OPTIONS = [
],
},
{ value: 'xai' as const, label: 'Grok (xAI)', models: ['grok-3', 'grok-3-fast', 'grok-2-1212'] },
{ value: 'openai' as const, label: 'OpenAI', models: ['gpt-4o', 'gpt-4o-mini'] },
{
value: 'anthropic' as const,
label: 'Anthropic',
models: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
},
{ value: 'google' as const, label: 'Google', models: ['gemini-2.0-flash', 'gemini-1.5-pro'] },
] as const
export type AccountAiCredentials = {
@ -66,6 +52,48 @@ export const DEFAULT_AI_CREDENTIALS: AccountAiCredentials = {
api_keys: {},
}
export function isSupportedProvider(value: string | undefined | null): value is ProviderId {
return value === 'xai' || value === 'opencode-go'
}
export function normalizeSupportedProvider(value: string | undefined | null): ProviderId {
return isSupportedProvider(value) ? value : DEFAULT_AI_CREDENTIALS.provider
}
function modelsForProvider(provider: ProviderId): readonly string[] {
return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? []
}
function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string {
const models = modelsForProvider(provider)
if (model && models.includes(model)) return model
return models[0] ?? fallback
}
export type ProviderSettingsInput = {
provider?: string
model?: string
research_provider?: string
research_model?: string
}
export function normalizeProviderSettings(
settings: ProviderSettingsInput,
): Pick<AccountAiCredentials, 'provider' | 'model' | 'research_provider' | 'research_model'> {
const provider = normalizeSupportedProvider(settings.provider)
const researchProvider = normalizeSupportedProvider(settings.research_provider ?? provider)
return {
provider,
model: normalizeModel(provider, settings.model, DEFAULT_AI_CREDENTIALS.model),
research_provider: researchProvider,
research_model: normalizeModel(
researchProvider,
settings.research_model,
DEFAULT_AI_CREDENTIALS.research_model ?? DEFAULT_AI_CREDENTIALS.model,
),
}
}
export function maskApiKey(key: string | undefined | null): string | null {
if (!key) return null
if (key.length <= 4) return '••••'
@ -91,7 +119,8 @@ export function mergeProviderApiKeys(
incoming: ProviderApiKeys,
): ProviderApiKeys {
const merged = { ...existing }
for (const [provider, value] of Object.entries(incoming) as [ProviderId, string][]) {
for (const provider of PROVIDER_ORDER) {
const value = incoming[provider]
const trimmed = value?.trim()
if (!trimmed || isMaskedKey(trimmed)) continue
merged[provider] = trimmed
@ -113,29 +142,28 @@ export function parseAccountAiCredentials(raw: Record<string, unknown> | undefin
raw.api_keys && typeof raw.api_keys === 'object'
? (raw.api_keys as ProviderApiKeys)
: {}
const provider = (raw.provider as ProviderId) || DEFAULT_AI_CREDENTIALS.provider
const researchProvider = (raw.research_provider as ProviderId) || provider
return {
provider,
const normalized = normalizeProviderSettings({
provider: typeof raw.provider === 'string' ? raw.provider : DEFAULT_AI_CREDENTIALS.provider,
model: typeof raw.model === 'string' ? raw.model : DEFAULT_AI_CREDENTIALS.model,
research_provider: researchProvider,
research_provider:
typeof raw.research_provider === 'string'
? raw.research_provider
: DEFAULT_AI_CREDENTIALS.research_provider,
research_model:
typeof raw.research_model === 'string'
? raw.research_model
: DEFAULT_AI_CREDENTIALS.research_model,
api_keys: apiKeys,
}
})
return { ...normalized, api_keys: apiKeys }
}
export function buildAccountAiCredentialsPayload(
current: AccountAiCredentials,
keyInputs: ProviderApiKeys,
): AccountAiCredentials {
const normalized = normalizeProviderSettings(current)
return {
provider: current.provider,
model: current.model,
research_provider: current.research_provider,
research_model: current.research_model,
...normalized,
api_keys: mergeProviderApiKeys(current.api_keys, keyInputs),
}
}

View File

@ -0,0 +1,60 @@
export type EasterEggKind = 'keyboard' | 'admin-page' | 'hidden-route'
export type EasterEggEntry = {
id: string
title: string
/** 鍵盤彩蛋的連續輸入密碼;頁面類彩蛋可為 null */
password: string | null
kind: EasterEggKind
summary: string
howTo: string[]
notes?: string
}
/** 島民鍵盤密碼 — unlock.ts 與本目錄共用 */
export const ISLANDER_UNLOCK_CODE = 'abababc'
/**
* 簿
*
*/
export const easterEggCatalog: EasterEggEntry[] = [
{
id: 'islander-guide',
title: '島民嚮導',
password: ISLANDER_UNLOCK_CODE,
kind: 'keyboard',
summary: '預設隱藏右下角浮動島民。連續輸入密碼可開關,狀態僅存於目前分頁的 session。',
howTo: [
'登入後,在任意已登入頁面(不需點特定區域)',
'鍵盤連續輸入密碼(英文小寫,不需 Enter',
'右下角出現島民浮動按鈕;再輸入一次相同密碼可隱藏',
'重新整理分頁後,若 session 仍有效會維持顯示狀態',
],
notes: '密碼欄位與 IME 組字中不會觸發;一般搜尋框內輸入亦可解鎖。',
},
{
id: 'easter-eggs-handbook',
title: '彩蛋手冊(本頁)',
password: null,
kind: 'admin-page',
summary: '記錄所有已知彩蛋密碼與操作說明,僅管理員可從側欄「系統」區進入。',
howTo: [
'以 admin 角色登入',
'桌面:左側 PATROL PAD → 系統 → 彩蛋手冊',
'手機:底部「更多」→ 彩蛋手冊',
],
notes: '路由:/easter-eggs。非 admin 會被導回總覽。',
},
]
export function easterEggKindLabel(kind: EasterEggKind): string {
switch (kind) {
case 'keyboard':
return '鍵盤密碼'
case 'admin-page':
return '管理員頁面'
case 'hidden-route':
return '隱藏路由'
}
}

View File

@ -11,8 +11,8 @@ export const routeGuides: RouteGuide[] = [
{
pattern: /^\/$/,
title: '總覽',
purpose: '查看巡樓工作台捷徑與近期狀態',
hints: ['建立經營帳號與連線請到設定', '背景任務進度可看左下「任」或任務列表'],
purpose: '查看工作台概況、核心工作流與快速入口',
hints: ['概況區顯示進行中任務與排程', '建立經營帳號與連線請到設定', '背景任務進度可看左下「任」或任務列表'],
suggestions: ['這頁要怎麼用?', '帶我去設定頁', '任務列表在哪?'],
},
{
@ -111,10 +111,10 @@ export const routeGuides: RouteGuide[] = [
},
{
pattern: /^\/jobs$/,
title: '任務列表',
purpose: '查看所有背景任務狀態',
hints: ['8D 風格分析會出現在這裡', '進行中任務也可看左下「任」'],
suggestions: ['8D 任務失敗怎麼辦?', '幫我刷新任務列表', '任務進度去哪看'],
title: '背景任務',
purpose: '查看歷史背景任務執行紀錄',
hints: ['列表直接顯示狀態與進度', '進行中任務也可看左下「任」'],
suggestions: ['幫我刷新任務列表', '任務進度去哪看', '8D 任務失敗怎麼辦'],
},
{
pattern: /^\/jobs\/[^/]+$/,
@ -125,10 +125,10 @@ export const routeGuides: RouteGuide[] = [
},
{
pattern: /^\/job-schedules$/,
title: '排程',
purpose: '管理定時觸發的背景任務',
hints: ['需先有任務模板', '注意 cron 時區僅用於解讀排程'],
suggestions: ['怎麼建立排程?', '這頁要怎麼用'],
title: '定時排程',
purpose: '管理找 TA 與拷貝忍者的自動海巡排程',
hints: ['選類型與對象後儲存', '勾選節點或標籤請在對應工作流頁完成'],
suggestions: ['怎麼建立排程?', '找 TA 排程要怎麼設'],
},
{
pattern: /^\/job-templates$/,
@ -151,13 +151,6 @@ export const routeGuides: RouteGuide[] = [
hints: ['開發模式可用 extension 同步 session', '正式發文前確認連線就緒'],
suggestions: ['怎麼同步 Chrome Session', '開發模式怎麼開?', '連線失敗怎麼辦?'],
},
{
pattern: /^\/ai$/,
title: 'AI 實驗台',
purpose: '直接測試 provider 與模型(進階)',
hints: ['日常建議用設定頁的 AI 設定', '島民聊天會自動讀設定頁的 key'],
suggestions: ['這頁要怎麼用?', 'AI key 要去哪裡設?'],
},
{
pattern: /^\/profile$/,
title: '會員資料',

View File

@ -0,0 +1,53 @@
import { ISLANDER_UNLOCK_CODE } from '../easterEggCatalog'
export { ISLANDER_UNLOCK_CODE }
export const ISLANDER_UNLOCK_KEY = 'haixun.islander.unlocked'
export const ISLANDER_UNLOCK_EVENT = 'haixun.islander-unlock-changed'
export function readIslanderUnlocked(): boolean {
try {
return sessionStorage.getItem(ISLANDER_UNLOCK_KEY) === '1'
} catch {
return false
}
}
export function writeIslanderUnlocked(unlocked: boolean) {
try {
sessionStorage.setItem(ISLANDER_UNLOCK_KEY, unlocked ? '1' : '0')
} catch {
// ignore
}
window.dispatchEvent(
new CustomEvent(ISLANDER_UNLOCK_EVENT, { detail: { unlocked } }),
)
}
export function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
const tag = target.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
}
export function toggleIslanderUnlocked(): boolean {
const next = !readIslanderUnlocked()
writeIslanderUnlocked(next)
return next
}
/** Append one key to the rolling buffer; returns true when the unlock code matches. */
export function appendIslanderUnlockKey(buffer: string, key: string): { buffer: string; matched: boolean } {
const code = ISLANDER_UNLOCK_CODE
if (key === 'Backspace') {
return { buffer: buffer.slice(0, -1), matched: false }
}
if (key.length !== 1) {
return { buffer, matched: false }
}
const next = (buffer + key.toLowerCase()).slice(-code.length)
return { buffer: next, matched: next === code }
}

View File

@ -1,5 +1,19 @@
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired'])
type JobErrorSource = {
error?: string
progress?: {
steps?: Array<{ status: string; message?: string }>
}
}
/** 任務失敗時的錯誤訊息(優先 failed step其次 job.error。 */
export function jobErrorMessage(job: JobErrorSource): string | null {
const failedStep = job.progress?.steps?.find((step) => step.status === 'failed')
const message = failedStep?.message?.trim() || job.error?.trim()
return message || null
}
export function isTerminalJobStatus(status: string): boolean {
return TERMINAL.has(status)
}

View File

@ -0,0 +1,14 @@
const JOB_TEMPLATE_LABELS: Record<string, string> = {
'style-8d': '8D 風格分析',
'expand-graph': '知識圖譜擴展',
'placement-scan': '雙軌海巡',
'scan-viral': '爆款掃描',
'analyze-copy-mission': '拷貝研究地圖',
'generate-copy-matrix': '內容矩陣產生',
'generate-copy-draft': '深度仿寫草稿',
demo_long_task: 'Demo 任務',
}
export function jobTemplateLabel(templateType: string): string {
return JOB_TEMPLATE_LABELS[templateType] ?? templateType
}

View File

@ -0,0 +1,68 @@
import type { NavItem } from './acAssets'
import type { MemberMeData } from '../types/api'
export type NavGroup = {
label: string
items: NavItem[]
}
const ROLE_LABELS: Record<string, string> = {
admin: '管理員',
member: '一般會員',
}
const STATUS_LABELS: Record<string, string> = {
active: '使用中',
inactive: '已停用',
}
const ORIGIN_LABELS: Record<string, string> = {
native: '本站註冊',
email: '電子郵件',
}
export function formatMemberRoles(roles: string[] | undefined): string {
if (!roles?.length) return ROLE_LABELS.member
return roles.map((role) => ROLE_LABELS[role] ?? role).join('、')
}
export function formatMemberStatus(status: string | undefined): string {
if (!status) return '—'
return STATUS_LABELS[status] ?? status
}
export function formatMemberOrigin(origin: string | undefined): string {
if (!origin) return '—'
return ORIGIN_LABELS[origin] ?? origin
}
const CJK_CHAR_RE = /\p{Script=Han}/u
/** 頂欄圓形頭像內文字:優先顯示名稱首字,否則依角色顯示中文。 */
export function memberAvatarGlyph(
member: Pick<MemberMeData, 'display_name' | 'roles'> | null | undefined,
): string {
const displayName = member?.display_name?.trim()
if (displayName) {
const cjk = displayName.match(CJK_CHAR_RE)
if (cjk) return cjk[0]
}
const roles = member?.roles ?? []
if (roles.includes('admin')) return '管'
return '員'
}
export function isAdminMember(member: MemberMeData | null | undefined): boolean {
const roles = member?.roles ?? []
if (roles.length === 0) return false
return roles.includes('admin')
}
export function filterAdminOnlyNavGroups(groups: NavGroup[], isAdmin: boolean): NavGroup[] {
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => !item.adminOnly || isAdmin),
}))
.filter((group) => group.items.length > 0)
}

View File

@ -1,5 +1,6 @@
import type { ThreadsAccountConnectionData } from '../types/api'
import { navGroups, type AcAppKey } from './acAssets'
import { navGroups } from './acAssets'
import { filterAdminOnlyNavGroups, type NavGroup } from './memberRole'
export type OnboardingStep = 'account' | 'connection' | 'persona'
@ -12,15 +13,7 @@ export type OnboardingSnapshot = {
nextStep: OnboardingStep | null
}
type NavItem = {
to: string
label: string
icon: AcAppKey
end?: boolean
matchPrefix?: string
}
export const onboardingNavGroups: { label: string; items: NavItem[] }[] = [
export const onboardingNavGroups: NavGroup[] = [
{
label: '入門設定',
items: [
@ -67,11 +60,13 @@ export function buildOnboardingSnapshot(input: {
}
}
export function navGroupsForOnboarding(complete: boolean) {
return complete ? navGroups : onboardingNavGroups
export function navGroupsForOnboarding(complete: boolean, isAdmin = false): NavGroup[] {
const groups: NavGroup[] = complete ? navGroups : onboardingNavGroups
return filterAdminOnlyNavGroups(groups, isAdmin)
}
export function isOnboardingAllowedPath(pathname: string) {
if (pathname === '/') return true
if (pathname === '/settings') return true
if (pathname === '/personas' || pathname.startsWith('/personas/')) return true
if (pathname === '/brands' || pathname.startsWith('/brands/')) return true
@ -88,8 +83,8 @@ export function onboardingRedirectPath(pathname: string, complete: boolean) {
return '/settings'
}
export function onboardingNavApps(complete: boolean) {
return navGroupsForOnboarding(complete).flatMap((group) => group.items)
export function onboardingNavApps(complete: boolean, isAdmin = false) {
return navGroupsForOnboarding(complete, isAdmin).flatMap((group) => group.items)
}
export function onboardingGlowClass(active: boolean) {

View File

@ -0,0 +1,133 @@
import type { JobScheduleData } from '../types/api'
import type { CopyMissionData } from '../types/copyMission'
import type { PlacementTopicData } from '../types/placementTopic'
import { describeCron } from './scheduleCron'
import { jobTemplateLabel } from './jobTemplate'
export const CRON_PRESETS: Array<{ label: string; value: string }> = [
{ label: '每天 09:00', value: '0 9 * * *' },
{ label: '每週一 09:00', value: '0 9 * * 1' },
{ label: '每 6 小時', value: '0 */6 * * *' },
{ label: '每 12 小時', value: '0 */12 * * *' },
]
export const SCHEDULE_TIMEZONE = 'Asia/Taipei'
export type ScheduleKind = 'placement_topic_scan' | 'copy_mission_scan'
export type ScheduleKindMeta = {
kind: ScheduleKind
label: string
description: string
templateType: 'placement-scan' | 'scan-viral'
configureHint: string
}
export const SCHEDULE_KINDS: ScheduleKindMeta[] = [
{
kind: 'placement_topic_scan',
label: '找 TA · 定期雙軌海巡',
description: '依主題研究地圖勾選的節點,定期重跑海巡找貼文;不會自動發文,留言仍須手動 po。',
templateType: 'placement-scan',
configureHint: '請先在主題研究地圖勾選節點並儲存,再啟用排程。',
},
{
kind: 'copy_mission_scan',
label: '拷貝忍者 · 定期爆款海巡',
description: '依拷貝任務內勾選的標籤,定期重跑爆款海巡;不會自動發文,仍須手動 po。',
templateType: 'scan-viral',
configureHint: '請先在拷貝任務勾選搜尋標籤並儲存,再啟用排程。',
},
]
const KIND_BY_TEMPLATE = new Map(
SCHEDULE_KINDS.map((item) => [item.templateType, item] as const),
)
export function isManagedScheduleTemplate(templateType: string): boolean {
return templateType === 'placement-scan' || templateType === 'scan-viral'
}
export function filterManagedSchedules<T extends { template_type: string }>(schedules: T[]): T[] {
return schedules.filter((item) => isManagedScheduleTemplate(item.template_type))
}
export function scheduleKindMeta(templateType: string): ScheduleKindMeta | null {
return KIND_BY_TEMPLATE.get(templateType as ScheduleKindMeta['templateType']) ?? null
}
export function cronPresetLabel(cron: string): string {
return CRON_PRESETS.find((preset) => preset.value === cron)?.label ?? describeCron(cron)
}
type BrandLike = { id: string; display_name?: string; seed_query?: string }
export function resolvePlacementScanTarget(
schedule: JobScheduleData,
topics: PlacementTopicData[],
brands: BrandLike[] = [],
): { label: string; href: string; source: string } {
const topic = topics.find((item) => item.id === schedule.scope_id)
if (topic) {
const name = topic.topic_name?.trim() || topic.seed_query?.trim() || '未命名主題'
return {
label: name,
href: `/placement/topics/${encodeURIComponent(topic.id)}/research-map`,
source: '找 TA 主題',
}
}
const brand = brands.find((item) => item.id === schedule.scope_id)
if (brand) {
const name = brand.display_name?.trim() || brand.seed_query?.trim() || '未命名品牌'
return {
label: name,
href: '/research',
source: '研究地圖',
}
}
if (schedule.scope === 'brand') {
return {
label: `品牌 ${schedule.scope_id.slice(0, 8)}`,
href: '/research',
source: '研究地圖',
}
}
return {
label: `主題 ${schedule.scope_id.slice(0, 8)}`,
href: '/placement/topics',
source: '找 TA',
}
}
/** @deprecated 使用 resolvePlacementScanTarget */
export function resolvePlacementTopicTarget(
schedule: JobScheduleData,
topics: PlacementTopicData[],
): { label: string; href: string } {
const { label, href } = resolvePlacementScanTarget(schedule, topics)
return { label, href }
}
export function resolveCopyMissionTarget(
schedule: JobScheduleData,
missions: CopyMissionData[],
): { label: string; href: string; personaId: string } {
const mission = missions.find((item) => item.id === schedule.scope_id)
if (!mission) {
return {
label: `任務 ${schedule.scope_id.slice(0, 8)}`,
href: '/matrix',
personaId: '',
}
}
const name = mission.label?.trim() || mission.seed_query?.trim() || '未命名任務'
return {
label: name,
href: `/matrix/missions/${encodeURIComponent(mission.id)}`,
personaId: mission.persona_id,
}
}
export function scheduleDisplayTitle(templateType: string): string {
return scheduleKindMeta(templateType)?.label ?? jobTemplateLabel(templateType)
}

View File

@ -0,0 +1,130 @@
export type ScheduleRepeatMode = 'daily' | 'weekly' | 'interval'
export type ScheduleFrequency = {
mode: ScheduleRepeatMode
hour: number
minute: number
weekday: number
intervalHours: number
}
export const WEEKDAY_OPTIONS: Array<{ value: number; label: string }> = [
{ value: 1, label: '週一' },
{ value: 2, label: '週二' },
{ value: 3, label: '週三' },
{ value: 4, label: '週四' },
{ value: 5, label: '週五' },
{ value: 6, label: '週六' },
{ value: 0, label: '週日' },
]
export const INTERVAL_HOUR_OPTIONS = [1, 2, 3, 4, 6, 8, 12, 24] as const
export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => hour)
export const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => minute)
export function defaultScheduleFrequency(): ScheduleFrequency {
return {
mode: 'daily',
hour: 9,
minute: 0,
weekday: 1,
intervalHours: 6,
}
}
export function buildCronFromFrequency(freq: ScheduleFrequency): string {
switch (freq.mode) {
case 'daily':
return `${freq.minute} ${freq.hour} * * *`
case 'weekly':
return `${freq.minute} ${freq.hour} * * ${freq.weekday}`
case 'interval':
return `0 */${freq.intervalHours} * * *`
}
}
export function parseCronToFrequency(cron: string): ScheduleFrequency | null {
const parts = cron.trim().split(/\s+/)
if (parts.length !== 5) return null
const [minute, hour, dom, month, dow] = parts
if (
minute === '0' &&
dom === '*' &&
month === '*' &&
dow === '*' &&
/^\*\/(\d+)$/.test(hour)
) {
const intervalHours = Number.parseInt(hour.slice(2), 10)
if (intervalHours >= 1 && intervalHours <= 24) {
return {
mode: 'interval',
hour: 0,
minute: 0,
weekday: 1,
intervalHours,
}
}
}
if (
dom === '*' &&
month === '*' &&
dow === '*' &&
/^\d+$/.test(minute) &&
/^\d+$/.test(hour)
) {
return {
mode: 'daily',
hour: Number.parseInt(hour, 10),
minute: Number.parseInt(minute, 10),
weekday: 1,
intervalHours: 6,
}
}
if (
dom === '*' &&
month === '*' &&
/^\d+$/.test(minute) &&
/^\d+$/.test(hour) &&
/^\d+$/.test(dow)
) {
return {
mode: 'weekly',
hour: Number.parseInt(hour, 10),
minute: Number.parseInt(minute, 10),
weekday: Number.parseInt(dow, 10),
intervalHours: 6,
}
}
return null
}
function padTimePart(value: number): string {
return String(value).padStart(2, '0')
}
export function describeCron(cron: string): string {
const freq = parseCronToFrequency(cron)
if (!freq) return cron
const time = `${padTimePart(freq.hour)}:${padTimePart(freq.minute)}`
switch (freq.mode) {
case 'daily':
return `每天 ${time}`
case 'weekly': {
const weekday = WEEKDAY_OPTIONS.find((item) => item.value === freq.weekday)?.label ?? `${freq.weekday}`
return `${weekday} ${time}`
}
case 'interval':
return `${freq.intervalHours} 小時`
}
}
export function frequencyFromCron(cron: string): ScheduleFrequency {
return parseCronToFrequency(cron) ?? defaultScheduleFrequency()
}

View File

@ -1,148 +0,0 @@
import { useEffect, useState } from 'react'
import { api, streamAIChat } from '../api/client'
import { ApiError } from '../api/client'
import { storage } from '../lib/storage'
import { Button, Card, ErrorText, Field, Input, PageTitle, Textarea } from '../components/ui'
import type { AIProviderOption } from '../types/api'
export function AiPage() {
const [providers, setProviders] = useState<AIProviderOption[]>([])
const [provider, setProvider] = useState('opencode-go')
const [model, setModel] = useState('')
const [models, setModels] = useState<string[]>([])
const [providerToken, setProviderToken] = useState(() => storage.getAiProviderToken())
const [prompt, setPrompt] = useState('用一句話介紹巡樓系統')
const [reply, setReply] = useState('')
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
api
.get<{ providers: AIProviderOption[] }>('/api/v1/ai/providers')
.then((d) => {
setProviders(d.providers)
if (d.providers[0]) setProvider(d.providers[0].id)
})
.catch((e: ApiError) => setError(e.message))
}, [])
const loadModels = async () => {
setError('')
storage.setAiProviderToken(providerToken)
try {
const data = await api.post<{ models: string[]; error?: string }>(
`/api/v1/ai/providers/${provider}/models`,
{ provider },
{ memberAuth: true, providerToken },
)
setModels(data.models)
if (data.models[0]) setModel(data.models[0])
if (data.error) setError(data.error)
} catch (e) {
setError(e instanceof ApiError ? e.message : '拉 models 失敗')
}
}
const chatOnce = async () => {
setError('')
setReply('')
storage.setAiProviderToken(providerToken)
try {
const data = await api.post<{ text: string }>(
'/api/v1/ai/chat',
{
provider,
model,
messages: [{ role: 'user', content: prompt }],
},
{ memberAuth: true, providerToken },
)
setReply(data.text)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'chat 失敗')
}
}
const chatStream = async () => {
setError('')
setReply('')
setStreaming(true)
storage.setAiProviderToken(providerToken)
await streamAIChat(
{ provider, model, messages: [{ role: 'user', content: prompt }] },
(t) => setReply((r) => r + t),
() => setStreaming(false),
(msg) => {
setError(msg)
setStreaming(false)
},
)
setStreaming(false)
}
return (
<div>
<PageTitle
title="AI"
subtitle="Provider token 放 Authorization會員 JWT 放 X-Member-Authorization"
/>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="space-y-4">
<Field label="Provider API Token">
<Input
type="password"
value={providerToken}
onChange={(e) => setProviderToken(e.target.value)}
placeholder="sk-..."
/>
</Field>
<Field label="Provider">
<select
className="w-full rounded-[var(--radius-md)] border border-line bg-surface px-4 py-3 text-[15px] text-ink outline-none focus:border-brand focus:ring-4 focus:ring-brand-soft"
value={provider}
onChange={(e) => setProvider(e.target.value)}
>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.label} {p.streams ? '(stream)' : ''}
</option>
))}
</select>
</Field>
<div className="flex gap-2">
<Button variant="ghost" onClick={loadModels}>
Provider
</Button>
<select
className="flex-1 rounded-[var(--radius-md)] border border-line bg-surface px-4 py-3 text-[15px] text-ink outline-none focus:border-brand focus:ring-4 focus:ring-brand-soft"
value={model}
onChange={(e) => setModel(e.target.value)}
>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
<Field label="Prompt">
<Textarea rows={4} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
</Field>
<div className="flex gap-2">
<Button onClick={chatOnce} disabled={!model || streaming}>
</Button>
<Button variant="ghost" onClick={chatStream} disabled={!model || streaming}>
{streaming ? '串流回覆中…' : '送出對話SSE 串流回覆)'}
</Button>
</div>
<ErrorText message={error} />
</Card>
<Card>
<h2 className="font-semibold text-ink"></h2>
<pre className="mt-3 whitespace-pre-wrap text-[15px] leading-relaxed text-ink">{reply || '—'}</pre>
</Card>
</div>
</div>
)
}

View File

@ -1,98 +1,181 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api/client'
import { useAuth } from '../auth/AuthContext'
import { CopyFlowPipeline } from '../components/CopyFlowPipeline'
import { PlacementFlowPipeline } from '../components/PlacementFlowPipeline'
import { Badge, Card, PageTitle, QuickLinkCard, StatCard } from '../components/ui'
import {
Badge,
Card,
PageTitle,
ProgressBar,
QuickLinkCard,
StatCard,
StatusBadge,
} from '../components/ui'
import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
import { jobTemplateLabel } from '../lib/jobTemplate'
import { filterManagedSchedules } from '../lib/scheduleCatalog'
import type { JobData, JobScheduleData, Pagination } from '../types/api'
type DashboardStats = {
activeJobs: number
scheduleTotal: number
enabledSchedules: number
}
export function DashboardPage() {
const { member, tenantId, uid } = useAuth()
const [health, setHealth] = useState('')
useEffect(() => {
api
.get<{ pong: string }>('/api/v1/health')
.then((d) => setHealth(d.pong))
.catch(() => setHealth('unreachable'))
const { member } = useAuth()
const [jobs, setJobs] = useState<JobData[]>([])
const [stats, setStats] = useState<DashboardStats | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
const loadStats = useCallback(async () => {
try {
const [jobsRes, schedulesRes] = await Promise.all([
api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
auth: true,
query: { page: 1, pageSize: 50 },
}),
api.get<{ list: JobScheduleData[]; pagination: Pagination }>('/api/v1/job/schedules', {
auth: true,
query: { page: 1, pageSize: 100 },
}),
])
const list = jobsRes.list ?? []
const activeJobs = list.filter((j) => isActiveJobStatus(j.status))
const managedSchedules = filterManagedSchedules(schedulesRes.list ?? [])
setJobs(activeJobs)
setStats({
activeJobs: activeJobs.length,
scheduleTotal: managedSchedules.length,
enabledSchedules: managedSchedules.filter((s) => s.enabled).length,
})
} catch {
setJobs([])
setStats(null)
} finally {
setStatsLoading(false)
}
}, [])
const healthy = health === 'pong'
useEffect(() => {
loadStats().catch(() => undefined)
}, [loadStats])
useEffect(() => {
const hasActive = jobs.some((j) => !isTerminalJobStatus(j.status))
if (!hasActive) return
const timer = window.setInterval(() => loadStats().catch(() => undefined), 3000)
return () => window.clearInterval(timer)
}, [jobs, loadStats])
const displayName =
member?.display_name?.trim() || member?.email?.split('@')[0] || '島民'
const activeJobs = useMemo(() => jobs.slice(0, 5), [jobs])
return (
<div>
<section className="ac-bulletin mb-10">
<div className="mb-3 flex items-center gap-2">
<span className="ac-mark" aria-hidden />
<p className="display-en text-xs font-semibold tracking-[0.16em] text-accent uppercase">Overview</p>
<p className="display-en text-xs font-semibold tracking-[0.16em] text-accent uppercase">
Overview
</p>
</div>
<h1 className="max-w-xl text-balance text-3xl font-bold tracking-tight text-ink md:text-4xl">
<span className="text-brand">{member?.email?.split('@')[0] || '使用者'}</span>
<span className="text-brand">{displayName}</span>
</h1>
<p className="mt-3 max-w-lg text-base leading-relaxed text-ink-secondary">
仿 TA
</p>
<div className="mt-5 flex flex-wrap gap-2">
<Badge tone="brand"></Badge>
<Badge tone="sky"></Badge>
<Badge tone={healthy ? 'success' : 'warning'}>{healthy ? 'API 正常' : 'API 異常'}</Badge>
</div>
{stats && stats.activeJobs > 0 ? (
<div className="mt-5 flex flex-wrap gap-2">
<Badge tone="sky">{stats.activeJobs} </Badge>
</div>
) : null}
</section>
<PageTitle title="系統狀態" subtitle="連線與登入資訊" />
{stats ? (
<>
<PageTitle title="概況" />
<div className="mb-10 grid gap-4 sm:grid-cols-2">
<StatCard
label="進行中任務"
value={stats.activeJobs}
hint={stats.activeJobs > 0 ? '背景任務執行或排隊中' : '目前沒有進行中的任務'}
tone={stats.activeJobs > 0 ? 'brand' : 'default'}
/>
<StatCard
label="定時排程"
value={stats.enabledSchedules > 0 ? `${stats.enabledSchedules} 啟用` : stats.scheduleTotal}
hint={
stats.scheduleTotal > 0
? stats.enabledSchedules > 0
? `${stats.scheduleTotal} 筆海巡排程`
: `${stats.scheduleTotal} 筆,尚未啟用`
: '尚未建立海巡排程'
}
tone={stats.enabledSchedules > 0 ? 'sky' : 'default'}
/>
</div>
</>
) : statsLoading ? (
<p className="mb-10 text-sm text-muted"></p>
) : null}
<div className="mb-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="API 連線"
value={healthy ? '正常' : health || '…'}
hint="GET /api/v1/health"
tone={healthy ? 'brand' : 'sky'}
/>
<StatCard label="租戶" value={tenantId} hint="Tenant ID" />
<StatCard label="角色" value={member?.roles?.join(', ') || 'user'} hint={member?.email} />
</div>
{activeJobs.length > 0 ? (
<section className="mb-10">
<PageTitle title="進行中任務" />
<Card className="divide-y divide-line p-0">
<ul>
{activeJobs.map((job) => {
const summary = job.progress?.summary?.trim()
return (
<li key={job.id}>
<Link
to={`/jobs/${job.id}`}
className="flex flex-wrap items-center justify-between gap-3 px-5 py-4 transition hover:bg-surface-muted"
>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-ink">
{jobTemplateLabel(job.template_type)}
</p>
{summary ? (
<p className="mt-0.5 truncate text-sm text-muted">{summary}</p>
) : null}
<div className="mt-2 max-w-xs">
<ProgressBar value={job.progress?.percentage ?? 0} />
</div>
</div>
<StatusBadge className={jobStatusBadgeClass(job.status)}>
{jobStatusLabel(job.status)}
</StatusBadge>
</Link>
</li>
)
})}
</ul>
<div className="px-5 py-3 text-right">
<Link to="/jobs" className="text-sm font-semibold text-brand hover:underline">
</Link>
</div>
</Card>
</section>
) : null}
<PageTitle title="核心工作流" subtitle="拷貝忍者與找 TA 可並行使用" />
<PageTitle title="核心工作流" />
<div className="mb-10 grid gap-8 lg:grid-cols-2">
<div className="mb-10 grid items-stretch gap-8 lg:grid-cols-2">
<CopyFlowPipeline />
<PlacementFlowPipeline />
</div>
<p className="-mt-4 mb-10 text-sm text-ink-secondary">
<Link to="/brands" className="font-semibold text-brand hover:underline">
</Link>
{'(找 TA 用)· '}
<Link to="/personas" className="font-semibold text-brand hover:underline">
</Link>
{'8D 對標與留言語氣)'}
</p>
<PageTitle title="快速入口" subtitle="常用功能" />
<div className="mb-10 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<QuickLinkCard to="/jobs" icon="jobs" title="背景任務" desc="建立、追蹤、取消或重試巡檢任務。" />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<QuickLinkCard to="/jobs" icon="jobs" title="背景任務" desc="建立、追蹤、取消或重試任務。" />
<QuickLinkCard to="/job-schedules" icon="schedule" title="定時排程" desc="以 cron 自動觸發背景任務。" />
<QuickLinkCard to="/ai" icon="ai" title="AI 助手" desc="串流對話,輔助紀錄與分析。" />
<QuickLinkCard to="/settings" icon="settings" title="系統設定" desc="管理 key-value 設定項目。" />
<QuickLinkCard to="/profile" icon="profile" title="個人資料" desc="更新顯示名稱與偏好。" />
<QuickLinkCard to="/job-templates" icon="template" title="任務模板" desc="查看可用的背景任務模板。" />
<QuickLinkCard to="/settings" icon="settings" title="系統設定" desc="連線、AI 與帳號偏好。" />
</div>
<Card>
<div className="ac-title-bar -mx-1 -mt-1 mb-4">Session</div>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div className="ac-slot px-4 py-3">
<dt className="text-xs font-semibold text-ink-secondary">UID</dt>
<dd className="mt-1 break-all font-mono text-sm text-ink">{member?.uid || uid}</dd>
</div>
<div className="ac-slot px-4 py-3">
<dt className="text-xs font-semibold text-ink-secondary">Email</dt>
<dd className="mt-1 text-ink">{member?.email}</dd>
</div>
</dl>
</Card>
</div>
)
}

View File

@ -0,0 +1,71 @@
import { Badge, Card, CopyableId, Notice, PageTitle } from '../components/ui'
import {
easterEggCatalog,
easterEggKindLabel,
type EasterEggEntry,
} from '../lib/easterEggCatalog'
function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
const kindTone =
entry.kind === 'keyboard' ? 'brand' : entry.kind === 'admin-page' ? 'sky' : 'neutral'
return (
<Card className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-ink">{entry.title}</h2>
<p className="mt-2 max-w-prose text-base leading-relaxed text-ink-secondary">{entry.summary}</p>
</div>
<Badge tone={kindTone}>{easterEggKindLabel(entry.kind)}</Badge>
</div>
{entry.password ? (
<CopyableId label="密碼(連續輸入)" value={entry.password} />
) : (
<div className="ac-slot px-5 py-5">
<div className="text-sm font-bold text-ink-secondary"></div>
<p className="mt-3 text-base text-muted"> </p>
</div>
)}
<div>
<h3 className="text-sm font-bold text-ink-secondary"></h3>
<ol className="mt-3 list-decimal space-y-2 pl-5 text-base leading-relaxed text-ink">
{entry.howTo.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
{entry.notes ? (
<p className="rounded-[var(--radius-md)] border border-line bg-surface-muted px-4 py-3 text-sm leading-relaxed text-ink-secondary">
<span className="font-bold text-ink"></span>
{entry.notes}
</p>
) : null}
</Card>
)
}
export function EasterEggsPage() {
return (
<div className="mx-auto max-w-3xl">
<PageTitle
title="彩蛋手冊"
subtitle="管理員專用備忘 — 密碼與功能說明都記在這裡,免得下次忘記。"
/>
<Notice
tone="info"
title="僅管理員可見"
message="此頁不對一般會員開放。新增或修改彩蛋時,請同步更新 lib/easterEggCatalog.ts。"
/>
<div className="mt-6 flex flex-col gap-5">
{easterEggCatalog.map((entry) => (
<EasterEggCard key={entry.id} entry={entry} />
))}
</div>
</div>
)
}

View File

@ -1,146 +1,412 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { ApiError } from '../api/client'
import { useAuth } from '../auth/AuthContext'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, ApiError } from '../api/client'
import { ScheduleFrequencyPicker } from '../components/ScheduleFrequencyPicker'
import {
Button,
Card,
ErrorText,
Field,
Input,
Notice,
PageTitle,
Select,
StatusBadge,
Table,
TableAction,
TablePanel,
TableShell,
TableEmpty,
} from '../components/ui'
import type { JobScheduleData, Pagination } from '../types/api'
import { formatUnixNano } from '../lib/formatDate'
import {
buildCronFromFrequency,
defaultScheduleFrequency,
describeCron,
frequencyFromCron,
type ScheduleFrequency,
} from '../lib/scheduleCron'
import { jobTemplateLabel } from '../lib/jobTemplate'
import {
SCHEDULE_KINDS,
SCHEDULE_TIMEZONE,
filterManagedSchedules,
isManagedScheduleTemplate,
resolveCopyMissionTarget,
resolvePlacementScanTarget,
scheduleDisplayTitle,
type ScheduleKind,
} from '../lib/scheduleCatalog'
import type { CopyMissionData } from '../types/copyMission'
import type { JobScheduleData, Pagination, PersonaData } from '../types/api'
import type { BrandData } from '../types/brand'
import type { PlacementTopicData } from '../types/placementTopic'
type CopyMissionOption = CopyMissionData & { persona_id: string }
export function JobSchedulesPage() {
const { uid } = useAuth()
const [schedules, setSchedules] = useState<JobScheduleData[]>([])
const [otherSchedules, setOtherSchedules] = useState<JobScheduleData[]>([])
const [topics, setTopics] = useState<PlacementTopicData[]>([])
const [brands, setBrands] = useState<BrandData[]>([])
const [missions, setMissions] = useState<CopyMissionOption[]>([])
const [pagination, setPagination] = useState<Pagination | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [deletingId, setDeletingId] = useState('')
const [error, setError] = useState('')
const [form, setForm] = useState({
template_type: 'placement-scan',
scope: 'persona',
scope_id: '',
cron: '0 9 * * *',
timezone: 'Asia/Taipei',
enabled: false,
})
const load = async () => {
const [kind, setKind] = useState<ScheduleKind>('placement_topic_scan')
const [topicId, setTopicId] = useState('')
const [missionKey, setMissionKey] = useState('')
const [frequency, setFrequency] = useState<ScheduleFrequency>(defaultScheduleFrequency())
const [enabled, setEnabled] = useState(false)
const cron = useMemo(() => buildCronFromFrequency(frequency), [frequency])
const loadCatalog = useCallback(async () => {
const [topicsRes, personasRes, brandsRes] = await Promise.all([
api.get<{ list: PlacementTopicData[] }>('/api/v1/placement/topics/', { auth: true }),
api.get<{ list: PersonaData[] }>('/api/v1/personas', { auth: true }),
api.get<{ list: BrandData[] }>('/api/v1/brands', { auth: true }),
])
const topicList = topicsRes.list ?? []
const personaList = personasRes.list ?? []
const missionGroups = await Promise.all(
personaList.map(async (persona) => {
try {
const data = await api.get<{ list: CopyMissionData[] }>(
`/api/v1/personas/${encodeURIComponent(persona.id)}/copy-missions`,
{ auth: true },
)
return (data.list ?? []).map((mission) => ({ ...mission, persona_id: persona.id }))
} catch {
return [] as CopyMissionOption[]
}
}),
)
const flatMissions = missionGroups.flat()
setTopics(topicList)
setBrands(brandsRes.list ?? [])
setMissions(flatMissions)
setTopicId((prev) => prev || topicList[0]?.id || '')
setMissionKey((prev) => {
if (prev) return prev
const first = flatMissions[0]
return first ? `${first.persona_id}:${first.id}` : ''
})
}, [])
const loadSchedules = useCallback(async () => {
setError('')
setLoading(true)
try {
const data = await api.get<{ list: JobScheduleData[]; pagination: Pagination }>(
'/api/v1/job/schedules',
{ auth: true, query: { page: 1, pageSize: 20 } },
{ auth: true, query: { page: 1, pageSize: 50 } },
)
setSchedules(data.list)
setPagination(data.pagination)
const all = data.list ?? []
setSchedules(filterManagedSchedules(all))
setOtherSchedules(all.filter((item) => !isManagedScheduleTemplate(item.template_type)))
setPagination(data.pagination ?? null)
} catch (e) {
setError(e instanceof ApiError ? e.message : '載入失敗')
setError(e instanceof ApiError ? e.message : '載入排程失敗')
setSchedules([])
setOtherSchedules([])
setPagination(null)
} finally {
setLoading(false)
}
}
}, [])
useEffect(() => {
if (uid) setForm((f) => ({ ...f, scope_id: uid }))
load()
}, [uid])
Promise.all([loadCatalog(), loadSchedules()]).catch(() => undefined)
}, [loadCatalog, loadSchedules])
const create = async () => {
const selectedKind = useMemo(
() => SCHEDULE_KINDS.find((item) => item.kind === kind) ?? SCHEDULE_KINDS[0],
[kind],
)
const selectedMission = useMemo(() => {
const [personaId, missionId] = missionKey.split(':')
if (!personaId || !missionId) return null
return missions.find((item) => item.persona_id === personaId && item.id === missionId) ?? null
}, [missionKey, missions])
const createSchedule = async () => {
setSaving(true)
setError('')
try {
await api.post('/api/v1/job/schedules', form, { auth: true })
await load()
if (kind === 'placement_topic_scan') {
if (!topicId) throw new ApiError(0, '請選擇找 TA 主題')
await api.put(
`/api/v1/placement/topics/${encodeURIComponent(topicId)}/scan-schedule`,
{ cron, timezone: SCHEDULE_TIMEZONE, enabled },
{ auth: true },
)
} else {
if (!selectedMission) throw new ApiError(0, '請選擇拷貝任務')
await api.put(
`/api/v1/personas/${encodeURIComponent(selectedMission.persona_id)}/copy-missions/${encodeURIComponent(selectedMission.id)}/scan-schedule`,
{ cron, timezone: SCHEDULE_TIMEZONE, enabled },
{ auth: true },
)
}
await loadSchedules()
} catch (e) {
setError(e instanceof ApiError ? e.message : '建立失敗')
setError(e instanceof ApiError ? e.message : '儲存排程失敗')
} finally {
setSaving(false)
}
}
const toggle = async (id: string, enabled: boolean) => {
const path = enabled ? 'disable' : 'enable'
await api.post(`/api/v1/job/schedules/${id}/${path}`, {}, { auth: true })
await load()
const updateCron = async (schedule: JobScheduleData, nextCron: string) => {
setError('')
try {
await api.put(
`/api/v1/job/schedules/${encodeURIComponent(schedule.id)}`,
{ cron: nextCron, timezone: schedule.timezone || SCHEDULE_TIMEZONE },
{ auth: true },
)
await loadSchedules()
} catch (e) {
setError(e instanceof ApiError ? e.message : '更新頻率失敗')
}
}
const toggle = async (schedule: JobScheduleData) => {
setError('')
const path = schedule.enabled ? 'disable' : 'enable'
try {
await api.post(`/api/v1/job/schedules/${encodeURIComponent(schedule.id)}/${path}`, {}, { auth: true })
await loadSchedules()
} catch (e) {
setError(e instanceof ApiError ? e.message : '更新狀態失敗')
}
}
const removeSchedule = async (schedule: JobScheduleData) => {
const target =
schedule.template_type === 'placement-scan'
? resolvePlacementScanTarget(schedule, topics, brands).label
: resolveCopyMissionTarget(schedule, missions).label
if (!window.confirm(`確定刪除「${target}」的排程?刪除後需重新設定,無法復原。`)) return
setDeletingId(schedule.id)
setError('')
try {
await api.delete(`/api/v1/job/schedules/${encodeURIComponent(schedule.id)}`, { auth: true })
await loadSchedules()
} catch (e) {
setError(e instanceof ApiError ? e.message : '刪除排程失敗')
} finally {
setDeletingId('')
}
}
return (
<div>
<PageTitle title="Job 排程" subtitle="建立、列表、啟用/停用" />
<Card className="mb-4 grid gap-3 md:grid-cols-2">
<Field label="Template Type" hint="建議在海巡研究頁用人設專用排程;此處可手動建立">
<Input
value={form.template_type}
onChange={(e) => setForm((f) => ({ ...f, template_type: e.target.value }))}
placeholder="placement-scan"
<PageTitle title="定時排程" subtitle="定期海巡掃貼文,發文仍須手動" />
<div className="mb-6 space-y-3">
<Notice
tone="warning"
title="排程不會自動發文"
message="定期任務只會依頻率重跑海巡、把新貼文收進候選列表,不會在 Threads 自動 po 文或回留言。掃到合適對象後,請到找 TA 留言或拷貝任務頁手動產草稿並發佈。"
/>
<Notice
tone="info"
title="目前支援兩種排程"
message="找 TA 雙軌海巡與拷貝忍者爆款海巡。執行目標與 payload 由系統依主題/任務自動帶入,這裡只需選對象與頻率。"
/>
</div>
<Card className="mb-8 space-y-4">
<div>
<h2 className="text-base font-bold text-ink"></h2>
<p className="mt-1 text-sm leading-relaxed text-ink-secondary">
</p>
</div>
<Field label="排程類型">
<Select value={kind} onChange={(e) => setKind(e.target.value as ScheduleKind)}>
{SCHEDULE_KINDS.map((item) => (
<option key={item.kind} value={item.kind}>
{item.label}
</option>
))}
</Select>
</Field>
<p className="-mt-2 text-sm text-ink-secondary">{selectedKind.description}</p>
{kind === 'placement_topic_scan' ? (
<Field label="找 TA 主題" hint={selectedKind.configureHint}>
<Select value={topicId} onChange={(e) => setTopicId(e.target.value)}>
{topics.length === 0 ? <option value=""> TA </option> : null}
{topics.map((topic) => {
const name = topic.topic_name?.trim() || topic.seed_query?.trim() || topic.id
return (
<option key={topic.id} value={topic.id}>
{name}
</option>
)
})}
</Select>
</Field>
) : (
<Field label="拷貝任務" hint={selectedKind.configureHint}>
<Select value={missionKey} onChange={(e) => setMissionKey(e.target.value)}>
{missions.length === 0 ? (
<option value=""></option>
) : null}
{missions.map((mission) => {
const name = mission.label?.trim() || mission.seed_query?.trim() || mission.id
return (
<option key={`${mission.persona_id}:${mission.id}`} value={`${mission.persona_id}:${mission.id}`}>
{name}
</option>
)
})}
</Select>
</Field>
)}
<ScheduleFrequencyPicker value={frequency} onChange={setFrequency} />
<p className="text-sm text-muted">{describeCron(cron)}</p>
<label className="flex items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-line"
/>
</Field>
<Field label="Scope">
<Input
value={form.scope}
onChange={(e) => setForm((f) => ({ ...f, scope: e.target.value }))}
placeholder="persona"
/>
</Field>
<Field label="Cron">
<Input value={form.cron} onChange={(e) => setForm((f) => ({ ...f, cron: e.target.value }))} />
</Field>
<Field label="Scope ID">
<Input
value={form.scope_id}
onChange={(e) => setForm((f) => ({ ...f, scope_id: e.target.value }))}
/>
</Field>
<Field label="Timezone">
<Input
value={form.timezone}
onChange={(e) => setForm((f) => ({ ...f, timezone: e.target.value }))}
/>
</Field>
<div className="flex flex-wrap gap-2 md:col-span-2">
<Button onClick={create}></Button>
<Button variant="ghost" onClick={load}>
</label>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void createSchedule()} disabled={saving || (kind === 'placement_topic_scan' ? !topicId : !selectedMission)}>
{saving ? '儲存中…' : '儲存排程'}
</Button>
<Button variant="ghost" onClick={() => void loadSchedules()} disabled={loading}>
</Button>
</div>
<ErrorText message={error} />
</Card>
<TablePanel title="島民服務處排程" count={pagination?.total}>
<TableShell>
<Table minWidth="40rem">
<thead>
<tr>
<th>ID</th>
<th>Cron</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{schedules.map((s) => (
<tr key={s.id}>
<td className="font-mono text-xs">{s.id.slice(0, 8)}</td>
<td className="font-semibold">{s.cron}</td>
<td>{s.template_type}</td>
<td>
<StatusBadge className={s.enabled ? 'ac-status-badge--success' : 'ac-status-badge--muted'}>
{s.enabled ? '啟用中' : '已停用'}
</StatusBadge>
</td>
<td>
<TableAction variant={s.enabled ? 'danger' : 'link'} onClick={() => toggle(s.id, s.enabled)}>
{s.enabled ? '停用' : '啟用'}
</TableAction>
</td>
</tr>
))}
</tbody>
</Table>
</TableShell>
</TablePanel>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-ink-secondary">
{pagination ? `${schedules.length} 筆可管理排程` : loading ? '載入中…' : '—'}
</p>
</div>
<Card className="overflow-hidden p-0">
{loading && schedules.length === 0 ? (
<TableEmpty></TableEmpty>
) : schedules.length === 0 ? (
<TableEmpty>
</TableEmpty>
) : (
<ul className="divide-y divide-line">
{schedules.map((schedule) => {
const target =
schedule.template_type === 'placement-scan'
? resolvePlacementScanTarget(schedule, topics, brands)
: resolveCopyMissionTarget(schedule, missions)
const nextRun = formatUnixNano(schedule.next_run_at)
const rowFrequency = frequencyFromCron(schedule.cron)
return (
<li key={schedule.id} className="ac-schedule-row">
<div className="ac-schedule-row__main">
<div className="ac-schedule-row__info">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-bold text-ink">
{scheduleDisplayTitle(schedule.template_type)}
</p>
<StatusBadge
className={schedule.enabled ? 'ac-status-badge--success' : 'ac-status-badge--muted'}
>
{schedule.enabled ? '啟用' : '停用'}
</StatusBadge>
</div>
<p className="text-xs text-ink-secondary">
{'source' in target ? (
<span className="text-muted">{target.source} · </span>
) : null}
<Link to={target.href} className="font-semibold text-brand hover:underline">
{target.label}
</Link>
{nextRun ? <span className="ml-2 text-muted"> {nextRun}</span> : null}
</p>
</div>
<div className="ac-schedule-row__controls">
<ScheduleFrequencyPicker
compact
value={rowFrequency}
onChange={(next) => {
const nextCron = buildCronFromFrequency(next)
if (nextCron !== schedule.cron) void updateCron(schedule, nextCron)
}}
/>
</div>
<div className="ac-schedule-row__actions">
<Button
variant={schedule.enabled ? 'ghost' : 'soft'}
className="min-h-8 px-3 py-1 text-sm"
onClick={() => void toggle(schedule)}
>
{schedule.enabled ? '停用' : '啟用'}
</Button>
<Button
variant="danger"
className="min-h-8 px-3 py-1 text-sm"
disabled={deletingId === schedule.id}
onClick={() => void removeSchedule(schedule)}
>
{deletingId === schedule.id ? '刪除中…' : '刪除'}
</Button>
</div>
</div>
</li>
)
})}
</ul>
)}
</Card>
{otherSchedules.length > 0 ? (
<Card className="mt-6 space-y-4 p-5">
<Notice
tone="warning"
title="另有其他類型排程"
message="這些排程不會出現在上方海巡列表,但會被總覽舊版統計計入。若不需要可刪除。"
/>
<ul className="divide-y divide-line rounded-[var(--radius-lg)] border-2 border-line">
{otherSchedules.map((schedule) => (
<li key={schedule.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-bold text-ink">{jobTemplateLabel(schedule.template_type)}</p>
<p className="text-xs text-muted">
{schedule.scope} / {schedule.scope_id.slice(0, 12)} · {schedule.enabled ? '啟用' : '停用'}
</p>
</div>
<Button
variant="danger"
className="min-h-8 px-3 py-1 text-sm"
disabled={deletingId === schedule.id}
onClick={() => void removeSchedule(schedule)}
>
{deletingId === schedule.id ? '刪除中…' : '刪除'}
</Button>
</li>
))}
</ul>
</Card>
) : null}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More