From 152d42e77b195dda65c59d026228613e6178a7f3 Mon Sep 17 00:00:00 2001 From: wang Date: Mon, 6 Jul 2026 06:13:14 +0000 Subject: [PATCH] feat/rebone (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 王性驊 Reviewed-on: https://code.30cm.net/daniel.w/thread-master/pulls/3 Co-authored-by: wang Co-committed-by: wang --- .DS_Store | Bin 10244 -> 0 bytes .dockerignore | 4 + .gitignore | 34 +- .run/api.pid | 1 - .run/logs/api.log | 2857 -------- .run/logs/web.log | 9 - .run/logs/worker.log | 1217 ---- .run/web.pid | 1 - .run/worker.pid | 1 - AGENTS.md | 1 + Makefile | 294 +- ai_threads_auto_account_ops.md | 1678 +++++ backend/.DS_Store | Bin 8196 -> 0 bytes backend/Dockerfile | 24 + backend/Makefile | 47 + backend/README.md | 19 +- backend/cmd/tool/main.go | 4 +- backend/cmd/worker/main.go | 11 +- backend/dev-console/README.md | 37 + .../dev-console/dist/assets/index-56hNfpGK.js | 3 + backend/dev-console/dist/index.html | 225 + backend/dev-console/index.html | 2188 ++++++ backend/dev-console/package-lock.json | 1141 +++ backend/dev-console/package.json | 12 + .../dev-console}/vite.config.ts | 4 +- backend/docs/phase1-methods.md | 176 - backend/docs/scan-placement-plan.md | 512 -- backend/docs/similar-accounts-ta-plan.md | 307 - backend/docs/threads-automation-gap-plan.md | 133 + backend/etc/gateway.dev.example.yaml | 45 - backend/etc/gateway.prod.yaml | 24 +- backend/etc/gateway.worker.prod.yaml | 29 +- backend/etc/gateway.worker.yaml | 29 +- backend/etc/gateway.yaml | 45 +- backend/gateway.go | 4 + backend/generate/api/brand.api | 20 + backend/generate/api/copy_mission.api | 39 +- backend/generate/api/member.api | 2 + backend/generate/api/persona.api | 511 +- backend/generate/api/placement_topic.api | 31 + backend/generate/api/threads_account.api | 852 ++- backend/go.mod | 2 +- backend/go.sum | 2 + backend/internal/bootstrap/admin.go | 12 + backend/internal/bootstrap/admin_test.go | 78 + backend/internal/bootstrap/envfile.go | 67 + backend/internal/bootstrap/envfile_test.go | 36 + backend/internal/bootstrap/init.go | 43 + backend/internal/config/config.go | 40 +- .../update_outreach_draft_handler.go} | 13 +- .../create_copy_mission_handler.go | 3 +- .../delete_copy_mission_handler.go | 3 +- .../generate_copy_mission_matrix_handler.go | 3 +- .../copy_mission/get_copy_mission_handler.go | 3 +- .../get_copy_mission_scan_schedule_handler.go | 3 +- .../inspire_copy_mission_handler.go | 6 +- .../list_copy_mission_copy_drafts_handler.go | 3 +- .../list_copy_mission_scan_posts_handler.go | 3 +- .../list_copy_missions_handler.go | 3 +- .../schedule_copy_mission_drafts_handler.go | 32 + .../start_copy_mission_analyze_job_handler.go | 3 +- ...art_copy_mission_copy_draft_job_handler.go | 6 +- .../start_copy_mission_matrix_job_handler.go | 6 +- .../start_copy_mission_scan_job_handler.go | 3 +- .../update_copy_mission_handler.go | 3 +- ...sert_copy_mission_scan_schedule_handler.go | 3 +- .../handler/crm/delete_crm_contact_handler.go | 25 - .../persona/create_content_plan_handler.go | 32 + .../persona/create_feedback_event_handler.go | 32 + .../persona/create_formula_pool_handler.go | 32 + .../create_knowledge_source_handler.go | 32 + .../persona/delete_style_preset_handler.go | 32 + .../generate_from_content_formula_handler.go | 29 + .../generate_persona_topic_matrix_handler.go | 29 + .../persona/list_content_plans_handler.go | 32 + .../persona/list_formula_pools_handler.go | 32 + .../persona/list_knowledge_chunks_handler.go | 32 + .../persona/list_knowledge_sources_handler.go | 32 + .../list_persona_content_inbox_handler.go | 29 + .../persona/list_style_presets_handler.go | 32 + .../persona/list_topic_candidates_handler.go | 32 + .../prune_persona_copy_drafts_handler.go | 30 + .../schedule_persona_copy_draft_handler.go | 29 + .../schedule_persona_drafts_handler.go | 32 + ...start_persona_formula_draft_job_handler.go | 30 + ...start_persona_rewrite_draft_job_handler.go | 32 + ...ersona_style_analysis_from_text_handler.go | 33 + .../start_persona_topic_matrix_job_handler.go | 30 + .../persona/update_content_plan_handler.go | 32 + .../persona/upsert_style_preset_handler.go | 32 + ...cement_topic_outreach_draft_job_handler.go | 30 + ..._placement_topic_outreach_draft_handler.go | 30 + backend/internal/handler/register_extra.go | 37 + backend/internal/handler/routes.go | 401 +- .../handler/static/extension_download.go | 42 + .../analyze_content_formula_handler.go | 29 + .../cancel_publish_queue_item_handler.go | 32 + .../create_publish_queue_item_handler.go | 32 + .../delete_content_formula_handler.go | 29 + .../delete_publish_queue_item_handler.go | 32 + .../delete_threads_account_handler.go | 33 + .../disconnect_threads_api_handler.go | 30 + .../generate_own_post_formula_handler.go | 28 + .../generate_own_post_reply_draft_handler.go | 28 + .../get_content_formula_handler.go | 29 + .../get_publish_dashboard_summary_handler.go | 20 + .../get_publish_guard_policy_handler.go | 32 + .../get_publish_inventory_policy_handler.go | 32 + .../get_publish_queue_item_handler.go | 32 + .../get_publish_slot_insights_handler.go | 32 + .../get_threads_diagnostics_handler.go | 32 + .../get_threads_oauth_config_handler.go | 16 + ...ort_own_post_to_content_formula_handler.go | 29 + .../list_content_formulas_handler.go | 29 + .../list_mention_inbox_handler.go} | 15 +- .../list_own_post_formulas_handler.go | 28 + .../list_publish_alerts_handler.go | 32 + .../list_publish_queue_events_handler.go | 32 + .../list_publish_queue_items_handler.go | 32 + .../list_publish_queue_range_handler.go | 32 + .../list_threads_oauth_logs_handler.go | 17 + .../list_threads_post_performance_handler.go | 32 + .../list_threads_publish_health_handler.go | 32 + .../patch_content_formula_handler.go | 29 + .../patch_publish_queue_item_handler.go | 32 + .../publish_mention_reply_handler.go | 28 + .../resume_publish_guard_handler.go | 32 + .../retry_publish_queue_item_handler.go | 32 + .../run_threads_api_playground_handler.go} | 12 +- .../run_threads_api_smoke_test_handler.go} | 12 +- .../search_content_formula_posts_handler.go | 29 + ...rt_publish_inventory_refill_job_handler.go | 32 + .../start_threads_oauth_handler.go} | 13 +- .../sync_mention_inbox_handler.go | 28 + .../threads_oauth_callback_handler.go | 29 + .../upsert_publish_guard_policy_handler.go | 32 + ...upsert_publish_inventory_policy_handler.go | 32 + .../internal/library/copymission/knowledge.go | 2 +- .../library/copymission/knowledge_test.go | 2 +- .../library/copymission/matrix_lock.go | 2 +- .../library/copymission/reference_urls.go | 2 +- .../copymission/reference_urls_test.go | 2 +- .../library/copymission/research_items.go | 2 +- .../copymission/research_items_test.go | 2 +- .../library/copymission/research_sources.go | 2 +- backend/internal/library/copyvoice/human.go | 50 + .../internal/library/copyvoice/human_test.go | 21 + backend/internal/library/embedding/client.go | 3 + .../library/formula/analyze_pasted.go | 163 + .../library/formula/generate_from_formula.go | 148 + .../formula/generate_from_formula_test.go | 14 + backend/internal/library/formula/jsonflex.go | 95 + .../internal/library/formula/jsonflex_test.go | 22 + backend/internal/library/knowledge/graph.go | 26 + .../library/knowledge/graph_rescore.go | 110 + .../library/knowledge/graph_selection_test.go | 21 + .../library/knowledge/patrol_input.go | 43 + .../library/knowledge/patrol_phrase.go | 42 + .../library/knowledge/patrol_phrase_test.go | 34 +- .../internal/library/knowledge/patrol_rank.go | 37 +- .../library/knowledge/patrol_rank_test.go | 9 +- .../library/knowledge/patrol_resolve.go | 21 +- .../library/knowledge/patrol_resolve_test.go | 42 +- .../library/knowledge/patrol_search.go | 82 + .../library/knowledge/patrol_search_test.go | 58 + .../internal/library/knowledge/patrol_tags.go | 33 +- .../library/knowledge/research_map_fit.go | 59 + .../library/knowledge/search_keyword_shape.go | 122 + .../knowledge/search_keyword_shape_test.go | 37 + .../library/knowledge/semantic_fit.go | 289 + .../library/knowledge/semantic_fit_test.go | 53 + backend/internal/library/matrix/generate.go | 41 +- .../internal/library/matrix/generate_test.go | 2 +- backend/internal/library/matrix/reference.go | 2 +- .../library/outreach/draft_context.go | 107 + .../internal/library/outreach/draft_format.go | 76 + .../library/outreach/draft_format_test.go | 36 + backend/internal/library/outreach/generate.go | 28 +- .../library/outreach/generate_draft.go | 188 + .../library/outreach/persona_voice.go | 33 + .../library/outreach/persona_voice_test.go | 34 + .../library/outreach/previous_drafts.go | 32 + .../library/outreach/previous_drafts_test.go | 19 + .../internal/library/ownpost/post_formula.go | 162 + .../library/ownpost/post_formula_test.go | 34 + .../internal/library/ownpost/reply_draft.go | 226 + .../library/ownpost/reply_draft_test.go | 66 + .../internal/library/permmatch/match_test.go | 11 +- .../library/personacopy/formula_draft.go | 161 + .../library/personacopy/rewrite_draft.go | 139 + .../library/personacopy/topic_matrix.go | 321 + .../library/placement/capabilities.go | 3 - backend/internal/library/placement/context.go | 21 + .../placement/context_api_only_test.go | 33 + .../library/placement/crawler_exec.go | 44 +- .../internal/library/placement/discover.go | 17 +- .../placement/discover_for_query_test.go | 69 + .../internal/library/placement/dual_track.go | 274 +- .../internal/library/placement/external_id.go | 50 + .../library/placement/external_id_test.go | 14 + backend/internal/library/placement/filter.go | 9 + .../library/placement/media_resolve.go | 57 + .../library/placement/parse_threads.go | 20 +- .../library/placement/patrol_queries.go | 118 +- .../library/placement/patrol_queries_test.go | 10 +- .../library/placement/post_relevance.go | 534 ++ .../library/placement/post_relevance_test.go | 148 + .../library/placement/product_catalog.go | 3 +- .../library/placement/product_context.go | 14 + .../product_context_placement_url_test.go | 25 + .../library/placement/product_match.go | 67 + .../library/placement/product_match_test.go | 39 + .../internal/library/placement/query_build.go | 32 +- .../library/placement/query_build_test.go | 32 + backend/internal/library/placement/recency.go | 10 + .../library/placement/recency_cascade.go | 89 + .../library/placement/recency_cascade_test.go | 40 + .../library/placement/research_map.go | 4 +- .../library/placement/resolve_media_exec.go | 143 + .../library/placement/resolve_reply_target.go | 18 + .../placement/resolve_reply_target_test.go | 17 + .../internal/library/placement/source_mode.go | 2 +- .../library/placement/source_mode_test.go | 6 +- .../internal/library/placement/threads_api.go | 30 +- .../internal/library/placement/topic_fit.go | 42 + .../internal/library/prompt/compose_test.go | 2 +- .../prompt/files/matrix_copy.system.md | 20 +- .../library/prompt/files/matrix_copy.user.md | 16 +- .../prompt/files/matrix_placement.system.md | 1 + .../prompt/files/outreach_placement.system.md | 3 +- .../prompt/files/outreach_placement.user.md | 1 + .../library/prompt/files/style8d.schema.md | 2 +- .../library/prompt/files/style8d.system.md | 26 +- .../internal/library/publishschedule/slots.go | 88 + .../library/publishschedule/slots_test.go | 40 + backend/internal/library/style8d/analyze.go | 202 +- .../internal/library/style8d/analyze_test.go | 10 +- backend/internal/library/style8d/prompt.go | 50 +- .../internal/library/style8d/prompt_test.go | 12 +- backend/internal/library/style8d/runner.go | 41 + .../library/style8d/text_input_test.go | 73 + .../internal/library/threadsapi/extended.go | 522 ++ .../library/threadsapi/fields_test.go | 27 + backend/internal/library/threadsapi/media.go | 106 +- .../internal/library/threadsapi/media_test.go | 27 + backend/internal/library/threadsapi/oauth.go | 257 + .../threadsapi/oauth_permission_test.go | 28 + .../internal/library/threadsapi/oauth_test.go | 28 + .../internal/library/threadsapi/publish.go | 27 +- .../internal/library/threadsapi/replies.go | 223 +- .../library/threadsapi/replies_test.go | 69 + .../library/threadsapi/resolve_media.go | 298 + .../threadsapi/resolve_media_hint_test.go | 13 + .../library/threadsapi/resolve_media_test.go | 19 + backend/internal/library/threadsapi/scopes.go | 23 + .../internal/library/threadspost/format.go | 35 + .../library/threadspost/format_test.go | 48 + backend/internal/library/threadspost/mimic.go | 65 + .../library/threadspost/mimic_test.go | 26 + .../internal/library/viral/analyze_viral.go | 67 +- .../library/viral/analyze_viral_test.go | 43 + backend/internal/library/viral/free_trends.go | 121 + .../internal/library/viral/mission_inspire.go | 119 +- .../library/viral/mission_inspire_collect.go | 24 + backend/internal/library/viral/replicate.go | 49 +- backend/internal/library/webpage/digest.go | 12 +- .../internal/library/webpage/digest_test.go | 2 +- backend/internal/library/webpage/format.go | 2 +- .../logic/brand/create_brand_product_logic.go | 1 + .../brand/generate_outreach_drafts_logic.go | 236 +- backend/internal/logic/brand/mapper.go | 1 + .../brand/patch_scan_post_outreach_logic.go | 1 + .../brand/publish_outreach_draft_logic.go | 49 +- .../logic/brand/start_brand_scan_job_logic.go | 7 +- .../logic/brand/update_brand_product_logic.go | 20 +- .../brand/update_outreach_draft_logic.go | 68 + .../logic/copy_mission/capabilities.go | 16 - .../copy_mission/create_copy_mission_logic.go | 38 +- .../copy_mission/delete_copy_mission_logic.go | 12 +- ...delete_copy_mission_matrix_drafts_logic.go | 51 +- .../expand_copy_mission_graph_logic.go | 77 +- .../generate_copy_mission_matrix_logic.go | 221 +- .../get_copy_mission_graph_logic.go | 26 +- .../copy_mission/get_copy_mission_logic.go | 19 +- .../get_copy_mission_scan_schedule_logic.go | 37 +- .../inspire_copy_mission_logic.go | 435 +- .../copy_mission/knowledge_graph_mapper.go | 67 - .../list_copy_mission_copy_drafts_logic.go | 35 +- .../list_copy_mission_scan_posts_logic.go | 44 +- .../copy_mission/list_copy_missions_logic.go | 22 +- backend/internal/logic/copy_mission/mapper.go | 299 - .../logic/copy_mission/matrix_guard.go | 42 - ...atch_copy_mission_audience_sample_logic.go | 24 +- .../patch_copy_mission_graph_nodes_logic.go | 82 +- ...atch_copy_mission_similar_account_logic.go | 44 +- .../logic/copy_mission/research_map_entity.go | 78 - .../schedule_copy_mission_drafts_logic.go | 33 + .../logic/copy_mission/schedule_helper.go | 49 - .../start_copy_mission_analyze_job_logic.go | 62 +- ...start_copy_mission_copy_draft_job_logic.go | 75 +- .../start_copy_mission_matrix_job_logic.go | 84 +- .../start_copy_mission_scan_job_logic.go | 90 +- .../copy_mission/update_copy_mission_logic.go | 48 +- ...upsert_copy_mission_scan_schedule_logic.go | 101 +- backend/internal/logic/crm/actor.go | 17 - .../logic/crm/create_crm_contact_logic.go | 46 - .../logic/crm/delete_crm_contact_logic.go | 29 - .../logic/crm/get_crm_contact_logic.go | 34 - .../logic/crm/list_crm_contacts_logic.go | 54 - backend/internal/logic/crm/mapper.go | 48 - .../logic/crm/update_crm_contact_logic.go | 42 - .../job/analyze_style8_d_from_worker_logic.go | 37 +- backend/internal/logic/member/mapper.go | 1 + .../update_member_placement_settings_logic.go | 1 + .../logic/persona/content_ops_mapper.go | 30 + .../logic/persona/copy_draft_mapper.go | 40 + .../persona/create_content_plan_logic.go | 41 + .../persona/create_feedback_event_logic.go | 41 + .../persona/create_formula_pool_logic.go | 41 + .../persona/create_knowledge_source_logic.go | 41 + .../delete_persona_copy_draft_logic.go | 2 +- .../persona/delete_style_preset_logic.go | 35 + .../generate_from_content_formula_logic.go | 143 + .../generate_persona_copy_draft_logic.go | 22 +- .../generate_persona_topic_matrix_logic.go | 65 + .../logic/persona/list_content_plans_logic.go | 43 + .../logic/persona/list_formula_pools_logic.go | 43 + .../persona/list_knowledge_chunks_logic.go | 43 + .../persona/list_knowledge_sources_logic.go | 43 + .../list_persona_content_inbox_logic.go | 112 + .../persona/list_persona_copy_drafts_logic.go | 17 +- .../logic/persona/list_style_presets_logic.go | 43 + .../persona/list_topic_candidates_logic.go | 43 + backend/internal/logic/persona/mapper.go | 9 + .../prune_persona_copy_drafts_logic.go | 46 + .../publish_persona_copy_draft_logic.go | 37 +- .../schedule_persona_copy_draft_logic.go | 83 + .../persona/schedule_persona_drafts_logic.go | 107 + .../start_persona_formula_draft_job_logic.go | 83 + .../start_persona_rewrite_draft_job_logic.go | 87 + ..._persona_style_analysis_from_text_logic.go | 154 + .../start_persona_topic_matrix_job_logic.go | 80 + .../persona/update_content_plan_logic.go | 41 + .../update_persona_copy_draft_logic.go | 30 +- .../persona/upsert_style_preset_logic.go | 44 + ...e_placement_topic_outreach_drafts_logic.go | 12 +- .../internal/logic/placement_topic/mapper.go | 1 + ...patch_placement_topic_graph_nodes_logic.go | 4 +- ...lacement_topic_outreach_draft_job_logic.go | 127 + .../start_placement_topic_scan_job_logic.go | 49 +- ...te_placement_topic_outreach_draft_logic.go | 42 + .../logic/scan_post/lookalike_logic.go | 126 - .../analyze_content_formula_logic.go | 227 + .../cancel_publish_queue_item_logic.go | 29 + .../threads_account/content_formula_mapper.go | 58 + .../create_publish_queue_item_logic.go | 37 + .../delete_content_formula_logic.go | 32 + .../delete_publish_queue_item_logic.go | 25 + .../delete_threads_account_logic.go | 36 + .../disconnect_threads_api_logic.go | 30 + .../generate_own_post_formula_logic.go | 124 + .../generate_own_post_reply_draft_logic.go | 98 + .../get_content_formula_logic.go | 30 + .../get_publish_dashboard_summary_logic.go | 72 + .../get_publish_guard_policy_logic.go | 39 + .../get_publish_inventory_policy_logic.go | 39 + .../get_publish_queue_item_logic.go | 29 + .../get_publish_slot_insights_logic.go | 53 + .../get_threads_diagnostics_logic.go | 55 + .../get_threads_oauth_config_logic.go | 27 + ...mport_own_post_to_content_formula_logic.go | 30 + .../list_content_formulas_logic.go | 63 + .../list_mention_inbox_logic.go | 53 + .../list_own_post_formulas_logic.go | 43 + .../list_publish_alerts_logic.go | 46 + .../list_publish_queue_events_logic.go | 39 + .../list_publish_queue_items_logic.go | 37 + .../list_publish_queue_range_logic.go | 43 + .../list_threads_oauth_logs_logic.go | 32 + .../list_threads_post_performance_logic.go | 39 + .../list_threads_publish_health_logic.go | 29 + .../internal/logic/threads_account/mapper.go | 196 +- .../threads_account/mention_inbox_mapper.go | 32 + .../own_post_formula_mapper.go | 25 + .../patch_content_formula_logic.go | 42 + .../patch_publish_queue_item_logic.go | 38 + .../publish_mention_reply_logic.go | 46 + .../resume_publish_guard_logic.go | 39 + .../retry_publish_queue_item_logic.go | 39 + .../run_threads_api_playground_logic.go | 44 + .../run_threads_api_smoke_test_logic.go | 51 + .../search_content_formula_posts_logic.go | 80 + ...tart_publish_inventory_refill_job_logic.go | 61 + .../start_threads_oauth_logic.go | 37 + .../sync_mention_inbox_logic.go | 47 + .../threads_oauth_callback_logic.go | 60 + .../upsert_publish_guard_policy_logic.go | 45 + .../upsert_publish_inventory_policy_logic.go | 55 + .../model/brand/domain/entity/product.go | 1 + .../model/brand/domain/usecase/usecase.go | 20 +- .../internal/model/brand/usecase/products.go | 12 + .../usecase/products_placement_url_test.go | 115 + .../internal/model/brand/usecase/usecase.go | 3 + .../content_formula/domain/entity/formula.go | 45 + .../domain/repository/repository.go | 31 + .../content_formula/domain/usecase/usecase.go | 90 + .../model/content_formula/repository/mongo.go | 165 + .../model/content_formula/usecase/usecase.go | 184 + .../model/content_ops/domain/entity/entity.go | 112 + .../domain/repository/repository.go | 24 + .../content_ops/domain/usecase/usecase.go | 163 + .../model/content_ops/repository/mongo.go | 232 + .../model/content_ops/usecase/usecase.go | 415 ++ .../model/copy_draft/domain/entity/draft.go | 53 +- .../domain/repository/repository.go | 19 + .../copy_draft/domain/usecase/usecase.go | 120 +- .../model/copy_draft/repository/mongo.go | 162 + .../model/copy_draft/usecase/usecase.go | 238 +- .../internal/model/job/domain/usecase/job.go | 16 +- .../internal/model/job/usecase/cancel_test.go | 50 + .../internal/model/job/usecase/dedupe_test.go | 12 + .../model/job/usecase/enqueue_run_test.go | 4 +- backend/internal/model/job/usecase/helpers.go | 34 +- .../internal/model/job/usecase/maintenance.go | 11 +- .../job/usecase/maintenance_stuck_test.go | 2 +- .../model/job/usecase/maintenance_test.go | 61 + .../job/usecase/retry_max_attempts_test.go | 41 + .../internal/model/job/usecase/retry_test.go | 36 + .../model/job/usecase/schedule_test.go | 2 +- .../internal/model/job/usecase/test_mocks.go | 41 +- backend/internal/model/job/usecase/usecase.go | 257 +- .../knowledge_graph/usecase/topic_graph.go | 57 + .../model/knowledge_graph/usecase/usecase.go | 25 +- .../model/member/domain/repository/member.go | 1 + .../internal/model/member/repository/mongo.go | 21 + .../mention_inbox/domain/entity/mention.go | 32 + .../domain/repository/repository.go | 23 + .../mention_inbox/domain/usecase/usecase.go | 78 + .../model/mention_inbox/repository/mongo.go | 184 + .../model/mention_inbox/usecase/usecase.go | 347 + .../domain/repository/repository.go | 2 + .../outreach_draft/domain/usecase/usecase.go | 10 + .../model/outreach_draft/repository/mongo.go | 51 + .../model/outreach_draft/usecase/errors.go | 16 + .../model/outreach_draft/usecase/usecase.go | 33 + .../own_post_formula/domain/entity/review.go | 28 + .../domain/repository/repository.go | 14 + .../own_post_formula/repository/mongo.go | 115 + .../model/own_post_formula/usecase/usecase.go | 93 + .../model/permission/usecase/usecase.go | 40 +- .../model/placement/usecase/settings.go | 11 + .../usecase/settings_dev_mode_test.go | 28 + .../domain/entity/analytics.go | 2 + .../domain/repository/repository.go | 1 + .../domain/usecase/usecase.go | 12 + .../publish_analytics/repository/mongo.go | 33 +- .../publish_analytics/usecase/schedule.go | 61 + .../publish_analytics/usecase/usecase.go | 10 +- .../publish_draft/domain/entity/draft.go | 15 + .../domain/repository/repository.go | 21 + .../publish_draft/domain/usecase/usecase.go | 48 + .../publish_guard/domain/entity/policy.go | 18 + .../domain/repository/repository.go | 14 + .../publish_guard/domain/usecase/usecase.go | 51 + .../model/publish_guard/repository/mongo.go | 83 + .../model/publish_guard/usecase/usecase.go | 171 + .../publish_guard/usecase/usecase_test.go | 100 + .../publish_inventory/domain/entity/policy.go | 32 + .../domain/repository/repository.go | 15 + .../domain/usecase/usecase.go | 47 + .../publish_inventory/repository/mongo.go | 102 + .../publish_inventory/usecase/usecase.go | 174 + .../publish_queue/domain/entity/queue_item.go | 37 + .../domain/repository/repository.go | 39 + .../publish_queue/domain/usecase/usecase.go | 171 + .../model/publish_queue/repository/mongo.go | 359 + .../model/publish_queue/usecase/usecase.go | 782 +++ .../domain/entity/event.go | 27 + .../domain/repository/repository.go | 15 + .../domain/usecase/usecase.go | 31 + .../publish_queue_event/repository/mongo.go | 99 + .../publish_queue_event/usecase/usecase.go | 75 + .../scan_post/domain/entity/outreach_patch.go | 1 + .../model/scan_post/domain/entity/post.go | 1 + .../model/scan_post/domain/usecase/usecase.go | 2 + .../model/scan_post/repository/mongo.go | 43 +- .../repository/mongo_recent7d_filter_test.go | 54 + .../model/scan_post/usecase/usecase.go | 15 +- .../style_preset/domain/entity/preset.go | 17 + .../domain/repository/repository.go | 15 + .../style_preset/domain/usecase/usecase.go | 34 + .../model/style_preset/repository/mongo.go | 109 + .../model/style_preset/usecase/usecase.go | 135 + .../domain/repository/repository.go | 19 + .../threads_account/domain/usecase/usecase.go | 146 +- .../model/threads_account/repository/mongo.go | 94 + .../repository/oauth_state_redis.go | 67 + .../repository/secrets_mongo.go | 90 +- .../threads_account/usecase/ai_credentials.go | 30 + .../threads_account/usecase/api_playground.go | 263 + .../threads_account/usecase/api_smoke.go | 168 + .../usecase/connection_prefs_test.go | 49 +- .../model/threads_account/usecase/oauth.go | 255 + .../usecase/oauth_debug_log.go | 102 + .../usecase/placement_context.go | 2 +- .../usecase/post_performance.go | 189 + .../usecase/publish_credentials.go | 18 +- .../threads_account/usecase/token_refresh.go | 83 + .../model/threads_account/usecase/usecase.go | 183 +- backend/internal/svc/job_workers.go | 62 + backend/internal/svc/service_context.go | 309 +- backend/internal/types/types.go | 1399 +++- .../worker/job/analyze_copy_mission.go | 390 -- .../internal/worker/job/audience_samples.go | 26 - .../worker/job/expand_copy_mission_graph.go | 387 -- backend/internal/worker/job/expand_graph.go | 23 +- .../worker/job/generate_copy_draft.go | 210 - .../worker/job/generate_copy_matrix.go | 290 - .../worker/job/generate_formula_draft.go | 81 + .../worker/job/generate_outreach_draft.go | 97 + .../worker/job/generate_rewrite_draft.go | 82 + .../worker/job/generate_topic_matrix.go | 78 + .../internal/worker/job/payload_helpers.go | 89 + .../internal/worker/job/publish_analytics.go | 18 +- .../worker/job/publish_queue_sweeper.go | 56 + .../worker/job/refresh_threads_token.go | 23 +- backend/internal/worker/job/scan_placement.go | 86 +- .../internal/worker/job/similar_accounts.go | 42 - .../worker/job/threads_token_sweeper.go | 94 + backend/web/Dockerfile | 13 + backend/web/index.html | 26 + backend/web/package-lock.json | 1718 +++++ backend/web/package.json | 23 + backend/web/src/App.tsx | 150 + backend/web/src/api/client.ts | 196 + backend/web/src/api/haixun.ts | 1172 ++++ backend/web/src/auth/AuthContext.tsx | 108 + backend/web/src/components/AcIcon.tsx | 34 + backend/web/src/components/AuthDecor.tsx | 22 + backend/web/src/components/AuthShell.tsx | 25 + backend/web/src/components/DevToolsPanel.tsx | 199 + .../src/components/ExtensionInstallCard.tsx | 46 + backend/web/src/components/HoverHelpTip.tsx | 19 + backend/web/src/components/JobMonitor.tsx | 336 + backend/web/src/components/Layout.tsx | 196 + .../src/components/MentionContextBlock.tsx | 68 + .../src/components/MentionProfileLookup.tsx | 189 + backend/web/src/components/ThemeToggle.tsx | 11 + backend/web/src/components/ui.tsx | 130 + backend/web/src/index.css | 3242 +++++++++ backend/web/src/lib/acAssets.ts | 23 + backend/web/src/lib/activeAccount.ts | 16 + backend/web/src/lib/extensionInstall.ts | 11 + backend/web/src/lib/extensionSync.ts | 77 + backend/web/src/lib/format.ts | 30 + backend/web/src/lib/formulaCache.ts | 49 + backend/web/src/lib/jobActions.ts | 26 + backend/web/src/lib/jobEvents.ts | 15 + backend/web/src/lib/jobLabels.ts | 73 + backend/web/src/lib/jobStatus.ts | 43 + backend/web/src/lib/mentionContext.ts | 157 + backend/web/src/lib/mentionInbox.ts | 133 + backend/web/src/lib/mentionInboxCache.ts | 28 + backend/web/src/lib/mentionLookup.ts | 67 + backend/web/src/lib/patrolPersona.ts | 16 + backend/web/src/lib/patrolPlan.ts | 400 ++ backend/web/src/lib/postScore.ts | 14 + backend/web/src/lib/postStats.ts | 69 + backend/web/src/lib/publishPageCache.ts | 49 + backend/web/src/lib/publishPersona.ts | 16 + backend/web/src/lib/replyMedia.ts | 73 + backend/web/src/lib/replyTree.ts | 207 + backend/web/src/lib/safeStorage.ts | 27 + backend/web/src/lib/threadsOAuth.ts | 76 + backend/web/src/main.tsx | 16 + backend/web/src/pages/AccountsPage.tsx | 444 ++ backend/web/src/pages/AuthPage.tsx | 97 + backend/web/src/pages/BrandsPage.tsx | 406 ++ backend/web/src/pages/DashboardPage.tsx | 345 + backend/web/src/pages/GapsPage.tsx | 62 + backend/web/src/pages/JobsPage.tsx | 343 + backend/web/src/pages/MissionsPage.tsx | 1234 ++++ backend/web/src/pages/PatrolPage.tsx | 1928 +++++ backend/web/src/pages/PersonasPage.tsx | 502 ++ backend/web/src/pages/PublishPage.tsx | 308 + backend/web/src/pages/SettingsPage.tsx | 155 + .../src/pages/publish/MentionsInboxPanel.tsx | 482 ++ backend/web/src/pages/publish/OwnPostsHub.tsx | 699 ++ .../web/src/pages/publish/ReplyInboxPanel.tsx | 266 + backend/web/src/theme/ThemeContext.tsx | 47 + backend/web/tsconfig.app.json | 21 + {frontend => backend/web}/tsconfig.json | 2 +- backend/web/tsconfig.node.json | 14 + backend/web/vite.config.ts | 23 + backend/worker/Dockerfile | 8 + backend/worker/keyword-search-server.ts | 47 + backend/worker/package-lock.json | 18 + backend/worker/package.json | 8 +- backend/worker/resolve-media-server.ts | 50 + backend/worker/style-8d-worker.ts | 64 +- backend/worker/threads-keyword-search.ts | 51 +- backend/worker/threads-media-id.ts | 67 + backend/worker/threads-profile-scraper.ts | 61 +- backend/worker/threads-resolve-media-cli.ts | 30 + backend/worker/threads-resolve-media.ts | 106 + backend/worker/tsconfig.json | 12 + deploy/.env.dev | 29 - deploy/.env.dev.example | 38 + deploy/.env.example | 29 - deploy/.env.prod.example | 62 + deploy/Dockerfile.backend | 25 - deploy/Dockerfile.node-worker | 15 - deploy/Dockerfile.web | 14 - deploy/README.md | 95 +- deploy/docker-compose.dev.yml | 34 - deploy/docker-compose.yml | 341 +- deploy/nginx.conf | 54 - deploy/nginx/default.conf | 29 + deploy/setup.sh | 108 + docs/post-project-plan.md | 262 + extension/haixun-threads-sync/README.md | 66 + .../haixun-threads-sync/content-haixun.js | 57 + extension/haixun-threads-sync/manifest.json | 55 + extension/haixun-threads-sync/options.html | 56 + extension/haixun-threads-sync/options.js | 71 + extension/haixun-threads-sync/popup.html | 62 + extension/haixun-threads-sync/popup.js | 42 + .../haixun-threads-sync/service-worker.js | 279 + .../haixun-threads-sync/storage-state.js | 111 + frontend/.gitignore | 4 - frontend/index.html | 25 - frontend/package-lock.json | 4008 ----------- frontend/package.json | 29 - .../public/downloads/haixun-threads-sync.zip | Bin 11471 -> 0 bytes .../public/illustrations/island/airplane.svg | 1 - frontend/public/illustrations/island/bell.svg | 1 - .../public/illustrations/island/cloud.svg | 1 - .../illustrations/island/desert-island.svg | 1 - frontend/public/illustrations/island/herb.svg | 1 - frontend/public/illustrations/island/hut.svg | 1 - .../illustrations/island/magnifying-glass.svg | 1 - .../public/illustrations/island/passport.svg | 1 - frontend/public/illustrations/island/sun.svg | 1 - .../illustrations/island/water-wave.svg | 1 - frontend/src/App.tsx | 88 - frontend/src/api/ai.ts | 26 - frontend/src/api/client.ts | 276 - frontend/src/auth/AuthContext.tsx | 148 - frontend/src/components/AcIcon.tsx | 117 - frontend/src/components/AccountAiSettings.tsx | 442 -- .../src/components/AccountConnectionMode.tsx | 189 - .../components/AccountDisplayNameSettings.tsx | 81 - frontend/src/components/AccountSwitcher.tsx | 222 - frontend/src/components/AdminRoute.tsx | 21 - frontend/src/components/AppBrandLink.tsx | 37 - frontend/src/components/AppSidebar.tsx | 100 - frontend/src/components/AuthDecor.tsx | 117 - frontend/src/components/AuthShell.tsx | 39 - .../src/components/BrandProductPicker.tsx | 151 - .../src/components/BrandProductsPanel.tsx | 138 - frontend/src/components/BrandSwitcher.tsx | 47 - frontend/src/components/CopyFlowNav.tsx | 74 - frontend/src/components/CopyFlowPipeline.tsx | 31 - .../components/CopyMissionActiveJobsCard.tsx | 135 - .../components/CopyMissionAnalyzeJobPanel.tsx | 60 - .../src/components/CopyMissionDiceButton.tsx | 50 - .../src/components/CopyMissionDraftCard.tsx | 183 - .../src/components/CopyMissionJobPanel.tsx | 61 - .../components/CopyMissionKnowledgeTab.tsx | 84 - .../components/CopyMissionResearchEditor.tsx | 356 - .../CopyMissionResearchOverview.tsx | 444 -- .../CopyMissionScanSchedulePanel.tsx | 135 - .../CopyMissionSimilarAccountsCard.tsx | 175 - .../src/components/CopyMissionSwitcher.tsx | 47 - .../src/components/CopyPersonaSwitcher.tsx | 47 - .../components/CopySuggestedTagsPicker.tsx | 79 - .../components/CreatePlacementTopicDialog.tsx | 240 - frontend/src/components/DevToolsPanel.tsx | 298 - frontend/src/components/ErrorBoundary.tsx | 50 - frontend/src/components/EvidenceList.tsx | 88 - .../src/components/ExpandGraphJobPanel.tsx | 65 - .../src/components/ExpandStrategyField.tsx | 66 - .../src/components/ExtensionInstallCard.tsx | 48 - frontend/src/components/JobMonitor.tsx | 511 -- .../src/components/KnowledgeGraphPanel.tsx | 162 - .../src/components/KnowledgeLayerRipple.tsx | 62 - .../src/components/KnowledgeOrbitDiagram.tsx | 178 - .../src/components/KnowledgeOverviewPanel.tsx | 349 - frontend/src/components/Layout.tsx | 62 - .../components/LegacyBrandRouteRedirect.tsx | 13 - frontend/src/components/MatrixEntryRoute.tsx | 6 - frontend/src/components/MemberMenu.tsx | 110 - frontend/src/components/MobileBottomNav.tsx | 177 - frontend/src/components/OnboardingBanner.tsx | 90 - frontend/src/components/OnboardingGuide.tsx | 46 - .../src/components/OnboardingRouteGuard.tsx | 16 - .../src/components/OutreachEntryRoute.tsx | 13 - .../src/components/PatrolKeywordsEditor.tsx | 90 - .../src/components/PatrolNodeTagsEditor.tsx | 68 - frontend/src/components/PersonaIslandArt.tsx | 173 - .../components/PersonaScanSchedulePanel.tsx | 122 - frontend/src/components/PlacementFlowNav.tsx | 89 - .../src/components/PlacementFlowPipeline.tsx | 27 - .../components/PlacementResearchSettings.tsx | 181 - .../src/components/PlacementScanJobPanel.tsx | 60 - .../src/components/ProductContextForm.tsx | 80 - frontend/src/components/ProductItemForm.tsx | 104 - frontend/src/components/ProtectedRoute.tsx | 15 - frontend/src/components/RegisterForm.tsx | 95 - .../src/components/ReplyPersonaPicker.tsx | 82 - frontend/src/components/ResearchMapEditor.tsx | 275 - .../src/components/ResearchMapOverview.tsx | 308 - frontend/src/components/ResearchMapPanel.tsx | 77 - .../components/ScheduleFrequencyPicker.tsx | 113 - frontend/src/components/SourceLinkPreview.tsx | 177 - frontend/src/components/StringListEditor.tsx | 65 - .../src/components/Style8DAnalysisMeta.tsx | 46 - .../src/components/Style8DDimensionEditor.tsx | 141 - frontend/src/components/Style8DJobPanel.tsx | 59 - frontend/src/components/ThemeToggle.tsx | 51 - .../components/ThreadsAccountWorkspace.tsx | 96 - .../src/components/ThreadsPostCharMeter.tsx | 26 - frontend/src/components/TopicSwitcher.tsx | 51 - .../src/components/VerifiedAuthorBadge.tsx | 29 - frontend/src/components/ViralScanJobPanel.tsx | 60 - .../components/islander/IslanderAvatar.tsx | 191 - .../components/islander/IslanderChatPanel.tsx | 177 - .../components/islander/IslanderCompanion.tsx | 299 - .../components/islander/IslanderMarkdown.tsx | 64 - frontend/src/components/ui.tsx | 389 -- frontend/src/hooks/useActiveBrand.ts | 108 - frontend/src/hooks/useActiveCopyPersona.ts | 82 - frontend/src/hooks/useActiveTopic.ts | 140 - frontend/src/hooks/useIslanderUnlock.ts | 9 - frontend/src/index.css | 6186 ----------------- frontend/src/lib/acAssets.ts | 58 - frontend/src/lib/aiCredentials.ts | 192 - frontend/src/lib/brandContext.ts | 63 - frontend/src/lib/connectionMode.ts | 20 - frontend/src/lib/copyFlow.ts | 168 - frontend/src/lib/copyMissionAudience.ts | 26 - frontend/src/lib/copyMissionGraph.ts | 86 - frontend/src/lib/copyMissionJobs.ts | 202 - frontend/src/lib/copyMissionRequirements.ts | 30 - .../src/lib/copyMissionSimilarAccounts.ts | 55 - frontend/src/lib/easterEggCatalog.ts | 72 - frontend/src/lib/extensionInstall.ts | 11 - frontend/src/lib/extensionSync.ts | 111 - frontend/src/lib/formatDate.ts | 37 - .../src/lib/islander/IslanderPageContext.tsx | 54 - frontend/src/lib/islander/actionExecutor.ts | 226 - .../src/lib/islander/buildIslanderContext.ts | 85 - frontend/src/lib/islander/config.ts | 56 - frontend/src/lib/islander/copyActions.ts | 184 - frontend/src/lib/islander/handoffStore.ts | 92 - frontend/src/lib/islander/index.ts | 30 - frontend/src/lib/islander/islanderActions.ts | 188 - frontend/src/lib/islander/islanderAgent.ts | 132 - frontend/src/lib/islander/outreachActions.ts | 181 - .../src/lib/islander/pageContextIntent.ts | 17 - frontend/src/lib/islander/pageRegistry.ts | 46 - frontend/src/lib/islander/pageSnapshot.ts | 195 - frontend/src/lib/islander/researchActions.ts | 114 - frontend/src/lib/islander/siteGuide.ts | 193 - frontend/src/lib/islander/types.ts | 67 - frontend/src/lib/islander/unlock.ts | 43 - .../src/lib/islander/useIslanderPosition.ts | 132 - frontend/src/lib/jobMonitorRefresh.ts | 6 - frontend/src/lib/jobMonitorUi.ts | 228 - frontend/src/lib/jobStatus.ts | 114 - frontend/src/lib/jobTemplate.ts | 15 - frontend/src/lib/knowledgeGraph.ts | 453 -- frontend/src/lib/memberCapabilities.ts | 20 - frontend/src/lib/memberRole.ts | 68 - frontend/src/lib/onboarding.ts | 116 - frontend/src/lib/pageJobScope.ts | 46 - frontend/src/lib/placementFlow.ts | 80 - frontend/src/lib/placementResearch.ts | 82 - frontend/src/lib/placementTopics.ts | 50 - frontend/src/lib/productCatalog.ts | 105 - frontend/src/lib/productContext.ts | 87 - frontend/src/lib/productMatch.ts | 30 - frontend/src/lib/publishPersona.ts | 34 - frontend/src/lib/scheduleCatalog.ts | 133 - frontend/src/lib/scheduleCron.ts | 130 - frontend/src/lib/searchSourceMode.ts | 66 - frontend/src/lib/sourcePreview.ts | 58 - frontend/src/lib/storage.ts | 32 - frontend/src/lib/styleEvidence.ts | 91 - frontend/src/lib/styleProfile.ts | 222 - frontend/src/lib/threadsAccount.ts | 22 - frontend/src/lib/threadsLinks.ts | 12 - frontend/src/lib/threadsPostLimits.ts | 71 - frontend/src/lib/viralSignals.ts | 69 - frontend/src/main.tsx | 10 - frontend/src/onboarding/OnboardingContext.tsx | 129 - frontend/src/pages/BrandDetailPage.tsx | 124 - frontend/src/pages/BrandProductEditPage.tsx | 179 - frontend/src/pages/BrandsPage.tsx | 132 - frontend/src/pages/CopyMatrixPage.tsx | 388 -- frontend/src/pages/CopyMissionDetailPage.tsx | 1428 ---- frontend/src/pages/DashboardPage.tsx | 182 - frontend/src/pages/EasterEggsPage.tsx | 149 - frontend/src/pages/JobDetailPage.tsx | 85 - frontend/src/pages/JobSchedulesPage.tsx | 412 -- frontend/src/pages/JobTemplatesPage.tsx | 43 - frontend/src/pages/JobsPage.tsx | 161 - frontend/src/pages/LoginPage.tsx | 62 - frontend/src/pages/PermissionsPage.tsx | 88 - frontend/src/pages/PersonaDetailPage.tsx | 427 -- frontend/src/pages/PersonaMatrixPage.tsx | 226 - frontend/src/pages/PersonaOutreachPage.tsx | 804 --- frontend/src/pages/PersonaResearchPage.tsx | 500 -- frontend/src/pages/PersonasPage.tsx | 147 - .../pages/PlacementTopicResearchMapPage.tsx | 464 -- .../src/pages/PlacementTopicSettingsPage.tsx | 224 - frontend/src/pages/PlacementTopicsPage.tsx | 190 - frontend/src/pages/ProfilePage.tsx | 192 - frontend/src/pages/SettingsPage.tsx | 115 - .../pages/ThreadsAccountConnectionsPage.tsx | 98 - .../src/pages/ThreadsAccountPublishPage.tsx | 189 - frontend/src/theme/ThemeContext.tsx | 48 - .../src/threads/ThreadsAccountContext.tsx | 127 - frontend/src/types/api.ts | 300 - frontend/src/types/brand.ts | 69 - frontend/src/types/copyMission.ts | 152 - frontend/src/types/placementTopic.ts | 38 - frontend/src/vite-env.d.ts | 1 - frontend/tsconfig.app.json | 22 - frontend/tsconfig.node.json | 20 - infra/.env.example | 20 - infra/README.md | 80 +- infra/docker-compose.yml | 11 +- infra/etc/haixun.env.example | 2 +- infra/nginx/haixun-dev.conf | 173 + infra/nginx/haixun.conf | 5 +- infra/start-api.sh | 41 + infra/start-infra.sh | 13 + infra/start-ngrok.sh | 24 + infra/start-node-worker.sh | 78 + scripts/check-dev.sh | 33 + scripts/dev-all | 145 + scripts/dev-all-stop.sh | 122 + scripts/dev-all.sh | 211 + scripts/package-extension.sh | 24 + start-prod.sh | 59 - status-prod.sh | 20 - stop-prod.sh | 6 - 848 files changed, 53447 insertions(+), 45668 deletions(-) delete mode 100644 .DS_Store delete mode 100644 .run/api.pid delete mode 100644 .run/logs/api.log delete mode 100644 .run/logs/web.log delete mode 100644 .run/logs/worker.log delete mode 100644 .run/web.pid delete mode 100644 .run/worker.pid create mode 100644 ai_threads_auto_account_ops.md delete mode 100644 backend/.DS_Store create mode 100644 backend/Dockerfile create mode 100644 backend/Makefile create mode 100644 backend/dev-console/README.md create mode 100644 backend/dev-console/dist/assets/index-56hNfpGK.js create mode 100644 backend/dev-console/dist/index.html create mode 100644 backend/dev-console/index.html create mode 100644 backend/dev-console/package-lock.json create mode 100644 backend/dev-console/package.json rename {frontend => backend/dev-console}/vite.config.ts (63%) delete mode 100644 backend/docs/phase1-methods.md delete mode 100644 backend/docs/scan-placement-plan.md delete mode 100644 backend/docs/similar-accounts-ta-plan.md create mode 100644 backend/docs/threads-automation-gap-plan.md delete mode 100644 backend/etc/gateway.dev.example.yaml create mode 100644 backend/internal/bootstrap/envfile.go create mode 100644 backend/internal/bootstrap/envfile_test.go rename backend/internal/handler/{scan_post/lookalike_handler.go => brand/update_outreach_draft_handler.go} (66%) create mode 100644 backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go delete mode 100644 backend/internal/handler/crm/delete_crm_contact_handler.go create mode 100644 backend/internal/handler/persona/create_content_plan_handler.go create mode 100644 backend/internal/handler/persona/create_feedback_event_handler.go create mode 100644 backend/internal/handler/persona/create_formula_pool_handler.go create mode 100644 backend/internal/handler/persona/create_knowledge_source_handler.go create mode 100644 backend/internal/handler/persona/delete_style_preset_handler.go create mode 100644 backend/internal/handler/persona/generate_from_content_formula_handler.go create mode 100644 backend/internal/handler/persona/generate_persona_topic_matrix_handler.go create mode 100644 backend/internal/handler/persona/list_content_plans_handler.go create mode 100644 backend/internal/handler/persona/list_formula_pools_handler.go create mode 100644 backend/internal/handler/persona/list_knowledge_chunks_handler.go create mode 100644 backend/internal/handler/persona/list_knowledge_sources_handler.go create mode 100644 backend/internal/handler/persona/list_persona_content_inbox_handler.go create mode 100644 backend/internal/handler/persona/list_style_presets_handler.go create mode 100644 backend/internal/handler/persona/list_topic_candidates_handler.go create mode 100644 backend/internal/handler/persona/prune_persona_copy_drafts_handler.go create mode 100644 backend/internal/handler/persona/schedule_persona_copy_draft_handler.go create mode 100644 backend/internal/handler/persona/schedule_persona_drafts_handler.go create mode 100644 backend/internal/handler/persona/start_persona_formula_draft_job_handler.go create mode 100644 backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go create mode 100644 backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go create mode 100644 backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go create mode 100644 backend/internal/handler/persona/update_content_plan_handler.go create mode 100644 backend/internal/handler/persona/upsert_style_preset_handler.go create mode 100644 backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go create mode 100644 backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go create mode 100644 backend/internal/handler/register_extra.go create mode 100644 backend/internal/handler/static/extension_download.go create mode 100644 backend/internal/handler/threads_account/analyze_content_formula_handler.go create mode 100644 backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go create mode 100644 backend/internal/handler/threads_account/create_publish_queue_item_handler.go create mode 100644 backend/internal/handler/threads_account/delete_content_formula_handler.go create mode 100644 backend/internal/handler/threads_account/delete_publish_queue_item_handler.go create mode 100644 backend/internal/handler/threads_account/delete_threads_account_handler.go create mode 100644 backend/internal/handler/threads_account/disconnect_threads_api_handler.go create mode 100644 backend/internal/handler/threads_account/generate_own_post_formula_handler.go create mode 100644 backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go create mode 100644 backend/internal/handler/threads_account/get_content_formula_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_guard_policy_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_queue_item_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_slot_insights_handler.go create mode 100644 backend/internal/handler/threads_account/get_threads_diagnostics_handler.go create mode 100644 backend/internal/handler/threads_account/get_threads_oauth_config_handler.go create mode 100644 backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go create mode 100644 backend/internal/handler/threads_account/list_content_formulas_handler.go rename backend/internal/handler/{crm/create_crm_contact_handler.go => threads_account/list_mention_inbox_handler.go} (66%) create mode 100644 backend/internal/handler/threads_account/list_own_post_formulas_handler.go create mode 100644 backend/internal/handler/threads_account/list_publish_alerts_handler.go create mode 100644 backend/internal/handler/threads_account/list_publish_queue_events_handler.go create mode 100644 backend/internal/handler/threads_account/list_publish_queue_items_handler.go create mode 100644 backend/internal/handler/threads_account/list_publish_queue_range_handler.go create mode 100644 backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go create mode 100644 backend/internal/handler/threads_account/list_threads_post_performance_handler.go create mode 100644 backend/internal/handler/threads_account/list_threads_publish_health_handler.go create mode 100644 backend/internal/handler/threads_account/patch_content_formula_handler.go create mode 100644 backend/internal/handler/threads_account/patch_publish_queue_item_handler.go create mode 100644 backend/internal/handler/threads_account/publish_mention_reply_handler.go create mode 100644 backend/internal/handler/threads_account/resume_publish_guard_handler.go create mode 100644 backend/internal/handler/threads_account/retry_publish_queue_item_handler.go rename backend/internal/handler/{crm/get_crm_contact_handler.go => threads_account/run_threads_api_playground_handler.go} (55%) rename backend/internal/handler/{crm/update_crm_contact_handler.go => threads_account/run_threads_api_smoke_test_handler.go} (56%) create mode 100644 backend/internal/handler/threads_account/search_content_formula_posts_handler.go create mode 100644 backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go rename backend/internal/handler/{crm/list_crm_contacts_handler.go => threads_account/start_threads_oauth_handler.go} (57%) create mode 100644 backend/internal/handler/threads_account/sync_mention_inbox_handler.go create mode 100644 backend/internal/handler/threads_account/threads_oauth_callback_handler.go create mode 100644 backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go create mode 100644 backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go create mode 100644 backend/internal/library/copyvoice/human.go create mode 100644 backend/internal/library/copyvoice/human_test.go create mode 100644 backend/internal/library/formula/analyze_pasted.go create mode 100644 backend/internal/library/formula/generate_from_formula.go create mode 100644 backend/internal/library/formula/generate_from_formula_test.go create mode 100644 backend/internal/library/formula/jsonflex.go create mode 100644 backend/internal/library/formula/jsonflex_test.go create mode 100644 backend/internal/library/knowledge/graph_rescore.go create mode 100644 backend/internal/library/knowledge/graph_selection_test.go create mode 100644 backend/internal/library/knowledge/patrol_search.go create mode 100644 backend/internal/library/knowledge/patrol_search_test.go create mode 100644 backend/internal/library/knowledge/research_map_fit.go create mode 100644 backend/internal/library/knowledge/search_keyword_shape.go create mode 100644 backend/internal/library/knowledge/search_keyword_shape_test.go create mode 100644 backend/internal/library/knowledge/semantic_fit.go create mode 100644 backend/internal/library/knowledge/semantic_fit_test.go create mode 100644 backend/internal/library/outreach/draft_context.go create mode 100644 backend/internal/library/outreach/draft_format.go create mode 100644 backend/internal/library/outreach/draft_format_test.go create mode 100644 backend/internal/library/outreach/generate_draft.go create mode 100644 backend/internal/library/outreach/persona_voice.go create mode 100644 backend/internal/library/outreach/persona_voice_test.go create mode 100644 backend/internal/library/outreach/previous_drafts.go create mode 100644 backend/internal/library/outreach/previous_drafts_test.go create mode 100644 backend/internal/library/ownpost/post_formula.go create mode 100644 backend/internal/library/ownpost/post_formula_test.go create mode 100644 backend/internal/library/ownpost/reply_draft.go create mode 100644 backend/internal/library/ownpost/reply_draft_test.go create mode 100644 backend/internal/library/personacopy/formula_draft.go create mode 100644 backend/internal/library/personacopy/rewrite_draft.go create mode 100644 backend/internal/library/personacopy/topic_matrix.go create mode 100644 backend/internal/library/placement/context_api_only_test.go create mode 100644 backend/internal/library/placement/discover_for_query_test.go create mode 100644 backend/internal/library/placement/external_id.go create mode 100644 backend/internal/library/placement/external_id_test.go create mode 100644 backend/internal/library/placement/media_resolve.go create mode 100644 backend/internal/library/placement/post_relevance.go create mode 100644 backend/internal/library/placement/post_relevance_test.go create mode 100644 backend/internal/library/placement/product_context_placement_url_test.go create mode 100644 backend/internal/library/placement/product_match_test.go create mode 100644 backend/internal/library/placement/query_build_test.go create mode 100644 backend/internal/library/placement/recency_cascade.go create mode 100644 backend/internal/library/placement/recency_cascade_test.go create mode 100644 backend/internal/library/placement/resolve_media_exec.go create mode 100644 backend/internal/library/placement/resolve_reply_target.go create mode 100644 backend/internal/library/placement/resolve_reply_target_test.go create mode 100644 backend/internal/library/placement/topic_fit.go create mode 100644 backend/internal/library/publishschedule/slots.go create mode 100644 backend/internal/library/publishschedule/slots_test.go create mode 100644 backend/internal/library/style8d/runner.go create mode 100644 backend/internal/library/style8d/text_input_test.go create mode 100644 backend/internal/library/threadsapi/extended.go create mode 100644 backend/internal/library/threadsapi/fields_test.go create mode 100644 backend/internal/library/threadsapi/media_test.go create mode 100644 backend/internal/library/threadsapi/oauth.go create mode 100644 backend/internal/library/threadsapi/oauth_permission_test.go create mode 100644 backend/internal/library/threadsapi/oauth_test.go create mode 100644 backend/internal/library/threadsapi/replies_test.go create mode 100644 backend/internal/library/threadsapi/resolve_media.go create mode 100644 backend/internal/library/threadsapi/resolve_media_hint_test.go create mode 100644 backend/internal/library/threadsapi/resolve_media_test.go create mode 100644 backend/internal/library/threadsapi/scopes.go create mode 100644 backend/internal/library/threadspost/format.go create mode 100644 backend/internal/library/threadspost/format_test.go create mode 100644 backend/internal/library/threadspost/mimic.go create mode 100644 backend/internal/library/threadspost/mimic_test.go create mode 100644 backend/internal/library/viral/analyze_viral_test.go create mode 100644 backend/internal/library/viral/free_trends.go create mode 100644 backend/internal/logic/brand/update_outreach_draft_logic.go delete mode 100644 backend/internal/logic/copy_mission/capabilities.go delete mode 100644 backend/internal/logic/copy_mission/knowledge_graph_mapper.go delete mode 100644 backend/internal/logic/copy_mission/mapper.go delete mode 100644 backend/internal/logic/copy_mission/matrix_guard.go delete mode 100644 backend/internal/logic/copy_mission/research_map_entity.go create mode 100644 backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go delete mode 100644 backend/internal/logic/copy_mission/schedule_helper.go delete mode 100644 backend/internal/logic/crm/actor.go delete mode 100644 backend/internal/logic/crm/create_crm_contact_logic.go delete mode 100644 backend/internal/logic/crm/delete_crm_contact_logic.go delete mode 100644 backend/internal/logic/crm/get_crm_contact_logic.go delete mode 100644 backend/internal/logic/crm/list_crm_contacts_logic.go delete mode 100644 backend/internal/logic/crm/mapper.go delete mode 100644 backend/internal/logic/crm/update_crm_contact_logic.go create mode 100644 backend/internal/logic/persona/content_ops_mapper.go create mode 100644 backend/internal/logic/persona/copy_draft_mapper.go create mode 100644 backend/internal/logic/persona/create_content_plan_logic.go create mode 100644 backend/internal/logic/persona/create_feedback_event_logic.go create mode 100644 backend/internal/logic/persona/create_formula_pool_logic.go create mode 100644 backend/internal/logic/persona/create_knowledge_source_logic.go create mode 100644 backend/internal/logic/persona/delete_style_preset_logic.go create mode 100644 backend/internal/logic/persona/generate_from_content_formula_logic.go create mode 100644 backend/internal/logic/persona/generate_persona_topic_matrix_logic.go create mode 100644 backend/internal/logic/persona/list_content_plans_logic.go create mode 100644 backend/internal/logic/persona/list_formula_pools_logic.go create mode 100644 backend/internal/logic/persona/list_knowledge_chunks_logic.go create mode 100644 backend/internal/logic/persona/list_knowledge_sources_logic.go create mode 100644 backend/internal/logic/persona/list_persona_content_inbox_logic.go create mode 100644 backend/internal/logic/persona/list_style_presets_logic.go create mode 100644 backend/internal/logic/persona/list_topic_candidates_logic.go create mode 100644 backend/internal/logic/persona/prune_persona_copy_drafts_logic.go create mode 100644 backend/internal/logic/persona/schedule_persona_copy_draft_logic.go create mode 100644 backend/internal/logic/persona/schedule_persona_drafts_logic.go create mode 100644 backend/internal/logic/persona/start_persona_formula_draft_job_logic.go create mode 100644 backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go create mode 100644 backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go create mode 100644 backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go create mode 100644 backend/internal/logic/persona/update_content_plan_logic.go create mode 100644 backend/internal/logic/persona/upsert_style_preset_logic.go create mode 100644 backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go create mode 100644 backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go delete mode 100644 backend/internal/logic/scan_post/lookalike_logic.go create mode 100644 backend/internal/logic/threads_account/analyze_content_formula_logic.go create mode 100644 backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go create mode 100644 backend/internal/logic/threads_account/content_formula_mapper.go create mode 100644 backend/internal/logic/threads_account/create_publish_queue_item_logic.go create mode 100644 backend/internal/logic/threads_account/delete_content_formula_logic.go create mode 100644 backend/internal/logic/threads_account/delete_publish_queue_item_logic.go create mode 100644 backend/internal/logic/threads_account/delete_threads_account_logic.go create mode 100644 backend/internal/logic/threads_account/disconnect_threads_api_logic.go create mode 100644 backend/internal/logic/threads_account/generate_own_post_formula_logic.go create mode 100644 backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go create mode 100644 backend/internal/logic/threads_account/get_content_formula_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_guard_policy_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_queue_item_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_slot_insights_logic.go create mode 100644 backend/internal/logic/threads_account/get_threads_diagnostics_logic.go create mode 100644 backend/internal/logic/threads_account/get_threads_oauth_config_logic.go create mode 100644 backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go create mode 100644 backend/internal/logic/threads_account/list_content_formulas_logic.go create mode 100644 backend/internal/logic/threads_account/list_mention_inbox_logic.go create mode 100644 backend/internal/logic/threads_account/list_own_post_formulas_logic.go create mode 100644 backend/internal/logic/threads_account/list_publish_alerts_logic.go create mode 100644 backend/internal/logic/threads_account/list_publish_queue_events_logic.go create mode 100644 backend/internal/logic/threads_account/list_publish_queue_items_logic.go create mode 100644 backend/internal/logic/threads_account/list_publish_queue_range_logic.go create mode 100644 backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go create mode 100644 backend/internal/logic/threads_account/list_threads_post_performance_logic.go create mode 100644 backend/internal/logic/threads_account/list_threads_publish_health_logic.go create mode 100644 backend/internal/logic/threads_account/mention_inbox_mapper.go create mode 100644 backend/internal/logic/threads_account/own_post_formula_mapper.go create mode 100644 backend/internal/logic/threads_account/patch_content_formula_logic.go create mode 100644 backend/internal/logic/threads_account/patch_publish_queue_item_logic.go create mode 100644 backend/internal/logic/threads_account/publish_mention_reply_logic.go create mode 100644 backend/internal/logic/threads_account/resume_publish_guard_logic.go create mode 100644 backend/internal/logic/threads_account/retry_publish_queue_item_logic.go create mode 100644 backend/internal/logic/threads_account/run_threads_api_playground_logic.go create mode 100644 backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go create mode 100644 backend/internal/logic/threads_account/search_content_formula_posts_logic.go create mode 100644 backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go create mode 100644 backend/internal/logic/threads_account/start_threads_oauth_logic.go create mode 100644 backend/internal/logic/threads_account/sync_mention_inbox_logic.go create mode 100644 backend/internal/logic/threads_account/threads_oauth_callback_logic.go create mode 100644 backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go create mode 100644 backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go create mode 100644 backend/internal/model/brand/usecase/products_placement_url_test.go create mode 100644 backend/internal/model/content_formula/domain/entity/formula.go create mode 100644 backend/internal/model/content_formula/domain/repository/repository.go create mode 100644 backend/internal/model/content_formula/domain/usecase/usecase.go create mode 100644 backend/internal/model/content_formula/repository/mongo.go create mode 100644 backend/internal/model/content_formula/usecase/usecase.go create mode 100644 backend/internal/model/content_ops/domain/entity/entity.go create mode 100644 backend/internal/model/content_ops/domain/repository/repository.go create mode 100644 backend/internal/model/content_ops/domain/usecase/usecase.go create mode 100644 backend/internal/model/content_ops/repository/mongo.go create mode 100644 backend/internal/model/content_ops/usecase/usecase.go create mode 100644 backend/internal/model/job/usecase/retry_max_attempts_test.go create mode 100644 backend/internal/model/knowledge_graph/usecase/topic_graph.go create mode 100644 backend/internal/model/mention_inbox/domain/entity/mention.go create mode 100644 backend/internal/model/mention_inbox/domain/repository/repository.go create mode 100644 backend/internal/model/mention_inbox/domain/usecase/usecase.go create mode 100644 backend/internal/model/mention_inbox/repository/mongo.go create mode 100644 backend/internal/model/mention_inbox/usecase/usecase.go create mode 100644 backend/internal/model/own_post_formula/domain/entity/review.go create mode 100644 backend/internal/model/own_post_formula/domain/repository/repository.go create mode 100644 backend/internal/model/own_post_formula/repository/mongo.go create mode 100644 backend/internal/model/own_post_formula/usecase/usecase.go create mode 100644 backend/internal/model/placement/usecase/settings_dev_mode_test.go create mode 100644 backend/internal/model/publish_analytics/usecase/schedule.go create mode 100644 backend/internal/model/publish_draft/domain/entity/draft.go create mode 100644 backend/internal/model/publish_draft/domain/repository/repository.go create mode 100644 backend/internal/model/publish_draft/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_guard/domain/entity/policy.go create mode 100644 backend/internal/model/publish_guard/domain/repository/repository.go create mode 100644 backend/internal/model/publish_guard/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_guard/repository/mongo.go create mode 100644 backend/internal/model/publish_guard/usecase/usecase.go create mode 100644 backend/internal/model/publish_guard/usecase/usecase_test.go create mode 100644 backend/internal/model/publish_inventory/domain/entity/policy.go create mode 100644 backend/internal/model/publish_inventory/domain/repository/repository.go create mode 100644 backend/internal/model/publish_inventory/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_inventory/repository/mongo.go create mode 100644 backend/internal/model/publish_inventory/usecase/usecase.go create mode 100644 backend/internal/model/publish_queue/domain/entity/queue_item.go create mode 100644 backend/internal/model/publish_queue/domain/repository/repository.go create mode 100644 backend/internal/model/publish_queue/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_queue/repository/mongo.go create mode 100644 backend/internal/model/publish_queue/usecase/usecase.go create mode 100644 backend/internal/model/publish_queue_event/domain/entity/event.go create mode 100644 backend/internal/model/publish_queue_event/domain/repository/repository.go create mode 100644 backend/internal/model/publish_queue_event/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_queue_event/repository/mongo.go create mode 100644 backend/internal/model/publish_queue_event/usecase/usecase.go create mode 100644 backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go create mode 100644 backend/internal/model/style_preset/domain/entity/preset.go create mode 100644 backend/internal/model/style_preset/domain/repository/repository.go create mode 100644 backend/internal/model/style_preset/domain/usecase/usecase.go create mode 100644 backend/internal/model/style_preset/repository/mongo.go create mode 100644 backend/internal/model/style_preset/usecase/usecase.go create mode 100644 backend/internal/model/threads_account/repository/oauth_state_redis.go create mode 100644 backend/internal/model/threads_account/usecase/api_playground.go create mode 100644 backend/internal/model/threads_account/usecase/api_smoke.go create mode 100644 backend/internal/model/threads_account/usecase/oauth.go create mode 100644 backend/internal/model/threads_account/usecase/oauth_debug_log.go create mode 100644 backend/internal/model/threads_account/usecase/post_performance.go create mode 100644 backend/internal/model/threads_account/usecase/token_refresh.go create mode 100644 backend/internal/svc/job_workers.go delete mode 100644 backend/internal/worker/job/analyze_copy_mission.go delete mode 100644 backend/internal/worker/job/audience_samples.go delete mode 100644 backend/internal/worker/job/expand_copy_mission_graph.go delete mode 100644 backend/internal/worker/job/generate_copy_draft.go delete mode 100644 backend/internal/worker/job/generate_copy_matrix.go create mode 100644 backend/internal/worker/job/generate_formula_draft.go create mode 100644 backend/internal/worker/job/generate_outreach_draft.go create mode 100644 backend/internal/worker/job/generate_rewrite_draft.go create mode 100644 backend/internal/worker/job/generate_topic_matrix.go create mode 100644 backend/internal/worker/job/payload_helpers.go create mode 100644 backend/internal/worker/job/publish_queue_sweeper.go delete mode 100644 backend/internal/worker/job/similar_accounts.go create mode 100644 backend/internal/worker/job/threads_token_sweeper.go create mode 100644 backend/web/Dockerfile create mode 100644 backend/web/index.html create mode 100644 backend/web/package-lock.json create mode 100644 backend/web/package.json create mode 100644 backend/web/src/App.tsx create mode 100644 backend/web/src/api/client.ts create mode 100644 backend/web/src/api/haixun.ts create mode 100644 backend/web/src/auth/AuthContext.tsx create mode 100644 backend/web/src/components/AcIcon.tsx create mode 100644 backend/web/src/components/AuthDecor.tsx create mode 100644 backend/web/src/components/AuthShell.tsx create mode 100644 backend/web/src/components/DevToolsPanel.tsx create mode 100644 backend/web/src/components/ExtensionInstallCard.tsx create mode 100644 backend/web/src/components/HoverHelpTip.tsx create mode 100644 backend/web/src/components/JobMonitor.tsx create mode 100644 backend/web/src/components/Layout.tsx create mode 100644 backend/web/src/components/MentionContextBlock.tsx create mode 100644 backend/web/src/components/MentionProfileLookup.tsx create mode 100644 backend/web/src/components/ThemeToggle.tsx create mode 100644 backend/web/src/components/ui.tsx create mode 100644 backend/web/src/index.css create mode 100644 backend/web/src/lib/acAssets.ts create mode 100644 backend/web/src/lib/activeAccount.ts create mode 100644 backend/web/src/lib/extensionInstall.ts create mode 100644 backend/web/src/lib/extensionSync.ts create mode 100644 backend/web/src/lib/format.ts create mode 100644 backend/web/src/lib/formulaCache.ts create mode 100644 backend/web/src/lib/jobActions.ts create mode 100644 backend/web/src/lib/jobEvents.ts create mode 100644 backend/web/src/lib/jobLabels.ts create mode 100644 backend/web/src/lib/jobStatus.ts create mode 100644 backend/web/src/lib/mentionContext.ts create mode 100644 backend/web/src/lib/mentionInbox.ts create mode 100644 backend/web/src/lib/mentionInboxCache.ts create mode 100644 backend/web/src/lib/mentionLookup.ts create mode 100644 backend/web/src/lib/patrolPersona.ts create mode 100644 backend/web/src/lib/patrolPlan.ts create mode 100644 backend/web/src/lib/postScore.ts create mode 100644 backend/web/src/lib/postStats.ts create mode 100644 backend/web/src/lib/publishPageCache.ts create mode 100644 backend/web/src/lib/publishPersona.ts create mode 100644 backend/web/src/lib/replyMedia.ts create mode 100644 backend/web/src/lib/replyTree.ts create mode 100644 backend/web/src/lib/safeStorage.ts create mode 100644 backend/web/src/lib/threadsOAuth.ts create mode 100644 backend/web/src/main.tsx create mode 100644 backend/web/src/pages/AccountsPage.tsx create mode 100644 backend/web/src/pages/AuthPage.tsx create mode 100644 backend/web/src/pages/BrandsPage.tsx create mode 100644 backend/web/src/pages/DashboardPage.tsx create mode 100644 backend/web/src/pages/GapsPage.tsx create mode 100644 backend/web/src/pages/JobsPage.tsx create mode 100644 backend/web/src/pages/MissionsPage.tsx create mode 100644 backend/web/src/pages/PatrolPage.tsx create mode 100644 backend/web/src/pages/PersonasPage.tsx create mode 100644 backend/web/src/pages/PublishPage.tsx create mode 100644 backend/web/src/pages/SettingsPage.tsx create mode 100644 backend/web/src/pages/publish/MentionsInboxPanel.tsx create mode 100644 backend/web/src/pages/publish/OwnPostsHub.tsx create mode 100644 backend/web/src/pages/publish/ReplyInboxPanel.tsx create mode 100644 backend/web/src/theme/ThemeContext.tsx create mode 100644 backend/web/tsconfig.app.json rename {frontend => backend/web}/tsconfig.json (98%) create mode 100644 backend/web/tsconfig.node.json create mode 100644 backend/web/vite.config.ts create mode 100644 backend/worker/Dockerfile create mode 100644 backend/worker/keyword-search-server.ts create mode 100644 backend/worker/resolve-media-server.ts create mode 100644 backend/worker/threads-media-id.ts create mode 100644 backend/worker/threads-resolve-media-cli.ts create mode 100644 backend/worker/threads-resolve-media.ts create mode 100644 backend/worker/tsconfig.json delete mode 100644 deploy/.env.dev create mode 100644 deploy/.env.dev.example delete mode 100644 deploy/.env.example create mode 100644 deploy/.env.prod.example delete mode 100644 deploy/Dockerfile.backend delete mode 100644 deploy/Dockerfile.node-worker delete mode 100644 deploy/Dockerfile.web delete mode 100644 deploy/docker-compose.dev.yml delete mode 100644 deploy/nginx.conf create mode 100644 deploy/nginx/default.conf create mode 100755 deploy/setup.sh create mode 100644 docs/post-project-plan.md create mode 100644 extension/haixun-threads-sync/README.md create mode 100644 extension/haixun-threads-sync/content-haixun.js create mode 100644 extension/haixun-threads-sync/manifest.json create mode 100644 extension/haixun-threads-sync/options.html create mode 100644 extension/haixun-threads-sync/options.js create mode 100644 extension/haixun-threads-sync/popup.html create mode 100644 extension/haixun-threads-sync/popup.js create mode 100644 extension/haixun-threads-sync/service-worker.js create mode 100644 extension/haixun-threads-sync/storage-state.js delete mode 100644 frontend/.gitignore delete mode 100644 frontend/index.html delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/public/downloads/haixun-threads-sync.zip delete mode 100644 frontend/public/illustrations/island/airplane.svg delete mode 100644 frontend/public/illustrations/island/bell.svg delete mode 100644 frontend/public/illustrations/island/cloud.svg delete mode 100644 frontend/public/illustrations/island/desert-island.svg delete mode 100644 frontend/public/illustrations/island/herb.svg delete mode 100644 frontend/public/illustrations/island/hut.svg delete mode 100644 frontend/public/illustrations/island/magnifying-glass.svg delete mode 100644 frontend/public/illustrations/island/passport.svg delete mode 100644 frontend/public/illustrations/island/sun.svg delete mode 100644 frontend/public/illustrations/island/water-wave.svg delete mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/api/ai.ts delete mode 100644 frontend/src/api/client.ts delete mode 100644 frontend/src/auth/AuthContext.tsx delete mode 100644 frontend/src/components/AcIcon.tsx delete mode 100644 frontend/src/components/AccountAiSettings.tsx delete mode 100644 frontend/src/components/AccountConnectionMode.tsx delete mode 100644 frontend/src/components/AccountDisplayNameSettings.tsx delete mode 100644 frontend/src/components/AccountSwitcher.tsx delete mode 100644 frontend/src/components/AdminRoute.tsx delete mode 100644 frontend/src/components/AppBrandLink.tsx delete mode 100644 frontend/src/components/AppSidebar.tsx delete mode 100644 frontend/src/components/AuthDecor.tsx delete mode 100644 frontend/src/components/AuthShell.tsx delete mode 100644 frontend/src/components/BrandProductPicker.tsx delete mode 100644 frontend/src/components/BrandProductsPanel.tsx delete mode 100644 frontend/src/components/BrandSwitcher.tsx delete mode 100644 frontend/src/components/CopyFlowNav.tsx delete mode 100644 frontend/src/components/CopyFlowPipeline.tsx delete mode 100644 frontend/src/components/CopyMissionActiveJobsCard.tsx delete mode 100644 frontend/src/components/CopyMissionAnalyzeJobPanel.tsx delete mode 100644 frontend/src/components/CopyMissionDiceButton.tsx delete mode 100644 frontend/src/components/CopyMissionDraftCard.tsx delete mode 100644 frontend/src/components/CopyMissionJobPanel.tsx delete mode 100644 frontend/src/components/CopyMissionKnowledgeTab.tsx delete mode 100644 frontend/src/components/CopyMissionResearchEditor.tsx delete mode 100644 frontend/src/components/CopyMissionResearchOverview.tsx delete mode 100644 frontend/src/components/CopyMissionScanSchedulePanel.tsx delete mode 100644 frontend/src/components/CopyMissionSimilarAccountsCard.tsx delete mode 100644 frontend/src/components/CopyMissionSwitcher.tsx delete mode 100644 frontend/src/components/CopyPersonaSwitcher.tsx delete mode 100644 frontend/src/components/CopySuggestedTagsPicker.tsx delete mode 100644 frontend/src/components/CreatePlacementTopicDialog.tsx delete mode 100644 frontend/src/components/DevToolsPanel.tsx delete mode 100644 frontend/src/components/ErrorBoundary.tsx delete mode 100644 frontend/src/components/EvidenceList.tsx delete mode 100644 frontend/src/components/ExpandGraphJobPanel.tsx delete mode 100644 frontend/src/components/ExpandStrategyField.tsx delete mode 100644 frontend/src/components/ExtensionInstallCard.tsx delete mode 100644 frontend/src/components/JobMonitor.tsx delete mode 100644 frontend/src/components/KnowledgeGraphPanel.tsx delete mode 100644 frontend/src/components/KnowledgeLayerRipple.tsx delete mode 100644 frontend/src/components/KnowledgeOrbitDiagram.tsx delete mode 100644 frontend/src/components/KnowledgeOverviewPanel.tsx delete mode 100644 frontend/src/components/Layout.tsx delete mode 100644 frontend/src/components/LegacyBrandRouteRedirect.tsx delete mode 100644 frontend/src/components/MatrixEntryRoute.tsx delete mode 100644 frontend/src/components/MemberMenu.tsx delete mode 100644 frontend/src/components/MobileBottomNav.tsx delete mode 100644 frontend/src/components/OnboardingBanner.tsx delete mode 100644 frontend/src/components/OnboardingGuide.tsx delete mode 100644 frontend/src/components/OnboardingRouteGuard.tsx delete mode 100644 frontend/src/components/OutreachEntryRoute.tsx delete mode 100644 frontend/src/components/PatrolKeywordsEditor.tsx delete mode 100644 frontend/src/components/PatrolNodeTagsEditor.tsx delete mode 100644 frontend/src/components/PersonaIslandArt.tsx delete mode 100644 frontend/src/components/PersonaScanSchedulePanel.tsx delete mode 100644 frontend/src/components/PlacementFlowNav.tsx delete mode 100644 frontend/src/components/PlacementFlowPipeline.tsx delete mode 100644 frontend/src/components/PlacementResearchSettings.tsx delete mode 100644 frontend/src/components/PlacementScanJobPanel.tsx delete mode 100644 frontend/src/components/ProductContextForm.tsx delete mode 100644 frontend/src/components/ProductItemForm.tsx delete mode 100644 frontend/src/components/ProtectedRoute.tsx delete mode 100644 frontend/src/components/RegisterForm.tsx delete mode 100644 frontend/src/components/ReplyPersonaPicker.tsx delete mode 100644 frontend/src/components/ResearchMapEditor.tsx delete mode 100644 frontend/src/components/ResearchMapOverview.tsx delete mode 100644 frontend/src/components/ResearchMapPanel.tsx delete mode 100644 frontend/src/components/ScheduleFrequencyPicker.tsx delete mode 100644 frontend/src/components/SourceLinkPreview.tsx delete mode 100644 frontend/src/components/StringListEditor.tsx delete mode 100644 frontend/src/components/Style8DAnalysisMeta.tsx delete mode 100644 frontend/src/components/Style8DDimensionEditor.tsx delete mode 100644 frontend/src/components/Style8DJobPanel.tsx delete mode 100644 frontend/src/components/ThemeToggle.tsx delete mode 100644 frontend/src/components/ThreadsAccountWorkspace.tsx delete mode 100644 frontend/src/components/ThreadsPostCharMeter.tsx delete mode 100644 frontend/src/components/TopicSwitcher.tsx delete mode 100644 frontend/src/components/VerifiedAuthorBadge.tsx delete mode 100644 frontend/src/components/ViralScanJobPanel.tsx delete mode 100644 frontend/src/components/islander/IslanderAvatar.tsx delete mode 100644 frontend/src/components/islander/IslanderChatPanel.tsx delete mode 100644 frontend/src/components/islander/IslanderCompanion.tsx delete mode 100644 frontend/src/components/islander/IslanderMarkdown.tsx delete mode 100644 frontend/src/components/ui.tsx delete mode 100644 frontend/src/hooks/useActiveBrand.ts delete mode 100644 frontend/src/hooks/useActiveCopyPersona.ts delete mode 100644 frontend/src/hooks/useActiveTopic.ts delete mode 100644 frontend/src/hooks/useIslanderUnlock.ts delete mode 100644 frontend/src/index.css delete mode 100644 frontend/src/lib/acAssets.ts delete mode 100644 frontend/src/lib/aiCredentials.ts delete mode 100644 frontend/src/lib/brandContext.ts delete mode 100644 frontend/src/lib/connectionMode.ts delete mode 100644 frontend/src/lib/copyFlow.ts delete mode 100644 frontend/src/lib/copyMissionAudience.ts delete mode 100644 frontend/src/lib/copyMissionGraph.ts delete mode 100644 frontend/src/lib/copyMissionJobs.ts delete mode 100644 frontend/src/lib/copyMissionRequirements.ts delete mode 100644 frontend/src/lib/copyMissionSimilarAccounts.ts delete mode 100644 frontend/src/lib/easterEggCatalog.ts delete mode 100644 frontend/src/lib/extensionInstall.ts delete mode 100644 frontend/src/lib/extensionSync.ts delete mode 100644 frontend/src/lib/formatDate.ts delete mode 100644 frontend/src/lib/islander/IslanderPageContext.tsx delete mode 100644 frontend/src/lib/islander/actionExecutor.ts delete mode 100644 frontend/src/lib/islander/buildIslanderContext.ts delete mode 100644 frontend/src/lib/islander/config.ts delete mode 100644 frontend/src/lib/islander/copyActions.ts delete mode 100644 frontend/src/lib/islander/handoffStore.ts delete mode 100644 frontend/src/lib/islander/index.ts delete mode 100644 frontend/src/lib/islander/islanderActions.ts delete mode 100644 frontend/src/lib/islander/islanderAgent.ts delete mode 100644 frontend/src/lib/islander/outreachActions.ts delete mode 100644 frontend/src/lib/islander/pageContextIntent.ts delete mode 100644 frontend/src/lib/islander/pageRegistry.ts delete mode 100644 frontend/src/lib/islander/pageSnapshot.ts delete mode 100644 frontend/src/lib/islander/researchActions.ts delete mode 100644 frontend/src/lib/islander/siteGuide.ts delete mode 100644 frontend/src/lib/islander/types.ts delete mode 100644 frontend/src/lib/islander/unlock.ts delete mode 100644 frontend/src/lib/islander/useIslanderPosition.ts delete mode 100644 frontend/src/lib/jobMonitorRefresh.ts delete mode 100644 frontend/src/lib/jobMonitorUi.ts delete mode 100644 frontend/src/lib/jobStatus.ts delete mode 100644 frontend/src/lib/jobTemplate.ts delete mode 100644 frontend/src/lib/knowledgeGraph.ts delete mode 100644 frontend/src/lib/memberCapabilities.ts delete mode 100644 frontend/src/lib/memberRole.ts delete mode 100644 frontend/src/lib/onboarding.ts delete mode 100644 frontend/src/lib/pageJobScope.ts delete mode 100644 frontend/src/lib/placementFlow.ts delete mode 100644 frontend/src/lib/placementResearch.ts delete mode 100644 frontend/src/lib/placementTopics.ts delete mode 100644 frontend/src/lib/productCatalog.ts delete mode 100644 frontend/src/lib/productContext.ts delete mode 100644 frontend/src/lib/productMatch.ts delete mode 100644 frontend/src/lib/publishPersona.ts delete mode 100644 frontend/src/lib/scheduleCatalog.ts delete mode 100644 frontend/src/lib/scheduleCron.ts delete mode 100644 frontend/src/lib/searchSourceMode.ts delete mode 100644 frontend/src/lib/sourcePreview.ts delete mode 100644 frontend/src/lib/storage.ts delete mode 100644 frontend/src/lib/styleEvidence.ts delete mode 100644 frontend/src/lib/styleProfile.ts delete mode 100644 frontend/src/lib/threadsAccount.ts delete mode 100644 frontend/src/lib/threadsLinks.ts delete mode 100644 frontend/src/lib/threadsPostLimits.ts delete mode 100644 frontend/src/lib/viralSignals.ts delete mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/onboarding/OnboardingContext.tsx delete mode 100644 frontend/src/pages/BrandDetailPage.tsx delete mode 100644 frontend/src/pages/BrandProductEditPage.tsx delete mode 100644 frontend/src/pages/BrandsPage.tsx delete mode 100644 frontend/src/pages/CopyMatrixPage.tsx delete mode 100644 frontend/src/pages/CopyMissionDetailPage.tsx delete mode 100644 frontend/src/pages/DashboardPage.tsx delete mode 100644 frontend/src/pages/EasterEggsPage.tsx delete mode 100644 frontend/src/pages/JobDetailPage.tsx delete mode 100644 frontend/src/pages/JobSchedulesPage.tsx delete mode 100644 frontend/src/pages/JobTemplatesPage.tsx delete mode 100644 frontend/src/pages/JobsPage.tsx delete mode 100644 frontend/src/pages/LoginPage.tsx delete mode 100644 frontend/src/pages/PermissionsPage.tsx delete mode 100644 frontend/src/pages/PersonaDetailPage.tsx delete mode 100644 frontend/src/pages/PersonaMatrixPage.tsx delete mode 100644 frontend/src/pages/PersonaOutreachPage.tsx delete mode 100644 frontend/src/pages/PersonaResearchPage.tsx delete mode 100644 frontend/src/pages/PersonasPage.tsx delete mode 100644 frontend/src/pages/PlacementTopicResearchMapPage.tsx delete mode 100644 frontend/src/pages/PlacementTopicSettingsPage.tsx delete mode 100644 frontend/src/pages/PlacementTopicsPage.tsx delete mode 100644 frontend/src/pages/ProfilePage.tsx delete mode 100644 frontend/src/pages/SettingsPage.tsx delete mode 100644 frontend/src/pages/ThreadsAccountConnectionsPage.tsx delete mode 100644 frontend/src/pages/ThreadsAccountPublishPage.tsx delete mode 100644 frontend/src/theme/ThemeContext.tsx delete mode 100644 frontend/src/threads/ThreadsAccountContext.tsx delete mode 100644 frontend/src/types/api.ts delete mode 100644 frontend/src/types/brand.ts delete mode 100644 frontend/src/types/copyMission.ts delete mode 100644 frontend/src/types/placementTopic.ts delete mode 100644 frontend/src/vite-env.d.ts delete mode 100644 frontend/tsconfig.app.json delete mode 100644 frontend/tsconfig.node.json delete mode 100644 infra/.env.example create mode 100644 infra/nginx/haixun-dev.conf create mode 100755 infra/start-api.sh create mode 100755 infra/start-infra.sh create mode 100755 infra/start-ngrok.sh create mode 100755 infra/start-node-worker.sh create mode 100755 scripts/check-dev.sh create mode 100755 scripts/dev-all create mode 100755 scripts/dev-all-stop.sh create mode 100755 scripts/dev-all.sh create mode 100755 scripts/package-extension.sh delete mode 100755 start-prod.sh delete mode 100755 status-prod.sh delete mode 100755 stop-prod.sh diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 8d07aef3e7f58514f024624d091b16e033258c3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHM&u`R56n+yz<87cgkfeZ$WU&_{5M3Zts;Y`*38E=#lj0~O4Y1hW*^P;JW@S4` zA&Rtzp5O!*PPAvPoGNkQMlZc0E>-^odaHVB-~3o&>_zlKL8}>Q=6jxb?~UiPpJ(4J z0Fc^R;{<>M02Wq(_AYGt6frJpp$zGsNkoG70Q2C&acDzhCMsx~4#R+9z%XDKFbo(5 z-UkM7&lbfRRcKnnfMLKe@R9-XeQ>Y}tZM8kl%Ec4WD0;djNP)JjCp{xaWqynb`?rf z^r^B3qD_fTF^Dq9aYv{lRyB4NDsv#p9EhHo=nRF(+0o9C>OiUrO=}o13=|m9SZ0INWOiEO{1ef;&>{zlT#zJKvMQJAEa z$}=*wV|eEWBi4vDYF%|#_=cN$=~`0r;$?9)&w1qcWY1p+=|g^b41AZ$=n zxpFyV4Zcz1Ypjt-TUQNOC971o_it`aO;wILr%zV4jyap3pG0};)Mr~;C2OMm=@*yk zt5KWr+j5ma^v4EvtLvBTogxQeu&!4{e(SC358LjotMuXMuH7H)vB&oA8yg>+n3$OS zc>jTe2S2IX`+P50ZN=h#&+&-IwP=klx8ktjcDx{Dt;Ll7khuSwe7C? zn;p~{-|mp^F2s~F>2J2#OlZ28k(cWBo+OBQ6wUD@Ohe9=t|Z9zOiJ4`tjTf4b!2+B zBZiU%8YhSao5dwBjy=|*wVPKX&!^EWtMN4DZJ{Z=1)R;rRCrnpQ5I_{pZQafS*~8} z2}hJ*Ls<#<%XGXaX(Z(*-gPO@GtOwKRIbkHAq$S;Yy1r?;7fT6?!p6j2#?`c_#Ixr z-(-XwAcx6OGDW^5XUJD%hI~%~vO+>~L)j1Y9`zyZPXkF%$i41S>x3Kdb|<5m(`fVQ zY5j^@ldC`8|L(7+XN!K?yzw8jIxBCQZQYGo#90ex5Ua4D2{CXC%(QQKK0=*rcoy^r zBtQ%6G9fy+AzFCYxuDnyAqIp94DZ$5iX6^`Lq>Bz0wJ>^69R)90-49$XNSs+W_PEr zm!JQvcc)i0ws)rf8hv)9jD0TnEZmnp`D3w(VHg3;h2Xth2;!wFh5^HXVZbo(_88cq zTsz|Y|NQI!|9^WoU_2WJ3#j)t(q!FnC5C_=BRP?}&PR7Af# xj>ije94`-au&*M9|T2ACtdK|7)|`M->$z9-e0#B4kN{}-glilhJl diff --git a/.dockerignore b/.dockerignore index fe512c9..09dc6ca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,11 @@ .run .cursor **/node_modules +**/.env backend/bin +backend/web/dist +backend/web/public/downloads/*.zip +backend/dev-console/dist frontend/dist **/*_test.go **/.DS_Store diff --git a/.gitignore b/.gitignore index 2eefe95..eae3dac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,29 @@ -# --- secrets / env(不要 commit 實值)--- +# --- secrets / env --- deploy/.env +deploy/.env.dev +deploy/.env.prod +infra/.env +*.env +**/.env +!.env.example +!*.env.example -# --- build 產物 --- -backend/bin/ -frontend/dist/ - -# --- 依賴 --- -node_modules/ - -# --- 其他 --- +# --- runtime / logs --- +.run/ *.log + +# --- build artifacts --- +backend/bin/ +backend/web/dist/ +backend/dev-console/dist/ +frontend/dist/ +dist/ +backend/web/public/downloads/*.zip + +# --- dependencies --- +node_modules/ +**/node_modules/ + +# --- OS / editor --- .DS_Store +.cursor/ diff --git a/.run/api.pid b/.run/api.pid deleted file mode 100644 index 8ce9362..0000000 --- a/.run/api.pid +++ /dev/null @@ -1 +0,0 @@ -72708 diff --git a/.run/logs/api.log b/.run/logs/api.log deleted file mode 100644 index 7266262..0000000 --- a/.run/logs/api.log +++ /dev/null @@ -1,2857 +0,0 @@ -2026/06/25 23:20:58 job worker started: id=wangxinghuadeMacBook-Pro-204.local-go-worker type=go -2026/06/25 23:20:58 job scheduler started: holder=wangxinghuadeMacBook-Pro-204.local-scheduler interval=1m0s -2026/06/25 23:20:58 job reaper started: interval=30s -Starting backend backend at 0.0.0.0:8890... -{"@timestamp":"2026-06-25T23:21:00.699+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.8ms)","duration":"2063.8ms","level":"slow","span":"903a2ea0cc8e8bfb","trace":"951a1587d9fbdbe17a5acd4b7b26d150"} -{"@timestamp":"2026-06-25T23:21:00.700+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.8ms","level":"info","span":"903a2ea0cc8e8bfb","trace":"951a1587d9fbdbe17a5acd4b7b26d150"} -{"@timestamp":"2026-06-25T23:21:05.761+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.5ms)","duration":"2049.5ms","level":"slow","span":"e2cd040270064cad","trace":"d1cc0d0f6dcce31e4d5c435b488e8bd6"} -{"@timestamp":"2026-06-25T23:21:05.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.5ms","level":"info","span":"e2cd040270064cad","trace":"d1cc0d0f6dcce31e4d5c435b488e8bd6"} -{"@timestamp":"2026-06-25T23:21:10.804+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.3ms)","duration":"2036.3ms","level":"slow","span":"e3b6ee784a8391bc","trace":"7684fdb33f70cef5006271bdd481652e"} -{"@timestamp":"2026-06-25T23:21:10.804+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.3ms","level":"info","span":"e3b6ee784a8391bc","trace":"7684fdb33f70cef5006271bdd481652e"} -{"@timestamp":"2026-06-25T23:21:15.872+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.1ms)","duration":"2063.1ms","level":"slow","span":"4c14f55871b3932a","trace":"d44a7bb31a00cad76a395c9bc0a1fb12"} -{"@timestamp":"2026-06-25T23:21:15.873+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.1ms","level":"info","span":"4c14f55871b3932a","trace":"d44a7bb31a00cad76a395c9bc0a1fb12"} -{"@timestamp":"2026-06-25T23:21:16.853+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63243 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.9ms","level":"info","span":"effd0f41a0300cef","trace":"4cf94eb44e02d137a61273e756709fe0"} -{"@timestamp":"2026-06-25T23:21:17.735+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63273 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"b4f7ac175c7572d8","trace":"ec669045143b50bbbda9b98a7011c6f1"} -{"@timestamp":"2026-06-25T23:21:17.735+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63272 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"15789f2e479bc44e","trace":"16fc58333cb5c300eefd47f7bb69ba6d"} -{"@timestamp":"2026-06-25T23:21:17.758+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63277 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"ca68f085b8ad5df8","trace":"33c0a41ddd31e47be6ff89a45c410190"} -{"@timestamp":"2026-06-25T23:21:17.758+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63282 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"0491558a5937a781","trace":"b5a6930db45c46dab604d1be861da49c"} -{"@timestamp":"2026-06-25T23:21:17.758+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63280 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"0ad3987aa1064db9","trace":"f30b25ac2897032944596e175e2e2a8c"} -{"@timestamp":"2026-06-25T23:21:17.759+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63278 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"0554a2ed825e1fb9","trace":"2e8e0da6f56f2194f706eca465c4fd45"} -{"@timestamp":"2026-06-25T23:21:17.760+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63279 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.4ms","level":"info","span":"e03d4e22744edabb","trace":"38c6287c386be304b6bc0cbf83c83bff"} -{"@timestamp":"2026-06-25T23:21:17.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63297 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.8ms","level":"info","span":"4c2250d1477e79f6","trace":"9e4a89ef0f394e99dd75f673ef350983"} -{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63295 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.3ms","level":"info","span":"6ac756f7d913ede3","trace":"c609cf74102c5f3c5bab0791056645d3"} -{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63296 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"e08b3248446cfb96","trace":"14302bfdab1befc06c8ab446c4f8e637"} -{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63298 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"9864285aeb64c8ed","trace":"7db16781b17317dadd902ff648c782d9"} -{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63281 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.3ms","level":"info","span":"de5aebb764afd4f2","trace":"c40205b4395e8f4aba8f728cb3e22245"} -{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63300 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.5ms","level":"info","span":"2058046e10189a38","trace":"90715a0615cf0827296837c8ab94bad4"} -{"@timestamp":"2026-06-25T23:21:17.930+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63308 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"768bd1a780d445f1","trace":"ca488dc314cd71d221580c8110b0f6b2"} -{"@timestamp":"2026-06-25T23:21:17.930+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63307 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"9.5ms","level":"info","span":"98d694dfe8e0018e","trace":"27a607ae067aa4bb1f9183caced8a01e"} -{"@timestamp":"2026-06-25T23:21:17.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63309 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.3ms","level":"info","span":"1a17383ec74564ae","trace":"ce1a5f75e7599a66f1f6c8700e7644bd"} -{"@timestamp":"2026-06-25T23:21:17.935+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63310 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"d8b7ae97cf24becc","trace":"ed0bd6aa1c098c925442d5e1e6b293a4"} -{"@timestamp":"2026-06-25T23:21:17.954+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63311 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"0e9c7c0261c095a3","trace":"14de14347ed1ea912cde0f4a9fb68f03"} -{"@timestamp":"2026-06-25T23:21:20.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63313 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"fc41382db181dd08","trace":"034d565a1f9acb479b75d58af79428c7"} -{"@timestamp":"2026-06-25T23:21:20.916+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.3ms)","duration":"2038.3ms","level":"slow","span":"754c61dc40d2ef0d","trace":"3be52fcbae07592e00baea8870e5a567"} -{"@timestamp":"2026-06-25T23:21:20.916+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.3ms","level":"info","span":"754c61dc40d2ef0d","trace":"3be52fcbae07592e00baea8870e5a567"} -{"@timestamp":"2026-06-25T23:21:22.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63319 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"3878a6f834201848","trace":"91170382c6cbcc53e59acf416a7e29e4"} -{"@timestamp":"2026-06-25T23:21:24.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63321 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"823376e7838edc25","trace":"a4a429dae24bc4c2216d3f0706a7727b"} -{"@timestamp":"2026-06-25T23:21:25.975+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.2ms)","duration":"2053.2ms","level":"slow","span":"aaf691ab935b4810","trace":"12ed2d62b6386e4afbbc04c152b95298"} -{"@timestamp":"2026-06-25T23:21:25.975+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.2ms","level":"info","span":"aaf691ab935b4810","trace":"12ed2d62b6386e4afbbc04c152b95298"} -{"@timestamp":"2026-06-25T23:21:26.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63329 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"1c41db93125a0549","trace":"c3b611c6c82f3dcf5a1a7ef64c210591"} -{"@timestamp":"2026-06-25T23:21:28.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63332 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.0ms","level":"info","span":"688f04cb011a7535","trace":"b8cbafdca9a896ff8ba0b68b94690d6f"} -{"@timestamp":"2026-06-25T23:21:30.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63338 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"800a15c4f2015764","trace":"a14481f5b7bd0f33981a70746aab9daf"} -{"@timestamp":"2026-06-25T23:21:30.991+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"604c7ccdb93456bd","trace":"a347dca3f0ec6421ec686da6bed56965"} -{"@timestamp":"2026-06-25T23:21:30.991+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"604c7ccdb93456bd","trace":"a347dca3f0ec6421ec686da6bed56965"} -{"@timestamp":"2026-06-25T23:21:32.166+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63340 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"e95cdcee16b4c99b","trace":"8d8893d4253b03a9f75fdc74c7c79192"} -{"@timestamp":"2026-06-25T23:21:34.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63346 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"b074893dba71f86e","trace":"ecc9b10291b11aacef7154ed6ddb9dd7"} -{"@timestamp":"2026-06-25T23:21:36.027+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.2ms)","duration":"2027.2ms","level":"slow","span":"06e85e8241d5cb00","trace":"93bc622224fd16c9a0eb1de5f35ac8b1"} -{"@timestamp":"2026-06-25T23:21:36.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.2ms","level":"info","span":"06e85e8241d5cb00","trace":"93bc622224fd16c9a0eb1de5f35ac8b1"} -{"@timestamp":"2026-06-25T23:21:36.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63349 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"23530fb79a86c4df","trace":"e851c79fe04e8243e061045f0733db44"} -{"@timestamp":"2026-06-25T23:21:38.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63354 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"5da62ae56d6506a8","trace":"a73ee0845e5f218c9d3d0e7e46f3e5fb"} -{"@timestamp":"2026-06-25T23:21:40.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63356 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"a6c2a5a39530255e","trace":"8c6c6f98af098f485fdeb69e53d52929"} -{"@timestamp":"2026-06-25T23:21:41.070+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.1ms)","duration":"2039.1ms","level":"slow","span":"3c253c633b600628","trace":"2ee0493c4d10a1bbd236970a51b64c15"} -{"@timestamp":"2026-06-25T23:21:41.070+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.1ms","level":"info","span":"3c253c633b600628","trace":"2ee0493c4d10a1bbd236970a51b64c15"} -{"@timestamp":"2026-06-25T23:21:42.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63361 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"95c63cae162791ea","trace":"c94f8dd7084e40e51b68a2111c316b89"} -{"@timestamp":"2026-06-25T23:21:43.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63363 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"71eef89ea02669d0","trace":"e5f58d36443f0a8f9ad0927110229b5d"} -{"@timestamp":"2026-06-25T23:21:45.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63371 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"9aa1ee1cd3979657","trace":"2435f14daa1afff96e74a26d0812a334"} -{"@timestamp":"2026-06-25T23:21:46.100+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.3ms)","duration":"2025.3ms","level":"slow","span":"59732de2a23f90e6","trace":"a787dae2fb17486a4b9f976de1e903ac"} -{"@timestamp":"2026-06-25T23:21:46.101+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.3ms","level":"info","span":"59732de2a23f90e6","trace":"a787dae2fb17486a4b9f976de1e903ac"} -{"@timestamp":"2026-06-25T23:21:47.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63373 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"8a7cb88b44d24dad","trace":"36e034eccf2d9185c64a76ff7b8ec4dd"} -{"@timestamp":"2026-06-25T23:21:49.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63379 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"e8cfd88aedde15eb","trace":"3105ba922bdc5761ee531cec50104739"} -{"@timestamp":"2026-06-25T23:21:51.127+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.2ms)","duration":"2020.2ms","level":"slow","span":"a2af25cc528cd86f","trace":"ef8c81d72f6e4b2a711e9fe1980a9b0e"} -{"@timestamp":"2026-06-25T23:21:51.127+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.2ms","level":"info","span":"a2af25cc528cd86f","trace":"ef8c81d72f6e4b2a711e9fe1980a9b0e"} -{"@timestamp":"2026-06-25T23:21:51.766+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63381 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"a6d67220fc6840c7","trace":"3beea41e65dc01a8ed571d79c16cf6fd"} -{"@timestamp":"2026-06-25T23:21:53.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63387 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"7b56c646e51af1a5","trace":"d6d9a50b40bd53468de070c5e24ac146"} -{"@timestamp":"2026-06-25T23:21:55.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63393 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"1ff19d75fd160853","trace":"9c1d8281af4b2d8762d0d7282644d6bf"} -{"@timestamp":"2026-06-25T23:21:56.174+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.1ms)","duration":"2042.1ms","level":"slow","span":"2d841a5f4f34474b","trace":"045bd9467926afd2d1f134d0817a30da"} -{"@timestamp":"2026-06-25T23:21:56.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.1ms","level":"info","span":"2d841a5f4f34474b","trace":"045bd9467926afd2d1f134d0817a30da"} -{"@timestamp":"2026-06-25T23:21:57.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63404 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"49ba950da981cd94","trace":"b3fe8ea5748c3da7d34673f2066ff4d7"} -{"@timestamp":"2026-06-25T23:21:58.070+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=7.5Mi, Sys=19.3Mi, NumGC=5","level":"stat"} -{"@timestamp":"2026-06-25T23:21:58.086+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 51, pass: 51, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:21:59.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63406 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"e4b4d378f6f3bb6c","trace":"37787f4fb3bf9d32d67969ddbf69f5ff"} -{"@timestamp":"2026-06-25T23:22:00.701+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.9/s, drops: 0, avg time: 473.0ms, med: 3.5ms, 90th: 2042.1ms, 99th: 2063.7ms, 99.9th: 2063.7ms","level":"stat"} -{"@timestamp":"2026-06-25T23:22:01.205+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"363e589de331a69e","trace":"45ac7af54d852e00b50d6c01f653c6c2"} -{"@timestamp":"2026-06-25T23:22:01.205+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"363e589de331a69e","trace":"45ac7af54d852e00b50d6c01f653c6c2"} -{"@timestamp":"2026-06-25T23:22:01.766+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63412 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"48e857a47436452a","trace":"3f4c442bbee7823d166a489fc138c249"} -{"@timestamp":"2026-06-25T23:22:03.764+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63414 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"e7b58c68c98ec6d0","trace":"d0a3922485f8e05d05e2b535ba6a013e"} -{"@timestamp":"2026-06-25T23:22:05.763+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63420 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"ddfa91c6d5dfed5e","trace":"7a03b77431e942a88681f67f7a16d61c"} -{"@timestamp":"2026-06-25T23:22:06.263+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.5ms)","duration":"2054.5ms","level":"slow","span":"052cc45f99ac36a4","trace":"350596b81248879cfc3ec3dde0cc05ee"} -{"@timestamp":"2026-06-25T23:22:06.263+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.5ms","level":"info","span":"052cc45f99ac36a4","trace":"350596b81248879cfc3ec3dde0cc05ee"} -{"@timestamp":"2026-06-25T23:22:07.760+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63422 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"5fb29bbf4d081b56","trace":"3653798b54c6ae2bef50c778a8170ea8"} -{"@timestamp":"2026-06-25T23:22:09.764+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63428 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"fb8a6e65403bd40e","trace":"025eb708616782c91b1f542ee814e16e"} -{"@timestamp":"2026-06-25T23:22:11.285+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.6ms)","duration":"2018.6ms","level":"slow","span":"11c39814b740b7d1","trace":"41af0482f61997a9a0a4be7e22d0eda6"} -{"@timestamp":"2026-06-25T23:22:11.285+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.6ms","level":"info","span":"11c39814b740b7d1","trace":"41af0482f61997a9a0a4be7e22d0eda6"} -{"@timestamp":"2026-06-25T23:22:11.764+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63430 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"227e65e0c97506a8","trace":"59e37f01211497684520440add1f5ec8"} -{"@timestamp":"2026-06-25T23:22:13.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63436 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"96a15f74cfd10956","trace":"f89ddbcbb791c21eeee12bf7954b5c39"} -{"@timestamp":"2026-06-25T23:22:15.763+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63438 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"b33095d0a0ac3004","trace":"0680deab2d35db5f7dadd7a3e5b70eed"} -{"@timestamp":"2026-06-25T23:22:16.340+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.0ms)","duration":"2051.0ms","level":"slow","span":"544e48ac9c63e029","trace":"468b5513c77a0bf43bf14db58cb0914c"} -{"@timestamp":"2026-06-25T23:22:16.340+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.0ms","level":"info","span":"544e48ac9c63e029","trace":"468b5513c77a0bf43bf14db58cb0914c"} -{"@timestamp":"2026-06-25T23:22:17.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63444 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"6744f4e8dee37b12","trace":"a2f7cb8a63cd6f38d1c49ac662e36897"} -{"@timestamp":"2026-06-25T23:22:19.766+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63446 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"723340c04ee348fd","trace":"c14652f1992a0468dbd2b21b01af335a"} -{"@timestamp":"2026-06-25T23:22:21.368+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"08d11fe7394cf164","trace":"e3bcabc670bcc01a6fb84a8c5aa3832a"} -{"@timestamp":"2026-06-25T23:22:21.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"08d11fe7394cf164","trace":"e3bcabc670bcc01a6fb84a8c5aa3832a"} -{"@timestamp":"2026-06-25T23:22:22.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63453 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"3cd93daf66cea043","trace":"f162b0aed6d136640f96b453e816a39b"} -{"@timestamp":"2026-06-25T23:22:23.996+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63455 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"c732c027456b325a","trace":"848a03cf4238a6e5b4b3a997bc26e910"} -{"@timestamp":"2026-06-25T23:22:25.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63468 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"79bde6146e277512","trace":"b2e50f3da3ce9f8b6ef806449ea68c82"} -{"@timestamp":"2026-06-25T23:22:25.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63467 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"42bc5ee5efaa9817","trace":"4cbad254020c873745ef2abe614fdf4e"} -{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63472 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"e8358c066d592b1b","trace":"2db77fcdc615879546cff2aeafd788c5"} -{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63475 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"f7a4c1592cf05806","trace":"70cd3f1f6725b3261d19ae5719af0d2f"} -{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63473 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"e6c3ebd5b4f98faf","trace":"7fde13c6a69b92b79146c8a82f0496e7"} -{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63474 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.6ms","level":"info","span":"9544c8c2935c438b","trace":"9eacdd577c7ce6394b2ba4fcb6be53d8"} -{"@timestamp":"2026-06-25T23:22:25.045+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63476 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"6a16294ff454a2eb","trace":"c88a923a583a90f00f1e3cd0a65fdf90"} -{"@timestamp":"2026-06-25T23:22:25.046+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63479 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.0ms","level":"info","span":"570217f08bbf3357","trace":"d173b645237cca6d8adb6de268599152"} -{"@timestamp":"2026-06-25T23:22:25.047+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63483 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"1dd7ba230086d25b","trace":"bbe1cdd10f68086bc775cf90ef23df7c"} -{"@timestamp":"2026-06-25T23:22:25.048+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63486 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"d22d84e2d2c984e7","trace":"426583f79b421debf6255d30a017e00d"} -{"@timestamp":"2026-06-25T23:22:25.048+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63484 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"c2b58e4d91891213","trace":"67ea1d3171d74939db16fc66c61f0c52"} -{"@timestamp":"2026-06-25T23:22:25.048+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63485 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"844ef73576e61912","trace":"1e929e1cc822c299dff9af2e6c3d4cdc"} -{"@timestamp":"2026-06-25T23:22:25.049+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63488 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.8ms","level":"info","span":"6b2c435ae5e519eb","trace":"36b7968305967d5e00816c805bf41a91"} -{"@timestamp":"2026-06-25T23:22:25.205+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63495 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"7ce807ec119552f8","trace":"9472847e9c983a658b7962d64f89cdec"} -{"@timestamp":"2026-06-25T23:22:25.208+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63496 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.1ms","level":"info","span":"778a5874f45be4ec","trace":"b680a224d66d78349cfa765136fb7243"} -{"@timestamp":"2026-06-25T23:22:25.208+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63497 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"5635d892cdf52f08","trace":"90b8bf6d1383c9c631992ca546e8d4fd"} -{"@timestamp":"2026-06-25T23:22:25.211+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63498 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"f48af1260d2bfb82","trace":"a5f66e7dddf9ba8b8c7f5cfad14d9c7d"} -{"@timestamp":"2026-06-25T23:22:25.215+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63499 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"7c7fa79f1b0cf9cb","trace":"37a27ded03bf28343ecb321a0c029d58"} -{"@timestamp":"2026-06-25T23:22:26.406+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.8ms)","duration":"2035.8ms","level":"slow","span":"1d71f11280c6dbb5","trace":"38e02b70cf9c6fe1c0f57df3475e5ee3"} -{"@timestamp":"2026-06-25T23:22:26.406+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.8ms","level":"info","span":"1d71f11280c6dbb5","trace":"38e02b70cf9c6fe1c0f57df3475e5ee3"} -{"@timestamp":"2026-06-25T23:22:27.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63501 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"dc8f643ed3090184","trace":"2396a240af108cb847a7328425c379d4"} -{"@timestamp":"2026-06-25T23:22:29.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63506 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"9c0759611fd0e353","trace":"ff867104b043fa97cd6aae00329aa004"} -{"@timestamp":"2026-06-25T23:22:31.164+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63509 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"13aeab5d0bd473c4","trace":"e13e6ab7d4a8fd330ea77cb8790f4bea"} -{"@timestamp":"2026-06-25T23:22:31.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.2ms)","duration":"2028.2ms","level":"slow","span":"5b5c3fee4f7873c4","trace":"c70c131eae02ef97c67e68b8e04e5349"} -{"@timestamp":"2026-06-25T23:22:31.438+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.2ms","level":"info","span":"5b5c3fee4f7873c4","trace":"c70c131eae02ef97c67e68b8e04e5349"} -{"@timestamp":"2026-06-25T23:22:33.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63515 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"8bdc8c74998a89d6","trace":"836d663cad7f3c632bc0e0151fada70a"} -{"@timestamp":"2026-06-25T23:22:35.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63519 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.5ms","level":"info","span":"1b2b9b85e67ee453","trace":"2b2f9779684a6fc04cea78a0cc77a1ce"} -{"@timestamp":"2026-06-25T23:22:36.503+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.8ms)","duration":"2061.8ms","level":"slow","span":"347260fd20dad4bb","trace":"b196b7aac59603b735f05343f70b6c12"} -{"@timestamp":"2026-06-25T23:22:36.503+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.8ms","level":"info","span":"347260fd20dad4bb","trace":"b196b7aac59603b735f05343f70b6c12"} -{"@timestamp":"2026-06-25T23:22:37.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63526 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"c771b5422396ec22","trace":"0e454f4a6e8af1b61bbb88bcdab91c4b"} -{"@timestamp":"2026-06-25T23:22:39.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63529 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"c637e670cfc480b1","trace":"8fe0af9921a6a4fe1aeed77a468998ff"} -{"@timestamp":"2026-06-25T23:22:41.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63536 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"8e11e6e9563c0f16","trace":"954e21a8e0116bea9db570187d629593"} -{"@timestamp":"2026-06-25T23:22:41.556+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.4ms)","duration":"2049.4ms","level":"slow","span":"b4d13ebff479b016","trace":"709d098accd8fa6230db75f9f3cd97fe"} -{"@timestamp":"2026-06-25T23:22:41.556+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.4ms","level":"info","span":"b4d13ebff479b016","trace":"709d098accd8fa6230db75f9f3cd97fe"} -{"@timestamp":"2026-06-25T23:22:43.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63538 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"6bb35798fcd33e42","trace":"8234f5d4e99e7628cc7f132f57e96604"} -{"@timestamp":"2026-06-25T23:22:45.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63546 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.5ms","level":"info","span":"77480f97deb4a8b1","trace":"f90f207e1f17b24ab5e93c486dc9d691"} -{"@timestamp":"2026-06-25T23:22:46.592+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.2ms)","duration":"2031.2ms","level":"slow","span":"a851442be0c158b6","trace":"62f38ff4fbc7f91d9457922e30701735"} -{"@timestamp":"2026-06-25T23:22:46.592+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.2ms","level":"info","span":"a851442be0c158b6","trace":"62f38ff4fbc7f91d9457922e30701735"} -{"@timestamp":"2026-06-25T23:22:47.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63548 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.8ms","level":"info","span":"7198577a0449cf01","trace":"78eda92a7e1d77ecb325858c0b3971ee"} -{"@timestamp":"2026-06-25T23:22:49.166+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63554 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"d4b9c4c52133371f","trace":"f9e1c5db86df24ae63a133b741cfb493"} -{"@timestamp":"2026-06-25T23:22:51.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63556 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"d2a69fc320e5c5b4","trace":"b0499a06e4c5d991ba2a5f146d4e6fee"} -{"@timestamp":"2026-06-25T23:22:51.658+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.9ms)","duration":"2061.9ms","level":"slow","span":"68b92bcf59896207","trace":"1cd4fae0b22c9bb2635ac6bd15a0ddd3"} -{"@timestamp":"2026-06-25T23:22:51.658+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.9ms","level":"info","span":"68b92bcf59896207","trace":"1cd4fae0b22c9bb2635ac6bd15a0ddd3"} -{"@timestamp":"2026-06-25T23:22:53.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63562 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"d8834c60c0239c26","trace":"a4d50b05e8f0b1d5dd211f7d7d8bd0da"} -{"@timestamp":"2026-06-25T23:22:55.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63564 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"e48294e69df2ea8c","trace":"09db5395467c780c5e02898828fea981"} -{"@timestamp":"2026-06-25T23:22:56.699+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.8ms)","duration":"2034.8ms","level":"slow","span":"786fee78440cafc1","trace":"e1b1f24c08e8839372c1574fd6caf457"} -{"@timestamp":"2026-06-25T23:22:56.699+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.8ms","level":"info","span":"786fee78440cafc1","trace":"e1b1f24c08e8839372c1574fd6caf457"} -{"@timestamp":"2026-06-25T23:22:57.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63569 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"78ef6ae9f75097ab","trace":"34fa750d5f668bbc82436fd70d562034"} -{"@timestamp":"2026-06-25T23:22:58.070+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=10.6Mi, Sys=19.6Mi, NumGC=7","level":"stat"} -{"@timestamp":"2026-06-25T23:22:58.086+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 59, pass: 59, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:22:59.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63572 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.4ms","level":"info","span":"ac05efbc5f01c6f4","trace":"d4ca9da8243dca41f26658335b37cdb4"} -{"@timestamp":"2026-06-25T23:23:00.702+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 1.0/s, drops: 0, avg time: 417.2ms, med: 3.4ms, 90th: 2049.3ms, 99th: 2061.9ms, 99.9th: 2061.9ms","level":"stat"} -{"@timestamp":"2026-06-25T23:23:01.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63580 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"27dd7b6ce508c9c1","trace":"bb1471b5807e22bbbd3277ddf4cc6b5d"} -{"@timestamp":"2026-06-25T23:23:01.752+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.4ms)","duration":"2050.4ms","level":"slow","span":"c8e0c80e8e75fe1e","trace":"f0078efa94a54ada139226d86873f307"} -{"@timestamp":"2026-06-25T23:23:01.753+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.4ms","level":"info","span":"c8e0c80e8e75fe1e","trace":"f0078efa94a54ada139226d86873f307"} -{"@timestamp":"2026-06-25T23:23:03.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63582 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"6e85bb2b211a5fc2","trace":"4452858af3443e8ab5ee0329b0ae9be4"} -{"@timestamp":"2026-06-25T23:23:05.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63588 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"0bdbb1f5df8ee51e","trace":"92541e7b78163584e966eed7fc523f9e"} -{"@timestamp":"2026-06-25T23:23:06.817+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.5ms)","duration":"2059.5ms","level":"slow","span":"f106ae0692959ea8","trace":"7ba603fd2b4bbc3ec1e932ac2a3fc86b"} -{"@timestamp":"2026-06-25T23:23:06.817+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.5ms","level":"info","span":"f106ae0692959ea8","trace":"7ba603fd2b4bbc3ec1e932ac2a3fc86b"} -{"@timestamp":"2026-06-25T23:23:07.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63590 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"988ce8d48182b3c2","trace":"439300bb124856f10612884bfda7eb92"} -{"@timestamp":"2026-06-25T23:23:09.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63596 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"d5afd0967247f80b","trace":"c155f9f43205255a6f2f9c56b089af87"} -{"@timestamp":"2026-06-25T23:23:11.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63598 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"937931e3e7b54ba9","trace":"f920b5163cc169a271493c751a943f71"} -{"@timestamp":"2026-06-25T23:23:11.872+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.4ms)","duration":"2051.4ms","level":"slow","span":"16d15031896a7022","trace":"3058e8c8264e024dc27e82bdb006153b"} -{"@timestamp":"2026-06-25T23:23:11.872+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.4ms","level":"info","span":"16d15031896a7022","trace":"3058e8c8264e024dc27e82bdb006153b"} -{"@timestamp":"2026-06-25T23:23:13.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63604 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"0b9cddb3c03007d3","trace":"bd5b77c04ab4ef33184301582d45fd1d"} -{"@timestamp":"2026-06-25T23:23:15.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63606 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"0867713ec24ff448","trace":"380cdf53ddc34c66fe0a5567d8da7dc7"} -{"@timestamp":"2026-06-25T23:23:16.939+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.0ms)","duration":"2061.0ms","level":"slow","span":"8d97a28a5719552d","trace":"7f5e44f0692991ace124110d9f68a575"} -{"@timestamp":"2026-06-25T23:23:16.939+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.0ms","level":"info","span":"8d97a28a5719552d","trace":"7f5e44f0692991ace124110d9f68a575"} -{"@timestamp":"2026-06-25T23:23:17.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63612 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"506898ab04c12dc1","trace":"fddb37cdd3491fb3c0eae585f8087771"} -{"@timestamp":"2026-06-25T23:23:19.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63614 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"65a5a0d3e02a4608","trace":"8ede8803e7f9223755a163927366e7a2"} -{"@timestamp":"2026-06-25T23:23:21.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63620 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"66a054a0ee52b684","trace":"65c1a75e5545fcf0f0213932efc8407e"} -{"@timestamp":"2026-06-25T23:23:21.995+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.6ms)","duration":"2050.6ms","level":"slow","span":"a97c628265e9ae50","trace":"84ff6cc9e834ec20311d131fc6536016"} -{"@timestamp":"2026-06-25T23:23:21.995+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.6ms","level":"info","span":"a97c628265e9ae50","trace":"84ff6cc9e834ec20311d131fc6536016"} -{"@timestamp":"2026-06-25T23:23:23.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63622 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"328db69159e6b205","trace":"a7dd2a115f84e1a515b8c2af2f4aef69"} -{"@timestamp":"2026-06-25T23:23:25.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63628 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"99d246be7803db8e","trace":"02061ed6186a77b4ee4a3013eea673fe"} -{"@timestamp":"2026-06-25T23:23:27.063+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.3ms)","duration":"2065.3ms","level":"slow","span":"9952d25873e0995b","trace":"c416525a1ae621e41ae76c94f02bf024"} -{"@timestamp":"2026-06-25T23:23:27.063+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.3ms","level":"info","span":"9952d25873e0995b","trace":"c416525a1ae621e41ae76c94f02bf024"} -{"@timestamp":"2026-06-25T23:23:32.104+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.7ms)","duration":"2037.7ms","level":"slow","span":"fcaebc14519b1175","trace":"0092203dcd252c13ea1488733dc02919"} -{"@timestamp":"2026-06-25T23:23:32.104+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.7ms","level":"info","span":"fcaebc14519b1175","trace":"0092203dcd252c13ea1488733dc02919"} -{"@timestamp":"2026-06-25T23:23:37.187+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2077.1ms)","duration":"2077.1ms","level":"slow","span":"84f8d88d60d67a6c","trace":"32d1f58302ce4437ec65d0eefaad0afd"} -{"@timestamp":"2026-06-25T23:23:37.187+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2077.1ms","level":"info","span":"84f8d88d60d67a6c","trace":"32d1f58302ce4437ec65d0eefaad0afd"} -{"@timestamp":"2026-06-25T23:23:42.227+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.0ms)","duration":"2035.0ms","level":"slow","span":"76c7cf2db1e79092","trace":"ab384d50c752ab1a2d9078b6635f5cd6"} -{"@timestamp":"2026-06-25T23:23:42.227+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.0ms","level":"info","span":"76c7cf2db1e79092","trace":"ab384d50c752ab1a2d9078b6635f5cd6"} -{"@timestamp":"2026-06-25T23:23:44.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63656 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"786d98a96d021de7","trace":"6b62eea3c2302128ec0a2910fcee116e"} -{"@timestamp":"2026-06-25T23:23:47.307+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2075.3ms)","duration":"2075.3ms","level":"slow","span":"e0447d8288325637","trace":"c7c4919c6f9151ee38fe4367d10a68e7"} -{"@timestamp":"2026-06-25T23:23:47.307+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2075.3ms","level":"info","span":"e0447d8288325637","trace":"c7c4919c6f9151ee38fe4367d10a68e7"} -{"@timestamp":"2026-06-25T23:23:52.340+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.5ms)","duration":"2029.5ms","level":"slow","span":"df109a14ef0d11e7","trace":"58f12ae6736040073e5ed53b5ed3b547"} -{"@timestamp":"2026-06-25T23:23:52.340+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.5ms","level":"info","span":"df109a14ef0d11e7","trace":"58f12ae6736040073e5ed53b5ed3b547"} -{"@timestamp":"2026-06-25T23:23:57.410+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.0ms)","duration":"2067.0ms","level":"slow","span":"41914d3b838ae0cc","trace":"f76c7dca251088b43cb57ea53a251cd4"} -{"@timestamp":"2026-06-25T23:23:57.410+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.0ms","level":"info","span":"41914d3b838ae0cc","trace":"f76c7dca251088b43cb57ea53a251cd4"} -{"@timestamp":"2026-06-25T23:23:58.071+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=12.1Mi, Sys=19.6Mi, NumGC=8","level":"stat"} -{"@timestamp":"2026-06-25T23:23:58.088+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 27, pass: 27, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:24:00.702+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.4/s, drops: 0, avg time: 950.1ms, med: 4.1ms, 90th: 2075.2ms, 99th: 2077.0ms, 99.9th: 2077.0ms","level":"stat"} -{"@timestamp":"2026-06-25T23:24:02.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.6ms)","duration":"2022.6ms","level":"slow","span":"6030f584e227777b","trace":"0a902081df62dc567634a18d44d54c2d"} -{"@timestamp":"2026-06-25T23:24:02.438+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.6ms","level":"info","span":"6030f584e227777b","trace":"0a902081df62dc567634a18d44d54c2d"} -{"@timestamp":"2026-06-25T23:24:07.496+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.7ms)","duration":"2054.7ms","level":"slow","span":"1605634566769a8d","trace":"0012b6cd78af9530e4b4193ff68c8220"} -{"@timestamp":"2026-06-25T23:24:07.496+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.7ms","level":"info","span":"1605634566769a8d","trace":"0012b6cd78af9530e4b4193ff68c8220"} -{"@timestamp":"2026-06-25T23:24:12.534+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.7ms)","duration":"2036.7ms","level":"slow","span":"e65683837c6f1789","trace":"842cd3a089cbc90e94340c82c96133d9"} -{"@timestamp":"2026-06-25T23:24:12.534+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.7ms","level":"info","span":"e65683837c6f1789","trace":"842cd3a089cbc90e94340c82c96133d9"} -{"@timestamp":"2026-06-25T23:24:17.567+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.8ms)","duration":"2028.8ms","level":"slow","span":"2b0df0edcb22fa4f","trace":"c97f70ef5bf63e73664f180fc67150ac"} -{"@timestamp":"2026-06-25T23:24:17.567+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.8ms","level":"info","span":"2b0df0edcb22fa4f","trace":"c97f70ef5bf63e73664f180fc67150ac"} -{"@timestamp":"2026-06-25T23:24:22.646+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2077.0ms)","duration":"2077.0ms","level":"slow","span":"e2137d3a9a3ebcb2","trace":"65bb6592f429c4c8adeedeeee0e26f1d"} -{"@timestamp":"2026-06-25T23:24:22.646+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2077.0ms","level":"info","span":"e2137d3a9a3ebcb2","trace":"65bb6592f429c4c8adeedeeee0e26f1d"} -{"@timestamp":"2026-06-25T23:24:27.711+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.2ms)","duration":"2062.2ms","level":"slow","span":"d6ab3b5697def333","trace":"3ebd25c9bdaca513d46adafb23b822dc"} -{"@timestamp":"2026-06-25T23:24:27.712+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.2ms","level":"info","span":"d6ab3b5697def333","trace":"3ebd25c9bdaca513d46adafb23b822dc"} -{"@timestamp":"2026-06-25T23:24:32.778+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"88a74f00bc07e019","trace":"5bbf116509bde55278bc91a4e65c6e70"} -{"@timestamp":"2026-06-25T23:24:32.778+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"88a74f00bc07e019","trace":"5bbf116509bde55278bc91a4e65c6e70"} -{"@timestamp":"2026-06-25T23:24:37.834+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.8ms)","duration":"2052.8ms","level":"slow","span":"fae2ea40013439ba","trace":"6b42e6dae44a7ab2f5bf3646f01e4937"} -{"@timestamp":"2026-06-25T23:24:37.834+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.8ms","level":"info","span":"fae2ea40013439ba","trace":"6b42e6dae44a7ab2f5bf3646f01e4937"} -{"@timestamp":"2026-06-25T23:24:42.880+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.4ms)","duration":"2044.4ms","level":"slow","span":"0b42051440f3e49b","trace":"c0fbe9f87c3e48c7d0bf50426dfd388e"} -{"@timestamp":"2026-06-25T23:24:42.881+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.4ms","level":"info","span":"0b42051440f3e49b","trace":"c0fbe9f87c3e48c7d0bf50426dfd388e"} -{"@timestamp":"2026-06-25T23:24:44.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63737 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"9ff3f58ac764b9e1","trace":"e42f2316e282a3c7ec6057f4313127ed"} -{"@timestamp":"2026-06-25T23:24:47.939+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.0ms)","duration":"2055.0ms","level":"slow","span":"5def9c071aace7d6","trace":"b8c817b2d393f94066bb4dfb274c45a4"} -{"@timestamp":"2026-06-25T23:24:47.939+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.0ms","level":"info","span":"5def9c071aace7d6","trace":"b8c817b2d393f94066bb4dfb274c45a4"} -{"@timestamp":"2026-06-25T23:24:53.024+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2080.6ms)","duration":"2080.6ms","level":"slow","span":"61fa88a60c6cccf0","trace":"46f0ed51f4db007bf5b06d610d2be303"} -{"@timestamp":"2026-06-25T23:24:53.024+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2080.6ms","level":"info","span":"61fa88a60c6cccf0","trace":"46f0ed51f4db007bf5b06d610d2be303"} -{"@timestamp":"2026-06-25T23:24:58.047+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.8ms)","duration":"2014.8ms","level":"slow","span":"9d8499548191b420","trace":"a9492b1534f9aca436e448c4fe6bbf4d"} -{"@timestamp":"2026-06-25T23:24:58.047+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.8ms","level":"info","span":"9d8499548191b420","trace":"a9492b1534f9aca436e448c4fe6bbf4d"} -{"@timestamp":"2026-06-25T23:24:58.073+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=12.4Mi, Sys=19.6Mi, NumGC=8","level":"stat"} -{"@timestamp":"2026-06-25T23:24:58.088+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:25:00.703+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1891.8ms, med: 2054.7ms, 90th: 2080.4ms, 99th: 2080.4ms, 99.9th: 2080.4ms","level":"stat"} -{"@timestamp":"2026-06-25T23:25:03.100+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.8ms)","duration":"2049.8ms","level":"slow","span":"f2f7e0a166310730","trace":"40430f698f50d55dd0c2d235251298f9"} -{"@timestamp":"2026-06-25T23:25:03.100+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.8ms","level":"info","span":"f2f7e0a166310730","trace":"40430f698f50d55dd0c2d235251298f9"} -{"@timestamp":"2026-06-25T23:25:08.113+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"65e31bba9dc0c0a2","trace":"4aaefd73afd02b780b849392b34e7ecf"} -{"@timestamp":"2026-06-25T23:25:08.113+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"65e31bba9dc0c0a2","trace":"4aaefd73afd02b780b849392b34e7ecf"} -{"@timestamp":"2026-06-25T23:25:13.165+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.4ms)","duration":"2047.4ms","level":"slow","span":"8b3d5553a62460cb","trace":"850be981238822f4b38f7068ea7ff22a"} -{"@timestamp":"2026-06-25T23:25:13.165+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.4ms","level":"info","span":"8b3d5553a62460cb","trace":"850be981238822f4b38f7068ea7ff22a"} -{"@timestamp":"2026-06-25T23:25:18.235+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.7ms)","duration":"2067.7ms","level":"slow","span":"37032234a5dc3b25","trace":"7505ed1c7d36f69d82168fef6c7a4ab8"} -{"@timestamp":"2026-06-25T23:25:18.235+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.7ms","level":"info","span":"37032234a5dc3b25","trace":"7505ed1c7d36f69d82168fef6c7a4ab8"} -{"@timestamp":"2026-06-25T23:25:23.283+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.0ms)","duration":"2046.0ms","level":"slow","span":"8e4100d09278e4c1","trace":"fd28dd92338888d470d6148232a38831"} -{"@timestamp":"2026-06-25T23:25:23.284+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.0ms","level":"info","span":"8e4100d09278e4c1","trace":"fd28dd92338888d470d6148232a38831"} -{"@timestamp":"2026-06-25T23:25:28.341+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.4ms)","duration":"2052.4ms","level":"slow","span":"377b62e3df612f8b","trace":"56e75ae626e6203bad71ba332144248a"} -{"@timestamp":"2026-06-25T23:25:28.341+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.4ms","level":"info","span":"377b62e3df612f8b","trace":"56e75ae626e6203bad71ba332144248a"} -{"@timestamp":"2026-06-25T23:25:33.402+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.8ms)","duration":"2057.8ms","level":"slow","span":"f485a6253e6e5935","trace":"9a83f77382788a0f2458a699f97d453f"} -{"@timestamp":"2026-06-25T23:25:33.402+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.8ms","level":"info","span":"f485a6253e6e5935","trace":"9a83f77382788a0f2458a699f97d453f"} -{"@timestamp":"2026-06-25T23:25:38.466+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.1ms)","duration":"2061.1ms","level":"slow","span":"a7afefa973200817","trace":"3f5eb1f47655f0a416c8603b460d59f4"} -{"@timestamp":"2026-06-25T23:25:38.466+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.1ms","level":"info","span":"a7afefa973200817","trace":"3f5eb1f47655f0a416c8603b460d59f4"} -{"@timestamp":"2026-06-25T23:25:43.514+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.1ms)","duration":"2045.1ms","level":"slow","span":"b6f720b90a1e62eb","trace":"7bc2f898c3388a9477bb99745cc92506"} -{"@timestamp":"2026-06-25T23:25:43.514+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.1ms","level":"info","span":"b6f720b90a1e62eb","trace":"7bc2f898c3388a9477bb99745cc92506"} -{"@timestamp":"2026-06-25T23:25:44.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 401 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63896 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.2ms","level":"info","span":"fcb29cf77227c051","trace":"2dd761726491e35edebf9817585a5c50"} -{"@timestamp":"2026-06-25T23:25:44.178+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/refresh - 127.0.0.1:63898 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"b25a6dd8febec790","trace":"5da6aac1e839d37c4b741c11ea5bc597"} -{"@timestamp":"2026-06-25T23:25:44.184+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63900 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"da90b69bd455ceb7","trace":"802bbdec4a473eba00b128f601f0e39a"} -{"@timestamp":"2026-06-25T23:25:48.541+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.7ms)","duration":"2022.7ms","level":"slow","span":"c647b94a9f5d446b","trace":"a5171bdb00ec0e0a37984e97591e70e9"} -{"@timestamp":"2026-06-25T23:25:48.541+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.7ms","level":"info","span":"c647b94a9f5d446b","trace":"a5171bdb00ec0e0a37984e97591e70e9"} -{"@timestamp":"2026-06-25T23:25:53.584+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.3ms)","duration":"2039.3ms","level":"slow","span":"18caad7f9a7561ba","trace":"449c11bdbce24f6ef9acd87eb33b6c89"} -{"@timestamp":"2026-06-25T23:25:53.585+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.3ms","level":"info","span":"18caad7f9a7561ba","trace":"449c11bdbce24f6ef9acd87eb33b6c89"} -{"@timestamp":"2026-06-25T23:25:58.072+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=13.1Mi, Sys=19.6Mi, NumGC=9","level":"stat"} -{"@timestamp":"2026-06-25T23:25:58.089+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 15, pass: 14, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:25:58.684+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2093.8ms)","duration":"2093.8ms","level":"slow","span":"3d64ec431c7afd71","trace":"8082d2fa1fc7b72c767eff7901bd68d0"} -{"@timestamp":"2026-06-25T23:25:58.684+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2093.8ms","level":"info","span":"3d64ec431c7afd71","trace":"8082d2fa1fc7b72c767eff7901bd68d0"} -{"@timestamp":"2026-06-25T23:26:00.703+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1639.9ms, med: 2047.3ms, 90th: 2093.7ms, 99th: 2093.7ms, 99.9th: 2093.7ms","level":"stat"} -{"@timestamp":"2026-06-25T23:26:03.767+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2078.8ms)","duration":"2078.8ms","level":"slow","span":"c125f7423c6ef84d","trace":"b8f46bb1dfd860d06dbf281fca5b24f9"} -{"@timestamp":"2026-06-25T23:26:03.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2078.8ms","level":"info","span":"c125f7423c6ef84d","trace":"b8f46bb1dfd860d06dbf281fca5b24f9"} -{"@timestamp":"2026-06-25T23:26:08.854+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2082.9ms)","duration":"2082.9ms","level":"slow","span":"be613cc8d8f1946d","trace":"f5b86234428dacfe05645152d3e8552a"} -{"@timestamp":"2026-06-25T23:26:08.854+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2082.9ms","level":"info","span":"be613cc8d8f1946d","trace":"f5b86234428dacfe05645152d3e8552a"} -{"@timestamp":"2026-06-25T23:26:13.916+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.9ms)","duration":"2057.9ms","level":"slow","span":"cca695efc87e6450","trace":"cfcc372e4d145662db2f21bbd451b7fb"} -{"@timestamp":"2026-06-25T23:26:13.916+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.9ms","level":"info","span":"cca695efc87e6450","trace":"cfcc372e4d145662db2f21bbd451b7fb"} -{"@timestamp":"2026-06-25T23:26:18.977+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.3ms)","duration":"2057.3ms","level":"slow","span":"20fb6400e9f516d3","trace":"0e77d7b1eca54b4ba65bc0e6e19623ff"} -{"@timestamp":"2026-06-25T23:26:18.977+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.3ms","level":"info","span":"20fb6400e9f516d3","trace":"0e77d7b1eca54b4ba65bc0e6e19623ff"} -{"@timestamp":"2026-06-25T23:26:24.003+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.3ms)","duration":"2022.3ms","level":"slow","span":"ffdd3e6652bd52c0","trace":"ebae617be52fc678be67088eef0bc389"} -{"@timestamp":"2026-06-25T23:26:24.003+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.3ms","level":"info","span":"ffdd3e6652bd52c0","trace":"ebae617be52fc678be67088eef0bc389"} -{"@timestamp":"2026-06-25T23:26:29.052+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.7ms)","duration":"2043.7ms","level":"slow","span":"431c7e3b52862ff5","trace":"63090189484e85a6e68eac1931778d03"} -{"@timestamp":"2026-06-25T23:26:29.052+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.7ms","level":"info","span":"431c7e3b52862ff5","trace":"63090189484e85a6e68eac1931778d03"} -{"@timestamp":"2026-06-25T23:26:34.099+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.2ms)","duration":"2042.2ms","level":"slow","span":"9da4a66898510b0d","trace":"4db1aa020b278bdef88ba2b190054ae2"} -{"@timestamp":"2026-06-25T23:26:34.099+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.2ms","level":"info","span":"9da4a66898510b0d","trace":"4db1aa020b278bdef88ba2b190054ae2"} -{"@timestamp":"2026-06-25T23:26:39.167+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.8ms)","duration":"2063.8ms","level":"slow","span":"96b93e19c553fd88","trace":"3a299d9c00684e24fae6f548bf202ed9"} -{"@timestamp":"2026-06-25T23:26:39.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.8ms","level":"info","span":"96b93e19c553fd88","trace":"3a299d9c00684e24fae6f548bf202ed9"} -{"@timestamp":"2026-06-25T23:26:44.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64040 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"12.0ms","level":"info","span":"ffbb68e4226223de","trace":"b08afb541144c1a4dc889209b7c818c1"} -{"@timestamp":"2026-06-25T23:26:44.211+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.0ms)","duration":"2041.0ms","level":"slow","span":"78a6fba4e8187c82","trace":"0173664952585cf922274f97ea4b15a0"} -{"@timestamp":"2026-06-25T23:26:44.211+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.0ms","level":"info","span":"78a6fba4e8187c82","trace":"0173664952585cf922274f97ea4b15a0"} -{"@timestamp":"2026-06-25T23:26:49.279+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.9ms)","duration":"2063.9ms","level":"slow","span":"d55b16330c6283bc","trace":"ea3bbffaf1ba0388a80b4c59f51f91ec"} -{"@timestamp":"2026-06-25T23:26:49.279+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.9ms","level":"info","span":"d55b16330c6283bc","trace":"ea3bbffaf1ba0388a80b4c59f51f91ec"} -{"@timestamp":"2026-06-25T23:26:54.311+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.7ms)","duration":"2027.7ms","level":"slow","span":"d59f0173311bf20c","trace":"dcdac3fb8265dcf3c3e224dae09e7933"} -{"@timestamp":"2026-06-25T23:26:54.311+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.7ms","level":"info","span":"d59f0173311bf20c","trace":"dcdac3fb8265dcf3c3e224dae09e7933"} -{"@timestamp":"2026-06-25T23:26:58.073+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=13.2Mi, Sys=19.6Mi, NumGC=9","level":"stat"} -{"@timestamp":"2026-06-25T23:26:58.089+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:26:59.385+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.4ms)","duration":"2071.4ms","level":"slow","span":"9fb1e20eb2377829","trace":"310a14095571fa90b2b919c2c52b3d66"} -{"@timestamp":"2026-06-25T23:26:59.386+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.4ms","level":"info","span":"9fb1e20eb2377829","trace":"310a14095571fa90b2b919c2c52b3d66"} -{"@timestamp":"2026-06-25T23:27:00.703+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1897.2ms, med: 2057.8ms, 90th: 2082.8ms, 99th: 2082.8ms, 99.9th: 2082.8ms","level":"stat"} -{"@timestamp":"2026-06-25T23:27:04.448+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.7ms)","duration":"2059.7ms","level":"slow","span":"0bd8a2eda0e62e18","trace":"b142e58a710260723033469a9757a40d"} -{"@timestamp":"2026-06-25T23:27:04.448+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.7ms","level":"info","span":"0bd8a2eda0e62e18","trace":"b142e58a710260723033469a9757a40d"} -{"@timestamp":"2026-06-25T23:27:09.500+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.9ms)","duration":"2047.9ms","level":"slow","span":"2173abea4044a125","trace":"2d8ab3549da67439a957fa6d9113c4c7"} -{"@timestamp":"2026-06-25T23:27:09.500+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.9ms","level":"info","span":"2173abea4044a125","trace":"2d8ab3549da67439a957fa6d9113c4c7"} -{"@timestamp":"2026-06-25T23:27:14.553+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.9ms)","duration":"2048.9ms","level":"slow","span":"226c92146f23fe48","trace":"146779ab2332357229af643c5d1858f5"} -{"@timestamp":"2026-06-25T23:27:14.553+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.9ms","level":"info","span":"226c92146f23fe48","trace":"146779ab2332357229af643c5d1858f5"} -{"@timestamp":"2026-06-25T23:27:19.602+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.6ms)","duration":"2045.6ms","level":"slow","span":"ce98288e5e171dc0","trace":"148941bb7d25944880b5a9519cff2118"} -{"@timestamp":"2026-06-25T23:27:19.603+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.6ms","level":"info","span":"ce98288e5e171dc0","trace":"148941bb7d25944880b5a9519cff2118"} -{"@timestamp":"2026-06-25T23:27:23.789+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64122 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"c5afd75f44f727d6","trace":"f01f1ed214c9ce2475826fdabda730e2"} -{"@timestamp":"2026-06-25T23:27:24.672+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2068.6ms)","duration":"2068.6ms","level":"slow","span":"4fdb53260e6b4928","trace":"149c88646cada3349945784c408b6ab3"} -{"@timestamp":"2026-06-25T23:27:24.672+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2068.6ms","level":"info","span":"4fdb53260e6b4928","trace":"149c88646cada3349945784c408b6ab3"} -{"@timestamp":"2026-06-25T23:27:25.057+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64126 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.4ms","level":"info","span":"d9646fd44b923e18","trace":"32f97dbabdba054d3412cfe32dbf48b1"} -{"@timestamp":"2026-06-25T23:27:27.052+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64135 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.6ms","level":"info","span":"d4ec4236132624b8","trace":"2398da95caa759b6ab55802da4497de0"} -{"@timestamp":"2026-06-25T23:27:29.056+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64139 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.3ms","level":"info","span":"2159e9e869fb6a7d","trace":"7a86dde370eac69903e67f30b1148607"} -{"@timestamp":"2026-06-25T23:27:29.779+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2103.4ms)","duration":"2103.4ms","level":"slow","span":"3dfd1fa162259abf","trace":"dd1288a237f971dcac90b92e3bfa540b"} -{"@timestamp":"2026-06-25T23:27:29.779+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2103.4ms","level":"info","span":"3dfd1fa162259abf","trace":"dd1288a237f971dcac90b92e3bfa540b"} -{"@timestamp":"2026-06-25T23:27:31.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64145 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"3f53684a1ef06b57","trace":"bb4d8b2db2e3df73cd4e75107f9e6f57"} -{"@timestamp":"2026-06-25T23:27:33.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64149 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"dbf8c1eb9c24ee00","trace":"e81e3d2aabdde00b424a686692cb5a36"} -{"@timestamp":"2026-06-25T23:27:34.849+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.9ms)","duration":"2065.9ms","level":"slow","span":"67a2397ac7237999","trace":"35257ea163d490bec921eec80f1ec878"} -{"@timestamp":"2026-06-25T23:27:34.849+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.9ms","level":"info","span":"67a2397ac7237999","trace":"35257ea163d490bec921eec80f1ec878"} -{"@timestamp":"2026-06-25T23:27:35.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64159 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.8ms","level":"info","span":"4c5141b3c66627b9","trace":"763a0894709f23b7a0359942bcc217a1"} -{"@timestamp":"2026-06-25T23:27:37.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64163 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"d5d1ac431686082f","trace":"c915287ebc7a96757659e2db7bb8f74d"} -{"@timestamp":"2026-06-25T23:27:39.182+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64171 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"293fd37c84459c83","trace":"d7c31d8545dd3afa2dd76ec95d493d4d"} -{"@timestamp":"2026-06-25T23:27:39.887+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.0ms)","duration":"2034.0ms","level":"slow","span":"7f9c6154a5a7a389","trace":"e581da17304d58865f98bc5010a889fe"} -{"@timestamp":"2026-06-25T23:27:39.887+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.0ms","level":"info","span":"7f9c6154a5a7a389","trace":"e581da17304d58865f98bc5010a889fe"} -{"@timestamp":"2026-06-25T23:27:41.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64176 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"b19891a7273fd208","trace":"9f157e03014618b096d1866359b4f7e3"} -{"@timestamp":"2026-06-25T23:27:43.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64182 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.3ms","level":"info","span":"1503df5e8cc99226","trace":"206b710ffe96e16f16c28d3ab8068274"} -{"@timestamp":"2026-06-25T23:27:44.920+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.4ms)","duration":"2029.4ms","level":"slow","span":"08708004ada52eb5","trace":"a052948a6b0060095f0a2d8e03c472b8"} -{"@timestamp":"2026-06-25T23:27:44.921+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.4ms","level":"info","span":"08708004ada52eb5","trace":"a052948a6b0060095f0a2d8e03c472b8"} -{"@timestamp":"2026-06-25T23:27:45.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64184 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"c58dceea5839c62f","trace":"2d6fc986b13144a263a5735e4c57d58c"} -{"@timestamp":"2026-06-25T23:27:47.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64197 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"5f855cc6b650f4ad","trace":"1651532fc8bd427e0b5ec1f7ffea5f4d"} -{"@timestamp":"2026-06-25T23:27:49.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64199 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"d3db9515f0fde7db","trace":"32145af5c2c40f59df3d002da44cc260"} -{"@timestamp":"2026-06-25T23:27:49.950+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.4ms)","duration":"2026.4ms","level":"slow","span":"c5b93ccb05d27ed0","trace":"e63c3d5d10ac2564cadd6dabb977b825"} -{"@timestamp":"2026-06-25T23:27:49.951+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.4ms","level":"info","span":"c5b93ccb05d27ed0","trace":"e63c3d5d10ac2564cadd6dabb977b825"} -{"@timestamp":"2026-06-25T23:27:51.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64202 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"c06f457a4254d3d8","trace":"13e67edb5c4493d5ba56d891915ec726"} -{"@timestamp":"2026-06-25T23:27:53.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64204 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.8ms","level":"info","span":"7aeffc5e1c70af0d","trace":"b107379d40caee2968c2401f324caff6"} -{"@timestamp":"2026-06-25T23:27:54.990+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.8ms)","duration":"2035.8ms","level":"slow","span":"7633fa78a5b5929d","trace":"32f872266b2daabeb293680982807ae1"} -{"@timestamp":"2026-06-25T23:27:54.990+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.8ms","level":"info","span":"7633fa78a5b5929d","trace":"32f872266b2daabeb293680982807ae1"} -{"@timestamp":"2026-06-25T23:27:55.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64211 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"52c49f30d90e4af6","trace":"43d6d5d7192946e64bbd9ce3dffb716a"} -{"@timestamp":"2026-06-25T23:27:57.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64213 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.5ms","level":"info","span":"4c93a0634fe3bf8a","trace":"6af1fd9ea5e926fd9fe040a5dd4aeeb4"} -{"@timestamp":"2026-06-25T23:27:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.9Mi, TotalAlloc=14.7Mi, Sys=19.6Mi, NumGC=10","level":"stat"} -{"@timestamp":"2026-06-25T23:27:58.090+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 30, pass: 30, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:27:59.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64223 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"b59ee02d2e6ab645","trace":"a40ed97121a8e1a8856a73e3540307a0"} -{"@timestamp":"2026-06-25T23:28:00.015+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.0ms)","duration":"2022.0ms","level":"slow","span":"7bb2a696b3db1870","trace":"ed65f60beedf5697aea2fac68c71db2d"} -{"@timestamp":"2026-06-25T23:28:00.015+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.0ms","level":"info","span":"7bb2a696b3db1870","trace":"ed65f60beedf5697aea2fac68c71db2d"} -{"@timestamp":"2026-06-25T23:28:00.704+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.5/s, drops: 0, avg time: 795.6ms, med: 5.3ms, 90th: 2065.8ms, 99th: 2103.3ms, 99.9th: 2103.3ms","level":"stat"} -{"@timestamp":"2026-06-25T23:28:01.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64226 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"24099aa91443c7b3","trace":"afbd9b817b99399d7b3e1d5578a25692"} -{"@timestamp":"2026-06-25T23:28:03.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64228 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"10fec45090658c25","trace":"66bcb8acaf9bae9fa4c8ffb4c3e84749"} -{"@timestamp":"2026-06-25T23:28:05.024+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.0ms)","duration":"2004.0ms","level":"slow","span":"82fc6b6150850cb8","trace":"af1b30a0e24d1612785d006f8005cffa"} -{"@timestamp":"2026-06-25T23:28:05.024+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.0ms","level":"info","span":"82fc6b6150850cb8","trace":"af1b30a0e24d1612785d006f8005cffa"} -{"@timestamp":"2026-06-25T23:28:05.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64230 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.5ms","level":"info","span":"a711f7d1e7bbaeb0","trace":"ff60082bff3c25f90ab08fb54698c546"} -{"@timestamp":"2026-06-25T23:28:07.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64238 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"c0ef429976bf533f","trace":"ead04edc736fcd99f935e77b8063daad"} -{"@timestamp":"2026-06-25T23:28:09.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64242 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.1ms","level":"info","span":"96238dcbb31de561","trace":"8661a8cd6b65506e59f9f2a5ac64bc21"} -{"@timestamp":"2026-06-25T23:28:10.030+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2003.2ms)","duration":"2003.2ms","level":"slow","span":"06670bb0c37b6744","trace":"a672471b47b5f781004fb91f3da7eda4"} -{"@timestamp":"2026-06-25T23:28:10.030+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2003.2ms","level":"info","span":"06670bb0c37b6744","trace":"a672471b47b5f781004fb91f3da7eda4"} -{"@timestamp":"2026-06-25T23:28:11.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64249 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"19b5471c11f37d07","trace":"1348f5c7fc2422827669a3259cfcfd75"} -{"@timestamp":"2026-06-25T23:28:13.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64251 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.5ms","level":"info","span":"d82c13b7ad6c4e24","trace":"85c1c6d06e7e385e6faee4fde37b7788"} -{"@timestamp":"2026-06-25T23:28:15.090+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.3ms)","duration":"2056.3ms","level":"slow","span":"6f27bc714e35574d","trace":"1e8783d20b916c6abf8f557599879d9b"} -{"@timestamp":"2026-06-25T23:28:15.090+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.3ms","level":"info","span":"6f27bc714e35574d","trace":"1e8783d20b916c6abf8f557599879d9b"} -{"@timestamp":"2026-06-25T23:28:15.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64256 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"b40a914a13120afd","trace":"1b30b7baafddf3e54ff2f71edd1f9b6c"} -{"@timestamp":"2026-06-25T23:28:17.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64259 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"99f00eacb5089bfb","trace":"a64eeffa5d1fdabc92b638422176de29"} -{"@timestamp":"2026-06-25T23:28:19.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64266 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"f8f934e247d3318e","trace":"da0b01de4a4eb82e0d2aaab3ca4344a2"} -{"@timestamp":"2026-06-25T23:28:20.128+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.0ms)","duration":"2034.0ms","level":"slow","span":"cbc778e378f89fac","trace":"9bce7a62ba9f8b2d3eee58392055bdd9"} -{"@timestamp":"2026-06-25T23:28:20.128+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.0ms","level":"info","span":"cbc778e378f89fac","trace":"9bce7a62ba9f8b2d3eee58392055bdd9"} -{"@timestamp":"2026-06-25T23:28:21.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64268 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"408cb1576b761b36","trace":"a7cd928a7bd34c8a4f3d57d980a18661"} -{"@timestamp":"2026-06-25T23:28:23.053+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64274 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"42654f43852de2c1","trace":"860d20962a89f44d29782172f91b0482"} -{"@timestamp":"2026-06-25T23:28:25.158+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"0a519afce1f2fb33","trace":"65559d4643b2b4ba14d70574c3661615"} -{"@timestamp":"2026-06-25T23:28:25.159+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"0a519afce1f2fb33","trace":"65559d4643b2b4ba14d70574c3661615"} -{"@timestamp":"2026-06-25T23:28:25.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64277 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"8837a37109f7566e","trace":"acf311b4b2d638cd7a585b9f8ddf258e"} -{"@timestamp":"2026-06-25T23:28:27.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64287 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"c8370c89e9a9fcc2","trace":"0e01fab6f1b9309b96704e8d9eee453b"} -{"@timestamp":"2026-06-25T23:28:29.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64290 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"43f41d63c8cb518c","trace":"82803c7ebb720c3bb76fceea6e738a35"} -{"@timestamp":"2026-06-25T23:28:30.188+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.3ms)","duration":"2026.3ms","level":"slow","span":"0b62538c9690d6dc","trace":"45b30d882031898ffa3db4786dc24470"} -{"@timestamp":"2026-06-25T23:28:30.188+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.3ms","level":"info","span":"0b62538c9690d6dc","trace":"45b30d882031898ffa3db4786dc24470"} -{"@timestamp":"2026-06-25T23:28:31.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64295 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"f8455c0775a0ae50","trace":"2f1282693d56e17535f70bafd0c8680c"} -{"@timestamp":"2026-06-25T23:28:33.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64301 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"49d037b0f0eb827a","trace":"ae6336467303c84541219c53f67a0c87"} -{"@timestamp":"2026-06-25T23:28:35.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64309 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"45ba8dc54e5eb35e","trace":"ba09f323ef7ba17aaba8b59b5cb63bd3"} -{"@timestamp":"2026-06-25T23:28:35.221+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.1ms)","duration":"2029.1ms","level":"slow","span":"f3f24841968f4ab9","trace":"60a66d8037057312da5192690241987b"} -{"@timestamp":"2026-06-25T23:28:35.221+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.1ms","level":"info","span":"f3f24841968f4ab9","trace":"60a66d8037057312da5192690241987b"} -{"@timestamp":"2026-06-25T23:28:37.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64318 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"8fba98c1a56e43c8","trace":"354bd5ab2edcf73c6def205af08cd964"} -{"@timestamp":"2026-06-25T23:28:39.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64321 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"cdac7fe5ba30e0ea","trace":"29a9f7b086a6a6764fc244929c414a20"} -{"@timestamp":"2026-06-25T23:28:40.252+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"2909371b8d4150db","trace":"c7fbe225c61aa4ce2402889ee0515b88"} -{"@timestamp":"2026-06-25T23:28:40.252+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"2909371b8d4150db","trace":"c7fbe225c61aa4ce2402889ee0515b88"} -{"@timestamp":"2026-06-25T23:28:41.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64323 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"8a1a172919540a7e","trace":"d6b91ec50e7a083f1835c9b72ae536d9"} -{"@timestamp":"2026-06-25T23:28:43.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64331 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"10f813c405b230e8","trace":"6392123895dd9778160f9b3615f8368a"} -{"@timestamp":"2026-06-25T23:28:45.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64333 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.8ms","level":"info","span":"18711de017df6631","trace":"9e2291c807f3cc8db9ea85d662ad488e"} -{"@timestamp":"2026-06-25T23:28:45.269+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.0ms)","duration":"2013.0ms","level":"slow","span":"f2fe0f31e66fe02b","trace":"15dfa2ad6d23cb07a7d8e8bfdc4c02ef"} -{"@timestamp":"2026-06-25T23:28:45.269+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.0ms","level":"info","span":"f2fe0f31e66fe02b","trace":"15dfa2ad6d23cb07a7d8e8bfdc4c02ef"} -{"@timestamp":"2026-06-25T23:28:47.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64339 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"af821ff4d4ededb2","trace":"b5caedcd7a46684ac987606f1b319b0e"} -{"@timestamp":"2026-06-25T23:28:49.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64341 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.6ms","level":"info","span":"dc442e60870610bf","trace":"119ac5052d06b31c3289524d916269b4"} -{"@timestamp":"2026-06-25T23:28:50.307+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.1ms)","duration":"2033.1ms","level":"slow","span":"c6198edcc989baea","trace":"aa315305e5e0a587622803eae99e0ec8"} -{"@timestamp":"2026-06-25T23:28:50.307+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.1ms","level":"info","span":"c6198edcc989baea","trace":"aa315305e5e0a587622803eae99e0ec8"} -{"@timestamp":"2026-06-25T23:28:51.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64348 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.3ms","level":"info","span":"90a78a1d88c79e0e","trace":"7bf1dcb16b3fa274c8fbd9d0cde5885b"} -{"@timestamp":"2026-06-25T23:28:53.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64351 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.3ms","level":"info","span":"c9296702b5f16938","trace":"02ada9cd2a6731670bacf6e505b573de"} -{"@timestamp":"2026-06-25T23:28:55.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64359 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"c5196e4b8b3bf53e","trace":"e56d75a7a6b90754dddadef33cc69fed"} -{"@timestamp":"2026-06-25T23:28:55.328+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.7ms)","duration":"2019.7ms","level":"slow","span":"b0559f3e27d628b7","trace":"be8932ff5825c72d43fdbd55e5dccb2d"} -{"@timestamp":"2026-06-25T23:28:55.328+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.7ms","level":"info","span":"b0559f3e27d628b7","trace":"be8932ff5825c72d43fdbd55e5dccb2d"} -{"@timestamp":"2026-06-25T23:28:57.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64365 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"31db38cb4434a033","trace":"7372ccf1d3c6cdc086d3f8ef39b57978"} -{"@timestamp":"2026-06-25T23:28:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=17.3Mi, Sys=19.6Mi, NumGC=12","level":"stat"} -{"@timestamp":"2026-06-25T23:28:58.091+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 41, pass: 42, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:28:59.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64373 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"9.9ms","level":"info","span":"9891c6f6fc3fd888","trace":"aa2bf8443d349ed5d30f821a2f5ef9e3"} -{"@timestamp":"2026-06-25T23:29:00.341+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.0ms)","duration":"2007.0ms","level":"slow","span":"cee9bed454f2ff11","trace":"7a35456fd9899f86738435456c0298ec"} -{"@timestamp":"2026-06-25T23:29:00.341+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.0ms","level":"info","span":"cee9bed454f2ff11","trace":"7a35456fd9899f86738435456c0298ec"} -{"@timestamp":"2026-06-25T23:29:00.705+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.7/s, drops: 0, avg time: 581.0ms, med: 4.5ms, 90th: 2029.0ms, 99th: 2056.2ms, 99.9th: 2056.2ms","level":"stat"} -{"@timestamp":"2026-06-25T23:29:01.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64375 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.0ms","level":"info","span":"0132e1c82ae2803d","trace":"3b415e6d5dc65e84def1933c4ec2cb8d"} -{"@timestamp":"2026-06-25T23:29:03.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64383 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"a25dee13b288e97b","trace":"716616ff3f95fe49e05b3273ab16370f"} -{"@timestamp":"2026-06-25T23:29:05.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64386 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"8e16fd08ab8f47dc","trace":"74637d6d0e3c3dca28f3bf90448c377b"} -{"@timestamp":"2026-06-25T23:29:05.360+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2015.4ms)","duration":"2015.4ms","level":"slow","span":"1b666a3a93b5f97e","trace":"2d17227d856dda08970bc324e922e28f"} -{"@timestamp":"2026-06-25T23:29:05.360+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2015.4ms","level":"info","span":"1b666a3a93b5f97e","trace":"2d17227d856dda08970bc324e922e28f"} -{"@timestamp":"2026-06-25T23:29:07.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64396 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"57e1d23c89dc7996","trace":"62073bc3f1a054dec6dda8dbf272e0bb"} -{"@timestamp":"2026-06-25T23:29:09.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64399 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.5ms","level":"info","span":"1e6ad96837d6584a","trace":"cc375464480588614bc2086266e466fd"} -{"@timestamp":"2026-06-25T23:29:10.390+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"5cd6bee3be8b1ac4","trace":"3e8cc4fecfea0a1fd9dc892d82b82c3d"} -{"@timestamp":"2026-06-25T23:29:10.390+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"5cd6bee3be8b1ac4","trace":"3e8cc4fecfea0a1fd9dc892d82b82c3d"} -{"@timestamp":"2026-06-25T23:29:11.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64407 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"988582218947032e","trace":"4588ff1a4f405028c39912fe16cf2f0d"} -{"@timestamp":"2026-06-25T23:29:13.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64410 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"f8e4d7668c1a5843","trace":"3e31252897c95d9ed1ab6badc4fc192f"} -{"@timestamp":"2026-06-25T23:29:15.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64413 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.7ms","level":"info","span":"dfffeec421988f7d","trace":"ad5fd23e24f88546ccc488d56853bb86"} -{"@timestamp":"2026-06-25T23:29:15.401+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.8ms)","duration":"2007.8ms","level":"slow","span":"75bea38511a6ddd2","trace":"9af9aee196f48d5dd11d8ed1f94dd2e1"} -{"@timestamp":"2026-06-25T23:29:15.401+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.8ms","level":"info","span":"75bea38511a6ddd2","trace":"9af9aee196f48d5dd11d8ed1f94dd2e1"} -{"@timestamp":"2026-06-25T23:29:17.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64415 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"1d7507ec5b9c1aa5","trace":"32090500f97d3619a285bf3a109010c5"} -{"@timestamp":"2026-06-25T23:29:19.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64423 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"f05615724675c2a4","trace":"950686abac4c700299ad9f7eac4a1ac0"} -{"@timestamp":"2026-06-25T23:29:20.449+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.6ms)","duration":"2043.6ms","level":"slow","span":"767245afe112782f","trace":"8a14d02c250edc803b63dfa0f1f923cf"} -{"@timestamp":"2026-06-25T23:29:20.449+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.6ms","level":"info","span":"767245afe112782f","trace":"8a14d02c250edc803b63dfa0f1f923cf"} -{"@timestamp":"2026-06-25T23:29:21.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64425 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"e764329c3745e6ad","trace":"21a4c3468a2cf160a58add45aa497b57"} -{"@timestamp":"2026-06-25T23:29:23.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64432 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"8b79ebc94be569e7","trace":"14b9b5acfe044f3795ed0b1126cc591b"} -{"@timestamp":"2026-06-25T23:29:25.469+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.4ms)","duration":"2016.4ms","level":"slow","span":"97e689f1d12fec40","trace":"39473dc432340a10f1af67d1b34bcc0e"} -{"@timestamp":"2026-06-25T23:29:25.469+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.4ms","level":"info","span":"97e689f1d12fec40","trace":"39473dc432340a10f1af67d1b34bcc0e"} -{"@timestamp":"2026-06-25T23:29:30.494+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.8ms)","duration":"2020.8ms","level":"slow","span":"0f924f0f329c923d","trace":"13564fe95544003d8676f19aa8e56680"} -{"@timestamp":"2026-06-25T23:29:30.494+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.8ms","level":"info","span":"0f924f0f329c923d","trace":"13564fe95544003d8676f19aa8e56680"} -{"@timestamp":"2026-06-25T23:29:35.521+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.2ms)","duration":"2023.2ms","level":"slow","span":"29c80637527a96cb","trace":"e67136765a75f8a305653aa838984690"} -{"@timestamp":"2026-06-25T23:29:35.522+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.2ms","level":"info","span":"29c80637527a96cb","trace":"e67136765a75f8a305653aa838984690"} -{"@timestamp":"2026-06-25T23:29:40.551+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.5ms)","duration":"2025.5ms","level":"slow","span":"4c3c06d19c449174","trace":"201afc497620cddeaff6dbf067e50ce2"} -{"@timestamp":"2026-06-25T23:29:40.551+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.5ms","level":"info","span":"4c3c06d19c449174","trace":"201afc497620cddeaff6dbf067e50ce2"} -{"@timestamp":"2026-06-25T23:29:44.180+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64455 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.1ms","level":"info","span":"cda189e58f7b1c4c","trace":"adf020ca0266d8c688c8fe9e42e3e801"} -{"@timestamp":"2026-06-25T23:29:45.589+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.4ms)","duration":"2034.4ms","level":"slow","span":"e443a957ff7cf45a","trace":"f1c471533c80ebbb118e9d3f07f3306b"} -{"@timestamp":"2026-06-25T23:29:45.591+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.4ms","level":"info","span":"e443a957ff7cf45a","trace":"f1c471533c80ebbb118e9d3f07f3306b"} -{"@timestamp":"2026-06-25T23:29:50.613+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.5ms)","duration":"2018.5ms","level":"slow","span":"c3e4bc2b4e09bfae","trace":"ba4b31c041aba4239b7c427d22ea1270"} -{"@timestamp":"2026-06-25T23:29:50.613+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.5ms","level":"info","span":"c3e4bc2b4e09bfae","trace":"ba4b31c041aba4239b7c427d22ea1270"} -{"@timestamp":"2026-06-25T23:29:55.647+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"1433d43f050d2a47","trace":"88df6667d3092624a20358f93e905137"} -{"@timestamp":"2026-06-25T23:29:55.647+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"1433d43f050d2a47","trace":"88df6667d3092624a20358f93e905137"} -{"@timestamp":"2026-06-25T23:29:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.9Mi, TotalAlloc=18.0Mi, Sys=19.6Mi, NumGC=12","level":"stat"} -{"@timestamp":"2026-06-25T23:29:58.090+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 26, pass: 26, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:30:00.660+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.9ms)","duration":"2008.9ms","level":"slow","span":"e6229c51eab6456e","trace":"e81bb27a5cd8f51c822f5d98574f6b35"} -{"@timestamp":"2026-06-25T23:30:00.660+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.9ms","level":"info","span":"e6229c51eab6456e","trace":"e81bb27a5cd8f51c822f5d98574f6b35"} -{"@timestamp":"2026-06-25T23:30:00.706+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.4/s, drops: 0, avg time: 972.9ms, med: 2007.7ms, 90th: 2034.3ms, 99th: 2043.5ms, 99.9th: 2043.5ms","level":"stat"} -{"@timestamp":"2026-06-25T23:30:05.707+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.7ms)","duration":"2043.7ms","level":"slow","span":"6fdcc2552fde4355","trace":"20afc2f8985b577f984c84c0d4859e87"} -{"@timestamp":"2026-06-25T23:30:05.707+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.7ms","level":"info","span":"6fdcc2552fde4355","trace":"20afc2f8985b577f984c84c0d4859e87"} -{"@timestamp":"2026-06-25T23:30:10.724+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.2ms)","duration":"2014.2ms","level":"slow","span":"a471d8f55c2464bb","trace":"04f02d48fb7d3c0f2173b61ef8c29644"} -{"@timestamp":"2026-06-25T23:30:10.724+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.2ms","level":"info","span":"a471d8f55c2464bb","trace":"04f02d48fb7d3c0f2173b61ef8c29644"} -{"@timestamp":"2026-06-25T23:30:15.739+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.6ms)","duration":"2011.6ms","level":"slow","span":"e2ca77d7542133b4","trace":"5b6d6e6cd5f4a377d883d4958133f9ec"} -{"@timestamp":"2026-06-25T23:30:15.739+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.6ms","level":"info","span":"e2ca77d7542133b4","trace":"5b6d6e6cd5f4a377d883d4958133f9ec"} -{"@timestamp":"2026-06-25T23:30:20.757+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.8ms)","duration":"2013.8ms","level":"slow","span":"2baef567262aad99","trace":"0f3ae5a815de6ad67110eb3bddffe657"} -{"@timestamp":"2026-06-25T23:30:20.757+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.8ms","level":"info","span":"2baef567262aad99","trace":"0f3ae5a815de6ad67110eb3bddffe657"} -{"@timestamp":"2026-06-25T23:30:25.797+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.5ms)","duration":"2035.5ms","level":"slow","span":"a72eaf878a308c16","trace":"de668d7f533f05e967a06c3989c09fe7"} -{"@timestamp":"2026-06-25T23:30:25.798+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.5ms","level":"info","span":"a72eaf878a308c16","trace":"de668d7f533f05e967a06c3989c09fe7"} -{"@timestamp":"2026-06-25T23:30:30.833+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.8ms)","duration":"2031.8ms","level":"slow","span":"b9e7948813ed5e1d","trace":"5d1c019f2c758d6e8ef96fb6bbfd7bf1"} -{"@timestamp":"2026-06-25T23:30:30.833+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.8ms","level":"info","span":"b9e7948813ed5e1d","trace":"5d1c019f2c758d6e8ef96fb6bbfd7bf1"} -{"@timestamp":"2026-06-25T23:30:35.849+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.0ms)","duration":"2012.0ms","level":"slow","span":"e645cd0af1caa378","trace":"5e88db2b65abec990000a5a1684027a1"} -{"@timestamp":"2026-06-25T23:30:35.849+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.0ms","level":"info","span":"e645cd0af1caa378","trace":"5e88db2b65abec990000a5a1684027a1"} -{"@timestamp":"2026-06-25T23:30:40.878+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.2ms)","duration":"2026.2ms","level":"slow","span":"e20f218c2aaf4d04","trace":"480ddf8d8562d22809d89ac432102982"} -{"@timestamp":"2026-06-25T23:30:40.879+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.2ms","level":"info","span":"e20f218c2aaf4d04","trace":"480ddf8d8562d22809d89ac432102982"} -{"@timestamp":"2026-06-25T23:30:44.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64530 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"73e2213acfc4ef8a","trace":"7ea6be3391b8e6cad0fd32629772f414"} -{"@timestamp":"2026-06-25T23:30:45.907+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.6ms)","duration":"2022.6ms","level":"slow","span":"2d4e4708a28af2fa","trace":"e921d02499b7defe826e2074bf85d837"} -{"@timestamp":"2026-06-25T23:30:45.907+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.6ms","level":"info","span":"2d4e4708a28af2fa","trace":"e921d02499b7defe826e2074bf85d837"} -{"@timestamp":"2026-06-25T23:30:50.932+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.5ms)","duration":"2021.5ms","level":"slow","span":"9a6efb58c94ee845","trace":"6d759f52584d1bd84f23a6bebacde00d"} -{"@timestamp":"2026-06-25T23:30:50.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.5ms","level":"info","span":"9a6efb58c94ee845","trace":"6d759f52584d1bd84f23a6bebacde00d"} -{"@timestamp":"2026-06-25T23:30:55.961+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.3ms)","duration":"2025.3ms","level":"slow","span":"c38577106540aa4b","trace":"4dcf912a1a006b45f498fd0b0f74a771"} -{"@timestamp":"2026-06-25T23:30:55.961+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.3ms","level":"info","span":"c38577106540aa4b","trace":"4dcf912a1a006b45f498fd0b0f74a771"} -{"@timestamp":"2026-06-25T23:30:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=18.9Mi, Sys=19.6Mi, NumGC=13","level":"stat"} -{"@timestamp":"2026-06-25T23:30:58.092+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:31:00.706+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1854.9ms, med: 2022.5ms, 90th: 2043.7ms, 99th: 2043.7ms, 99.9th: 2043.7ms","level":"stat"} -{"@timestamp":"2026-06-25T23:31:00.994+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.0ms)","duration":"2028.0ms","level":"slow","span":"c5636144a8c7127a","trace":"5f64ebe2fd9b450079f4bf54421fb9a2"} -{"@timestamp":"2026-06-25T23:31:00.994+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.0ms","level":"info","span":"c5636144a8c7127a","trace":"5f64ebe2fd9b450079f4bf54421fb9a2"} -{"@timestamp":"2026-06-25T23:31:06.028+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.7ms)","duration":"2026.7ms","level":"slow","span":"79577d5ebafb1a41","trace":"5dd869e957d71047c457dc8b20bc801a"} -{"@timestamp":"2026-06-25T23:31:06.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.7ms","level":"info","span":"79577d5ebafb1a41","trace":"5dd869e957d71047c457dc8b20bc801a"} -{"@timestamp":"2026-06-25T23:31:11.054+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"a127326ac7cfb903","trace":"1f45c10590c23fc186215054aebc6602"} -{"@timestamp":"2026-06-25T23:31:11.054+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"a127326ac7cfb903","trace":"1f45c10590c23fc186215054aebc6602"} -{"@timestamp":"2026-06-25T23:31:16.091+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"cbf931ad43b78ea8","trace":"8b7b5ac7e57394cc3c853ac311b764c7"} -{"@timestamp":"2026-06-25T23:31:16.091+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"cbf931ad43b78ea8","trace":"8b7b5ac7e57394cc3c853ac311b764c7"} -{"@timestamp":"2026-06-25T23:31:21.121+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.0ms)","duration":"2025.0ms","level":"slow","span":"6f5ab72564ad28df","trace":"58220a65133e5cdc95e038d693aa032a"} -{"@timestamp":"2026-06-25T23:31:21.121+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.0ms","level":"info","span":"6f5ab72564ad28df","trace":"58220a65133e5cdc95e038d693aa032a"} -{"@timestamp":"2026-06-25T23:31:26.155+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.1ms)","duration":"2031.1ms","level":"slow","span":"05dfa72f8a7904b4","trace":"542a0f801eedf516e149abfe0a51e791"} -{"@timestamp":"2026-06-25T23:31:26.155+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.1ms","level":"info","span":"05dfa72f8a7904b4","trace":"542a0f801eedf516e149abfe0a51e791"} -{"@timestamp":"2026-06-25T23:31:31.186+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.6ms)","duration":"2026.6ms","level":"slow","span":"e97d7d3cfcc2b551","trace":"117b75369c20aa2bc17adf5fd217cd35"} -{"@timestamp":"2026-06-25T23:31:31.186+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.6ms","level":"info","span":"e97d7d3cfcc2b551","trace":"117b75369c20aa2bc17adf5fd217cd35"} -{"@timestamp":"2026-06-25T23:31:36.202+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.0ms)","duration":"2013.0ms","level":"slow","span":"830b984d56abece6","trace":"0523fe942ce36f98704f4a35db956828"} -{"@timestamp":"2026-06-25T23:31:36.202+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.0ms","level":"info","span":"830b984d56abece6","trace":"0523fe942ce36f98704f4a35db956828"} -{"@timestamp":"2026-06-25T23:31:41.212+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.3ms)","duration":"2008.3ms","level":"slow","span":"f8164c3d74541f0b","trace":"8b9c4651133a378357b9654d68989b5d"} -{"@timestamp":"2026-06-25T23:31:41.212+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.3ms","level":"info","span":"f8164c3d74541f0b","trace":"8b9c4651133a378357b9654d68989b5d"} -{"@timestamp":"2026-06-25T23:31:44.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64608 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"dfd16c4b32f98e76","trace":"3913d11048e74f5f5bd4b44f8d849282"} -{"@timestamp":"2026-06-25T23:31:46.232+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.2ms)","duration":"2016.2ms","level":"slow","span":"a5445252684bffa4","trace":"81645b286ce1b169d9f4dbbe79ed8cc0"} -{"@timestamp":"2026-06-25T23:31:46.232+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.2ms","level":"info","span":"a5445252684bffa4","trace":"81645b286ce1b169d9f4dbbe79ed8cc0"} -{"@timestamp":"2026-06-25T23:31:51.259+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.3ms)","duration":"2024.3ms","level":"slow","span":"16f861cf5d9c0c87","trace":"1f302bbc73782f01718f556879e2d724"} -{"@timestamp":"2026-06-25T23:31:51.260+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.3ms","level":"info","span":"16f861cf5d9c0c87","trace":"1f302bbc73782f01718f556879e2d724"} -{"@timestamp":"2026-06-25T23:31:54.134+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/session/import - 127.0.0.1:64624 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.9ms","level":"info","span":"40caca524b90ee6b","trace":"ff351e5843b273b8a3cf969b8e930413"} -{"@timestamp":"2026-06-25T23:31:56.287+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.4ms)","duration":"2023.4ms","level":"slow","span":"ae6683eb21985953","trace":"afe717a958a4d3edfecd84dc8a1312c2"} -{"@timestamp":"2026-06-25T23:31:56.287+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.4ms","level":"info","span":"ae6683eb21985953","trace":"afe717a958a4d3edfecd84dc8a1312c2"} -{"@timestamp":"2026-06-25T23:31:58.076+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=19.2Mi, Sys=19.6Mi, NumGC=13","level":"stat"} -{"@timestamp":"2026-06-25T23:31:58.092+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 14, pass: 14, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:32:00.707+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1734.7ms, med: 2024.3ms, 90th: 2033.2ms, 99th: 2033.2ms, 99.9th: 2033.2ms","level":"stat"} -{"@timestamp":"2026-06-25T23:32:01.315+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.7ms)","duration":"2024.7ms","level":"slow","span":"e43b06de55b273fe","trace":"322eb3370c2dd7e41a66ff5af2f7e107"} -{"@timestamp":"2026-06-25T23:32:01.315+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.7ms","level":"info","span":"e43b06de55b273fe","trace":"322eb3370c2dd7e41a66ff5af2f7e107"} -{"@timestamp":"2026-06-25T23:32:06.347+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.5ms)","duration":"2028.5ms","level":"slow","span":"8b86367f29e39fdf","trace":"8dbc9a2800dca63c7ca279a4a80affc6"} -{"@timestamp":"2026-06-25T23:32:06.347+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.5ms","level":"info","span":"8b86367f29e39fdf","trace":"8dbc9a2800dca63c7ca279a4a80affc6"} -{"@timestamp":"2026-06-25T23:32:11.362+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.6ms)","duration":"2010.6ms","level":"slow","span":"bfcfd3f9430ea1c7","trace":"fcc84f242d6306240b65fb47dc110c55"} -{"@timestamp":"2026-06-25T23:32:11.362+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.6ms","level":"info","span":"bfcfd3f9430ea1c7","trace":"fcc84f242d6306240b65fb47dc110c55"} -{"@timestamp":"2026-06-25T23:32:16.409+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.8ms)","duration":"2042.8ms","level":"slow","span":"17ab06852facad13","trace":"dcc98a15c130fb2b35b4e34e87ec5f9c"} -{"@timestamp":"2026-06-25T23:32:16.409+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.8ms","level":"info","span":"17ab06852facad13","trace":"dcc98a15c130fb2b35b4e34e87ec5f9c"} -{"@timestamp":"2026-06-25T23:32:21.426+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.5ms)","duration":"2014.5ms","level":"slow","span":"ff93893877a99e71","trace":"b45da5abb53ef8ffeb91bc390a441582"} -{"@timestamp":"2026-06-25T23:32:21.427+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.5ms","level":"info","span":"ff93893877a99e71","trace":"b45da5abb53ef8ffeb91bc390a441582"} -{"@timestamp":"2026-06-25T23:32:26.454+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"cc0937dd45da2a0c","trace":"ff4e1fbe2b1361f44af69d711d3a836f"} -{"@timestamp":"2026-06-25T23:32:26.454+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"cc0937dd45da2a0c","trace":"ff4e1fbe2b1361f44af69d711d3a836f"} -{"@timestamp":"2026-06-25T23:32:31.473+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.6ms)","duration":"2014.6ms","level":"slow","span":"7dc17694006c1132","trace":"b2e6496346473e4f993ff2667fd1898d"} -{"@timestamp":"2026-06-25T23:32:31.473+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.6ms","level":"info","span":"7dc17694006c1132","trace":"b2e6496346473e4f993ff2667fd1898d"} -{"@timestamp":"2026-06-25T23:32:36.504+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.3ms)","duration":"2028.3ms","level":"slow","span":"e8f8300d84925dc6","trace":"dc7923bcc35188ee0e4bf34082b5eb8b"} -{"@timestamp":"2026-06-25T23:32:36.504+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.3ms","level":"info","span":"e8f8300d84925dc6","trace":"dc7923bcc35188ee0e4bf34082b5eb8b"} -{"@timestamp":"2026-06-25T23:32:41.521+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.7ms)","duration":"2013.7ms","level":"slow","span":"d1aeb30bad35475e","trace":"897611bf6c2b3d196d48129eb0aa3fbf"} -{"@timestamp":"2026-06-25T23:32:41.521+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.7ms","level":"info","span":"d1aeb30bad35475e","trace":"897611bf6c2b3d196d48129eb0aa3fbf"} -{"@timestamp":"2026-06-25T23:32:44.182+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64683 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"11.1ms","level":"info","span":"8d4ec5b487fb4430","trace":"08641888177b189bbe70ae34525485c8"} -{"@timestamp":"2026-06-25T23:32:46.529+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.3ms)","duration":"2004.3ms","level":"slow","span":"50d23489909088bc","trace":"89ea5a2d270cdc33fa869f6bcba88c70"} -{"@timestamp":"2026-06-25T23:32:46.529+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.3ms","level":"info","span":"50d23489909088bc","trace":"89ea5a2d270cdc33fa869f6bcba88c70"} -{"@timestamp":"2026-06-25T23:32:51.552+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.8ms)","duration":"2018.8ms","level":"slow","span":"1f173d7c177bc74d","trace":"8fad08da8061e3cd6eca7acd03ed8351"} -{"@timestamp":"2026-06-25T23:32:51.552+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.8ms","level":"info","span":"1f173d7c177bc74d","trace":"8fad08da8061e3cd6eca7acd03ed8351"} -{"@timestamp":"2026-06-25T23:32:56.578+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.5ms)","duration":"2023.5ms","level":"slow","span":"294256bb9f86f69b","trace":"bc2bea100eb9c76b110ff546fc879d14"} -{"@timestamp":"2026-06-25T23:32:56.579+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.5ms","level":"info","span":"294256bb9f86f69b","trace":"bc2bea100eb9c76b110ff546fc879d14"} -{"@timestamp":"2026-06-25T23:32:58.076+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=20.0Mi, Sys=19.6Mi, NumGC=14","level":"stat"} -{"@timestamp":"2026-06-25T23:32:58.093+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:33:00.465+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64705 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"93051b92fd84ae2f","trace":"4fc9be6fe1d3eb96bb131ac6b2a94c0a"} -{"@timestamp":"2026-06-25T23:33:00.707+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1733.1ms, med: 2018.8ms, 90th: 2042.7ms, 99th: 2042.7ms, 99.9th: 2042.7ms","level":"stat"} -{"@timestamp":"2026-06-25T23:33:01.059+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64707 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"3e3e4585cb639a10","trace":"be92379bd492cfc98d7d783163d0efb3"} -{"@timestamp":"2026-06-25T23:33:01.598+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2015.5ms)","duration":"2015.5ms","level":"slow","span":"c90db6e9eb0cede9","trace":"7d88b0961fd437be391bd0a6804667ac"} -{"@timestamp":"2026-06-25T23:33:01.599+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2015.5ms","level":"info","span":"c90db6e9eb0cede9","trace":"7d88b0961fd437be391bd0a6804667ac"} -{"@timestamp":"2026-06-25T23:33:03.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64711 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"fc84c1ea5d654663","trace":"5f00f27a8a2ab4d4c0c1b2e4a011cb85"} -{"@timestamp":"2026-06-25T23:33:05.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64714 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"3b7675ca462452a9","trace":"1ee1957412524bfec3959ca9834c8e23"} -{"@timestamp":"2026-06-25T23:33:06.621+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.8ms)","duration":"2019.8ms","level":"slow","span":"fe67744349646cac","trace":"8b277c0216c09b9812e39bc06b04e4c0"} -{"@timestamp":"2026-06-25T23:33:06.621+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.8ms","level":"info","span":"fe67744349646cac","trace":"8b277c0216c09b9812e39bc06b04e4c0"} -{"@timestamp":"2026-06-25T23:33:07.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64720 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"684de8cf21a7bb9b","trace":"a1cbb1010c8059293515b30b34ded1f3"} -{"@timestamp":"2026-06-25T23:33:09.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64722 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"28d147321af30f17","trace":"e2c3e5d3978b060ee15e9c1acc27e74a"} -{"@timestamp":"2026-06-25T23:33:11.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64732 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"5107bdc76e4b13f3","trace":"dd9199e5975cef89e7f0d8d5c2c65a47"} -{"@timestamp":"2026-06-25T23:33:11.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"de7ca461ca69d981","trace":"25377db4784b37800c1bc1defa41f0a2"} -{"@timestamp":"2026-06-25T23:33:11.652+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"de7ca461ca69d981","trace":"25377db4784b37800c1bc1defa41f0a2"} -{"@timestamp":"2026-06-25T23:33:13.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64734 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"8d93f9e0cbd3990f","trace":"823dd6f30667bacaa45cb322527763a1"} -{"@timestamp":"2026-06-25T23:33:15.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64738 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"6cbfe9b46734f28d","trace":"fb1e374c2f2f3d9b773e0e5e711a510b"} -{"@timestamp":"2026-06-25T23:33:16.675+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"ba122c400e89e591","trace":"ee679e10f2ff265dfad522709a2723a5"} -{"@timestamp":"2026-06-25T23:33:16.675+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"ba122c400e89e591","trace":"ee679e10f2ff265dfad522709a2723a5"} -{"@timestamp":"2026-06-25T23:33:17.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64741 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"04aa52f5433d4734","trace":"03439e362929495d53444fd8c65382f8"} -{"@timestamp":"2026-06-25T23:33:19.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64747 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"568382928744fa33","trace":"26bb505584bb74f0dd7264c55aea909c"} -{"@timestamp":"2026-06-25T23:33:21.055+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64749 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"97a440406591c1c4","trace":"1790652b55ff158a06529f3f64b3e0f9"} -{"@timestamp":"2026-06-25T23:33:21.696+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.8ms)","duration":"2018.8ms","level":"slow","span":"f995707ffa03611a","trace":"dbf71f1c7a761a8f2f490d8b2f450386"} -{"@timestamp":"2026-06-25T23:33:21.696+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.8ms","level":"info","span":"f995707ffa03611a","trace":"dbf71f1c7a761a8f2f490d8b2f450386"} -{"@timestamp":"2026-06-25T23:33:23.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64763 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"a159f8e750ba6ac6","trace":"1d895a06fd5df5c2989b856cbb0ac051"} -{"@timestamp":"2026-06-25T23:33:25.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64765 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"fbe272e149c8fece","trace":"28ffeef580aed07ebb13d10bcb8de9bb"} -{"@timestamp":"2026-06-25T23:33:26.725+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.6ms)","duration":"2025.6ms","level":"slow","span":"a271f3b4617b3881","trace":"965da7e0966408a6758e92394ff3fcb9"} -{"@timestamp":"2026-06-25T23:33:26.725+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.6ms","level":"info","span":"a271f3b4617b3881","trace":"965da7e0966408a6758e92394ff3fcb9"} -{"@timestamp":"2026-06-25T23:33:27.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64773 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"927af6f0a3153d3b","trace":"f4239eea0ba4f4e1754af72251d65f64"} -{"@timestamp":"2026-06-25T23:33:29.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64775 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.6ms","level":"info","span":"885017e5fcfa880b","trace":"c7b704e42a91ce3f359da357a3721e29"} -{"@timestamp":"2026-06-25T23:33:31.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64781 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"ccffb84d6532f39f","trace":"2a9229d4bb7a54d63f83d2b13d051e21"} -{"@timestamp":"2026-06-25T23:33:31.755+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.1ms)","duration":"2028.1ms","level":"slow","span":"20b274bc184fe78a","trace":"14660811a54f37f2e624dd53e742a60a"} -{"@timestamp":"2026-06-25T23:33:31.756+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.1ms","level":"info","span":"20b274bc184fe78a","trace":"14660811a54f37f2e624dd53e742a60a"} -{"@timestamp":"2026-06-25T23:33:33.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64783 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"e375294f3f2dbb15","trace":"6311c5dfd7191b07ef0effdab1b1610a"} -{"@timestamp":"2026-06-25T23:33:35.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64790 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"d94264e5c052939f","trace":"7f09583b4edb4d972c65432112903a9b"} -{"@timestamp":"2026-06-25T23:33:36.781+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"e8ea08d27720bc2d","trace":"89d26d0b0b8fc4c52c9c32ea24910e1a"} -{"@timestamp":"2026-06-25T23:33:36.781+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"e8ea08d27720bc2d","trace":"89d26d0b0b8fc4c52c9c32ea24910e1a"} -{"@timestamp":"2026-06-25T23:33:37.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64793 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"0d91521c5118ef33","trace":"f7772f7b41f09d7e4575ab84bbe400af"} -{"@timestamp":"2026-06-25T23:33:39.057+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64800 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"30440de91448f42d","trace":"b0d459542e665c22714b1333654b17eb"} -{"@timestamp":"2026-06-25T23:33:41.811+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.6ms)","duration":"2026.6ms","level":"slow","span":"9f90ec1f1cbadf09","trace":"1b6f4be47867d446b9f0a1673097f6d8"} -{"@timestamp":"2026-06-25T23:33:41.811+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.6ms","level":"info","span":"9f90ec1f1cbadf09","trace":"1b6f4be47867d446b9f0a1673097f6d8"} -{"@timestamp":"2026-06-25T23:33:46.840+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.7ms)","duration":"2025.7ms","level":"slow","span":"b8d6a76305382cac","trace":"dbebbc81fb67fd32afd8f7f72bc84437"} -{"@timestamp":"2026-06-25T23:33:46.840+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.7ms","level":"info","span":"b8d6a76305382cac","trace":"dbebbc81fb67fd32afd8f7f72bc84437"} -{"@timestamp":"2026-06-25T23:33:51.850+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.7ms)","duration":"2007.7ms","level":"slow","span":"d03ca2709a5ccd43","trace":"c0f7bf87473e4b51f2a931547359f3fc"} -{"@timestamp":"2026-06-25T23:33:51.851+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.7ms","level":"info","span":"d03ca2709a5ccd43","trace":"c0f7bf87473e4b51f2a931547359f3fc"} -{"@timestamp":"2026-06-25T23:33:56.904+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.1ms)","duration":"2050.1ms","level":"slow","span":"599205d47f52c3b5","trace":"70cbdab4d4145d1c8d6f807e491db8a0"} -{"@timestamp":"2026-06-25T23:33:56.904+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.1ms","level":"info","span":"599205d47f52c3b5","trace":"70cbdab4d4145d1c8d6f807e491db8a0"} -{"@timestamp":"2026-06-25T23:33:58.077+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.4Mi, TotalAlloc=21.1Mi, Sys=19.6Mi, NumGC=14","level":"stat"} -{"@timestamp":"2026-06-25T23:33:58.094+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 33, pass: 33, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:34:00.708+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.5/s, drops: 0, avg time: 761.0ms, med: 3.8ms, 90th: 2026.9ms, 99th: 2050.0ms, 99.9th: 2050.0ms","level":"stat"} -{"@timestamp":"2026-06-25T23:34:01.925+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.3ms)","duration":"2018.3ms","level":"slow","span":"54d41a01dd90f510","trace":"6b3f4c53e871a79a70489bd44d224330"} -{"@timestamp":"2026-06-25T23:34:01.925+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.3ms","level":"info","span":"54d41a01dd90f510","trace":"6b3f4c53e871a79a70489bd44d224330"} -{"@timestamp":"2026-06-25T23:34:06.955+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.7ms)","duration":"2026.7ms","level":"slow","span":"f39203b5399232ad","trace":"7274d1d53447f877ec001d48c355ec4e"} -{"@timestamp":"2026-06-25T23:34:06.956+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.7ms","level":"info","span":"f39203b5399232ad","trace":"7274d1d53447f877ec001d48c355ec4e"} -{"@timestamp":"2026-06-25T23:34:11.976+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.8ms)","duration":"2017.8ms","level":"slow","span":"65eaf93725d3d48d","trace":"7edd63ce2e5f3aa83529f8ebdb7bbf06"} -{"@timestamp":"2026-06-25T23:34:11.976+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.8ms","level":"info","span":"65eaf93725d3d48d","trace":"7edd63ce2e5f3aa83529f8ebdb7bbf06"} -{"@timestamp":"2026-06-25T23:34:16.980+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2001.1ms)","duration":"2001.1ms","level":"slow","span":"b120856b4afb28ac","trace":"104eca024742afa5e885e09186ab23c6"} -{"@timestamp":"2026-06-25T23:34:16.980+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2001.1ms","level":"info","span":"b120856b4afb28ac","trace":"104eca024742afa5e885e09186ab23c6"} -{"@timestamp":"2026-06-25T23:34:22.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"38071c58deb0851e","trace":"a6e8da01dedabcd02932f1dbd4116c4b"} -{"@timestamp":"2026-06-25T23:34:22.016+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"38071c58deb0851e","trace":"a6e8da01dedabcd02932f1dbd4116c4b"} -{"@timestamp":"2026-06-25T23:34:27.043+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.8ms)","duration":"2023.8ms","level":"slow","span":"133c07d47c573f94","trace":"391f58e6e594d59fd8800e73e39f4d02"} -{"@timestamp":"2026-06-25T23:34:27.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.8ms","level":"info","span":"133c07d47c573f94","trace":"391f58e6e594d59fd8800e73e39f4d02"} -{"@timestamp":"2026-06-25T23:34:32.069+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"81512c3fe7a754fa","trace":"1089035399a6a80ec486f91ecfef7225"} -{"@timestamp":"2026-06-25T23:34:32.070+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"81512c3fe7a754fa","trace":"1089035399a6a80ec486f91ecfef7225"} -{"@timestamp":"2026-06-25T23:34:37.077+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.1ms)","duration":"2004.1ms","level":"slow","span":"bccdd03b48f865c7","trace":"580edb3cdadc374695cc843051dcc5c4"} -{"@timestamp":"2026-06-25T23:34:37.077+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.1ms","level":"info","span":"bccdd03b48f865c7","trace":"580edb3cdadc374695cc843051dcc5c4"} -{"@timestamp":"2026-06-25T23:34:42.123+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.2ms)","duration":"2043.2ms","level":"slow","span":"b74ebba6687d53c2","trace":"64b14a87da3b2ee4156cac294cd568c8"} -{"@timestamp":"2026-06-25T23:34:42.124+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.2ms","level":"info","span":"b74ebba6687d53c2","trace":"64b14a87da3b2ee4156cac294cd568c8"} -{"@timestamp":"2026-06-25T23:34:47.155+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.6ms)","duration":"2026.6ms","level":"slow","span":"6291d86709129bd6","trace":"98ca85716eba76d9e301a5c160c1d3b3"} -{"@timestamp":"2026-06-25T23:34:47.155+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.6ms","level":"info","span":"6291d86709129bd6","trace":"98ca85716eba76d9e301a5c160c1d3b3"} -{"@timestamp":"2026-06-25T23:34:52.189+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.9ms)","duration":"2029.9ms","level":"slow","span":"52b3403e6cc9ed8e","trace":"cce81ec3ee76127713f88cfd72cfb331"} -{"@timestamp":"2026-06-25T23:34:52.189+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.9ms","level":"info","span":"52b3403e6cc9ed8e","trace":"cce81ec3ee76127713f88cfd72cfb331"} -{"@timestamp":"2026-06-25T23:34:57.219+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.0ms)","duration":"2029.0ms","level":"slow","span":"1add5aa7651354b1","trace":"d0ffd765645305cfe28ead9e76ed2448"} -{"@timestamp":"2026-06-25T23:34:57.220+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.0ms","level":"info","span":"1add5aa7651354b1","trace":"d0ffd765645305cfe28ead9e76ed2448"} -{"@timestamp":"2026-06-25T23:34:58.077+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=22.0Mi, Sys=19.6Mi, NumGC=15","level":"stat"} -{"@timestamp":"2026-06-25T23:34:58.094+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:35:00.708+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2022.8ms, med: 2026.5ms, 90th: 2043.1ms, 99th: 2043.1ms, 99.9th: 2043.1ms","level":"stat"} -{"@timestamp":"2026-06-25T23:35:02.253+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.9ms)","duration":"2030.9ms","level":"slow","span":"fec64e03d5d165b6","trace":"c85c59a792b3b07653240afa0a4abe8d"} -{"@timestamp":"2026-06-25T23:35:02.253+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.9ms","level":"info","span":"fec64e03d5d165b6","trace":"c85c59a792b3b07653240afa0a4abe8d"} -{"@timestamp":"2026-06-25T23:35:07.271+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.8ms)","duration":"2013.8ms","level":"slow","span":"39b590d47f382c3e","trace":"cfa2af39b839acbf34c89ae2d08cdca6"} -{"@timestamp":"2026-06-25T23:35:07.271+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.8ms","level":"info","span":"39b590d47f382c3e","trace":"cfa2af39b839acbf34c89ae2d08cdca6"} -{"@timestamp":"2026-06-25T23:35:12.301+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.4ms)","duration":"2027.4ms","level":"slow","span":"edb37ac898e54e2d","trace":"18c83943d6f14840fc786071f847c2c5"} -{"@timestamp":"2026-06-25T23:35:12.302+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.4ms","level":"info","span":"edb37ac898e54e2d","trace":"18c83943d6f14840fc786071f847c2c5"} -{"@timestamp":"2026-06-25T23:35:17.322+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.1ms)","duration":"2017.1ms","level":"slow","span":"2d0d22b95841241d","trace":"9078d7bab2607b54eccd1ce4dd88fe3d"} -{"@timestamp":"2026-06-25T23:35:17.322+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.1ms","level":"info","span":"2d0d22b95841241d","trace":"9078d7bab2607b54eccd1ce4dd88fe3d"} -{"@timestamp":"2026-06-25T23:35:22.334+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2009.0ms)","duration":"2009.0ms","level":"slow","span":"aedf8c7aa14fc63b","trace":"2d9bfa9c694922f3e74aee21bba30e05"} -{"@timestamp":"2026-06-25T23:35:22.334+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2009.0ms","level":"info","span":"aedf8c7aa14fc63b","trace":"2d9bfa9c694922f3e74aee21bba30e05"} -{"@timestamp":"2026-06-25T23:35:27.364+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.0ms)","duration":"2024.0ms","level":"slow","span":"ccdea5c09739c8bb","trace":"7beeaf229fa6f85256ef8844d56e29dd"} -{"@timestamp":"2026-06-25T23:35:27.365+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.0ms","level":"info","span":"ccdea5c09739c8bb","trace":"7beeaf229fa6f85256ef8844d56e29dd"} -{"@timestamp":"2026-06-25T23:35:32.330+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.2ms)","duration":"2024.2ms","level":"slow","span":"48f9cc537a32686d","trace":"7adbe3386f44e22a2cc57ab717f86315"} -{"@timestamp":"2026-06-25T23:35:32.330+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.2ms","level":"info","span":"48f9cc537a32686d","trace":"7adbe3386f44e22a2cc57ab717f86315"} -{"@timestamp":"2026-06-25T23:35:37.350+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.4ms)","duration":"2016.4ms","level":"slow","span":"9ac6dbbe87e22467","trace":"32e62a4c9e524fa491b95acda63c17ba"} -{"@timestamp":"2026-06-25T23:35:37.350+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.4ms","level":"info","span":"9ac6dbbe87e22467","trace":"32e62a4c9e524fa491b95acda63c17ba"} -{"@timestamp":"2026-06-25T23:35:42.378+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"79b63a998ad7adeb","trace":"f5358e4ab32942e91a017a410acf3c75"} -{"@timestamp":"2026-06-25T23:35:42.378+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"79b63a998ad7adeb","trace":"f5358e4ab32942e91a017a410acf3c75"} -{"@timestamp":"2026-06-25T23:35:47.404+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.2ms)","duration":"2023.2ms","level":"slow","span":"9396d73c0556478f","trace":"10a6a1483a3d940fcdf3b79cd795f916"} -{"@timestamp":"2026-06-25T23:35:47.404+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.2ms","level":"info","span":"9396d73c0556478f","trace":"10a6a1483a3d940fcdf3b79cd795f916"} -{"@timestamp":"2026-06-25T23:35:52.432+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.0ms)","duration":"2025.0ms","level":"slow","span":"7ad96347c83974e0","trace":"5035d688d4964ff9ad91028420caab14"} -{"@timestamp":"2026-06-25T23:35:52.433+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.0ms","level":"info","span":"7ad96347c83974e0","trace":"5035d688d4964ff9ad91028420caab14"} -{"@timestamp":"2026-06-25T23:35:57.467+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.0ms)","duration":"2026.0ms","level":"slow","span":"6f33a4e6bae02132","trace":"0e12872f3caaa405f45f84b280ecf195"} -{"@timestamp":"2026-06-25T23:35:57.467+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.0ms","level":"info","span":"6f33a4e6bae02132","trace":"0e12872f3caaa405f45f84b280ecf195"} -{"@timestamp":"2026-06-25T23:35:58.012+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=22.2Mi, Sys=19.6Mi, NumGC=15","level":"stat"} -{"@timestamp":"2026-06-25T23:35:58.029+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:36:00.643+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2021.7ms, med: 2024.0ms, 90th: 2030.8ms, 99th: 2030.8ms, 99.9th: 2030.8ms","level":"stat"} -{"@timestamp":"2026-06-25T23:36:02.495+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.9ms)","duration":"2020.9ms","level":"slow","span":"0ec118b0941d56b3","trace":"64d8eb11ad0470870a4ef7ea1082934c"} -{"@timestamp":"2026-06-25T23:36:02.495+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.9ms","level":"info","span":"0ec118b0941d56b3","trace":"64d8eb11ad0470870a4ef7ea1082934c"} -{"@timestamp":"2026-06-25T23:36:07.520+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"337ea61c2557d0ef","trace":"524b519fdb529265e8063ab707087883"} -{"@timestamp":"2026-06-25T23:36:07.520+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"337ea61c2557d0ef","trace":"524b519fdb529265e8063ab707087883"} -{"@timestamp":"2026-06-25T23:36:12.549+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.9ms)","duration":"2025.9ms","level":"slow","span":"9713980b42fc6103","trace":"2f00cb4327cbcccaed93fe89fc6d5887"} -{"@timestamp":"2026-06-25T23:36:12.549+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.9ms","level":"info","span":"9713980b42fc6103","trace":"2f00cb4327cbcccaed93fe89fc6d5887"} -{"@timestamp":"2026-06-25T23:36:17.580+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.0ms)","duration":"2027.0ms","level":"slow","span":"280cdfe12490bc1d","trace":"7677a4a41c1a5ac7adfbf2c909393e61"} -{"@timestamp":"2026-06-25T23:36:17.580+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.0ms","level":"info","span":"280cdfe12490bc1d","trace":"7677a4a41c1a5ac7adfbf2c909393e61"} -{"@timestamp":"2026-06-25T23:36:22.609+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.2ms)","duration":"2026.2ms","level":"slow","span":"603096a4ad06b04f","trace":"e97b9ebbc7631a3b95579b354c1ef698"} -{"@timestamp":"2026-06-25T23:36:22.609+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.2ms","level":"info","span":"603096a4ad06b04f","trace":"e97b9ebbc7631a3b95579b354c1ef698"} -{"@timestamp":"2026-06-25T23:36:27.638+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.4ms)","duration":"2026.4ms","level":"slow","span":"8a20e2a8acb73b58","trace":"829b8ccae1a22e05363728a0a448ee39"} -{"@timestamp":"2026-06-25T23:36:27.638+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.4ms","level":"info","span":"8a20e2a8acb73b58","trace":"829b8ccae1a22e05363728a0a448ee39"} -{"@timestamp":"2026-06-25T23:36:32.665+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"90fe97098e1fff58","trace":"c40ed3d3e24f85b49d31906878a3f0e1"} -{"@timestamp":"2026-06-25T23:36:32.665+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"90fe97098e1fff58","trace":"c40ed3d3e24f85b49d31906878a3f0e1"} -{"@timestamp":"2026-06-25T23:36:37.696+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.2ms)","duration":"2024.2ms","level":"slow","span":"5cdec7882cb60c77","trace":"6b694ef8bd3469a9cd1c26352e9ee5c6"} -{"@timestamp":"2026-06-25T23:36:37.696+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.2ms","level":"info","span":"5cdec7882cb60c77","trace":"6b694ef8bd3469a9cd1c26352e9ee5c6"} -{"@timestamp":"2026-06-25T23:36:42.722+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.5ms)","duration":"2022.5ms","level":"slow","span":"c98edc521d79a291","trace":"a8c89301ba6f40ea887de6b39970ec29"} -{"@timestamp":"2026-06-25T23:36:42.722+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.5ms","level":"info","span":"c98edc521d79a291","trace":"a8c89301ba6f40ea887de6b39970ec29"} -{"@timestamp":"2026-06-25T23:36:47.760+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.3ms)","duration":"2034.3ms","level":"slow","span":"242e27088cf354ca","trace":"6b36e4c1b0528c9d90ebad62b78b9fe1"} -{"@timestamp":"2026-06-25T23:36:47.760+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.3ms","level":"info","span":"242e27088cf354ca","trace":"6b36e4c1b0528c9d90ebad62b78b9fe1"} -{"@timestamp":"2026-06-25T23:36:52.776+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.6ms)","duration":"2013.6ms","level":"slow","span":"26edec9f0c804bc6","trace":"734230c8cffb302a22519bf29553faff"} -{"@timestamp":"2026-06-25T23:36:52.776+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.6ms","level":"info","span":"26edec9f0c804bc6","trace":"734230c8cffb302a22519bf29553faff"} -{"@timestamp":"2026-06-25T23:36:57.789+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"7d9cb7cdc65507cb","trace":"98e495a6a9a4afb283d65f6a887c2aeb"} -{"@timestamp":"2026-06-25T23:36:57.789+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"7d9cb7cdc65507cb","trace":"98e495a6a9a4afb283d65f6a887c2aeb"} -{"@timestamp":"2026-06-25T23:36:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=22.7Mi, Sys=19.6Mi, NumGC=16","level":"stat"} -{"@timestamp":"2026-06-25T23:36:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:37:00.643+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2023.0ms, med: 2024.2ms, 90th: 2034.2ms, 99th: 2034.2ms, 99.9th: 2034.2ms","level":"stat"} -{"@timestamp":"2026-06-25T23:37:02.822+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.4ms)","duration":"2030.4ms","level":"slow","span":"f705c867c7ae074e","trace":"efe8add21dfa341667829b3ed52d39b8"} -{"@timestamp":"2026-06-25T23:37:02.822+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.4ms","level":"info","span":"f705c867c7ae074e","trace":"efe8add21dfa341667829b3ed52d39b8"} -{"@timestamp":"2026-06-25T23:37:07.856+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.4ms)","duration":"2029.4ms","level":"slow","span":"fd5a4a966c266aa2","trace":"24885d605e8144f8538cefa28f0216bb"} -{"@timestamp":"2026-06-25T23:37:07.857+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.4ms","level":"info","span":"fd5a4a966c266aa2","trace":"24885d605e8144f8538cefa28f0216bb"} -{"@timestamp":"2026-06-25T23:37:12.885+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"e143c7c5bc753712","trace":"d76bf7a5c5901b3931057794fe161456"} -{"@timestamp":"2026-06-25T23:37:12.885+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"e143c7c5bc753712","trace":"d76bf7a5c5901b3931057794fe161456"} -{"@timestamp":"2026-06-25T23:37:17.920+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.8ms)","duration":"2031.8ms","level":"slow","span":"e1cc5fb2a88ed4a5","trace":"aa4c8d4dda65bde21baf3a4649ab1181"} -{"@timestamp":"2026-06-25T23:37:17.920+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.8ms","level":"info","span":"e1cc5fb2a88ed4a5","trace":"aa4c8d4dda65bde21baf3a4649ab1181"} -{"@timestamp":"2026-06-25T23:37:22.934+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.6ms)","duration":"2008.6ms","level":"slow","span":"c333ba87a218549b","trace":"f75823387e38292c611b5d0020d51a24"} -{"@timestamp":"2026-06-25T23:37:22.934+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.6ms","level":"info","span":"c333ba87a218549b","trace":"f75823387e38292c611b5d0020d51a24"} -{"@timestamp":"2026-06-25T23:37:27.963+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.5ms)","duration":"2022.5ms","level":"slow","span":"bcb8d063fd24d51b","trace":"70f2a08ae0eac80df23abd1348a45e4f"} -{"@timestamp":"2026-06-25T23:37:27.963+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.5ms","level":"info","span":"bcb8d063fd24d51b","trace":"70f2a08ae0eac80df23abd1348a45e4f"} -{"@timestamp":"2026-06-25T23:37:33.007+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.4ms)","duration":"2034.4ms","level":"slow","span":"11d9775a148f5a12","trace":"f9fc6eff86b32e9f48ad4194b1c7c1e2"} -{"@timestamp":"2026-06-25T23:37:33.007+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.4ms","level":"info","span":"11d9775a148f5a12","trace":"f9fc6eff86b32e9f48ad4194b1c7c1e2"} -{"@timestamp":"2026-06-25T23:37:38.038+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.0ms)","duration":"2029.0ms","level":"slow","span":"8f609432bc9faf61","trace":"b054a7996fcc7d85d0c503411b82d776"} -{"@timestamp":"2026-06-25T23:37:38.038+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.0ms","level":"info","span":"8f609432bc9faf61","trace":"b054a7996fcc7d85d0c503411b82d776"} -{"@timestamp":"2026-06-25T23:37:43.071+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.5ms)","duration":"2029.5ms","level":"slow","span":"cf9efd0d2d96eb53","trace":"ca25cd7376f8d5a0408e82833635204c"} -{"@timestamp":"2026-06-25T23:37:43.071+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.5ms","level":"info","span":"cf9efd0d2d96eb53","trace":"ca25cd7376f8d5a0408e82833635204c"} -{"@timestamp":"2026-06-25T23:37:48.097+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.3ms)","duration":"2023.3ms","level":"slow","span":"a103c555d5d85e8f","trace":"2c8e15f9b480764199b3e5baa0634aba"} -{"@timestamp":"2026-06-25T23:37:48.098+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.3ms","level":"info","span":"a103c555d5d85e8f","trace":"2c8e15f9b480764199b3e5baa0634aba"} -{"@timestamp":"2026-06-25T23:37:53.118+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.2ms)","duration":"2016.2ms","level":"slow","span":"2420bedb4586f390","trace":"1613ce7f5aa9a595ac154bf5fb8f231a"} -{"@timestamp":"2026-06-25T23:37:53.118+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.2ms","level":"info","span":"2420bedb4586f390","trace":"1613ce7f5aa9a595ac154bf5fb8f231a"} -{"@timestamp":"2026-06-25T23:37:58.012+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=22.9Mi, Sys=19.6Mi, NumGC=16","level":"stat"} -{"@timestamp":"2026-06-25T23:37:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:37:58.138+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.5ms)","duration":"2016.5ms","level":"slow","span":"51979cf48a99030a","trace":"6855684717d52a026c2598d009d165c7"} -{"@timestamp":"2026-06-25T23:37:58.138+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.5ms","level":"info","span":"51979cf48a99030a","trace":"6855684717d52a026c2598d009d165c7"} -{"@timestamp":"2026-06-25T23:38:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.5ms, med: 2028.9ms, 90th: 2034.3ms, 99th: 2034.3ms, 99.9th: 2034.3ms","level":"stat"} -{"@timestamp":"2026-06-25T23:38:03.165+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.2ms)","duration":"2022.2ms","level":"slow","span":"feeba565dde2db97","trace":"3835f66964fce786eb6a84f5a38b4f59"} -{"@timestamp":"2026-06-25T23:38:03.165+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.2ms","level":"info","span":"feeba565dde2db97","trace":"3835f66964fce786eb6a84f5a38b4f59"} -{"@timestamp":"2026-06-25T23:38:08.193+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.9ms)","duration":"2025.9ms","level":"slow","span":"2a8b6b99d669dd5b","trace":"7a1f8052cb4dd218fbd73b04a6bb863d"} -{"@timestamp":"2026-06-25T23:38:08.194+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.9ms","level":"info","span":"2a8b6b99d669dd5b","trace":"7a1f8052cb4dd218fbd73b04a6bb863d"} -{"@timestamp":"2026-06-25T23:38:13.219+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.5ms)","duration":"2023.5ms","level":"slow","span":"0147ca8ea31491b2","trace":"bd4eb31a74852588b2ffd8b69e66b848"} -{"@timestamp":"2026-06-25T23:38:13.219+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.5ms","level":"info","span":"0147ca8ea31491b2","trace":"bd4eb31a74852588b2ffd8b69e66b848"} -{"@timestamp":"2026-06-25T23:38:18.250+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"838e6e473492d1f5","trace":"8f76cc6ce22e0fb696816df31737ef62"} -{"@timestamp":"2026-06-25T23:38:18.250+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"838e6e473492d1f5","trace":"8f76cc6ce22e0fb696816df31737ef62"} -{"@timestamp":"2026-06-25T23:38:23.276+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.1ms)","duration":"2023.1ms","level":"slow","span":"2df28dbdd7c49f50","trace":"23447716d609f78700c5079dfdd24a28"} -{"@timestamp":"2026-06-25T23:38:23.276+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.1ms","level":"info","span":"2df28dbdd7c49f50","trace":"23447716d609f78700c5079dfdd24a28"} -{"@timestamp":"2026-06-25T23:38:28.306+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.5ms)","duration":"2025.5ms","level":"slow","span":"452839e7ce8b3bc3","trace":"4b39195474fc7c5d9a11484319b5b7e6"} -{"@timestamp":"2026-06-25T23:38:28.307+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.5ms","level":"info","span":"452839e7ce8b3bc3","trace":"4b39195474fc7c5d9a11484319b5b7e6"} -{"@timestamp":"2026-06-25T23:38:33.329+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.7ms)","duration":"2020.7ms","level":"slow","span":"4c87051248197308","trace":"200270696e2afddc8b5c092067d37d3b"} -{"@timestamp":"2026-06-25T23:38:33.329+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.7ms","level":"info","span":"4c87051248197308","trace":"200270696e2afddc8b5c092067d37d3b"} -{"@timestamp":"2026-06-25T23:38:38.357+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.0ms)","duration":"2019.0ms","level":"slow","span":"1f221d3bcef8803d","trace":"4f6dd10cf6a279174a1eba2509676103"} -{"@timestamp":"2026-06-25T23:38:38.358+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.0ms","level":"info","span":"1f221d3bcef8803d","trace":"4f6dd10cf6a279174a1eba2509676103"} -{"@timestamp":"2026-06-25T23:38:43.391+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.0ms)","duration":"2029.0ms","level":"slow","span":"789e2b13f0a374a1","trace":"5fe7d6bc7d4c0082a25338bdd8b1e1c8"} -{"@timestamp":"2026-06-25T23:38:43.391+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.0ms","level":"info","span":"789e2b13f0a374a1","trace":"5fe7d6bc7d4c0082a25338bdd8b1e1c8"} -{"@timestamp":"2026-06-25T23:38:48.427+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.0ms)","duration":"2031.0ms","level":"slow","span":"d8a972458cbdedd0","trace":"1ff9d55fff83d21623f41d804c07d232"} -{"@timestamp":"2026-06-25T23:38:48.427+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.0ms","level":"info","span":"d8a972458cbdedd0","trace":"1ff9d55fff83d21623f41d804c07d232"} -{"@timestamp":"2026-06-25T23:38:53.452+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.9ms)","duration":"2021.9ms","level":"slow","span":"547a404d920567c9","trace":"b1a5f0d6a5de17c64de3afdc73540486"} -{"@timestamp":"2026-06-25T23:38:53.452+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.9ms","level":"info","span":"547a404d920567c9","trace":"b1a5f0d6a5de17c64de3afdc73540486"} -{"@timestamp":"2026-06-25T23:38:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=23.5Mi, Sys=19.6Mi, NumGC=17","level":"stat"} -{"@timestamp":"2026-06-25T23:38:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:38:58.482+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"4f967b8530befd01","trace":"ca3d70f3838e614d351fac72e3c2566c"} -{"@timestamp":"2026-06-25T23:38:58.482+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"4f967b8530befd01","trace":"ca3d70f3838e614d351fac72e3c2566c"} -{"@timestamp":"2026-06-25T23:39:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.3ms, med: 2025.4ms, 90th: 2030.8ms, 99th: 2030.8ms, 99.9th: 2030.8ms","level":"stat"} -{"@timestamp":"2026-06-25T23:39:03.489+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2003.2ms)","duration":"2003.2ms","level":"slow","span":"429a25bc6b65db60","trace":"95fd3bfd99f5fbccef9316e0903daf32"} -{"@timestamp":"2026-06-25T23:39:03.489+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2003.2ms","level":"info","span":"429a25bc6b65db60","trace":"95fd3bfd99f5fbccef9316e0903daf32"} -{"@timestamp":"2026-06-25T23:39:08.529+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.5ms)","duration":"2035.5ms","level":"slow","span":"73130fc2b7b01e7a","trace":"edc46bc86ac7a1c9955ce419adfc7473"} -{"@timestamp":"2026-06-25T23:39:08.529+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.5ms","level":"info","span":"73130fc2b7b01e7a","trace":"edc46bc86ac7a1c9955ce419adfc7473"} -{"@timestamp":"2026-06-25T23:39:13.553+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.0ms)","duration":"2022.0ms","level":"slow","span":"63abe5e2c464e8b9","trace":"02c8942269f2019b101b28b09fbdd052"} -{"@timestamp":"2026-06-25T23:39:13.553+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.0ms","level":"info","span":"63abe5e2c464e8b9","trace":"02c8942269f2019b101b28b09fbdd052"} -{"@timestamp":"2026-06-25T23:39:18.597+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.4ms)","duration":"2039.4ms","level":"slow","span":"ede9c0951e9b31dc","trace":"b0d48a395f0fd58fe1ad2f86ebb151f7"} -{"@timestamp":"2026-06-25T23:39:18.597+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.4ms","level":"info","span":"ede9c0951e9b31dc","trace":"b0d48a395f0fd58fe1ad2f86ebb151f7"} -{"@timestamp":"2026-06-25T23:39:23.631+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.8ms)","duration":"2029.8ms","level":"slow","span":"e5d0e9cd7f95e7c5","trace":"634d689d30866c45281de9d8584b18f2"} -{"@timestamp":"2026-06-25T23:39:23.631+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.8ms","level":"info","span":"e5d0e9cd7f95e7c5","trace":"634d689d30866c45281de9d8584b18f2"} -{"@timestamp":"2026-06-25T23:39:28.647+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.2ms)","duration":"2012.2ms","level":"slow","span":"1fbca200948aaf9b","trace":"69bce246a4836048e84277a60dd5c272"} -{"@timestamp":"2026-06-25T23:39:28.647+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.2ms","level":"info","span":"1fbca200948aaf9b","trace":"69bce246a4836048e84277a60dd5c272"} -{"@timestamp":"2026-06-25T23:39:33.661+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.7ms)","duration":"2011.7ms","level":"slow","span":"fd280423cd0d3680","trace":"fea7c5e952f83504228b16128029b42b"} -{"@timestamp":"2026-06-25T23:39:33.661+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.7ms","level":"info","span":"fd280423cd0d3680","trace":"fea7c5e952f83504228b16128029b42b"} -{"@timestamp":"2026-06-25T23:39:38.680+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.1ms)","duration":"2017.1ms","level":"slow","span":"4115dc3e55d4f165","trace":"c85ab5630ef5412af20c003f4c5233fd"} -{"@timestamp":"2026-06-25T23:39:38.680+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.1ms","level":"info","span":"4115dc3e55d4f165","trace":"c85ab5630ef5412af20c003f4c5233fd"} -{"@timestamp":"2026-06-25T23:39:43.713+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.6ms)","duration":"2029.6ms","level":"slow","span":"55fb0fb4801d62c7","trace":"85b93646208b4c5358bebde5b9655816"} -{"@timestamp":"2026-06-25T23:39:43.713+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.6ms","level":"info","span":"55fb0fb4801d62c7","trace":"85b93646208b4c5358bebde5b9655816"} -{"@timestamp":"2026-06-25T23:39:48.740+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.9ms)","duration":"2024.9ms","level":"slow","span":"204245d95a6cb5a5","trace":"507b75780baefdec3f2da79e254a3873"} -{"@timestamp":"2026-06-25T23:39:48.741+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.9ms","level":"info","span":"204245d95a6cb5a5","trace":"507b75780baefdec3f2da79e254a3873"} -{"@timestamp":"2026-06-25T23:39:53.757+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.2ms)","duration":"2014.2ms","level":"slow","span":"1f7462f70df0f4dc","trace":"b4877c2bd39867344a82d5c5a10ed1fe"} -{"@timestamp":"2026-06-25T23:39:53.757+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.2ms","level":"info","span":"1f7462f70df0f4dc","trace":"b4877c2bd39867344a82d5c5a10ed1fe"} -{"@timestamp":"2026-06-25T23:39:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=23.6Mi, Sys=19.6Mi, NumGC=17","level":"stat"} -{"@timestamp":"2026-06-25T23:39:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:39:58.805+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.2ms)","duration":"2045.2ms","level":"slow","span":"9a3325996b8e4fe7","trace":"c1a90c04b746eb88fd8b774217a5b497"} -{"@timestamp":"2026-06-25T23:39:58.806+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.2ms","level":"info","span":"9a3325996b8e4fe7","trace":"c1a90c04b746eb88fd8b774217a5b497"} -{"@timestamp":"2026-06-25T23:40:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2023.2ms, med: 2024.8ms, 90th: 2039.9ms, 99th: 2039.9ms, 99.9th: 2039.9ms","level":"stat"} -{"@timestamp":"2026-06-25T23:40:03.833+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.3ms)","duration":"2023.3ms","level":"slow","span":"bd34807c3bd49d3c","trace":"864484ea963b209b073b5bca6d4d8fe8"} -{"@timestamp":"2026-06-25T23:40:03.833+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.3ms","level":"info","span":"bd34807c3bd49d3c","trace":"864484ea963b209b073b5bca6d4d8fe8"} -{"@timestamp":"2026-06-25T23:40:08.860+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.4ms)","duration":"2020.4ms","level":"slow","span":"d3bf22cc0d2646e4","trace":"24bf12ef41a6da7bb72ce19923618580"} -{"@timestamp":"2026-06-25T23:40:08.861+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.4ms","level":"info","span":"d3bf22cc0d2646e4","trace":"24bf12ef41a6da7bb72ce19923618580"} -{"@timestamp":"2026-06-25T23:40:13.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.7ms)","duration":"2032.7ms","level":"slow","span":"b3237e95681113db","trace":"83ad3165b22b163ff85dc847fef4e823"} -{"@timestamp":"2026-06-25T23:40:13.896+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.7ms","level":"info","span":"b3237e95681113db","trace":"83ad3165b22b163ff85dc847fef4e823"} -{"@timestamp":"2026-06-25T23:40:18.921+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.3ms)","duration":"2021.3ms","level":"slow","span":"9cfb3917a6fb1ce2","trace":"83d1bc974a6f10f87150e6d29dd90ae7"} -{"@timestamp":"2026-06-25T23:40:18.922+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.3ms","level":"info","span":"9cfb3917a6fb1ce2","trace":"83d1bc974a6f10f87150e6d29dd90ae7"} -{"@timestamp":"2026-06-25T23:40:23.982+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.9ms)","duration":"2055.9ms","level":"slow","span":"d8829b69b3886ea4","trace":"a787d4a53e9d92a7e4c9fafaad1cd07a"} -{"@timestamp":"2026-06-25T23:40:23.982+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.9ms","level":"info","span":"d8829b69b3886ea4","trace":"a787d4a53e9d92a7e4c9fafaad1cd07a"} -{"@timestamp":"2026-06-25T23:40:29.002+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.4ms)","duration":"2017.4ms","level":"slow","span":"70f1628009908592","trace":"176b6af8b918fd85972e37fe2765613a"} -{"@timestamp":"2026-06-25T23:40:29.003+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.4ms","level":"info","span":"70f1628009908592","trace":"176b6af8b918fd85972e37fe2765613a"} -{"@timestamp":"2026-06-25T23:40:34.015+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2009.5ms)","duration":"2009.5ms","level":"slow","span":"b8a89e93e9b10e8d","trace":"ddab89b8c8cc8670c315f4cfa1394608"} -{"@timestamp":"2026-06-25T23:40:34.015+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2009.5ms","level":"info","span":"b8a89e93e9b10e8d","trace":"ddab89b8c8cc8670c315f4cfa1394608"} -{"@timestamp":"2026-06-25T23:40:39.036+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.5ms)","duration":"2014.5ms","level":"slow","span":"23e5e4ae68a3da48","trace":"802aaa4162f5d4f767d9e3016df21365"} -{"@timestamp":"2026-06-25T23:40:39.036+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.5ms","level":"info","span":"23e5e4ae68a3da48","trace":"802aaa4162f5d4f767d9e3016df21365"} -{"@timestamp":"2026-06-25T23:40:44.060+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.7ms)","duration":"2021.7ms","level":"slow","span":"02d3071610235a6d","trace":"d1d615ac8fa0f6b258f9f270cd43d33d"} -{"@timestamp":"2026-06-25T23:40:44.060+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.7ms","level":"info","span":"02d3071610235a6d","trace":"d1d615ac8fa0f6b258f9f270cd43d33d"} -{"@timestamp":"2026-06-25T23:40:49.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.7ms)","duration":"2031.7ms","level":"slow","span":"ec052c1d73ec301f","trace":"3e8eaed9542ce4f1dbe3106c05dec3b5"} -{"@timestamp":"2026-06-25T23:40:49.096+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.7ms","level":"info","span":"ec052c1d73ec301f","trace":"3e8eaed9542ce4f1dbe3106c05dec3b5"} -{"@timestamp":"2026-06-25T23:40:54.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"dde568b17c425be2","trace":"a52503fef844743ff39043e8f7bdb0f2"} -{"@timestamp":"2026-06-25T23:40:54.125+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"dde568b17c425be2","trace":"a52503fef844743ff39043e8f7bdb0f2"} -{"@timestamp":"2026-06-25T23:40:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=24.2Mi, Sys=19.6Mi, NumGC=18","level":"stat"} -{"@timestamp":"2026-06-25T23:40:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:40:59.148+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.6ms)","duration":"2019.6ms","level":"slow","span":"636918dbe3676070","trace":"0455f403193202efeaad5e0a8c6d38de"} -{"@timestamp":"2026-06-25T23:40:59.148+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.6ms","level":"info","span":"636918dbe3676070","trace":"0455f403193202efeaad5e0a8c6d38de"} -{"@timestamp":"2026-06-25T23:41:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.0ms, med: 2021.6ms, 90th: 2055.8ms, 99th: 2055.8ms, 99.9th: 2055.8ms","level":"stat"} -{"@timestamp":"2026-06-25T23:41:04.196+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.5ms)","duration":"2043.5ms","level":"slow","span":"dbafc7b8d05e7c52","trace":"0e54e30fd55deb8ce5e235878c779119"} -{"@timestamp":"2026-06-25T23:41:04.197+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.5ms","level":"info","span":"dbafc7b8d05e7c52","trace":"0e54e30fd55deb8ce5e235878c779119"} -{"@timestamp":"2026-06-25T23:41:09.222+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.9ms)","duration":"2023.9ms","level":"slow","span":"c65f29588a679ade","trace":"174dffa79e1ded1d5be0b00685c14c3d"} -{"@timestamp":"2026-06-25T23:41:09.223+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.9ms","level":"info","span":"c65f29588a679ade","trace":"174dffa79e1ded1d5be0b00685c14c3d"} -{"@timestamp":"2026-06-25T23:41:14.257+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.5ms)","duration":"2032.5ms","level":"slow","span":"0bfaa6b3456e1cee","trace":"4ac6562354bb2a09b37701f009bbdebf"} -{"@timestamp":"2026-06-25T23:41:14.257+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.5ms","level":"info","span":"0bfaa6b3456e1cee","trace":"4ac6562354bb2a09b37701f009bbdebf"} -{"@timestamp":"2026-06-25T23:41:19.291+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.4ms)","duration":"2030.4ms","level":"slow","span":"bee20c5d5db72b19","trace":"16cab850f614050df693d55af67c9694"} -{"@timestamp":"2026-06-25T23:41:19.291+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.4ms","level":"info","span":"bee20c5d5db72b19","trace":"16cab850f614050df693d55af67c9694"} -{"@timestamp":"2026-06-25T23:41:24.326+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.4ms)","duration":"2032.4ms","level":"slow","span":"f97d56462103f33b","trace":"9aea5a71fdff9115ec8c52d42a18bfb1"} -{"@timestamp":"2026-06-25T23:41:24.326+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.4ms","level":"info","span":"f97d56462103f33b","trace":"9aea5a71fdff9115ec8c52d42a18bfb1"} -{"@timestamp":"2026-06-25T23:41:29.338+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.6ms)","duration":"2007.6ms","level":"slow","span":"ca948191fc231df1","trace":"453fbb0fa369a8f0eea98597e394249f"} -{"@timestamp":"2026-06-25T23:41:29.338+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.6ms","level":"info","span":"ca948191fc231df1","trace":"453fbb0fa369a8f0eea98597e394249f"} -{"@timestamp":"2026-06-25T23:41:34.389+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.0ms)","duration":"2048.0ms","level":"slow","span":"85b7655318276771","trace":"ca3a5c450ed829d8f90c03f59e56e28b"} -{"@timestamp":"2026-06-25T23:41:34.389+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.0ms","level":"info","span":"85b7655318276771","trace":"ca3a5c450ed829d8f90c03f59e56e28b"} -{"@timestamp":"2026-06-25T23:41:39.427+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.3ms)","duration":"2034.3ms","level":"slow","span":"21771c9a9f0c136e","trace":"0ce0a77bed1dc550f6fc2c254e6441d9"} -{"@timestamp":"2026-06-25T23:41:39.427+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.3ms","level":"info","span":"21771c9a9f0c136e","trace":"0ce0a77bed1dc550f6fc2c254e6441d9"} -{"@timestamp":"2026-06-25T23:41:44.493+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.0ms)","duration":"2062.0ms","level":"slow","span":"7125acfde88dd2ac","trace":"c9940eda76e39bda478a66b47257b0f3"} -{"@timestamp":"2026-06-25T23:41:44.493+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.0ms","level":"info","span":"7125acfde88dd2ac","trace":"c9940eda76e39bda478a66b47257b0f3"} -{"@timestamp":"2026-06-25T23:41:49.522+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.6ms)","duration":"2025.6ms","level":"slow","span":"882a9aeb638eb5be","trace":"f72fe9563889f3a9b729821fb7d88832"} -{"@timestamp":"2026-06-25T23:41:49.522+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.6ms","level":"info","span":"882a9aeb638eb5be","trace":"f72fe9563889f3a9b729821fb7d88832"} -{"@timestamp":"2026-06-25T23:41:54.559+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.8ms)","duration":"2033.8ms","level":"slow","span":"d0be1ee5fcb299d2","trace":"1d8215bc8d4cf2de142053752bf9760b"} -{"@timestamp":"2026-06-25T23:41:54.559+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.8ms","level":"info","span":"d0be1ee5fcb299d2","trace":"1d8215bc8d4cf2de142053752bf9760b"} -{"@timestamp":"2026-06-25T23:41:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=24.3Mi, Sys=19.6Mi, NumGC=18","level":"stat"} -{"@timestamp":"2026-06-25T23:41:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:41:59.593+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.6ms)","duration":"2029.6ms","level":"slow","span":"b6851655136ed851","trace":"7b309d9335fd427c44b7a664524ae986"} -{"@timestamp":"2026-06-25T23:41:59.593+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.6ms","level":"info","span":"b6851655136ed851","trace":"7b309d9335fd427c44b7a664524ae986"} -{"@timestamp":"2026-06-25T23:42:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2033.5ms, med: 2032.4ms, 90th: 2061.9ms, 99th: 2061.9ms, 99.9th: 2061.9ms","level":"stat"} -{"@timestamp":"2026-06-25T23:42:04.618+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.3ms)","duration":"2021.3ms","level":"slow","span":"fd46092898ca8242","trace":"322869f8674039b8ef7d2a09207a8747"} -{"@timestamp":"2026-06-25T23:42:04.618+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.3ms","level":"info","span":"fd46092898ca8242","trace":"322869f8674039b8ef7d2a09207a8747"} -{"@timestamp":"2026-06-25T23:42:09.631+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"5bf3cabe5314c7be","trace":"309c9a24587f21cd1b2b3789d470a1e1"} -{"@timestamp":"2026-06-25T23:42:09.632+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"5bf3cabe5314c7be","trace":"309c9a24587f21cd1b2b3789d470a1e1"} -{"@timestamp":"2026-06-25T23:42:14.646+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.4ms)","duration":"2008.4ms","level":"slow","span":"765414a589a11e28","trace":"0261294e6ebe8a409dd2b8b4daa00579"} -{"@timestamp":"2026-06-25T23:42:14.646+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.4ms","level":"info","span":"765414a589a11e28","trace":"0261294e6ebe8a409dd2b8b4daa00579"} -{"@timestamp":"2026-06-25T23:42:19.673+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.3ms)","duration":"2023.3ms","level":"slow","span":"18da86b9fade2dac","trace":"857be7a2868dda67a58bb463d38c4c02"} -{"@timestamp":"2026-06-25T23:42:19.673+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.3ms","level":"info","span":"18da86b9fade2dac","trace":"857be7a2868dda67a58bb463d38c4c02"} -{"@timestamp":"2026-06-25T23:42:24.696+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.6ms)","duration":"2020.6ms","level":"slow","span":"11868ce394938cc1","trace":"756b067c2279f564724c272817b5de41"} -{"@timestamp":"2026-06-25T23:42:24.697+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.6ms","level":"info","span":"11868ce394938cc1","trace":"756b067c2279f564724c272817b5de41"} -{"@timestamp":"2026-06-25T23:42:29.736+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.5ms)","duration":"2036.5ms","level":"slow","span":"37a3295e7cdf74ca","trace":"4e658df85ffc7c6b5d600503f5897239"} -{"@timestamp":"2026-06-25T23:42:29.736+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.5ms","level":"info","span":"37a3295e7cdf74ca","trace":"4e658df85ffc7c6b5d600503f5897239"} -{"@timestamp":"2026-06-25T23:42:34.763+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"824a14f9d5f20eb3","trace":"ddbccf518aedbf1efcfec30ab796ca20"} -{"@timestamp":"2026-06-25T23:42:34.763+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"824a14f9d5f20eb3","trace":"ddbccf518aedbf1efcfec30ab796ca20"} -{"@timestamp":"2026-06-25T23:42:39.793+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"0f00458144759434","trace":"0e0d92e753c49f955746ea12d960c2b5"} -{"@timestamp":"2026-06-25T23:42:39.793+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"0f00458144759434","trace":"0e0d92e753c49f955746ea12d960c2b5"} -{"@timestamp":"2026-06-25T23:42:44.823+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.9ms)","duration":"2027.9ms","level":"slow","span":"8037c277a47c1c2d","trace":"95df05c3fcef2404de9adff1b5ad02be"} -{"@timestamp":"2026-06-25T23:42:44.823+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.9ms","level":"info","span":"8037c277a47c1c2d","trace":"95df05c3fcef2404de9adff1b5ad02be"} -{"@timestamp":"2026-06-25T23:42:49.854+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.8ms)","duration":"2027.8ms","level":"slow","span":"873a20ea20fca831","trace":"755a66ca0011de8afd3e69a917a475ce"} -{"@timestamp":"2026-06-25T23:42:49.854+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.8ms","level":"info","span":"873a20ea20fca831","trace":"755a66ca0011de8afd3e69a917a475ce"} -{"@timestamp":"2026-06-25T23:42:54.882+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.2ms)","duration":"2023.2ms","level":"slow","span":"bc4816f50fb7eb11","trace":"e9027a29b65305d422b97ef81a5ac60c"} -{"@timestamp":"2026-06-25T23:42:54.882+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.2ms","level":"info","span":"bc4816f50fb7eb11","trace":"e9027a29b65305d422b97ef81a5ac60c"} -{"@timestamp":"2026-06-25T23:42:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=24.9Mi, Sys=19.6Mi, NumGC=19","level":"stat"} -{"@timestamp":"2026-06-25T23:42:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:42:59.913+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.3ms)","duration":"2026.3ms","level":"slow","span":"6881863f3b6e5cc8","trace":"b64e3778a51356cb83b765f9253e50e2"} -{"@timestamp":"2026-06-25T23:42:59.913+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.3ms","level":"info","span":"6881863f3b6e5cc8","trace":"b64e3778a51356cb83b765f9253e50e2"} -{"@timestamp":"2026-06-25T23:43:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2022.8ms, med: 2023.6ms, 90th: 2036.5ms, 99th: 2036.5ms, 99.9th: 2036.5ms","level":"stat"} -{"@timestamp":"2026-06-25T23:43:04.926+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2009.0ms)","duration":"2009.0ms","level":"slow","span":"f3b778d70a552fe3","trace":"0bab6f31c60b77cb0608b598428d4f69"} -{"@timestamp":"2026-06-25T23:43:04.926+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2009.0ms","level":"info","span":"f3b778d70a552fe3","trace":"0bab6f31c60b77cb0608b598428d4f69"} -{"@timestamp":"2026-06-25T23:43:09.947+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.3ms)","duration":"2019.3ms","level":"slow","span":"f67a0292d70fe429","trace":"d0060732c841798f094a4919f1c41863"} -{"@timestamp":"2026-06-25T23:43:09.947+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.3ms","level":"info","span":"f67a0292d70fe429","trace":"d0060732c841798f094a4919f1c41863"} -{"@timestamp":"2026-06-25T23:43:15.004+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.7ms)","duration":"2053.7ms","level":"slow","span":"5a08e051bd7c962c","trace":"48ed8479421c571e6d2603fed5ba56e7"} -{"@timestamp":"2026-06-25T23:43:15.004+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.7ms","level":"info","span":"5a08e051bd7c962c","trace":"48ed8479421c571e6d2603fed5ba56e7"} -{"@timestamp":"2026-06-25T23:43:20.030+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.4ms)","duration":"2023.4ms","level":"slow","span":"697a47877a31b52b","trace":"3a84bd09ca4a724978c788d15775b7cf"} -{"@timestamp":"2026-06-25T23:43:20.030+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.4ms","level":"info","span":"697a47877a31b52b","trace":"3a84bd09ca4a724978c788d15775b7cf"} -{"@timestamp":"2026-06-25T23:43:25.060+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"b8c6d1d1deb64cde","trace":"f62ec178fe718b9bb7e52ab83dd4a194"} -{"@timestamp":"2026-06-25T23:43:25.060+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"b8c6d1d1deb64cde","trace":"f62ec178fe718b9bb7e52ab83dd4a194"} -{"@timestamp":"2026-06-25T23:43:30.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.3ms)","duration":"2032.3ms","level":"slow","span":"7a5d161704d3b414","trace":"ef968487b03899032daa76ac6714504c"} -{"@timestamp":"2026-06-25T23:43:30.096+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.3ms","level":"info","span":"7a5d161704d3b414","trace":"ef968487b03899032daa76ac6714504c"} -{"@timestamp":"2026-06-25T23:43:35.121+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.2ms)","duration":"2022.2ms","level":"slow","span":"f90889cbde7e7773","trace":"4b1a9954878bb80cb8bd9d3fe2e2388d"} -{"@timestamp":"2026-06-25T23:43:35.121+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.2ms","level":"info","span":"f90889cbde7e7773","trace":"4b1a9954878bb80cb8bd9d3fe2e2388d"} -{"@timestamp":"2026-06-25T23:43:40.126+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2001.8ms)","duration":"2001.8ms","level":"slow","span":"f647401953fe644f","trace":"61cddf27ce8238997407f16c14c298d0"} -{"@timestamp":"2026-06-25T23:43:40.126+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2001.8ms","level":"info","span":"f647401953fe644f","trace":"61cddf27ce8238997407f16c14c298d0"} -{"@timestamp":"2026-06-25T23:43:45.164+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"f920e6d591a938fe","trace":"c8e80d675853b8624abc00c5ba16c2cb"} -{"@timestamp":"2026-06-25T23:43:45.164+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"f920e6d591a938fe","trace":"c8e80d675853b8624abc00c5ba16c2cb"} -{"@timestamp":"2026-06-25T23:43:50.209+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.1ms)","duration":"2044.1ms","level":"slow","span":"0ebd0926d5c30044","trace":"d74e077be51357435bc38a2c7aacc190"} -{"@timestamp":"2026-06-25T23:43:50.209+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.1ms","level":"info","span":"0ebd0926d5c30044","trace":"d74e077be51357435bc38a2c7aacc190"} -{"@timestamp":"2026-06-25T23:43:55.235+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"6606a557c477de35","trace":"7fd46c68006c2df28a8b1d990158ce2a"} -{"@timestamp":"2026-06-25T23:43:55.235+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"6606a557c477de35","trace":"7fd46c68006c2df28a8b1d990158ce2a"} -{"@timestamp":"2026-06-25T23:43:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=25.0Mi, Sys=19.6Mi, NumGC=19","level":"stat"} -{"@timestamp":"2026-06-25T23:43:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:44:00.258+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.2ms)","duration":"2021.2ms","level":"slow","span":"5bc45fb0a2f0fb1a","trace":"f0598b0ab1a143218ee6d3f8e282625e"} -{"@timestamp":"2026-06-25T23:44:00.258+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.2ms","level":"info","span":"5bc45fb0a2f0fb1a","trace":"f0598b0ab1a143218ee6d3f8e282625e"} -{"@timestamp":"2026-06-25T23:44:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2025.7ms, med: 2023.6ms, 90th: 2053.6ms, 99th: 2053.6ms, 99.9th: 2053.6ms","level":"stat"} -{"@timestamp":"2026-06-25T23:44:05.293+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.5ms)","duration":"2031.5ms","level":"slow","span":"b96b8412980cc580","trace":"1fe87b7a9c00985700f70cdddb6552d5"} -{"@timestamp":"2026-06-25T23:44:05.293+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.5ms","level":"info","span":"b96b8412980cc580","trace":"1fe87b7a9c00985700f70cdddb6552d5"} -{"@timestamp":"2026-06-25T23:44:10.324+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.0ms)","duration":"2028.0ms","level":"slow","span":"26de593ed9924c71","trace":"e215365209728d1895664436fb68c5fc"} -{"@timestamp":"2026-06-25T23:44:10.325+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.0ms","level":"info","span":"26de593ed9924c71","trace":"e215365209728d1895664436fb68c5fc"} -{"@timestamp":"2026-06-25T23:44:15.344+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.1ms)","duration":"2016.1ms","level":"slow","span":"e6ac7817fc07ffd4","trace":"8c96bec047e0cf07f058a4bf8d656397"} -{"@timestamp":"2026-06-25T23:44:15.344+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.1ms","level":"info","span":"e6ac7817fc07ffd4","trace":"8c96bec047e0cf07f058a4bf8d656397"} -{"@timestamp":"2026-06-25T23:44:20.373+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"5bb6f1f334d262f6","trace":"7914bc90b87397398b55ad9bff817997"} -{"@timestamp":"2026-06-25T23:44:20.373+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"5bb6f1f334d262f6","trace":"7914bc90b87397398b55ad9bff817997"} -{"@timestamp":"2026-06-25T23:44:25.407+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.6ms)","duration":"2030.6ms","level":"slow","span":"b698044cceccec85","trace":"df87a634ca4c2f3f376e5d77cbc6e994"} -{"@timestamp":"2026-06-25T23:44:25.407+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.6ms","level":"info","span":"b698044cceccec85","trace":"df87a634ca4c2f3f376e5d77cbc6e994"} -{"@timestamp":"2026-06-25T23:44:30.442+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.8ms)","duration":"2032.8ms","level":"slow","span":"70c696a6e619eacb","trace":"9a7156e7b45df4d8861c0f3b26c727b2"} -{"@timestamp":"2026-06-25T23:44:30.442+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.8ms","level":"info","span":"70c696a6e619eacb","trace":"9a7156e7b45df4d8861c0f3b26c727b2"} -{"@timestamp":"2026-06-25T23:44:35.459+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.1ms)","duration":"2012.1ms","level":"slow","span":"d7471984205dfbfa","trace":"208408cda06b01747f19b0fad58015fb"} -{"@timestamp":"2026-06-25T23:44:35.459+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.1ms","level":"info","span":"d7471984205dfbfa","trace":"208408cda06b01747f19b0fad58015fb"} -{"@timestamp":"2026-06-25T23:44:40.490+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.7ms)","duration":"2027.7ms","level":"slow","span":"f59baeb64e50d2ea","trace":"9b193564f9d1a8030176e866587df7c4"} -{"@timestamp":"2026-06-25T23:44:40.490+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.7ms","level":"info","span":"f59baeb64e50d2ea","trace":"9b193564f9d1a8030176e866587df7c4"} -{"@timestamp":"2026-06-25T23:44:45.524+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.7ms)","duration":"2030.7ms","level":"slow","span":"44964a2d1d2fb507","trace":"0ea425cf47c3d12ded16f5324b557952"} -{"@timestamp":"2026-06-25T23:44:45.524+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.7ms","level":"info","span":"44964a2d1d2fb507","trace":"0ea425cf47c3d12ded16f5324b557952"} -{"@timestamp":"2026-06-25T23:44:50.538+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.0ms)","duration":"2011.0ms","level":"slow","span":"61f3e43ed87018a1","trace":"5f3b3ac413caef9d430f696c056cefc1"} -{"@timestamp":"2026-06-25T23:44:50.538+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.0ms","level":"info","span":"61f3e43ed87018a1","trace":"5f3b3ac413caef9d430f696c056cefc1"} -{"@timestamp":"2026-06-25T23:44:55.560+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.5ms)","duration":"2018.5ms","level":"slow","span":"387bd3651a314ef0","trace":"6b879eb60f11edd434c95c28f11bc6a1"} -{"@timestamp":"2026-06-25T23:44:55.560+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.5ms","level":"info","span":"387bd3651a314ef0","trace":"6b879eb60f11edd434c95c28f11bc6a1"} -{"@timestamp":"2026-06-25T23:44:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=25.6Mi, Sys=19.6Mi, NumGC=20","level":"stat"} -{"@timestamp":"2026-06-25T23:44:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:45:00.606+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.4ms)","duration":"2043.4ms","level":"slow","span":"4c5b52dce74c3a74","trace":"d49f21cb6efb8f5a7389ad33ecc3ed05"} -{"@timestamp":"2026-06-25T23:45:00.607+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.4ms","level":"info","span":"4c5b52dce74c3a74","trace":"d49f21cb6efb8f5a7389ad33ecc3ed05"} -{"@timestamp":"2026-06-25T23:45:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2025.7ms, med: 2027.9ms, 90th: 2043.4ms, 99th: 2043.4ms, 99.9th: 2043.4ms","level":"stat"} -{"@timestamp":"2026-06-25T23:45:05.638+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.1ms)","duration":"2028.1ms","level":"slow","span":"ffa4daaa7ba64039","trace":"c3955998fb4a04d42e09605169485b4a"} -{"@timestamp":"2026-06-25T23:45:05.639+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.1ms","level":"info","span":"ffa4daaa7ba64039","trace":"c3955998fb4a04d42e09605169485b4a"} -{"@timestamp":"2026-06-25T23:45:10.661+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.9ms)","duration":"2019.9ms","level":"slow","span":"572f39422341eb2c","trace":"f1440c69d9e4494c4c444ccad317d52c"} -{"@timestamp":"2026-06-25T23:45:10.661+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.9ms","level":"info","span":"572f39422341eb2c","trace":"f1440c69d9e4494c4c444ccad317d52c"} -{"@timestamp":"2026-06-25T23:45:15.689+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.5ms)","duration":"2025.5ms","level":"slow","span":"09e8b7fc27a9c3f7","trace":"24e6e9f1e83e6c47b33ac62b68faf4ae"} -{"@timestamp":"2026-06-25T23:45:15.689+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.5ms","level":"info","span":"09e8b7fc27a9c3f7","trace":"24e6e9f1e83e6c47b33ac62b68faf4ae"} -{"@timestamp":"2026-06-25T23:45:20.693+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2001.7ms)","duration":"2001.7ms","level":"slow","span":"69d6500785772b41","trace":"316e83ae121742172209cd8a328b8174"} -{"@timestamp":"2026-06-25T23:45:20.693+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2001.7ms","level":"info","span":"69d6500785772b41","trace":"316e83ae121742172209cd8a328b8174"} -{"@timestamp":"2026-06-25T23:45:25.748+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.6ms)","duration":"2050.6ms","level":"slow","span":"68a268366118f14d","trace":"c5370cf86f0ddcd99276098d1592dba2"} -{"@timestamp":"2026-06-25T23:45:25.748+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.6ms","level":"info","span":"68a268366118f14d","trace":"c5370cf86f0ddcd99276098d1592dba2"} -{"@timestamp":"2026-06-25T23:45:30.777+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"336e981c7318325f","trace":"00cf66966a187a16f80b857e246da23a"} -{"@timestamp":"2026-06-25T23:45:30.778+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"336e981c7318325f","trace":"00cf66966a187a16f80b857e246da23a"} -{"@timestamp":"2026-06-25T23:45:35.793+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.0ms)","duration":"2013.0ms","level":"slow","span":"c958ecb39be5df92","trace":"158c569710c7d7dc684f988545e97eb6"} -{"@timestamp":"2026-06-25T23:45:35.793+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.0ms","level":"info","span":"c958ecb39be5df92","trace":"158c569710c7d7dc684f988545e97eb6"} -{"@timestamp":"2026-06-25T23:45:40.827+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.6ms)","duration":"2030.6ms","level":"slow","span":"bc0dac4d01a90236","trace":"e535e8f481c3978cafe8cd425caeacd4"} -{"@timestamp":"2026-06-25T23:45:40.828+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.6ms","level":"info","span":"bc0dac4d01a90236","trace":"e535e8f481c3978cafe8cd425caeacd4"} -{"@timestamp":"2026-06-25T23:45:45.847+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.5ms)","duration":"2016.5ms","level":"slow","span":"8df916307b29c5da","trace":"f21e44a9c5c6ad5a35e42c1a9a2db572"} -{"@timestamp":"2026-06-25T23:45:45.847+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.5ms","level":"info","span":"8df916307b29c5da","trace":"f21e44a9c5c6ad5a35e42c1a9a2db572"} -{"@timestamp":"2026-06-25T23:45:50.865+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.7ms)","duration":"2013.7ms","level":"slow","span":"38e4f49d7b922b99","trace":"8c6d04072a9c7f4f470948571cea228f"} -{"@timestamp":"2026-06-25T23:45:50.865+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.7ms","level":"info","span":"38e4f49d7b922b99","trace":"8c6d04072a9c7f4f470948571cea228f"} -{"@timestamp":"2026-06-25T23:45:55.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.9ms)","duration":"2024.9ms","level":"slow","span":"26aa509c8bec0aa9","trace":"d7b75da89751b4467954ad3f32eca634"} -{"@timestamp":"2026-06-25T23:45:55.896+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.9ms","level":"info","span":"26aa509c8bec0aa9","trace":"d7b75da89751b4467954ad3f32eca634"} -{"@timestamp":"2026-06-25T23:45:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=25.8Mi, Sys=19.6Mi, NumGC=20","level":"stat"} -{"@timestamp":"2026-06-25T23:45:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:46:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2022.6ms, med: 2025.4ms, 90th: 2050.5ms, 99th: 2050.5ms, 99.9th: 2050.5ms","level":"stat"} -{"@timestamp":"2026-06-25T23:46:00.927+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.2ms)","duration":"2025.2ms","level":"slow","span":"6291df86410ccc1e","trace":"fbebb1d5c15fb0b555d2a413e22d6401"} -{"@timestamp":"2026-06-25T23:46:00.927+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.2ms","level":"info","span":"6291df86410ccc1e","trace":"fbebb1d5c15fb0b555d2a413e22d6401"} -{"@timestamp":"2026-06-25T23:46:05.945+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.6ms)","duration":"2013.6ms","level":"slow","span":"68ac545a6d4c54d5","trace":"b9666109e237c61d8e23e94f3c680cd0"} -{"@timestamp":"2026-06-25T23:46:05.945+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.6ms","level":"info","span":"68ac545a6d4c54d5","trace":"b9666109e237c61d8e23e94f3c680cd0"} -{"@timestamp":"2026-06-25T23:46:10.979+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"b258ef6d8a3389d0","trace":"2cc76fcc44a3460b535e14ae5062454b"} -{"@timestamp":"2026-06-25T23:46:10.979+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"b258ef6d8a3389d0","trace":"2cc76fcc44a3460b535e14ae5062454b"} -{"@timestamp":"2026-06-25T23:46:16.000+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.9ms)","duration":"2016.9ms","level":"slow","span":"0df4a1537a0d8e8c","trace":"bad4a4b7c488beccf291a0453f9950b8"} -{"@timestamp":"2026-06-25T23:46:16.001+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.9ms","level":"info","span":"0df4a1537a0d8e8c","trace":"bad4a4b7c488beccf291a0453f9950b8"} -{"@timestamp":"2026-06-25T23:46:21.028+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"3f33cbe175aab89e","trace":"1d5f3f3ecdbd27f6e99c0008b9917750"} -{"@timestamp":"2026-06-25T23:46:21.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"3f33cbe175aab89e","trace":"1d5f3f3ecdbd27f6e99c0008b9917750"} -{"@timestamp":"2026-06-25T23:46:26.065+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"3c73e07ff14651ac","trace":"21490d35686ed6f09322971cdecdcb34"} -{"@timestamp":"2026-06-25T23:46:26.065+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"3c73e07ff14651ac","trace":"21490d35686ed6f09322971cdecdcb34"} -{"@timestamp":"2026-06-25T23:46:31.112+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.3ms)","duration":"2043.3ms","level":"slow","span":"abb7a14863ad589c","trace":"d9c57fbbbe4ac56b40096ff71539f142"} -{"@timestamp":"2026-06-25T23:46:31.112+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.3ms","level":"info","span":"abb7a14863ad589c","trace":"d9c57fbbbe4ac56b40096ff71539f142"} -{"@timestamp":"2026-06-25T23:46:36.145+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"9d75519f160d30b9","trace":"2b7791530fdd7e799d970cc0fb28bb93"} -{"@timestamp":"2026-06-25T23:46:36.146+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"9d75519f160d30b9","trace":"2b7791530fdd7e799d970cc0fb28bb93"} -{"@timestamp":"2026-06-25T23:46:41.174+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.3ms)","duration":"2026.3ms","level":"slow","span":"8facbbde6d3a8e17","trace":"275906fd784c23a14106f807a91b0f6d"} -{"@timestamp":"2026-06-25T23:46:41.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.3ms","level":"info","span":"8facbbde6d3a8e17","trace":"275906fd784c23a14106f807a91b0f6d"} -{"@timestamp":"2026-06-25T23:46:46.200+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.8ms)","duration":"2022.8ms","level":"slow","span":"62f16be507a5e590","trace":"528001eb010734f5b48c97fcb2987e14"} -{"@timestamp":"2026-06-25T23:46:46.200+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.8ms","level":"info","span":"62f16be507a5e590","trace":"528001eb010734f5b48c97fcb2987e14"} -{"@timestamp":"2026-06-25T23:46:51.229+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"f4826c6ff3848a0b","trace":"aebe2abd461dddc24909bde6fb08abde"} -{"@timestamp":"2026-06-25T23:46:51.229+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"f4826c6ff3848a0b","trace":"aebe2abd461dddc24909bde6fb08abde"} -{"@timestamp":"2026-06-25T23:46:56.242+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.6ms)","duration":"2008.6ms","level":"slow","span":"724a2b444fdccb0b","trace":"514b85d4e8ef8a6dee22f21b346552e7"} -{"@timestamp":"2026-06-25T23:46:56.242+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.6ms","level":"info","span":"724a2b444fdccb0b","trace":"514b85d4e8ef8a6dee22f21b346552e7"} -{"@timestamp":"2026-06-25T23:46:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=26.3Mi, Sys=19.6Mi, NumGC=21","level":"stat"} -{"@timestamp":"2026-06-25T23:46:58.026+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:47:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.8ms, med: 2025.7ms, 90th: 2043.2ms, 99th: 2043.2ms, 99.9th: 2043.2ms","level":"stat"} -{"@timestamp":"2026-06-25T23:47:01.251+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2006.5ms)","duration":"2006.5ms","level":"slow","span":"2c8eb86adda030ca","trace":"60d43a3beafce6c35834ecc86bb709cb"} -{"@timestamp":"2026-06-25T23:47:01.251+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2006.5ms","level":"info","span":"2c8eb86adda030ca","trace":"60d43a3beafce6c35834ecc86bb709cb"} -{"@timestamp":"2026-06-25T23:47:06.308+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.9ms)","duration":"2053.9ms","level":"slow","span":"76d6b7a3cd64ba89","trace":"5af619e568ffa9151e13a8c1e6dfbe68"} -{"@timestamp":"2026-06-25T23:47:06.308+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.9ms","level":"info","span":"76d6b7a3cd64ba89","trace":"5af619e568ffa9151e13a8c1e6dfbe68"} -{"@timestamp":"2026-06-25T23:47:11.350+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.2ms)","duration":"2041.2ms","level":"slow","span":"a48e621bc2caad0a","trace":"bc3a41c69b8a63b94eb6c458684ce65f"} -{"@timestamp":"2026-06-25T23:47:11.351+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.2ms","level":"info","span":"a48e621bc2caad0a","trace":"bc3a41c69b8a63b94eb6c458684ce65f"} -{"@timestamp":"2026-06-25T23:47:16.376+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"c700c5e4875c3b62","trace":"4a1a8bf28ecac32c987c064e77697710"} -{"@timestamp":"2026-06-25T23:47:16.376+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"c700c5e4875c3b62","trace":"4a1a8bf28ecac32c987c064e77697710"} -{"@timestamp":"2026-06-25T23:47:21.409+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.0ms)","duration":"2030.0ms","level":"slow","span":"5c0f42ff1ff1b6b9","trace":"0dbff98c0e8ddb3d7414f8752e0a8ed4"} -{"@timestamp":"2026-06-25T23:47:21.409+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.0ms","level":"info","span":"5c0f42ff1ff1b6b9","trace":"0dbff98c0e8ddb3d7414f8752e0a8ed4"} -{"@timestamp":"2026-06-25T23:47:26.428+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.8ms)","duration":"2016.8ms","level":"slow","span":"87467e956c49839c","trace":"130a00336f283d185d0d68af62be201f"} -{"@timestamp":"2026-06-25T23:47:26.428+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.8ms","level":"info","span":"87467e956c49839c","trace":"130a00336f283d185d0d68af62be201f"} -{"@timestamp":"2026-06-25T23:47:31.449+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.7ms)","duration":"2018.7ms","level":"slow","span":"77a457f12bc5f95d","trace":"a82ef55fe4c012e2c8c4b8f858eb2965"} -{"@timestamp":"2026-06-25T23:47:31.449+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.7ms","level":"info","span":"77a457f12bc5f95d","trace":"a82ef55fe4c012e2c8c4b8f858eb2965"} -{"@timestamp":"2026-06-25T23:47:36.477+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.4ms)","duration":"2025.4ms","level":"slow","span":"0f96750ce7636a23","trace":"57da459e9e4757e68bde3f0f7caad501"} -{"@timestamp":"2026-06-25T23:47:36.477+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.4ms","level":"info","span":"0f96750ce7636a23","trace":"57da459e9e4757e68bde3f0f7caad501"} -{"@timestamp":"2026-06-25T23:47:41.503+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.9ms)","duration":"2023.9ms","level":"slow","span":"0fdff2473f3455e4","trace":"2e87097b63b98c33e8d3eeb4732bfdf7"} -{"@timestamp":"2026-06-25T23:47:41.503+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.9ms","level":"info","span":"0fdff2473f3455e4","trace":"2e87097b63b98c33e8d3eeb4732bfdf7"} -{"@timestamp":"2026-06-25T23:47:46.542+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"b19121950e913398","trace":"b12aab2a619dbdcf913d03c781cd2877"} -{"@timestamp":"2026-06-25T23:47:46.542+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"b19121950e913398","trace":"b12aab2a619dbdcf913d03c781cd2877"} -{"@timestamp":"2026-06-25T23:47:51.573+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.8ms)","duration":"2026.8ms","level":"slow","span":"efde736ba027033c","trace":"1b77be89a577ce949e5ef52dbf3d5168"} -{"@timestamp":"2026-06-25T23:47:51.573+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.8ms","level":"info","span":"efde736ba027033c","trace":"1b77be89a577ce949e5ef52dbf3d5168"} -{"@timestamp":"2026-06-25T23:47:56.579+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2002.5ms)","duration":"2002.5ms","level":"slow","span":"d4d8026ad11d5cdd","trace":"83155612cc830f43ec8dd822c10331bd"} -{"@timestamp":"2026-06-25T23:47:56.579+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2002.5ms","level":"info","span":"d4d8026ad11d5cdd","trace":"83155612cc830f43ec8dd822c10331bd"} -{"@timestamp":"2026-06-25T23:47:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=26.5Mi, Sys=19.6Mi, NumGC=21","level":"stat"} -{"@timestamp":"2026-06-25T23:47:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:48:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2025.0ms, med: 2025.4ms, 90th: 2053.8ms, 99th: 2053.8ms, 99.9th: 2053.8ms","level":"stat"} -{"@timestamp":"2026-06-25T23:48:01.590+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.3ms)","duration":"2007.3ms","level":"slow","span":"0b2771b64df747df","trace":"1e00ec1f51520f13b94be02f69691eb8"} -{"@timestamp":"2026-06-25T23:48:01.591+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.3ms","level":"info","span":"0b2771b64df747df","trace":"1e00ec1f51520f13b94be02f69691eb8"} -{"@timestamp":"2026-06-25T23:48:06.622+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.6ms)","duration":"2027.6ms","level":"slow","span":"499e85ccb8e71daa","trace":"2cb6e5eff796ca1485455520dea4eda6"} -{"@timestamp":"2026-06-25T23:48:06.622+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.6ms","level":"info","span":"499e85ccb8e71daa","trace":"2cb6e5eff796ca1485455520dea4eda6"} -{"@timestamp":"2026-06-25T23:48:11.692+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.6ms)","duration":"2067.6ms","level":"slow","span":"54443017aaa1f8b5","trace":"bf0e6a50a02f2e8f5aae2d2f0e4282d9"} -{"@timestamp":"2026-06-25T23:48:11.692+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.6ms","level":"info","span":"54443017aaa1f8b5","trace":"bf0e6a50a02f2e8f5aae2d2f0e4282d9"} -{"@timestamp":"2026-06-25T23:48:16.714+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.0ms)","duration":"2019.0ms","level":"slow","span":"7b81be510385a389","trace":"3982fef6913067d24e92b85b48790147"} -{"@timestamp":"2026-06-25T23:48:16.714+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.0ms","level":"info","span":"7b81be510385a389","trace":"3982fef6913067d24e92b85b48790147"} -{"@timestamp":"2026-06-25T23:48:21.731+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.4ms)","duration":"2013.4ms","level":"slow","span":"431c6bc9ffecf236","trace":"9c249d7ddf82c9bff5610f4a91ce52ca"} -{"@timestamp":"2026-06-25T23:48:21.731+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.4ms","level":"info","span":"431c6bc9ffecf236","trace":"9c249d7ddf82c9bff5610f4a91ce52ca"} -{"@timestamp":"2026-06-25T23:48:26.748+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.6ms)","duration":"2012.6ms","level":"slow","span":"1b1a9b457208f1ab","trace":"be1689ccabde59ce471d49fcc037f80e"} -{"@timestamp":"2026-06-25T23:48:26.748+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.6ms","level":"info","span":"1b1a9b457208f1ab","trace":"be1689ccabde59ce471d49fcc037f80e"} -{"@timestamp":"2026-06-25T23:48:31.776+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.3ms)","duration":"2025.3ms","level":"slow","span":"5bb54c796c0e99f4","trace":"579d8e02bd009a5c7153dc05f5e55514"} -{"@timestamp":"2026-06-25T23:48:31.776+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.3ms","level":"info","span":"5bb54c796c0e99f4","trace":"579d8e02bd009a5c7153dc05f5e55514"} -{"@timestamp":"2026-06-25T23:48:36.788+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.2ms)","duration":"2008.2ms","level":"slow","span":"67556add6048b470","trace":"292e60e87a7273cdf1cdbdf4aac59ac0"} -{"@timestamp":"2026-06-25T23:48:36.788+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.2ms","level":"info","span":"67556add6048b470","trace":"292e60e87a7273cdf1cdbdf4aac59ac0"} -{"@timestamp":"2026-06-25T23:48:41.829+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.3ms)","duration":"2037.3ms","level":"slow","span":"253a350ab0d314bd","trace":"63675c12d230c401d708ff44c24bab25"} -{"@timestamp":"2026-06-25T23:48:41.829+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.3ms","level":"info","span":"253a350ab0d314bd","trace":"63675c12d230c401d708ff44c24bab25"} -{"@timestamp":"2026-06-25T23:48:46.853+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.4ms)","duration":"2020.4ms","level":"slow","span":"3cf3b65d53a11fdf","trace":"c19ad4fa2e5c5c5a6396971849cc5fe8"} -{"@timestamp":"2026-06-25T23:48:46.853+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.4ms","level":"info","span":"3cf3b65d53a11fdf","trace":"c19ad4fa2e5c5c5a6396971849cc5fe8"} -{"@timestamp":"2026-06-25T23:48:51.883+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"917d2289bf67d192","trace":"e0a2c820c48953028e3b07efe814e045"} -{"@timestamp":"2026-06-25T23:48:51.883+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"917d2289bf67d192","trace":"e0a2c820c48953028e3b07efe814e045"} -{"@timestamp":"2026-06-25T23:48:56.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.4ms)","duration":"2024.4ms","level":"slow","span":"cada348a4214fad7","trace":"e32a29ac753ac06ecc00a6a6e9e17d71"} -{"@timestamp":"2026-06-25T23:48:56.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.4ms","level":"info","span":"cada348a4214fad7","trace":"e32a29ac753ac06ecc00a6a6e9e17d71"} -{"@timestamp":"2026-06-25T23:48:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=27.0Mi, Sys=19.6Mi, NumGC=22","level":"stat"} -{"@timestamp":"2026-06-25T23:48:58.026+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:49:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.0ms, med: 2024.3ms, 90th: 2067.5ms, 99th: 2067.5ms, 99.9th: 2067.5ms","level":"stat"} -{"@timestamp":"2026-06-25T23:49:01.930+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.0ms)","duration":"2014.0ms","level":"slow","span":"726b661cb11a3747","trace":"383241375b7b615282bce2f2ca092834"} -{"@timestamp":"2026-06-25T23:49:01.930+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.0ms","level":"info","span":"726b661cb11a3747","trace":"383241375b7b615282bce2f2ca092834"} -{"@timestamp":"2026-06-25T23:49:06.957+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.7ms)","duration":"2024.7ms","level":"slow","span":"2b3f19e467c76686","trace":"da3776e1b72584c02b75697524136d72"} -{"@timestamp":"2026-06-25T23:49:06.957+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.7ms","level":"info","span":"2b3f19e467c76686","trace":"da3776e1b72584c02b75697524136d72"} -{"@timestamp":"2026-06-25T23:49:11.986+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.7ms)","duration":"2024.7ms","level":"slow","span":"070859a6459ecf81","trace":"0cacebcf8b5014f47352906dbb49dc49"} -{"@timestamp":"2026-06-25T23:49:11.986+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.7ms","level":"info","span":"070859a6459ecf81","trace":"0cacebcf8b5014f47352906dbb49dc49"} -{"@timestamp":"2026-06-25T23:49:17.029+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.4ms)","duration":"2035.4ms","level":"slow","span":"0fa5235b981ced96","trace":"1dfd290f7db0b457eaec79e465f1525c"} -{"@timestamp":"2026-06-25T23:49:17.030+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.4ms","level":"info","span":"0fa5235b981ced96","trace":"1dfd290f7db0b457eaec79e465f1525c"} -{"@timestamp":"2026-06-25T23:58:48.863+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.0ms)","duration":"2036.0ms","level":"slow","span":"058614292f0cac32","trace":"dafa9bdcb35b5cb80e09073e94632408"} -{"@timestamp":"2026-06-25T23:58:48.863+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.0ms","level":"info","span":"058614292f0cac32","trace":"dafa9bdcb35b5cb80e09073e94632408"} -{"@timestamp":"2026-06-25T23:58:53.903+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.9ms)","duration":"2028.9ms","level":"slow","span":"1f2398d14c268363","trace":"94380091eadc9ab77d574429ad6d093b"} -{"@timestamp":"2026-06-25T23:58:53.903+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.9ms","level":"info","span":"1f2398d14c268363","trace":"94380091eadc9ab77d574429ad6d093b"} -{"@timestamp":"2026-06-25T23:58:58.943+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.4ms)","duration":"2032.4ms","level":"slow","span":"cb317771fa6d5176","trace":"06ce5a622b500ab61e3656cc25f50652"} -{"@timestamp":"2026-06-25T23:58:58.943+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.4ms","level":"info","span":"cb317771fa6d5176","trace":"06ce5a622b500ab61e3656cc25f50652"} -{"@timestamp":"2026-06-25T23:59:03.975+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.9ms)","duration":"2023.9ms","level":"slow","span":"029a097e7b5fdd1e","trace":"7b16c3a0cb34c73221faf6b4784cd389"} -{"@timestamp":"2026-06-25T23:59:03.975+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.9ms","level":"info","span":"029a097e7b5fdd1e","trace":"7b16c3a0cb34c73221faf6b4784cd389"} -{"@timestamp":"2026-06-25T23:59:09.009+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.5ms)","duration":"2027.5ms","level":"slow","span":"dcc1e4064ae394f4","trace":"8e32e244013e597bf89937c86bcf97bf"} -{"@timestamp":"2026-06-25T23:59:09.009+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.5ms","level":"info","span":"dcc1e4064ae394f4","trace":"8e32e244013e597bf89937c86bcf97bf"} -{"@timestamp":"2026-06-25T23:59:14.044+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.5ms)","duration":"2027.5ms","level":"slow","span":"4e210f23e9dea25d","trace":"9685971af3b3bcf8865a66b8f3a5d0f5"} -{"@timestamp":"2026-06-25T23:59:14.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.5ms","level":"info","span":"4e210f23e9dea25d","trace":"9685971af3b3bcf8865a66b8f3a5d0f5"} -{"@timestamp":"2026-06-25T23:59:19.078+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.3ms)","duration":"2027.3ms","level":"slow","span":"7b8340911f2cf0d2","trace":"fb11764a6cab16a7f155b6e109ee7306"} -{"@timestamp":"2026-06-25T23:59:19.078+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.3ms","level":"info","span":"7b8340911f2cf0d2","trace":"fb11764a6cab16a7f155b6e109ee7306"} -{"@timestamp":"2026-06-25T23:59:24.108+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.3ms)","duration":"2024.3ms","level":"slow","span":"2bf6be5a1ff281a4","trace":"dcd2a084f40e2107c81419585f67b916"} -{"@timestamp":"2026-06-25T23:59:24.108+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.3ms","level":"info","span":"2bf6be5a1ff281a4","trace":"dcd2a084f40e2107c81419585f67b916"} -{"@timestamp":"2026-06-25T23:59:24.838+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=27.1Mi, Sys=19.6Mi, NumGC=22","level":"stat"} -{"@timestamp":"2026-06-25T23:59:24.855+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-25T23:59:27.469+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2027.1ms, med: 2027.4ms, 90th: 2035.9ms, 99th: 2035.9ms, 99.9th: 2035.9ms","level":"stat"} -{"@timestamp":"2026-06-25T23:59:29.145+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.9ms)","duration":"2032.9ms","level":"slow","span":"f4ae2c793a238106","trace":"05a19991b88dc1b1ba4300dba2ad72b8"} -{"@timestamp":"2026-06-25T23:59:29.145+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.9ms","level":"info","span":"f4ae2c793a238106","trace":"05a19991b88dc1b1ba4300dba2ad72b8"} -{"@timestamp":"2026-06-26T00:00:15.737+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(43586.3ms)","duration":"43586.3ms","level":"slow","span":"d4b194d2f1f14451","trace":"13bb30158e54f5331bbe9ffa9a2a67ee"} -{"@timestamp":"2026-06-26T00:00:15.737+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"43586.3ms","level":"info","span":"d4b194d2f1f14451","trace":"13bb30158e54f5331bbe9ffa9a2a67ee"} -{"@timestamp":"2026-06-26T00:00:20.805+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.2ms)","duration":"2063.2ms","level":"slow","span":"1f473414065a5b7e","trace":"c7a34762b1b9959c50fe61f2f46fb859"} -{"@timestamp":"2026-06-26T00:00:20.806+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.2ms","level":"info","span":"1f473414065a5b7e","trace":"c7a34762b1b9959c50fe61f2f46fb859"} -2026/06/26 00:00:32 job worker claim error: read tcp 127.0.0.1:63284->127.0.0.1:6379: i/o timeout -{"@timestamp":"2026-06-26T00:00:35.813+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(12003.5ms)","duration":"12003.5ms","level":"slow","span":"aaac067343d0d23c","trace":"f9182e9205d733f25bdfba67c24ce28a"} -{"@timestamp":"2026-06-26T00:00:35.814+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"12003.5ms","level":"error","span":"aaac067343d0d23c","trace":"f9182e9205d733f25bdfba67c24ce28a"} -{"@timestamp":"2026-06-26T00:00:41.851+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.1ms)","duration":"3025.1ms","level":"slow","span":"fd556025a73295b2","trace":"c36b47f8fa0715d9726cdecdb57c472c"} -{"@timestamp":"2026-06-26T00:00:41.851+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.1ms","level":"error","span":"fd556025a73295b2","trace":"c36b47f8fa0715d9726cdecdb57c472c"} -2026/06/26 00:00:45 job worker claim error: read tcp 127.0.0.1:63274->127.0.0.1:6379: i/o timeout -{"@timestamp":"2026-06-26T00:00:47.879+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.9ms)","duration":"3021.9ms","level":"slow","span":"08a241c1d1de2580","trace":"2933abe562601d49c5778fec76de3aef"} -{"@timestamp":"2026-06-26T00:00:47.879+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.9ms","level":"error","span":"08a241c1d1de2580","trace":"2933abe562601d49c5778fec76de3aef"} -2026/06/26 00:00:49 job worker claim error: i/o timeout -2026/06/26 00:00:53 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:00:53.910+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.6ms)","duration":"3024.6ms","level":"slow","span":"49e51e3b05b8c938","trace":"2a806208f774ba50a624274f050b13bc"} -{"@timestamp":"2026-06-26T00:00:53.910+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.6ms","level":"error","span":"49e51e3b05b8c938","trace":"2a806208f774ba50a624274f050b13bc"} -2026/06/26 00:00:57 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:00:59.931+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"701ea188de4877d5","trace":"762e3fa3a00868fa15724b7c14b6cae9"} -{"@timestamp":"2026-06-26T00:00:59.931+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"701ea188de4877d5","trace":"762e3fa3a00868fa15724b7c14b6cae9"} -2026/06/26 00:01:01 job worker claim error: i/o timeout -2026/06/26 00:16:28 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:16:28.161+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(925224.9ms)","duration":"925224.9ms","level":"slow","span":"fcf5530048609174","trace":"75242eb90a37f5aabd0d42a4741ce5ca"} -{"@timestamp":"2026-06-26T00:16:28.162+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"925224.9ms","level":"error","span":"fcf5530048609174","trace":"75242eb90a37f5aabd0d42a4741ce5ca"} -{"@timestamp":"2026-06-26T00:16:28.618+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.8Mi, TotalAlloc=28.3Mi, Sys=19.6Mi, NumGC=23","level":"stat"} -{"@timestamp":"2026-06-26T00:16:28.633+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 9, pass: 9, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T00:16:31.846+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 110777.1ms, med: 3024.9ms, 90th: 925224.8ms, 99th: 925224.8ms, 99.9th: 925224.8ms","level":"stat"} -2026/06/26 00:16:32 job scheduler tick error: i/o timeout -2026/06/26 00:16:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:16:34.786+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.3ms)","duration":"3022.3ms","level":"slow","span":"aba585ad5848f8b6","trace":"d71aef62958e7f5e7078721cbf456dd3"} -{"@timestamp":"2026-06-26T00:16:34.787+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.3ms","level":"error","span":"aba585ad5848f8b6","trace":"d71aef62958e7f5e7078721cbf456dd3"} -2026/06/26 00:16:36 job worker claim error: i/o timeout -2026/06/26 00:16:40 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:16:40.825+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.8ms)","duration":"3028.8ms","level":"slow","span":"e3cc815f90642546","trace":"98a80ee77a72019765648cd07c5df31f"} -{"@timestamp":"2026-06-26T00:16:40.825+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.8ms","level":"error","span":"e3cc815f90642546","trace":"98a80ee77a72019765648cd07c5df31f"} -2026/06/26 00:16:41 job reaper error: connection(127.0.0.1:27017[-6]) incomplete read of message header: read tcp 127.0.0.1:63288->127.0.0.1:27017: use of closed network connection -2026/06/26 00:16:44 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:16:46.861+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.2ms)","duration":"3029.2ms","level":"slow","span":"97ef0e5f90bc905b","trace":"0cf70adf043fea7812601e36bf06ddf1"} -{"@timestamp":"2026-06-26T00:16:46.861+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.2ms","level":"error","span":"97ef0e5f90bc905b","trace":"0cf70adf043fea7812601e36bf06ddf1"} -2026/06/26 00:16:48 job worker claim error: i/o timeout -2026/06/26 00:16:52 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:16:52.885+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.0ms)","duration":"3017.0ms","level":"slow","span":"0f3e486257713b99","trace":"45dd9bcac5a0622881df87335c5e6438"} -{"@timestamp":"2026-06-26T00:16:52.885+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.0ms","level":"error","span":"0f3e486257713b99","trace":"45dd9bcac5a0622881df87335c5e6438"} -2026/06/26 00:16:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:16:58.911+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.7ms)","duration":"3018.7ms","level":"slow","span":"d27ef3933f196943","trace":"71d3ee6f6b06af1060b247c196f6bed8"} -{"@timestamp":"2026-06-26T00:16:58.911+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.7ms","level":"error","span":"d27ef3933f196943","trace":"71d3ee6f6b06af1060b247c196f6bed8"} -2026/06/26 00:17:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:17:04.936+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"1d69fd3610ae7a2f","trace":"39f8b6c482ef158a012cfe814f36e916"} -{"@timestamp":"2026-06-26T00:17:04.937+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"1d69fd3610ae7a2f","trace":"39f8b6c482ef158a012cfe814f36e916"} -2026/06/26 00:17:04 job worker claim error: i/o timeout -2026/06/26 00:17:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:17:10.966+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"53bf84db7a55bb3c","trace":"693296624af79d064d4fbb48fd9f438a"} -{"@timestamp":"2026-06-26T00:17:10.966+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"53bf84db7a55bb3c","trace":"693296624af79d064d4fbb48fd9f438a"} -2026/06/26 00:17:11 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-21]) incomplete read of message header: read tcp 127.0.0.1:49822->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-21]) incomplete read of message header: read tcp 127.0.0.1:49822->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 00:17:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:17:16.992+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"85b6cddfcc5f4cec","trace":"ceac752b51da147b4f4ce8deb84a8efe"} -{"@timestamp":"2026-06-26T00:17:16.992+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"85b6cddfcc5f4cec","trace":"ceac752b51da147b4f4ce8deb84a8efe"} -2026/06/26 00:17:17 job worker claim error: i/o timeout -2026/06/26 00:34:22 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:34:24.673+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.2ms)","duration":"3027.2ms","level":"slow","span":"0297a2da521c5450","trace":"6f609c994d3687ad3f184a4364e3a544"} -{"@timestamp":"2026-06-26T00:34:24.673+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.2ms","level":"error","span":"0297a2da521c5450","trace":"6f609c994d3687ad3f184a4364e3a544"} -2026/06/26 00:50:04 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:08.343+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.9ms)","duration":"3027.9ms","level":"slow","span":"b06745ec630a4ed6","trace":"861134996da931a2dd6cbcbe24b21158"} -{"@timestamp":"2026-06-26T00:50:08.343+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.9ms","level":"error","span":"b06745ec630a4ed6","trace":"861134996da931a2dd6cbcbe24b21158"} -2026/06/26 00:50:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:08.521+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.7Mi, TotalAlloc=30.6Mi, Sys=19.6Mi, NumGC=24","level":"stat"} -{"@timestamp":"2026-06-26T00:50:08.536+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T00:50:11.151+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3023.1ms, med: 3022.4ms, 90th: 3029.1ms, 99th: 3029.1ms, 99.9th: 3029.1ms","level":"stat"} -2026/06/26 00:50:11 job scheduler tick error: i/o timeout -2026/06/26 00:50:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:14.361+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.4ms)","duration":"3012.4ms","level":"slow","span":"8146f995031c4ec8","trace":"5ac85ed5dd76f3c4186451c893057c74"} -{"@timestamp":"2026-06-26T00:50:14.361+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.4ms","level":"error","span":"8146f995031c4ec8","trace":"5ac85ed5dd76f3c4186451c893057c74"} -2026/06/26 00:50:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:20.392+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"957f85e8ea10547a","trace":"5321761c8cbd017d85742d833fd0c7bd"} -{"@timestamp":"2026-06-26T00:50:20.392+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"957f85e8ea10547a","trace":"5321761c8cbd017d85742d833fd0c7bd"} -2026/06/26 00:50:20 job worker claim error: i/o timeout -2026/06/26 00:50:20 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-27]) incomplete read of message header: read tcp 127.0.0.1:49881->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-27]) incomplete read of message header: read tcp 127.0.0.1:49881->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 00:50:24 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:26.417+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"308a0591b20fa253","trace":"cff835ffee04efae575e81db10c9b51b"} -{"@timestamp":"2026-06-26T00:50:26.417+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"308a0591b20fa253","trace":"cff835ffee04efae575e81db10c9b51b"} -2026/06/26 00:50:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:37.451+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.2ms)","duration":"3021.2ms","level":"slow","span":"9ee1f6b0a633f6ba","trace":"0f3cb381e9f2268c45a0c4be7fc05a5d"} -{"@timestamp":"2026-06-26T00:50:37.452+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.2ms","level":"error","span":"9ee1f6b0a633f6ba","trace":"0f3cb381e9f2268c45a0c4be7fc05a5d"} -2026/06/26 00:50:40 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:43.485+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.0ms)","duration":"3029.0ms","level":"slow","span":"2981095dadae3d8c","trace":"88eab3c55a53304ee00b89174c53fa47"} -{"@timestamp":"2026-06-26T00:50:43.485+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.0ms","level":"error","span":"2981095dadae3d8c","trace":"88eab3c55a53304ee00b89174c53fa47"} -2026/06/26 00:50:44 job worker claim error: i/o timeout -2026/06/26 00:50:48 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:49.509+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.0ms)","duration":"3017.0ms","level":"slow","span":"8ded3b36d3984fca","trace":"aba4f629314605850f820cfc0b2a58d9"} -{"@timestamp":"2026-06-26T00:50:49.509+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.0ms","level":"error","span":"8ded3b36d3984fca","trace":"aba4f629314605850f820cfc0b2a58d9"} -2026/06/26 00:50:52 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:50:55.532+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.3ms)","duration":"3016.3ms","level":"slow","span":"504863caca22a5cc","trace":"88b3d9a2dfb508268583e59b5e3af911"} -{"@timestamp":"2026-06-26T00:50:55.532+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.3ms","level":"error","span":"504863caca22a5cc","trace":"88b3d9a2dfb508268583e59b5e3af911"} -2026/06/26 00:50:56 job worker claim error: i/o timeout -2026/06/26 00:50:58 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-33]) incomplete read of message header: read tcp 127.0.0.1:49965->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-33]) incomplete read of message header: read tcp 127.0.0.1:49965->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 00:51:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:51:01.567+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.2ms)","duration":"3029.2ms","level":"slow","span":"b019cbca4e7af5bc","trace":"b17cd747a520725c9af937d018523430"} -{"@timestamp":"2026-06-26T00:51:01.567+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.2ms","level":"error","span":"b019cbca4e7af5bc","trace":"b17cd747a520725c9af937d018523430"} -2026/06/26 00:51:04 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:51:07.593+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.6ms)","duration":"3019.6ms","level":"slow","span":"85a4ad522795b184","trace":"ea0878e5735fa19266deed58f9273744"} -{"@timestamp":"2026-06-26T00:51:07.593+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.6ms","level":"error","span":"85a4ad522795b184","trace":"ea0878e5735fa19266deed58f9273744"} -2026/06/26 00:51:08 job worker claim error: i/o timeout -2026/06/26 00:51:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:51:13.616+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.9ms)","duration":"3016.9ms","level":"slow","span":"0156823d21c1e98f","trace":"03c73fcbad3388eaacf4fa423db4fdb3"} -{"@timestamp":"2026-06-26T00:51:13.616+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.9ms","level":"error","span":"0156823d21c1e98f","trace":"03c73fcbad3388eaacf4fa423db4fdb3"} -{"@timestamp":"2026-06-26T00:51:16.051+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.4Mi, TotalAlloc=32.4Mi, Sys=19.6Mi, NumGC=24","level":"stat"} -{"@timestamp":"2026-06-26T00:51:16.069+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 00:51:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T00:51:18.682+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.1ms, med: 3019.3ms, 90th: 3029.1ms, 99th: 3029.1ms, 99.9th: 3029.1ms","level":"stat"} -2026/06/26 00:51:19 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T00:51:19.632+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3009.9ms)","duration":"3009.9ms","level":"slow","span":"e49367bece3beff6","trace":"f6dc6e41c6243f0b7fe7f57e6ae05c64"} -{"@timestamp":"2026-06-26T00:51:19.632+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3009.9ms","level":"error","span":"e49367bece3beff6","trace":"f6dc6e41c6243f0b7fe7f57e6ae05c64"} -2026/06/26 00:51:20 job worker claim error: i/o timeout -2026/06/26 01:08:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:08:10.384+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.5ms)","duration":"3016.5ms","level":"slow","span":"032d47955fe1dc64","trace":"6a127de95d99870e84352cfe1562cef7"} -{"@timestamp":"2026-06-26T01:08:10.384+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.5ms","level":"error","span":"032d47955fe1dc64","trace":"6a127de95d99870e84352cfe1562cef7"} -2026/06/26 01:08:17 job worker claim error: i/o timeout -2026/06/26 01:08:17 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-39]) incomplete read of message header: read tcp 127.0.0.1:50002->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-39]) incomplete read of message header: read tcp 127.0.0.1:50002->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T01:08:20.629+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.2ms)","duration":"3022.2ms","level":"slow","span":"a653d986b5858eb4","trace":"a8d9cd47e55ab59cebe94f71a6ab0c46"} -{"@timestamp":"2026-06-26T01:08:20.629+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.2ms","level":"error","span":"a653d986b5858eb4","trace":"a8d9cd47e55ab59cebe94f71a6ab0c46"} -2026/06/26 01:08:21 job worker claim error: i/o timeout -2026/06/26 01:08:25 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:08:26.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"808529a0130ca481","trace":"61bb26b0adbeaa07278b9c8c01c8aedc"} -{"@timestamp":"2026-06-26T01:08:26.652+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"808529a0130ca481","trace":"61bb26b0adbeaa07278b9c8c01c8aedc"} -2026/06/26 01:08:29 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:08:32.674+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.9ms)","duration":"3016.9ms","level":"slow","span":"8ec163e197d1a456","trace":"b451a5a4af23a593aec07f051d5da692"} -{"@timestamp":"2026-06-26T01:08:32.674+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.9ms","level":"error","span":"8ec163e197d1a456","trace":"b451a5a4af23a593aec07f051d5da692"} -2026/06/26 01:08:33 job worker claim error: i/o timeout -2026/06/26 01:08:37 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:08:38.700+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.7ms)","duration":"3021.7ms","level":"slow","span":"2ea2a36aee9bafce","trace":"684ce346cca9479adfdad70e292d5c0a"} -{"@timestamp":"2026-06-26T01:08:38.700+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.7ms","level":"error","span":"2ea2a36aee9bafce","trace":"684ce346cca9479adfdad70e292d5c0a"} -2026/06/26 01:08:41 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:08:44.728+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"9c69c93857fd5fc7","trace":"281b65bfeb1b06df0c9d2bb6113447a4"} -{"@timestamp":"2026-06-26T01:08:44.728+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"9c69c93857fd5fc7","trace":"281b65bfeb1b06df0c9d2bb6113447a4"} -2026/06/26 01:08:45 job worker claim error: i/o timeout -2026/06/26 01:08:47 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-45]) incomplete read of message header: read tcp 127.0.0.1:50090->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-45]) incomplete read of message header: read tcp 127.0.0.1:50090->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 01:08:49 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:08:50.752+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.5ms)","duration":"3017.5ms","level":"slow","span":"910e7c32fd790525","trace":"f8bd566cabebd564bb7de14d615705da"} -{"@timestamp":"2026-06-26T01:08:50.752+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.5ms","level":"error","span":"910e7c32fd790525","trace":"f8bd566cabebd564bb7de14d615705da"} -2026/06/26 01:08:53 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:08:56.774+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.1ms)","duration":"3015.1ms","level":"slow","span":"28034a2d92f9fd11","trace":"d52693aa3aae634b2977015f3a69e3c8"} -{"@timestamp":"2026-06-26T01:08:56.774+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.1ms","level":"error","span":"28034a2d92f9fd11","trace":"d52693aa3aae634b2977015f3a69e3c8"} -2026/06/26 01:08:57 job worker claim error: i/o timeout -2026/06/26 01:09:01 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:09:02.807+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.0ms)","duration":"3027.0ms","level":"slow","span":"9fbe459c9a563e3d","trace":"ff923b8d4fc74ca23fa20d6b4a566f76"} -{"@timestamp":"2026-06-26T01:09:02.807+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.0ms","level":"error","span":"9fbe459c9a563e3d","trace":"ff923b8d4fc74ca23fa20d6b4a566f76"} -{"@timestamp":"2026-06-26T01:25:41.903+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.8Mi, TotalAlloc=34.5Mi, Sys=19.6Mi, NumGC=25","level":"stat"} -{"@timestamp":"2026-06-26T01:25:41.920+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 01:25:42 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:25:44.534+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3018.6ms, med: 3018.5ms, 90th: 3026.9ms, 99th: 3026.9ms, 99.9th: 3026.9ms","level":"stat"} -2026/06/26 01:25:44 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T01:25:45.743+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.0ms)","duration":"3026.0ms","level":"slow","span":"3f4c25b233f22c7c","trace":"e857f9147d2f901ad3ebb05e50496edf"} -{"@timestamp":"2026-06-26T01:25:45.743+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.0ms","level":"error","span":"3f4c25b233f22c7c","trace":"e857f9147d2f901ad3ebb05e50496edf"} -2026/06/26 01:25:46 job worker claim error: i/o timeout -2026/06/26 01:25:50 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:25:51.776+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.4ms)","duration":"3027.4ms","level":"slow","span":"bb4d7207197c0e28","trace":"c9180a19857d978c89473b775af5eacf"} -{"@timestamp":"2026-06-26T01:25:51.776+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.4ms","level":"error","span":"bb4d7207197c0e28","trace":"c9180a19857d978c89473b775af5eacf"} -2026/06/26 01:25:54 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-51]) incomplete read of message header: read tcp 127.0.0.1:50144->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-51]) incomplete read of message header: read tcp 127.0.0.1:50144->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 01:25:54 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:25:57.803+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.2ms)","duration":"3020.2ms","level":"slow","span":"c249df6358abbbca","trace":"95e66983a574b7db08b603c2caeef70f"} -{"@timestamp":"2026-06-26T01:25:57.803+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.2ms","level":"error","span":"c249df6358abbbca","trace":"95e66983a574b7db08b603c2caeef70f"} -2026/06/26 01:25:58 job worker claim error: i/o timeout -2026/06/26 01:26:02 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:26:03.832+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.3ms)","duration":"3024.3ms","level":"slow","span":"14fe7214037a1163","trace":"461cced2c5282bf4040fc32b4263bc32"} -{"@timestamp":"2026-06-26T01:26:03.832+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.3ms","level":"error","span":"14fe7214037a1163","trace":"461cced2c5282bf4040fc32b4263bc32"} -2026/06/26 01:26:06 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:26:09.866+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.6ms)","duration":"3028.6ms","level":"slow","span":"0713e45c42738146","trace":"c411150ac4516b45614e386c5c181332"} -{"@timestamp":"2026-06-26T01:26:09.866+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.6ms","level":"error","span":"0713e45c42738146","trace":"c411150ac4516b45614e386c5c181332"} -2026/06/26 01:26:10 job worker claim error: i/o timeout -2026/06/26 01:26:14 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:26:15.889+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.6ms)","duration":"3016.6ms","level":"slow","span":"7c6e5fa94c3c1a85","trace":"059d6e5c8eafa94454064e8d7da41e5e"} -{"@timestamp":"2026-06-26T01:26:15.889+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.6ms","level":"error","span":"7c6e5fa94c3c1a85","trace":"059d6e5c8eafa94454064e8d7da41e5e"} -2026/06/26 01:26:18 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:26:21.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"a7905c161094774c","trace":"dc72ff56d341f7d723edc279d69d4c13"} -{"@timestamp":"2026-06-26T01:26:21.912+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"a7905c161094774c","trace":"dc72ff56d341f7d723edc279d69d4c13"} -2026/06/26 01:26:22 job worker claim error: i/o timeout -2026/06/26 01:26:24 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-57]) incomplete read of message header: read tcp 127.0.0.1:50224->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-57]) incomplete read of message header: read tcp 127.0.0.1:50224->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 01:26:26 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:42:26.574+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(961654.8ms)","duration":"961654.8ms","level":"slow","span":"4ccd8cb4862f9273","trace":"b85955a4498055a05b51afd8aa3e48f9"} -{"@timestamp":"2026-06-26T01:42:26.574+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"961654.8ms","level":"error","span":"4ccd8cb4862f9273","trace":"b85955a4498055a05b51afd8aa3e48f9"} -2026/06/26 01:42:29 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:14.241+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(464665.5ms)","duration":"464665.5ms","level":"slow","span":"326da01d4f93ee92","trace":"ddf312e331e2d768ca5a416c76e85e82"} -{"@timestamp":"2026-06-26T01:50:14.241+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"464665.5ms","level":"error","span":"326da01d4f93ee92","trace":"ddf312e331e2d768ca5a416c76e85e82"} -2026/06/26 01:50:14 job worker claim error: i/o timeout -2026/06/26 01:50:18 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:20.259+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.2ms)","duration":"3017.2ms","level":"slow","span":"5c63de540b027d45","trace":"8efa3dc22cf7c65eaa521a48a15677e4"} -{"@timestamp":"2026-06-26T01:50:20.259+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.2ms","level":"error","span":"5c63de540b027d45","trace":"8efa3dc22cf7c65eaa521a48a15677e4"} -{"@timestamp":"2026-06-26T01:50:22.117+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.3Mi, TotalAlloc=36.8Mi, Sys=19.6Mi, NumGC=26","level":"stat"} -{"@timestamp":"2026-06-26T01:50:22.133+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 01:50:22 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:24.747+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 145049.8ms, med: 3025.9ms, 90th: 961654.6ms, 99th: 961654.6ms, 99.9th: 961654.6ms","level":"stat"} -2026/06/26 01:50:25 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T01:50:26.277+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"25e349a556307dfa","trace":"c61884edf77f2e5cd65f439ccf6e4dfa"} -{"@timestamp":"2026-06-26T01:50:26.277+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"25e349a556307dfa","trace":"c61884edf77f2e5cd65f439ccf6e4dfa"} -2026/06/26 01:50:26 job worker claim error: i/o timeout -2026/06/26 01:50:30 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:32.299+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.3ms)","duration":"3017.3ms","level":"slow","span":"cc2b6e8815d77b73","trace":"a01c46c1885aebe3e97f0e149e52a96f"} -{"@timestamp":"2026-06-26T01:50:32.299+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.3ms","level":"error","span":"cc2b6e8815d77b73","trace":"a01c46c1885aebe3e97f0e149e52a96f"} -2026/06/26 01:50:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-63]) incomplete read of message header: read tcp 127.0.0.1:50312->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-63]) incomplete read of message header: read tcp 127.0.0.1:50312->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 01:50:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:38.324+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.1ms)","duration":"3020.1ms","level":"slow","span":"f9283706ba20bd97","trace":"a4a8cb5ef179856fde5d4f5e0a78f8f0"} -{"@timestamp":"2026-06-26T01:50:38.324+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.1ms","level":"error","span":"f9283706ba20bd97","trace":"a4a8cb5ef179856fde5d4f5e0a78f8f0"} -2026/06/26 01:50:39 job worker claim error: i/o timeout -2026/06/26 01:50:43 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:44.357+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.1ms)","duration":"3027.1ms","level":"slow","span":"56a85365c08addc7","trace":"6fed5c39154ff045e314d35546d50d66"} -{"@timestamp":"2026-06-26T01:50:44.357+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.1ms","level":"error","span":"56a85365c08addc7","trace":"6fed5c39154ff045e314d35546d50d66"} -2026/06/26 01:50:47 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:50.389+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.5ms)","duration":"3025.5ms","level":"slow","span":"74009f81b623698f","trace":"bdcb213731376cf3bb13a20ed07fea4e"} -{"@timestamp":"2026-06-26T01:50:50.389+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.5ms","level":"error","span":"74009f81b623698f","trace":"bdcb213731376cf3bb13a20ed07fea4e"} -2026/06/26 01:50:51 job worker claim error: i/o timeout -2026/06/26 01:50:55 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T01:50:56.416+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.0ms)","duration":"3023.0ms","level":"slow","span":"2bab0ddc3790604e","trace":"c47ae7f58371f33b32cc295e0dbca1db"} -{"@timestamp":"2026-06-26T01:50:56.417+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.0ms","level":"error","span":"2bab0ddc3790604e","trace":"c47ae7f58371f33b32cc295e0dbca1db"} -2026/06/26 01:50:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:02:48.978+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(709556.5ms)","duration":"709556.5ms","level":"slow","span":"730c62962b6a7f6e","trace":"55299e367c3c6bd2c5429778bc812757"} -{"@timestamp":"2026-06-26T02:02:48.978+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"709556.5ms","level":"error","span":"730c62962b6a7f6e","trace":"55299e367c3c6bd2c5429778bc812757"} -2026/06/26 02:02:49 job worker claim error: i/o timeout -2026/06/26 02:02:50 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-69]) incomplete read of message header: read tcp 127.0.0.1:50348->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-69]) incomplete read of message header: read tcp 127.0.0.1:50348->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 02:02:53 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:02:54.991+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3011.3ms)","duration":"3011.3ms","level":"slow","span":"b405645a8ed7047a","trace":"f094b7e594f150725bbb3823bde9edfd"} -{"@timestamp":"2026-06-26T02:02:54.992+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3011.3ms","level":"error","span":"b405645a8ed7047a","trace":"f094b7e594f150725bbb3823bde9edfd"} -2026/06/26 02:02:57 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:01.019+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.3ms)","duration":"3023.3ms","level":"slow","span":"a60483c5c8991daa","trace":"2217caeaa8a4ed342783582f2a354f74"} -{"@timestamp":"2026-06-26T02:03:01.019+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.3ms","level":"error","span":"a60483c5c8991daa","trace":"2217caeaa8a4ed342783582f2a354f74"} -2026/06/26 02:03:01 job worker claim error: i/o timeout -2026/06/26 02:03:05 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:07.049+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.2ms)","duration":"3023.2ms","level":"slow","span":"6abc2b12ada2a6ec","trace":"95b373922c191575df3752056c6603c8"} -{"@timestamp":"2026-06-26T02:03:07.049+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.2ms","level":"error","span":"6abc2b12ada2a6ec","trace":"95b373922c191575df3752056c6603c8"} -{"@timestamp":"2026-06-26T02:03:08.639+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.0Mi, TotalAlloc=39.0Mi, Sys=19.6Mi, NumGC=27","level":"stat"} -{"@timestamp":"2026-06-26T02:03:08.657+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 02:03:09 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:11.271+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 73674.1ms, med: 3023.1ms, 90th: 709556.4ms, 99th: 709556.4ms, 99.9th: 709556.4ms","level":"stat"} -2026/06/26 02:03:11 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T02:03:13.075+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"a5703d014fd7e53b","trace":"cedc1a5b6842ed55fd757ee41ec7ea21"} -{"@timestamp":"2026-06-26T02:03:13.076+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"a5703d014fd7e53b","trace":"cedc1a5b6842ed55fd757ee41ec7ea21"} -2026/06/26 02:03:13 job worker claim error: i/o timeout -2026/06/26 02:03:17 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:19.108+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.8ms)","duration":"3026.8ms","level":"slow","span":"393cf02974c2df5a","trace":"4693f7c23305f6595b6327c834f12ba7"} -{"@timestamp":"2026-06-26T02:03:19.109+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.8ms","level":"error","span":"393cf02974c2df5a","trace":"4693f7c23305f6595b6327c834f12ba7"} -2026/06/26 02:03:20 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-75]) incomplete read of message header: read tcp 127.0.0.1:50395->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-75]) incomplete read of message header: read tcp 127.0.0.1:50395->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 02:03:21 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:25.136+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.4ms)","duration":"3021.4ms","level":"slow","span":"842bfef61740588e","trace":"f8cccd7dcf427d2b120d9658db8e8562"} -{"@timestamp":"2026-06-26T02:03:25.136+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.4ms","level":"error","span":"842bfef61740588e","trace":"f8cccd7dcf427d2b120d9658db8e8562"} -2026/06/26 02:03:25 job worker claim error: i/o timeout -2026/06/26 02:03:29 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:31.159+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.0ms)","duration":"3018.0ms","level":"slow","span":"a8386b16dc265660","trace":"35848dc98b94b1ed5c314267094711fa"} -{"@timestamp":"2026-06-26T02:03:31.160+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.0ms","level":"error","span":"a8386b16dc265660","trace":"35848dc98b94b1ed5c314267094711fa"} -2026/06/26 02:03:33 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:45.052+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(10886.4ms)","duration":"10886.4ms","level":"slow","span":"a1cc0335b1cd915c","trace":"71d55f15be54d964db010c9846dcd5c4"} -{"@timestamp":"2026-06-26T02:03:45.052+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"10886.4ms","level":"error","span":"a1cc0335b1cd915c","trace":"71d55f15be54d964db010c9846dcd5c4"} -2026/06/26 02:03:45 job worker claim error: i/o timeout -2026/06/26 02:03:49 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:51.073+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"a38ca2cdef450c72","trace":"9bdcf04b0374f2abaaf81fa4a3b6f607"} -{"@timestamp":"2026-06-26T02:03:51.073+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"a38ca2cdef450c72","trace":"9bdcf04b0374f2abaaf81fa4a3b6f607"} -2026/06/26 02:03:53 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:03:57.103+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.8ms)","duration":"3024.8ms","level":"slow","span":"07ec05a938bca568","trace":"231743f30dc2dd11ea1e3737a38cd1f3"} -{"@timestamp":"2026-06-26T02:03:57.104+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.8ms","level":"error","span":"07ec05a938bca568","trace":"231743f30dc2dd11ea1e3737a38cd1f3"} -2026/06/26 02:03:57 job worker claim error: i/o timeout -2026/06/26 02:03:58 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-81]) incomplete read of message header: read tcp 127.0.0.1:50415->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-81]) incomplete read of message header: read tcp 127.0.0.1:50415->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 02:04:01 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:04:03.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.0ms)","duration":"3016.0ms","level":"slow","span":"682b7f7c50d09126","trace":"84d8c5fec06fe687307b82b2d79ed6b6"} -{"@timestamp":"2026-06-26T02:04:03.125+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.0ms","level":"error","span":"682b7f7c50d09126","trace":"84d8c5fec06fe687307b82b2d79ed6b6"} -2026/06/26 02:04:05 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:04:09.143+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.6ms)","duration":"3012.6ms","level":"slow","span":"e72d3d8798d43eb2","trace":"6ee43498bbd29daecc0f6e5380ffb5fc"} -{"@timestamp":"2026-06-26T02:04:09.143+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.6ms","level":"error","span":"e72d3d8798d43eb2","trace":"6ee43498bbd29daecc0f6e5380ffb5fc"} -2026/06/26 02:04:10 job worker claim error: i/o timeout -2026/06/26 02:04:14 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:04:15.177+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.4ms)","duration":"3028.4ms","level":"slow","span":"2a54c720d6000387","trace":"db8718ce072178e6aa59adf4022e058b"} -{"@timestamp":"2026-06-26T02:04:15.177+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.4ms","level":"error","span":"2a54c720d6000387","trace":"db8718ce072178e6aa59adf4022e058b"} -{"@timestamp":"2026-06-26T02:04:16.507+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.8Mi, TotalAlloc=40.8Mi, Sys=19.6Mi, NumGC=27","level":"stat"} -{"@timestamp":"2026-06-26T02:04:16.523+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 02:04:18 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:04:19.136+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3806.8ms, med: 3021.3ms, 90th: 10886.2ms, 99th: 10886.2ms, 99.9th: 10886.2ms","level":"stat"} -2026/06/26 02:20:42 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T02:20:44.355+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(986172.9ms)","duration":"986172.9ms","level":"slow","span":"8a4e39a42bf85bd4","trace":"576eca181786a42d2e0491c6d1f2f344"} -{"@timestamp":"2026-06-26T02:20:44.355+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"986172.9ms","level":"error","span":"8a4e39a42bf85bd4","trace":"576eca181786a42d2e0491c6d1f2f344"} -2026/06/26 02:20:45 job worker claim error: i/o timeout -2026/06/26 02:20:49 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:20:50.375+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.6ms)","duration":"3014.6ms","level":"slow","span":"1605bf446984b666","trace":"c29ec19e59b1a7f2943e897b255cf8ae"} -{"@timestamp":"2026-06-26T02:20:50.376+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.6ms","level":"error","span":"1605bf446984b666","trace":"c29ec19e59b1a7f2943e897b255cf8ae"} -2026/06/26 02:20:51 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-87]) incomplete read of message header: read tcp 127.0.0.1:50434->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-87]) incomplete read of message header: read tcp 127.0.0.1:50434->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 02:20:53 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:20:56.398+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.5ms)","duration":"3016.5ms","level":"slow","span":"38623bddb3e61fa9","trace":"3f91316598f179fcc27e3b4823725827"} -{"@timestamp":"2026-06-26T02:20:56.398+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.5ms","level":"error","span":"38623bddb3e61fa9","trace":"3f91316598f179fcc27e3b4823725827"} -2026/06/26 02:20:57 job worker claim error: i/o timeout -2026/06/26 02:21:01 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:21:02.427+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.9ms)","duration":"3021.9ms","level":"slow","span":"f80b8b9190e0259a","trace":"e75104eb8678c32f8fc7a030fcb5ed72"} -{"@timestamp":"2026-06-26T02:21:02.427+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.9ms","level":"error","span":"f80b8b9190e0259a","trace":"e75104eb8678c32f8fc7a030fcb5ed72"} -2026/06/26 02:21:05 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:38:37.960+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1052527.2ms)","duration":"1052527.2ms","level":"slow","span":"62194537ad676d4a","trace":"455bf365beff4f24308e65228079e09c"} -{"@timestamp":"2026-06-26T02:38:37.961+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1052527.2ms","level":"error","span":"62194537ad676d4a","trace":"455bf365beff4f24308e65228079e09c"} -2026/06/26 02:38:38 job worker claim error: i/o timeout -2026/06/26 02:47:55 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:47:56.796+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"c7bc1e227be83514","trace":"4ff0b6806604525d4763fe4c427c8e3a"} -{"@timestamp":"2026-06-26T02:47:56.796+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"c7bc1e227be83514","trace":"4ff0b6806604525d4763fe4c427c8e3a"} -2026/06/26 02:47:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:48:02.827+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.5ms)","duration":"3026.5ms","level":"slow","span":"d1acd982cf559f15","trace":"363cf4335c76f2aa8af82701f7c0b3b2"} -{"@timestamp":"2026-06-26T02:48:02.828+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.5ms","level":"error","span":"d1acd982cf559f15","trace":"363cf4335c76f2aa8af82701f7c0b3b2"} -2026/06/26 02:48:03 job worker claim error: i/o timeout -2026/06/26 02:48:04 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-93]) incomplete read of message header: read tcp 127.0.0.1:50478->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-93]) incomplete read of message header: read tcp 127.0.0.1:50478->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 02:48:07 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:48:08.857+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.7ms)","duration":"3024.7ms","level":"slow","span":"111cd25807d51fee","trace":"8b0203c714a697069ade124dd8334b79"} -{"@timestamp":"2026-06-26T02:48:08.857+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.7ms","level":"error","span":"111cd25807d51fee","trace":"8b0203c714a697069ade124dd8334b79"} -2026/06/26 02:48:11 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:48:14.886+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.7ms)","duration":"3023.7ms","level":"slow","span":"eb97c18845b5b304","trace":"cb72989b21be13137205201604f1c13c"} -{"@timestamp":"2026-06-26T02:48:14.886+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.7ms","level":"error","span":"eb97c18845b5b304","trace":"cb72989b21be13137205201604f1c13c"} -2026/06/26 02:48:15 job worker claim error: i/o timeout -2026/06/26 02:48:19 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:48:20.916+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"c612a9a00c0a959a","trace":"c47f7618c8eae9165e881b85e0622a8d"} -{"@timestamp":"2026-06-26T02:48:20.916+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"c612a9a00c0a959a","trace":"c47f7618c8eae9165e881b85e0622a8d"} -{"@timestamp":"2026-06-26T02:48:21.968+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.0Mi, TotalAlloc=42.9Mi, Sys=19.6Mi, NumGC=28","level":"stat"} -{"@timestamp":"2026-06-26T02:48:21.984+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 02:48:23 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:48:24.600+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 206287.0ms, med: 3023.7ms, 90th: 1052527.1ms, 99th: 1052527.1ms, 99.9th: 1052527.1ms","level":"stat"} -2026/06/26 02:48:25 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T02:48:26.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.3ms)","duration":"3012.3ms","level":"slow","span":"d321bc5279253f70","trace":"c1108848bef19f1e9f7684a111397d8d"} -{"@timestamp":"2026-06-26T02:48:26.933+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.3ms","level":"error","span":"d321bc5279253f70","trace":"c1108848bef19f1e9f7684a111397d8d"} -2026/06/26 02:48:27 job worker claim error: i/o timeout -2026/06/26 02:48:31 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:48:32.954+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"c600d4ac39f2e6b4","trace":"021e5db869ff04e78d41998d98acff89"} -{"@timestamp":"2026-06-26T02:48:32.954+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"c600d4ac39f2e6b4","trace":"021e5db869ff04e78d41998d98acff89"} -2026/06/26 02:48:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-99]) incomplete read of message header: read tcp 127.0.0.1:50531->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-99]) incomplete read of message header: read tcp 127.0.0.1:50531->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 02:48:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:48:38.979+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.0ms)","duration":"3018.0ms","level":"slow","span":"29fee7c70ea90a28","trace":"901b2a38be9ceb172ed8899856979bb0"} -{"@timestamp":"2026-06-26T02:48:38.979+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.0ms","level":"error","span":"29fee7c70ea90a28","trace":"901b2a38be9ceb172ed8899856979bb0"} -2026/06/26 02:48:39 job worker claim error: i/o timeout -2026/06/26 02:51:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:17.300+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"602bd90effcbbcde","trace":"50941a5e68209958ff239230637bf880"} -{"@timestamp":"2026-06-26T02:51:17.300+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"602bd90effcbbcde","trace":"50941a5e68209958ff239230637bf880"} -2026/06/26 02:51:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:23.334+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.2ms)","duration":"3029.2ms","level":"slow","span":"4da2ebc5c668ed39","trace":"21eb035c713a0acc79229e7a59a7ad64"} -{"@timestamp":"2026-06-26T02:51:23.334+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.2ms","level":"error","span":"4da2ebc5c668ed39","trace":"21eb035c713a0acc79229e7a59a7ad64"} -2026/06/26 02:51:24 job worker claim error: i/o timeout -2026/06/26 02:51:28 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:29.361+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.1ms)","duration":"3020.1ms","level":"slow","span":"ac213a7705a6eae3","trace":"2ae12edfcab5ea8796fdf75fe76f522f"} -{"@timestamp":"2026-06-26T02:51:29.361+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.1ms","level":"error","span":"ac213a7705a6eae3","trace":"2ae12edfcab5ea8796fdf75fe76f522f"} -2026/06/26 02:51:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:35.391+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.0ms)","duration":"3026.0ms","level":"slow","span":"827ae27d2eb52fd4","trace":"9f14685e80c17ce309e74c8542f90c77"} -{"@timestamp":"2026-06-26T02:51:35.391+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.0ms","level":"error","span":"827ae27d2eb52fd4","trace":"9f14685e80c17ce309e74c8542f90c77"} -2026/06/26 02:51:36 job worker claim error: i/o timeout -2026/06/26 02:51:36 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-105]) incomplete read of message header: read tcp 127.0.0.1:50557->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-105]) incomplete read of message header: read tcp 127.0.0.1:50557->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 02:51:40 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:41.420+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.9ms)","duration":"3024.9ms","level":"slow","span":"7050292decfc8da2","trace":"d09cba0b8735353f4991872ce0e0ed54"} -{"@timestamp":"2026-06-26T02:51:41.420+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.9ms","level":"error","span":"7050292decfc8da2","trace":"d09cba0b8735353f4991872ce0e0ed54"} -2026/06/26 02:51:44 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:47.444+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.0ms)","duration":"3019.0ms","level":"slow","span":"5d655c3395718a32","trace":"339ae1a1ae29e6ed89fee8e34b6c5fe8"} -{"@timestamp":"2026-06-26T02:51:47.444+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.0ms","level":"error","span":"5d655c3395718a32","trace":"339ae1a1ae29e6ed89fee8e34b6c5fe8"} -2026/06/26 02:51:48 job worker claim error: i/o timeout -2026/06/26 02:51:52 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:53.466+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.8ms)","duration":"3018.8ms","level":"slow","span":"3a71ada98755517e","trace":"d81f60446e6fb8bc144fff0b6b480ba2"} -{"@timestamp":"2026-06-26T02:51:53.467+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.8ms","level":"error","span":"3a71ada98755517e","trace":"d81f60446e6fb8bc144fff0b6b480ba2"} -{"@timestamp":"2026-06-26T02:51:54.266+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.6Mi, TotalAlloc=45.2Mi, Sys=19.6Mi, NumGC=29","level":"stat"} -{"@timestamp":"2026-06-26T02:51:54.283+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 02:51:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T02:51:56.897+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.2ms, med: 3019.7ms, 90th: 3029.1ms, 99th: 3029.1ms, 99.9th: 3029.1ms","level":"stat"} -2026/06/26 02:51:57 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T02:51:59.492+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.5ms)","duration":"3019.5ms","level":"slow","span":"5e1ee7bff3450cfa","trace":"2b8129512dc1974b3bddf3425bd1175b"} -{"@timestamp":"2026-06-26T02:51:59.492+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.5ms","level":"error","span":"5e1ee7bff3450cfa","trace":"2b8129512dc1974b3bddf3425bd1175b"} -2026/06/26 02:52:00 job worker claim error: i/o timeout -2026/06/26 03:04:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:04:33.204+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.5ms)","duration":"3018.5ms","level":"slow","span":"b58a16c7b68ef52e","trace":"9821b08120f00f08d15ff96f56dbf30e"} -{"@timestamp":"2026-06-26T03:04:33.204+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.5ms","level":"error","span":"b58a16c7b68ef52e","trace":"9821b08120f00f08d15ff96f56dbf30e"} -2026/06/26 03:04:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-111]) incomplete read of message header: read tcp 127.0.0.1:50580->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-111]) incomplete read of message header: read tcp 127.0.0.1:50580->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 03:04:36 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:04:39.237+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.3ms)","duration":"3023.3ms","level":"slow","span":"ab2305e68a82242a","trace":"8d939025d1dd02757a0c9ef965a8ffba"} -{"@timestamp":"2026-06-26T03:04:39.237+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.3ms","level":"error","span":"ab2305e68a82242a","trace":"8d939025d1dd02757a0c9ef965a8ffba"} -2026/06/26 03:04:40 job worker claim error: i/o timeout -2026/06/26 03:04:44 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:04:45.262+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.9ms)","duration":"3015.9ms","level":"slow","span":"e0aab781fbbec74b","trace":"117136d6871a8c14126b1bd56956f337"} -{"@timestamp":"2026-06-26T03:04:45.263+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.9ms","level":"error","span":"e0aab781fbbec74b","trace":"117136d6871a8c14126b1bd56956f337"} -2026/06/26 03:04:48 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:04:51.298+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.9ms)","duration":"3026.9ms","level":"slow","span":"da1f46092f51f85d","trace":"c4fd0fce6e64ec2849774ba5128f8189"} -{"@timestamp":"2026-06-26T03:04:51.298+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.9ms","level":"error","span":"da1f46092f51f85d","trace":"c4fd0fce6e64ec2849774ba5128f8189"} -2026/06/26 03:04:52 job worker claim error: i/o timeout -2026/06/26 03:04:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:04:57.325+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.5ms)","duration":"3019.5ms","level":"slow","span":"9b891ac2cea85b7c","trace":"f4092dbf67af229434043fc546bdbcb8"} -{"@timestamp":"2026-06-26T03:04:57.325+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.5ms","level":"error","span":"9b891ac2cea85b7c","trace":"f4092dbf67af229434043fc546bdbcb8"} -2026/06/26 03:05:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:05:03.356+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.6ms)","duration":"3024.6ms","level":"slow","span":"0f6ca6536cec8a33","trace":"328c4e04d20bcccc4e8d25c46eac01e7"} -{"@timestamp":"2026-06-26T03:05:03.357+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.6ms","level":"error","span":"0f6ca6536cec8a33","trace":"328c4e04d20bcccc4e8d25c46eac01e7"} -2026/06/26 03:05:04 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-117]) incomplete read of message header: read tcp 127.0.0.1:50623->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-117]) incomplete read of message header: read tcp 127.0.0.1:50623->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 03:05:04 job worker claim error: i/o timeout -2026/06/26 03:05:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:05:09.384+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.6ms)","duration":"3020.6ms","level":"slow","span":"47f7a12d87442ad6","trace":"7630cc3dc4d01addd43a06d05d21a7c6"} -{"@timestamp":"2026-06-26T03:05:09.384+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.6ms","level":"error","span":"47f7a12d87442ad6","trace":"7630cc3dc4d01addd43a06d05d21a7c6"} -2026/06/26 03:05:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:05:15.407+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.6ms)","duration":"3016.6ms","level":"slow","span":"e19d13b530acd392","trace":"4484c721ed6f8f74e01aa1dbedabc53d"} -{"@timestamp":"2026-06-26T03:05:15.407+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.6ms","level":"error","span":"e19d13b530acd392","trace":"4484c721ed6f8f74e01aa1dbedabc53d"} -2026/06/26 03:21:55 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:21:57.665+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.3ms)","duration":"3023.3ms","level":"slow","span":"5c179caf48d17b65","trace":"97b2ede52a10ca3b03fdd18ec28baafc"} -{"@timestamp":"2026-06-26T03:21:57.665+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.3ms","level":"error","span":"5c179caf48d17b65","trace":"97b2ede52a10ca3b03fdd18ec28baafc"} -2026/06/26 03:21:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:38:30.400+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.4Mi, TotalAlloc=47.4Mi, Sys=19.6Mi, NumGC=30","level":"stat"} -{"@timestamp":"2026-06-26T03:38:30.417+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"} -2026/06/26 03:38:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:38:33.031+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.7ms, med: 3020.5ms, 90th: 3026.8ms, 99th: 3026.8ms, 99.9th: 3026.8ms","level":"stat"} -{"@timestamp":"2026-06-26T03:38:33.349+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.4ms)","duration":"3015.4ms","level":"slow","span":"6df079a2d405503a","trace":"42415f188c2cad1a4a264079c8b5dca3"} -{"@timestamp":"2026-06-26T03:38:33.349+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.4ms","level":"error","span":"6df079a2d405503a","trace":"42415f188c2cad1a4a264079c8b5dca3"} -2026/06/26 03:38:33 job scheduler tick error: i/o timeout -2026/06/26 03:52:15 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:52:18.057+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.8ms)","duration":"3020.8ms","level":"slow","span":"862f0878d0cd2ccf","trace":"89789dd8244f494e8b3da68850365d07"} -{"@timestamp":"2026-06-26T03:52:18.058+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.8ms","level":"error","span":"862f0878d0cd2ccf","trace":"89789dd8244f494e8b3da68850365d07"} -2026/06/26 03:52:19 job worker claim error: i/o timeout -2026/06/26 03:52:21 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-123]) incomplete read of message header: read tcp 127.0.0.1:50644->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-123]) incomplete read of message header: read tcp 127.0.0.1:50644->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 03:52:23 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:52:24.087+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"0caac9290c7a8aaa","trace":"d1e2a8f0f0839e68237a98a37bb615a8"} -{"@timestamp":"2026-06-26T03:52:24.087+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"0caac9290c7a8aaa","trace":"d1e2a8f0f0839e68237a98a37bb615a8"} -2026/06/26 03:52:27 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:52:30.119+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.7ms)","duration":"3026.7ms","level":"slow","span":"b5ade64029c2f718","trace":"ef8ca68d27b3b05644406da7f8b0d14a"} -{"@timestamp":"2026-06-26T03:52:30.119+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.7ms","level":"error","span":"b5ade64029c2f718","trace":"ef8ca68d27b3b05644406da7f8b0d14a"} -2026/06/26 03:52:31 job worker claim error: i/o timeout -2026/06/26 03:52:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:52:36.146+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.8ms)","duration":"3021.8ms","level":"slow","span":"3505e73a6cda4810","trace":"0a616653cedf5a80c4642038dbb7386d"} -{"@timestamp":"2026-06-26T03:52:36.146+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.8ms","level":"error","span":"3505e73a6cda4810","trace":"0a616653cedf5a80c4642038dbb7386d"} -2026/06/26 03:52:39 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:52:42.172+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.6ms)","duration":"3019.6ms","level":"slow","span":"de3fa9840dcd9d63","trace":"c68bd077925845bbdfc6c1f031512257"} -{"@timestamp":"2026-06-26T03:52:42.172+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.6ms","level":"error","span":"de3fa9840dcd9d63","trace":"c68bd077925845bbdfc6c1f031512257"} -2026/06/26 03:52:43 job worker claim error: i/o timeout -2026/06/26 03:52:47 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:52:48.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"db320c05d1144d96","trace":"b8d5f5e944aab39369f1b09e1b884e14"} -{"@timestamp":"2026-06-26T03:52:48.195+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"db320c05d1144d96","trace":"b8d5f5e944aab39369f1b09e1b884e14"} -2026/06/26 03:52:51 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-129]) incomplete read of message header: read tcp 127.0.0.1:50733->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-129]) incomplete read of message header: read tcp 127.0.0.1:50733->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 03:52:51 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:52:54.216+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"de3150f871399f2b","trace":"1457b894ccd0304871cbc15a57d1223d"} -{"@timestamp":"2026-06-26T03:52:54.216+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"de3150f871399f2b","trace":"1457b894ccd0304871cbc15a57d1223d"} -2026/06/26 03:52:55 job worker claim error: i/o timeout -2026/06/26 03:52:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T03:53:00.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.0ms)","duration":"3026.0ms","level":"slow","span":"c366a90772617cf9","trace":"5fcf6e2f7a40b612bc42b96bcbacedc8"} -{"@timestamp":"2026-06-26T03:53:00.249+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.0ms","level":"error","span":"c366a90772617cf9","trace":"5fcf6e2f7a40b612bc42b96bcbacedc8"} -2026/06/26 04:10:04 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:11.023+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(7255.2ms)","duration":"7255.2ms","level":"slow","span":"7455ee3e3c3b137e","trace":"6587d9fb744494d15d7296c58077f559"} -{"@timestamp":"2026-06-26T04:10:11.023+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"7255.2ms","level":"error","span":"7455ee3e3c3b137e","trace":"6587d9fb744494d15d7296c58077f559"} -2026/06/26 04:10:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:13.780+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.7Mi, TotalAlloc=49.6Mi, Sys=19.6Mi, NumGC=31","level":"stat"} -{"@timestamp":"2026-06-26T04:10:13.795+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 9, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T04:10:16.413+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3444.2ms, med: 3021.7ms, 90th: 7255.1ms, 99th: 7255.1ms, 99.9th: 7255.1ms","level":"stat"} -2026/06/26 04:10:16 job worker claim error: i/o timeout -2026/06/26 04:10:16 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T04:10:17.052+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.6ms)","duration":"3021.6ms","level":"slow","span":"a9d89f4f8dbce0e6","trace":"bc6f92267115d87bcdf2137cd7ee78e4"} -{"@timestamp":"2026-06-26T04:10:17.052+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.6ms","level":"error","span":"a9d89f4f8dbce0e6","trace":"bc6f92267115d87bcdf2137cd7ee78e4"} -2026/06/26 04:10:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:23.077+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"6e84e0e9a68369d8","trace":"2b0c86bc969843336c7e03bd58a72b92"} -{"@timestamp":"2026-06-26T04:10:23.077+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"6e84e0e9a68369d8","trace":"2b0c86bc969843336c7e03bd58a72b92"} -2026/06/26 04:10:24 job worker claim error: i/o timeout -2026/06/26 04:10:26 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-135]) incomplete read of message header: read tcp 127.0.0.1:50756->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-135]) incomplete read of message header: read tcp 127.0.0.1:50756->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 04:10:28 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:29.099+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.1ms)","duration":"3015.1ms","level":"slow","span":"6d96764465912111","trace":"6feae2fc92c1a39a2c60a2c4c0b47b89"} -{"@timestamp":"2026-06-26T04:10:29.099+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.1ms","level":"error","span":"6d96764465912111","trace":"6feae2fc92c1a39a2c60a2c4c0b47b89"} -2026/06/26 04:10:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:35.124+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.2ms)","duration":"3018.2ms","level":"slow","span":"4c7decc8a7c5f0a9","trace":"56bd100a954f4881f9a8b9c3067a01ca"} -{"@timestamp":"2026-06-26T04:10:35.125+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.2ms","level":"error","span":"4c7decc8a7c5f0a9","trace":"56bd100a954f4881f9a8b9c3067a01ca"} -2026/06/26 04:10:36 job worker claim error: i/o timeout -2026/06/26 04:10:40 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:41.148+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.1ms)","duration":"3019.1ms","level":"slow","span":"11d9e17a6096eab1","trace":"bb1b1f52c19f6437c564577f38fdd35f"} -{"@timestamp":"2026-06-26T04:10:41.149+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.1ms","level":"error","span":"11d9e17a6096eab1","trace":"bb1b1f52c19f6437c564577f38fdd35f"} -2026/06/26 04:10:44 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:47.176+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.9ms)","duration":"3020.9ms","level":"slow","span":"6da9553649230ec2","trace":"c5e5b24efca3cdffcf9081e3cefa23a1"} -{"@timestamp":"2026-06-26T04:10:47.176+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.9ms","level":"error","span":"6da9553649230ec2","trace":"c5e5b24efca3cdffcf9081e3cefa23a1"} -2026/06/26 04:10:48 job worker claim error: i/o timeout -2026/06/26 04:10:52 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:10:53.201+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.5ms)","duration":"3021.5ms","level":"slow","span":"7fb85f8f2aaed5d3","trace":"a48af14bf85a6e8c070d48c0696533e8"} -{"@timestamp":"2026-06-26T04:10:53.201+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.5ms","level":"error","span":"7fb85f8f2aaed5d3","trace":"a48af14bf85a6e8c070d48c0696533e8"} -2026/06/26 04:10:56 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-141]) incomplete read of message header: read tcp 127.0.0.1:50798->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-141]) incomplete read of message header: read tcp 127.0.0.1:50798->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 04:10:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:25:59.986+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(903778.9ms)","duration":"903778.9ms","level":"slow","span":"c7cb250e147e7c77","trace":"1e4dd4ff0d15eb7ee3b0fb59a2f6409b"} -{"@timestamp":"2026-06-26T04:25:59.986+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"903778.9ms","level":"error","span":"c7cb250e147e7c77","trace":"1e4dd4ff0d15eb7ee3b0fb59a2f6409b"} -2026/06/26 04:26:01 job worker claim error: i/o timeout -2026/06/26 04:26:05 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:06.017+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.1ms)","duration":"3026.1ms","level":"slow","span":"9887e8a1a334ef79","trace":"67d6fe5b3452c3b85dbdb63f2de9d5ec"} -{"@timestamp":"2026-06-26T04:26:06.018+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.1ms","level":"error","span":"9887e8a1a334ef79","trace":"67d6fe5b3452c3b85dbdb63f2de9d5ec"} -2026/06/26 04:26:09 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:12.042+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"a2f8b957da0bc113","trace":"16d100a3ee6ac1a39274a5faa7a78543"} -{"@timestamp":"2026-06-26T04:26:12.042+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"a2f8b957da0bc113","trace":"16d100a3ee6ac1a39274a5faa7a78543"} -2026/06/26 04:26:13 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:14.545+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.4Mi, TotalAlloc=51.3Mi, Sys=19.6Mi, NumGC=31","level":"stat"} -{"@timestamp":"2026-06-26T04:26:14.561+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T04:26:17.174+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 93095.7ms, med: 3020.8ms, 90th: 903778.8ms, 99th: 903778.8ms, 99.9th: 903778.8ms","level":"stat"} -2026/06/26 04:26:17 job scheduler tick error: i/o timeout -2026/06/26 04:26:17 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:18.067+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.2ms)","duration":"3020.2ms","level":"slow","span":"456148bebfbacd74","trace":"24a2a35cb247c4889871f67fbc00939e"} -{"@timestamp":"2026-06-26T04:26:18.067+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.2ms","level":"error","span":"456148bebfbacd74","trace":"24a2a35cb247c4889871f67fbc00939e"} -2026/06/26 04:26:21 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:24.086+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.1ms)","duration":"3013.1ms","level":"slow","span":"95736b5d71df84cc","trace":"745f5d44e6d30e28952542dfa776228a"} -{"@timestamp":"2026-06-26T04:26:24.086+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.1ms","level":"error","span":"95736b5d71df84cc","trace":"745f5d44e6d30e28952542dfa776228a"} -2026/06/26 04:26:25 job worker claim error: i/o timeout -2026/06/26 04:26:26 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-147]) incomplete read of message header: read tcp 127.0.0.1:50839->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-147]) incomplete read of message header: read tcp 127.0.0.1:50839->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 04:26:29 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:30.106+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.7ms)","duration":"3015.7ms","level":"slow","span":"9037a91630b06b30","trace":"ad34304e9d0a270460016d2536ee55c7"} -{"@timestamp":"2026-06-26T04:26:30.107+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.7ms","level":"error","span":"9037a91630b06b30","trace":"ad34304e9d0a270460016d2536ee55c7"} -2026/06/26 04:26:33 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:36.132+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"b47dac2c88cc5d52","trace":"5088634e5b2b40f0d7058b0c0727f59f"} -{"@timestamp":"2026-06-26T04:26:36.132+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"b47dac2c88cc5d52","trace":"5088634e5b2b40f0d7058b0c0727f59f"} -2026/06/26 04:26:37 job worker claim error: i/o timeout -2026/06/26 04:26:41 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:26:42.151+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"d7a1ca26cc1c5d86","trace":"9c383bc3ae7d4a8129dc68c7f1a5b3a7"} -{"@timestamp":"2026-06-26T04:26:42.151+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"d7a1ca26cc1c5d86","trace":"9c383bc3ae7d4a8129dc68c7f1a5b3a7"} -2026/06/26 04:26:45 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:43:18.147+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(992990.2ms)","duration":"992990.2ms","level":"slow","span":"29c9afbff492bd03","trace":"656319d43b2f25544bc8f8e4c15f6e5b"} -{"@timestamp":"2026-06-26T04:43:18.147+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"992990.2ms","level":"error","span":"29c9afbff492bd03","trace":"656319d43b2f25544bc8f8e4c15f6e5b"} -2026/06/26 04:43:19 job worker claim error: i/o timeout -2026/06/26 04:49:54 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:49:55.148+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(393994.5ms)","duration":"393994.5ms","level":"slow","span":"2e11b2f5cd56495e","trace":"80a8afbe92ced63e1d0cfeddd6d3fa64"} -{"@timestamp":"2026-06-26T04:49:55.148+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"393994.5ms","level":"error","span":"2e11b2f5cd56495e","trace":"80a8afbe92ced63e1d0cfeddd6d3fa64"} -2026/06/26 04:49:57 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-153]) incomplete read of message header: read tcp 127.0.0.1:50864->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-153]) incomplete read of message header: read tcp 127.0.0.1:50864->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 04:49:58 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:50:01.171+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.6ms)","duration":"3014.6ms","level":"slow","span":"66a0dc481252928f","trace":"87a1f7eee70dc0f927c298b3492ca092"} -{"@timestamp":"2026-06-26T04:50:01.171+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.6ms","level":"error","span":"66a0dc481252928f","trace":"87a1f7eee70dc0f927c298b3492ca092"} -2026/06/26 04:50:02 job worker claim error: i/o timeout -2026/06/26 04:50:06 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:50:07.196+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"8976a6219542f1d5","trace":"35ced97aef881eb2bce72697ea0a7db1"} -{"@timestamp":"2026-06-26T04:50:07.196+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"8976a6219542f1d5","trace":"35ced97aef881eb2bce72697ea0a7db1"} -2026/06/26 04:50:11 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:50:13.216+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.2ms)","duration":"3014.2ms","level":"slow","span":"4daa97481cba48e1","trace":"ebf1fffefe0e3611a957a9b841af316c"} -{"@timestamp":"2026-06-26T04:50:13.216+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.2ms","level":"error","span":"4daa97481cba48e1","trace":"ebf1fffefe0e3611a957a9b841af316c"} -2026/06/26 04:50:15 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:50:15.491+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.6Mi, TotalAlloc=53.6Mi, Sys=19.6Mi, NumGC=32","level":"stat"} -{"@timestamp":"2026-06-26T04:50:15.508+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T04:50:18.123+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 141111.4ms, med: 3019.3ms, 90th: 992990.1ms, 99th: 992990.1ms, 99.9th: 992990.1ms","level":"stat"} -2026/06/26 04:50:18 job scheduler tick error: i/o timeout -2026/06/26 04:50:19 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:50:19.245+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.9ms)","duration":"3021.9ms","level":"slow","span":"1ea85267e12cf0b5","trace":"083f8881440e5ad6989f77fb435f2805"} -{"@timestamp":"2026-06-26T04:50:19.246+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.9ms","level":"error","span":"1ea85267e12cf0b5","trace":"083f8881440e5ad6989f77fb435f2805"} -2026/06/26 04:50:23 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:50:25.271+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"78340b46d4f2bb32","trace":"9ec77aff5b8ba79249fcabfe26b8dbb0"} -{"@timestamp":"2026-06-26T04:50:25.271+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"78340b46d4f2bb32","trace":"9ec77aff5b8ba79249fcabfe26b8dbb0"} -2026/06/26 04:53:14 job worker claim error: i/o timeout -2026/06/26 04:53:15 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-159]) incomplete read of message header: read tcp 127.0.0.1:50922->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-159]) incomplete read of message header: read tcp 127.0.0.1:50922->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T04:53:16.549+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.2ms)","duration":"3020.2ms","level":"slow","span":"380f9204bc7a796c","trace":"337c257a443d9340348a64cece0dfab4"} -{"@timestamp":"2026-06-26T04:53:16.549+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.2ms","level":"error","span":"380f9204bc7a796c","trace":"337c257a443d9340348a64cece0dfab4"} -2026/06/26 04:53:18 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:53:22.574+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.3ms)","duration":"3019.3ms","level":"slow","span":"842e2bd38ae6004a","trace":"e58d2aea8cd2ee147546f660d01c610e"} -{"@timestamp":"2026-06-26T04:53:22.574+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.3ms","level":"error","span":"842e2bd38ae6004a","trace":"e58d2aea8cd2ee147546f660d01c610e"} -2026/06/26 04:53:22 job worker claim error: i/o timeout -2026/06/26 04:53:26 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:53:28.595+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.8ms)","duration":"3014.8ms","level":"slow","span":"cb26294f5368e6ea","trace":"9fbf6d0fbeb70c9bf6881eb72b22203c"} -{"@timestamp":"2026-06-26T04:53:28.595+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.8ms","level":"error","span":"cb26294f5368e6ea","trace":"9fbf6d0fbeb70c9bf6881eb72b22203c"} -2026/06/26 04:53:30 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:53:34.623+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.3ms)","duration":"3021.3ms","level":"slow","span":"5edaa95cbc5c1917","trace":"6a4ba71af1094a029ca1feceac3e1c21"} -{"@timestamp":"2026-06-26T04:53:34.623+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.3ms","level":"error","span":"5edaa95cbc5c1917","trace":"6a4ba71af1094a029ca1feceac3e1c21"} -2026/06/26 04:53:34 job worker claim error: i/o timeout -2026/06/26 04:53:39 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:53:40.653+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.8ms)","duration":"3022.8ms","level":"slow","span":"d7629d363ab41fb4","trace":"ef703e3c3de6a13877a29f9a6195a7e1"} -{"@timestamp":"2026-06-26T04:53:40.653+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.8ms","level":"error","span":"d7629d363ab41fb4","trace":"ef703e3c3de6a13877a29f9a6195a7e1"} -2026/06/26 04:53:43 job worker claim error: i/o timeout -2026/06/26 04:53:45 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-165]) incomplete read of message header: read tcp 127.0.0.1:50947->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-165]) incomplete read of message header: read tcp 127.0.0.1:50947->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T04:53:46.679+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.5ms)","duration":"3020.5ms","level":"slow","span":"aa6760f943044257","trace":"57649a4e3dfcda337f01f29a9700a3dd"} -{"@timestamp":"2026-06-26T04:53:46.679+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.5ms","level":"error","span":"aa6760f943044257","trace":"57649a4e3dfcda337f01f29a9700a3dd"} -2026/06/26 04:53:47 job worker claim error: i/o timeout -2026/06/26 04:53:51 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:53:52.707+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.1ms)","duration":"3022.1ms","level":"slow","span":"20033fe8f782af6d","trace":"3c80883032c8c0c9a5c4cbc0a1c6c5a4"} -{"@timestamp":"2026-06-26T04:53:52.707+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.1ms","level":"error","span":"20033fe8f782af6d","trace":"3c80883032c8c0c9a5c4cbc0a1c6c5a4"} -2026/06/26 04:53:55 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T04:53:58.740+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.6ms)","duration":"3026.6ms","level":"slow","span":"f17c4cd427f5fae2","trace":"4fa4856d5105cf9eb1e09e449f58854d"} -{"@timestamp":"2026-06-26T04:53:58.740+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.6ms","level":"error","span":"f17c4cd427f5fae2","trace":"4fa4856d5105cf9eb1e09e449f58854d"} -2026/06/26 04:53:59 job worker claim error: i/o timeout -2026/06/26 05:09:09 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:09:09.122+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.1Mi, TotalAlloc=55.9Mi, Sys=19.6Mi, NumGC=33","level":"stat"} -{"@timestamp":"2026-06-26T05:09:09.137+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T05:09:10.616+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"71eb45a074e9dee5","trace":"062da34884313f8233870a0cbee144f1"} -{"@timestamp":"2026-06-26T05:09:10.616+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"71eb45a074e9dee5","trace":"062da34884313f8233870a0cbee144f1"} -{"@timestamp":"2026-06-26T05:09:17.097+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.1ms, med: 3021.2ms, 90th: 3026.5ms, 99th: 3026.5ms, 99.9th: 3026.5ms","level":"stat"} -2026/06/26 05:09:17 job scheduler tick error: i/o timeout -2026/06/26 05:09:18 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:09:21.927+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"b440c8fa86cc07de","trace":"6e7813bac51497c3a9de4c66de13bf4a"} -{"@timestamp":"2026-06-26T05:09:21.927+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"b440c8fa86cc07de","trace":"6e7813bac51497c3a9de4c66de13bf4a"} -2026/06/26 05:09:22 job worker claim error: i/o timeout -2026/06/26 05:09:26 job worker claim error: i/o timeout -2026/06/26 05:09:26 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-171]) incomplete read of message header: read tcp 127.0.0.1:50967->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-171]) incomplete read of message header: read tcp 127.0.0.1:50967->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T05:09:27.955+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.5ms)","duration":"3021.5ms","level":"slow","span":"4fba5744ff8e8e8d","trace":"18f62c16c96f5f6bef562b0309c4453f"} -{"@timestamp":"2026-06-26T05:09:27.955+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.5ms","level":"error","span":"4fba5744ff8e8e8d","trace":"18f62c16c96f5f6bef562b0309c4453f"} -2026/06/26 05:09:30 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:09:33.979+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"3ed6eb75f8a6c895","trace":"fb9c5d232c7af9524d892984e83c402f"} -{"@timestamp":"2026-06-26T05:09:33.979+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"3ed6eb75f8a6c895","trace":"fb9c5d232c7af9524d892984e83c402f"} -2026/06/26 05:09:34 job worker claim error: i/o timeout -2026/06/26 05:09:38 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:09:40.004+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.5ms)","duration":"3020.5ms","level":"slow","span":"3c95da658fb47658","trace":"8bc8d287a2704f058652eb7525faa881"} -{"@timestamp":"2026-06-26T05:09:40.004+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.5ms","level":"error","span":"3c95da658fb47658","trace":"8bc8d287a2704f058652eb7525faa881"} -2026/06/26 05:09:42 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:09:46.025+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.8ms)","duration":"3013.8ms","level":"slow","span":"270af4d2a6656e25","trace":"036abe2ea985d266b93a4af69383db9a"} -{"@timestamp":"2026-06-26T05:09:46.025+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.8ms","level":"error","span":"270af4d2a6656e25","trace":"036abe2ea985d266b93a4af69383db9a"} -2026/06/26 05:09:46 job worker claim error: i/o timeout -2026/06/26 05:09:50 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:09:52.056+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"ad220e036e0679e7","trace":"8144f69c19e080e3297abd3032d37212"} -{"@timestamp":"2026-06-26T05:09:52.056+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"ad220e036e0679e7","trace":"8144f69c19e080e3297abd3032d37212"} -2026/06/26 05:09:54 job worker claim error: i/o timeout -2026/06/26 05:09:56 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-177]) incomplete read of message header: read tcp 127.0.0.1:51014->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-177]) incomplete read of message header: read tcp 127.0.0.1:51014->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T05:09:58.078+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.4ms)","duration":"3017.4ms","level":"slow","span":"03bbde39680af84f","trace":"3496bcd7d145dfa543dc89b87ff4e48e"} -{"@timestamp":"2026-06-26T05:09:58.078+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.4ms","level":"error","span":"03bbde39680af84f","trace":"3496bcd7d145dfa543dc89b87ff4e48e"} -2026/06/26 05:09:58 job worker claim error: i/o timeout -2026/06/26 05:10:02 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:10:04.108+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"ed8f725f12a3aba4","trace":"ab1682755f9abdf90f064c51cd979d05"} -{"@timestamp":"2026-06-26T05:10:04.108+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"ed8f725f12a3aba4","trace":"ab1682755f9abdf90f064c51cd979d05"} -2026/06/26 05:27:02 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:27:03.518+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.3ms)","duration":"3015.3ms","level":"slow","span":"70d9ae8c928b9198","trace":"b704cf48cca3d2813a53ff15107f312d"} -{"@timestamp":"2026-06-26T05:27:03.518+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.3ms","level":"error","span":"70d9ae8c928b9198","trace":"b704cf48cca3d2813a53ff15107f312d"} -2026/06/26 05:43:40 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:43:43.205+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.7ms)","duration":"3017.7ms","level":"slow","span":"96d17d4cf9e626ea","trace":"27bf4ddb6d57fe168121781aadcfffbf"} -{"@timestamp":"2026-06-26T05:43:43.205+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.7ms","level":"error","span":"96d17d4cf9e626ea","trace":"27bf4ddb6d57fe168121781aadcfffbf"} -{"@timestamp":"2026-06-26T05:43:43.983+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.7Mi, TotalAlloc=58.1Mi, Sys=19.6Mi, NumGC=34","level":"stat"} -{"@timestamp":"2026-06-26T05:43:43.999+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 11, drop: 0","level":"stat"} -2026/06/26 05:43:44 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:15.282+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3019.7ms, med: 3020.4ms, 90th: 3025.7ms, 99th: 3025.7ms, 99.9th: 3025.7ms","level":"stat"} -2026/06/26 05:54:15 job scheduler tick error: i/o timeout -2026/06/26 05:54:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:17.893+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.5ms)","duration":"3013.5ms","level":"slow","span":"08e3f6b986888df6","trace":"223ba6d086594cd4d450826c4b383468"} -{"@timestamp":"2026-06-26T05:54:17.893+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.5ms","level":"error","span":"08e3f6b986888df6","trace":"223ba6d086594cd4d450826c4b383468"} -2026/06/26 05:54:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:23.921+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.7ms)","duration":"3022.7ms","level":"slow","span":"b9f3c9fae3635792","trace":"1657bfe1fbfd9e06f7d93ba914dfe638"} -{"@timestamp":"2026-06-26T05:54:23.922+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.7ms","level":"error","span":"b9f3c9fae3635792","trace":"1657bfe1fbfd9e06f7d93ba914dfe638"} -2026/06/26 05:54:24 job worker claim error: i/o timeout -2026/06/26 05:54:25 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-183]) incomplete read of message header: read tcp 127.0.0.1:51055->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-183]) incomplete read of message header: read tcp 127.0.0.1:51055->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 05:54:28 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:29.954+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.8ms)","duration":"3026.8ms","level":"slow","span":"18c83ec16921428c","trace":"03244652c8b9f5074f771f6903e69d76"} -{"@timestamp":"2026-06-26T05:54:29.955+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.8ms","level":"error","span":"18c83ec16921428c","trace":"03244652c8b9f5074f771f6903e69d76"} -2026/06/26 05:54:33 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:35.978+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.3ms)","duration":"3016.3ms","level":"slow","span":"a8c1a7d7ba9f548f","trace":"6b84226c9931ef712cfe828d97d6b36d"} -{"@timestamp":"2026-06-26T05:54:35.978+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.3ms","level":"error","span":"a8c1a7d7ba9f548f","trace":"6b84226c9931ef712cfe828d97d6b36d"} -2026/06/26 05:54:37 job worker claim error: i/o timeout -2026/06/26 05:54:41 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:42.000+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.3ms)","duration":"3016.3ms","level":"slow","span":"e33dd2e142aa92a8","trace":"422b03286c86d191d1a367d00814b823"} -{"@timestamp":"2026-06-26T05:54:42.000+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.3ms","level":"error","span":"e33dd2e142aa92a8","trace":"422b03286c86d191d1a367d00814b823"} -2026/06/26 05:54:45 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:48.029+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.5ms)","duration":"3023.5ms","level":"slow","span":"dc570b45bd41a892","trace":"a2e70f611054e2ff3b3431fc84ad570d"} -{"@timestamp":"2026-06-26T05:54:48.029+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.5ms","level":"error","span":"dc570b45bd41a892","trace":"a2e70f611054e2ff3b3431fc84ad570d"} -2026/06/26 05:54:49 job worker claim error: i/o timeout -2026/06/26 05:54:53 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:54:54.058+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"b9131de00f179815","trace":"944bcfd62adda978ee20ebbf306867cc"} -{"@timestamp":"2026-06-26T05:54:54.058+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"b9131de00f179815","trace":"944bcfd62adda978ee20ebbf306867cc"} -2026/06/26 05:54:55 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-189]) incomplete read of message header: read tcp 127.0.0.1:51118->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-189]) incomplete read of message header: read tcp 127.0.0.1:51118->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 05:55:29 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:55:32.920+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.9ms)","duration":"3024.9ms","level":"slow","span":"4aba3433b4e90351","trace":"8fbc3aa16ea0bfb457b7ea54ff44d316"} -{"@timestamp":"2026-06-26T05:55:32.920+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.9ms","level":"error","span":"4aba3433b4e90351","trace":"8fbc3aa16ea0bfb457b7ea54ff44d316"} -2026/06/26 05:55:33 job worker claim error: i/o timeout -2026/06/26 05:55:38 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:55:38.947+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.2ms)","duration":"3021.2ms","level":"slow","span":"1ea3625937122790","trace":"bfd99b692fd66b20e1f0bd550656d2b9"} -{"@timestamp":"2026-06-26T05:55:38.947+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.2ms","level":"error","span":"1ea3625937122790","trace":"bfd99b692fd66b20e1f0bd550656d2b9"} -2026/06/26 05:55:42 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:55:44.966+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.9ms)","duration":"3012.9ms","level":"slow","span":"32b86db67871f526","trace":"22dc3ea03b3a8d6d7e2da6552b345658"} -{"@timestamp":"2026-06-26T05:55:44.966+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.9ms","level":"error","span":"32b86db67871f526","trace":"22dc3ea03b3a8d6d7e2da6552b345658"} -{"@timestamp":"2026-06-26T05:55:45.486+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.4Mi, TotalAlloc=59.8Mi, Sys=19.6Mi, NumGC=34","level":"stat"} -{"@timestamp":"2026-06-26T05:55:45.503+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 05:55:46 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:55:48.117+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.0ms, med: 3022.6ms, 90th: 3026.7ms, 99th: 3026.7ms, 99.9th: 3026.7ms","level":"stat"} -2026/06/26 05:55:48 job scheduler tick error: i/o timeout -2026/06/26 05:55:50 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:55:50.987+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.0ms)","duration":"3015.0ms","level":"slow","span":"dc918d287a4c60e2","trace":"36c3bbf8947afc39cec579f7e6917a2b"} -{"@timestamp":"2026-06-26T05:55:50.987+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.0ms","level":"error","span":"dc918d287a4c60e2","trace":"36c3bbf8947afc39cec579f7e6917a2b"} -2026/06/26 05:55:54 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:55:57.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.2ms)","duration":"3023.2ms","level":"slow","span":"b93f989d2a492c14","trace":"48edffb2a96de1edffbed00deecc73fb"} -{"@timestamp":"2026-06-26T05:55:57.016+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.2ms","level":"error","span":"b93f989d2a492c14","trace":"48edffb2a96de1edffbed00deecc73fb"} -2026/06/26 05:55:57 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-195]) incomplete read of message header: read tcp 127.0.0.1:51139->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-195]) incomplete read of message header: read tcp 127.0.0.1:51139->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 05:55:58 job worker claim error: i/o timeout -2026/06/26 05:56:02 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T05:56:03.041+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"d3404f28985d6d99","trace":"e153cb377527ddbc853ca596e405ab81"} -{"@timestamp":"2026-06-26T05:56:03.041+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"d3404f28985d6d99","trace":"e153cb377527ddbc853ca596e405ab81"} -2026/06/26 06:12:47 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:12:54.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(6375.4ms)","duration":"6375.4ms","level":"slow","span":"27c05ced3eaaee67","trace":"017e1d22c871eea0a47901ce57d64e0a"} -{"@timestamp":"2026-06-26T06:12:54.218+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"6375.4ms","level":"error","span":"27c05ced3eaaee67","trace":"017e1d22c871eea0a47901ce57d64e0a"} -2026/06/26 06:12:55 job worker claim error: i/o timeout -2026/06/26 06:12:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:13:00.237+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.7ms)","duration":"3013.7ms","level":"slow","span":"863239b44bbff703","trace":"90db8a101cc7696c900678ab4cd4c51d"} -{"@timestamp":"2026-06-26T06:13:00.237+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.7ms","level":"error","span":"863239b44bbff703","trace":"90db8a101cc7696c900678ab4cd4c51d"} -2026/06/26 06:13:03 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:13:06.262+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.7ms)","duration":"3020.7ms","level":"slow","span":"d02c77ed51e65980","trace":"bf85b7833068f2e3cc643a11a8faddf8"} -{"@timestamp":"2026-06-26T06:13:06.262+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.7ms","level":"error","span":"d02c77ed51e65980","trace":"bf85b7833068f2e3cc643a11a8faddf8"} -2026/06/26 06:13:07 job worker claim error: i/o timeout -2026/06/26 06:13:11 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:13:12.284+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.8ms)","duration":"3016.8ms","level":"slow","span":"0c0de1c70d4eb5f0","trace":"d8616456c2a579f41055a25572b1b5a0"} -{"@timestamp":"2026-06-26T06:13:12.285+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.8ms","level":"error","span":"0c0de1c70d4eb5f0","trace":"d8616456c2a579f41055a25572b1b5a0"} -2026/06/26 06:13:12 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-201]) incomplete read of message header: read tcp 127.0.0.1:51181->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-201]) incomplete read of message header: read tcp 127.0.0.1:51181->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 06:13:15 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:13:18.318+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.0ms)","duration":"3027.0ms","level":"slow","span":"69674cc715fe5ae4","trace":"84f58f363ceb387259d08525df457cb4"} -{"@timestamp":"2026-06-26T06:13:18.318+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.0ms","level":"error","span":"69674cc715fe5ae4","trace":"84f58f363ceb387259d08525df457cb4"} -2026/06/26 06:13:19 job worker claim error: i/o timeout -2026/06/26 06:13:23 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:13:24.348+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.1ms)","duration":"3026.1ms","level":"slow","span":"dd38f6501c2f6828","trace":"d01c0995578852eec89796013ab76f7e"} -{"@timestamp":"2026-06-26T06:13:24.348+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.1ms","level":"error","span":"dd38f6501c2f6828","trace":"d01c0995578852eec89796013ab76f7e"} -2026/06/26 06:13:27 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:13:30.375+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.7ms)","duration":"3022.7ms","level":"slow","span":"29b50735193f147d","trace":"74ce3df7adcbb6bad6f5c8fa83a05dbd"} -{"@timestamp":"2026-06-26T06:13:30.375+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.7ms","level":"error","span":"29b50735193f147d","trace":"74ce3df7adcbb6bad6f5c8fa83a05dbd"} -{"@timestamp":"2026-06-26T06:13:30.438+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.8Mi, TotalAlloc=62.0Mi, Sys=19.6Mi, NumGC=35","level":"stat"} -{"@timestamp":"2026-06-26T06:13:30.453+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 06:31:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:31:13.841+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3355.9ms, med: 3022.6ms, 90th: 6375.3ms, 99th: 6375.3ms, 99.9th: 6375.3ms","level":"stat"} -2026/06/26 06:31:14 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T06:31:14.651+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.2ms)","duration":"3021.2ms","level":"slow","span":"ceaf4869af4f9bfd","trace":"928da8566cdeeb4f41941efbb7a29c96"} -{"@timestamp":"2026-06-26T06:31:14.651+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.2ms","level":"error","span":"ceaf4869af4f9bfd","trace":"928da8566cdeeb4f41941efbb7a29c96"} -2026/06/26 06:31:16 job worker claim error: i/o timeout -2026/06/26 06:48:43 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:48:44.328+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.5ms)","duration":"3023.5ms","level":"slow","span":"c30108fe8b868263","trace":"6fa4c7c00a2a0bb6d9231ce14e4a1f18"} -{"@timestamp":"2026-06-26T06:48:44.329+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.5ms","level":"error","span":"c30108fe8b868263","trace":"6fa4c7c00a2a0bb6d9231ce14e4a1f18"} -2026/06/26 06:55:14 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-207]) incomplete read of message header: read tcp 127.0.0.1:51204->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-207]) incomplete read of message header: read tcp 127.0.0.1:51204->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 06:55:15 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:55:17.980+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3011.8ms)","duration":"3011.8ms","level":"slow","span":"86126878e2678826","trace":"e196eb2cf7be6ca7ddd87bf273409897"} -{"@timestamp":"2026-06-26T06:55:17.980+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3011.8ms","level":"error","span":"86126878e2678826","trace":"e196eb2cf7be6ca7ddd87bf273409897"} -2026/06/26 06:55:19 job worker claim error: i/o timeout -2026/06/26 06:55:23 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:55:23.996+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.8ms)","duration":"3012.8ms","level":"slow","span":"c909814cb56c77c9","trace":"96d9d7f4d382c7339b3ea36619632d8c"} -{"@timestamp":"2026-06-26T06:55:23.996+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.8ms","level":"error","span":"c909814cb56c77c9","trace":"96d9d7f4d382c7339b3ea36619632d8c"} -2026/06/26 06:55:27 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:55:30.025+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"829c25e12eb5abf1","trace":"f8d54addd7af6c5882a11c35614f2203"} -{"@timestamp":"2026-06-26T06:55:30.025+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"829c25e12eb5abf1","trace":"f8d54addd7af6c5882a11c35614f2203"} -2026/06/26 06:55:31 job worker claim error: i/o timeout -2026/06/26 06:55:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:55:36.048+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"3647c104623909c5","trace":"ea20d6e4f66d92251398a922ce9421ea"} -{"@timestamp":"2026-06-26T06:55:36.048+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"3647c104623909c5","trace":"ea20d6e4f66d92251398a922ce9421ea"} -2026/06/26 06:55:39 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:55:42.079+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.9ms)","duration":"3026.9ms","level":"slow","span":"9af217accaae6662","trace":"0de3a2fea59bd568fe6942a09c893dfe"} -{"@timestamp":"2026-06-26T06:55:42.079+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.9ms","level":"error","span":"9af217accaae6662","trace":"0de3a2fea59bd568fe6942a09c893dfe"} -2026/06/26 06:55:43 job worker claim error: i/o timeout -2026/06/26 06:55:44 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-213]) incomplete read of message header: read tcp 127.0.0.1:51283->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-213]) incomplete read of message header: read tcp 127.0.0.1:51283->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 06:55:47 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:55:48.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"6be2945789045e84","trace":"bfa67c873d7ecba24ecef83087764009"} -{"@timestamp":"2026-06-26T06:55:48.097+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"6be2945789045e84","trace":"bfa67c873d7ecba24ecef83087764009"} -2026/06/26 06:55:51 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:55:54.123+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"2f34ca20ff9e0c8f","trace":"03e549f3a03851524bb76de8cb1690a9"} -{"@timestamp":"2026-06-26T06:55:54.124+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"2f34ca20ff9e0c8f","trace":"03e549f3a03851524bb76de8cb1690a9"} -2026/06/26 06:55:55 job worker claim error: i/o timeout -2026/06/26 06:55:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T06:56:00.141+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3011.6ms)","duration":"3011.6ms","level":"slow","span":"d1bc1f59634fa8e6","trace":"77071c10188e5d038dc9a036f844aa41"} -{"@timestamp":"2026-06-26T06:56:00.141+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3011.6ms","level":"error","span":"d1bc1f59634fa8e6","trace":"77071c10188e5d038dc9a036f844aa41"} -{"@timestamp":"2026-06-26T07:00:14.780+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.4Mi, TotalAlloc=64.3Mi, Sys=19.6Mi, NumGC=36","level":"stat"} -{"@timestamp":"2026-06-26T07:00:14.797+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T07:00:15.959+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.2ms)","duration":"3019.2ms","level":"slow","span":"6d539df7f6e138f6","trace":"bb9fa17ec862deb92164578b35ee1bb4"} -{"@timestamp":"2026-06-26T07:00:15.959+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.2ms","level":"error","span":"6d539df7f6e138f6","trace":"bb9fa17ec862deb92164578b35ee1bb4"} -2026/06/26 07:00:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:00:17.409+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3018.8ms, med: 3021.1ms, 90th: 3026.7ms, 99th: 3026.7ms, 99.9th: 3026.7ms","level":"stat"} -2026/06/26 07:00:17 job scheduler tick error: i/o timeout -2026/06/26 07:00:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:00:21.989+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.2ms)","duration":"3024.2ms","level":"slow","span":"b9a3e387d85789d9","trace":"b65b0fc2ae10b021833c95ea47402737"} -{"@timestamp":"2026-06-26T07:00:21.989+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.2ms","level":"error","span":"b9a3e387d85789d9","trace":"b65b0fc2ae10b021833c95ea47402737"} -2026/06/26 07:00:24 job worker claim error: i/o timeout -2026/06/26 07:00:27 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-219]) incomplete read of message header: read tcp 127.0.0.1:51307->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-219]) incomplete read of message header: read tcp 127.0.0.1:51307->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T07:00:28.015+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"506ff50fa12b5fea","trace":"f296daa39634abd2f6abb8b8ae6ef644"} -{"@timestamp":"2026-06-26T07:00:28.015+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"506ff50fa12b5fea","trace":"f296daa39634abd2f6abb8b8ae6ef644"} -2026/06/26 07:00:28 job worker claim error: i/o timeout -2026/06/26 07:00:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:00:34.037+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"471a5cb1934a73a9","trace":"e44ab83dbf2fe2ef558d5f93294e1bd9"} -{"@timestamp":"2026-06-26T07:00:34.038+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"471a5cb1934a73a9","trace":"e44ab83dbf2fe2ef558d5f93294e1bd9"} -2026/06/26 07:00:36 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:00:40.064+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"bf6608e63292dbe0","trace":"ff371f71f4c25f4639bff65849b21982"} -{"@timestamp":"2026-06-26T07:00:40.064+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"bf6608e63292dbe0","trace":"ff371f71f4c25f4639bff65849b21982"} -2026/06/26 07:00:40 job worker claim error: i/o timeout -2026/06/26 07:00:44 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:00:46.084+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"21b58a1fd178a802","trace":"c626c34941584bd0ddc91213b8c1b2f9"} -{"@timestamp":"2026-06-26T07:00:46.084+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"21b58a1fd178a802","trace":"c626c34941584bd0ddc91213b8c1b2f9"} -2026/06/26 07:00:48 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:00:52.116+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.9ms)","duration":"3025.9ms","level":"slow","span":"a21ad5ffed0e5bb4","trace":"4099744634b8cb5d90c070e4555e71cc"} -{"@timestamp":"2026-06-26T07:00:52.116+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.9ms","level":"error","span":"a21ad5ffed0e5bb4","trace":"4099744634b8cb5d90c070e4555e71cc"} -2026/06/26 07:00:52 job worker claim error: i/o timeout -2026/06/26 07:17:21 job worker claim error: i/o timeout -2026/06/26 07:17:22 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-225]) incomplete read of message header: read tcp 127.0.0.1:51333->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-225]) incomplete read of message header: read tcp 127.0.0.1:51333->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T07:17:23.409+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.7ms)","duration":"3020.7ms","level":"slow","span":"a7bcc8f6a3243683","trace":"f95114da93683ac5fd0e6cfe31f85659"} -{"@timestamp":"2026-06-26T07:17:23.409+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.7ms","level":"error","span":"a7bcc8f6a3243683","trace":"f95114da93683ac5fd0e6cfe31f85659"} -2026/06/26 07:18:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:18:39.266+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3033.8ms)","duration":"3033.8ms","level":"slow","span":"612916807468385e","trace":"b8a2bc8a56fa985ece009e5a71d28499"} -{"@timestamp":"2026-06-26T07:18:39.267+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3033.8ms","level":"error","span":"612916807468385e","trace":"b8a2bc8a56fa985ece009e5a71d28499"} -2026/06/26 07:18:39 job worker claim error: i/o timeout -2026/06/26 07:18:43 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:18:45.294+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"4495fadefa9aa4a7","trace":"4d595973b93e850b01212f15f03c4490"} -{"@timestamp":"2026-06-26T07:18:45.294+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"4495fadefa9aa4a7","trace":"4d595973b93e850b01212f15f03c4490"} -2026/06/26 07:18:47 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:18:49.885+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.0Mi, TotalAlloc=66.5Mi, Sys=19.6Mi, NumGC=37","level":"stat"} -{"@timestamp":"2026-06-26T07:18:49.902+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T07:18:51.325+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.1ms)","duration":"3022.1ms","level":"slow","span":"c3b57a8655e3b395","trace":"177590471697981d95409ced4400cf2e"} -{"@timestamp":"2026-06-26T07:18:51.325+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.1ms","level":"error","span":"c3b57a8655e3b395","trace":"177590471697981d95409ced4400cf2e"} -2026/06/26 07:18:51 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:18:52.519+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3021.4ms, med: 3022.0ms, 90th: 3033.7ms, 99th: 3033.7ms, 99.9th: 3033.7ms","level":"stat"} -2026/06/26 07:18:52 job scheduler tick error: i/o timeout -2026/06/26 07:18:55 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:18:57.358+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"d42d739286582f74","trace":"a3dbbc7779fe7883207870631371473f"} -{"@timestamp":"2026-06-26T07:18:57.358+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"d42d739286582f74","trace":"a3dbbc7779fe7883207870631371473f"} -2026/06/26 07:18:59 job worker claim error: i/o timeout -2026/06/26 07:19:02 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-231]) incomplete read of message header: read tcp 127.0.0.1:51377->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-231]) incomplete read of message header: read tcp 127.0.0.1:51377->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T07:34:47.272+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(946906.6ms)","duration":"946906.6ms","level":"slow","span":"a57211457bdf8c80","trace":"d822f69cdc4bc1ea833918630dee49ff"} -{"@timestamp":"2026-06-26T07:34:47.272+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"946906.6ms","level":"error","span":"a57211457bdf8c80","trace":"d822f69cdc4bc1ea833918630dee49ff"} -2026/06/26 07:34:47 job worker claim error: i/o timeout -2026/06/26 07:34:51 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:34:53.296+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.0ms)","duration":"3017.0ms","level":"slow","span":"e33805ca839a5f1b","trace":"a1252bae28253a265430c74875a3fc53"} -{"@timestamp":"2026-06-26T07:34:53.296+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.0ms","level":"error","span":"e33805ca839a5f1b","trace":"a1252bae28253a265430c74875a3fc53"} -2026/06/26 07:34:55 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:34:59.312+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.4ms)","duration":"3012.4ms","level":"slow","span":"cff3e29a34c1a85b","trace":"7df1436b9471d7f456954e936672a54a"} -{"@timestamp":"2026-06-26T07:34:59.313+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.4ms","level":"error","span":"cff3e29a34c1a85b","trace":"7df1436b9471d7f456954e936672a54a"} -2026/06/26 07:34:59 job worker claim error: i/o timeout -2026/06/26 07:35:03 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:35:05.335+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.9ms)","duration":"3017.9ms","level":"slow","span":"a5846753014f8931","trace":"5dced19dfea091b15a3dfbe66cfa8530"} -{"@timestamp":"2026-06-26T07:35:05.335+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.9ms","level":"error","span":"a5846753014f8931","trace":"5dced19dfea091b15a3dfbe66cfa8530"} -2026/06/26 07:35:07 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:35:11.359+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"4a80a22de8c6d4ab","trace":"86562201cc1e12ba903e726546107ce3"} -{"@timestamp":"2026-06-26T07:35:11.359+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"4a80a22de8c6d4ab","trace":"86562201cc1e12ba903e726546107ce3"} -2026/06/26 07:35:11 job worker claim error: i/o timeout -2026/06/26 07:35:15 job worker claim error: i/o timeout -2026/06/26 07:35:16 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-237]) incomplete read of message header: read tcp 127.0.0.1:51423->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-237]) incomplete read of message header: read tcp 127.0.0.1:51423->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T07:35:17.380+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.7ms)","duration":"3016.7ms","level":"slow","span":"8e96227453acb4e6","trace":"850a61a30562aa9a046e16d0b72e4ade"} -{"@timestamp":"2026-06-26T07:35:17.381+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.7ms","level":"error","span":"8e96227453acb4e6","trace":"850a61a30562aa9a046e16d0b72e4ade"} -2026/06/26 07:35:19 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:35:23.404+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.9ms)","duration":"3017.9ms","level":"slow","span":"bdd3e6b7d876973a","trace":"ee74f3dda6744891a611b65be7d1dcd9"} -{"@timestamp":"2026-06-26T07:35:23.404+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.9ms","level":"error","span":"bdd3e6b7d876973a","trace":"ee74f3dda6744891a611b65be7d1dcd9"} -2026/06/26 07:35:23 job worker claim error: i/o timeout -2026/06/26 07:35:27 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:35:29.423+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"bcfb25dc84b55b8b","trace":"c505bc6f827c916ced8d5e84784d35cf"} -{"@timestamp":"2026-06-26T07:35:29.423+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"bcfb25dc84b55b8b","trace":"c505bc6f827c916ced8d5e84784d35cf"} -2026/06/26 07:35:31 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:35:33.764+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=68.8Mi, Sys=19.6Mi, NumGC=38","level":"stat"} -{"@timestamp":"2026-06-26T07:35:33.783+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T07:46:01.088+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(628659.3ms)","duration":"628659.3ms","level":"slow","span":"7b59cfa667d4b29d","trace":"e0cac95b3c7dddc5c14b69faf6d01fa8"} -{"@timestamp":"2026-06-26T07:46:01.088+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"628659.3ms","level":"error","span":"7b59cfa667d4b29d","trace":"e0cac95b3c7dddc5c14b69faf6d01fa8"} -2026/06/26 07:46:01 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:46:02.028+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 159970.2ms, med: 3017.8ms, 90th: 946906.4ms, 99th: 946906.4ms, 99.9th: 946906.4ms","level":"stat"} -2026/06/26 07:46:02 job scheduler tick error: i/o timeout -2026/06/26 07:46:05 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:46:07.115+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.3ms)","duration":"3021.3ms","level":"slow","span":"61f13b9761138927","trace":"8d18ca2c18870d96bcb8983cac765aa8"} -{"@timestamp":"2026-06-26T07:46:07.115+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.3ms","level":"error","span":"61f13b9761138927","trace":"8d18ca2c18870d96bcb8983cac765aa8"} -2026/06/26 07:46:09 job worker claim error: i/o timeout -2026/06/26 07:46:11 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-243]) incomplete read of message header: read tcp 127.0.0.1:51445->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-243]) incomplete read of message header: read tcp 127.0.0.1:51445->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T07:46:13.142+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.1ms)","duration":"3023.1ms","level":"slow","span":"4a8b0b124815a4ce","trace":"d013c0e0c9d455bda331af6d68bc8c95"} -{"@timestamp":"2026-06-26T07:46:13.142+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.1ms","level":"error","span":"4a8b0b124815a4ce","trace":"d013c0e0c9d455bda331af6d68bc8c95"} -2026/06/26 07:46:13 job worker claim error: i/o timeout -2026/06/26 07:46:17 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:46:19.160+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.7ms)","duration":"3012.7ms","level":"slow","span":"93c9ed06fa73ce40","trace":"a81a44c9ed58c849fe642004ed75d899"} -{"@timestamp":"2026-06-26T07:46:19.160+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.7ms","level":"error","span":"93c9ed06fa73ce40","trace":"a81a44c9ed58c849fe642004ed75d899"} -2026/06/26 07:46:21 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:56:15.348+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(593181.6ms)","duration":"593181.6ms","level":"slow","span":"ec34624021fed3ce","trace":"85e3ed3944e2baca5b169ee1ebb62bb7"} -{"@timestamp":"2026-06-26T07:56:15.348+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"593181.6ms","level":"error","span":"ec34624021fed3ce","trace":"85e3ed3944e2baca5b169ee1ebb62bb7"} -2026/06/26 07:56:15 job worker claim error: i/o timeout -2026/06/26 07:56:19 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:56:21.370+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"55f9446a4be1ba11","trace":"9001a5169fa898b6fb5eeac899861284"} -{"@timestamp":"2026-06-26T07:56:21.371+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"55f9446a4be1ba11","trace":"9001a5169fa898b6fb5eeac899861284"} -2026/06/26 07:56:23 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:56:27.392+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.9ms)","duration":"3019.9ms","level":"slow","span":"32be0e5647ededab","trace":"528f90b86d66b7a5ab5b3008d0fc6f92"} -{"@timestamp":"2026-06-26T07:56:27.393+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.9ms","level":"error","span":"32be0e5647ededab","trace":"528f90b86d66b7a5ab5b3008d0fc6f92"} -2026/06/26 07:56:27 job worker claim error: i/o timeout -2026/06/26 07:56:31 job worker claim error: i/o timeout -2026/06/26 07:56:31 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-249]) incomplete read of message header: read tcp 127.0.0.1:51480->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-249]) incomplete read of message header: read tcp 127.0.0.1:51480->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T07:56:33.413+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"e8032c46a495f285","trace":"de91a967f4da4a11852a57c1400738ba"} -{"@timestamp":"2026-06-26T07:56:33.413+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"e8032c46a495f285","trace":"de91a967f4da4a11852a57c1400738ba"} -2026/06/26 07:56:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:56:39.437+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.8ms)","duration":"3021.8ms","level":"slow","span":"852ed51d940f5c27","trace":"bc51920ff716c6024783326c2bee8c15"} -{"@timestamp":"2026-06-26T07:56:39.437+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.8ms","level":"error","span":"852ed51d940f5c27","trace":"bc51920ff716c6024783326c2bee8c15"} -2026/06/26 07:56:39 job worker claim error: i/o timeout -2026/06/26 07:56:43 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:56:45.461+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.6ms)","duration":"3019.6ms","level":"slow","span":"a4fa84852bbb9eed","trace":"54d9b6690fd6c3c8af79f27db9f17d4d"} -{"@timestamp":"2026-06-26T07:56:45.461+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.6ms","level":"error","span":"a4fa84852bbb9eed","trace":"54d9b6690fd6c3c8af79f27db9f17d4d"} -2026/06/26 07:56:47 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:56:49.527+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.3Mi, TotalAlloc=70.5Mi, Sys=19.6Mi, NumGC=38","level":"stat"} -{"@timestamp":"2026-06-26T07:56:49.544+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T07:56:51.488+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"0eb43ba81e2a12d9","trace":"7d6050cc5c8bd41dc8b90654f8d88fd9"} -{"@timestamp":"2026-06-26T07:56:51.488+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"0eb43ba81e2a12d9","trace":"7d6050cc5c8bd41dc8b90654f8d88fd9"} -2026/06/26 07:56:51 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T07:56:52.158+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 62035.7ms, med: 3021.2ms, 90th: 593181.5ms, 99th: 593181.5ms, 99.9th: 593181.5ms","level":"stat"} -2026/06/26 07:56:52 job scheduler tick error: i/o timeout -2026/06/26 08:00:06 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:00:06.888+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.3ms)","duration":"2065.3ms","level":"slow","span":"98588c303dddb024","trace":"e0cc1ab253c65e80a971df9389a29941"} -{"@timestamp":"2026-06-26T08:00:06.888+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.3ms","level":"info","span":"98588c303dddb024","trace":"e0cc1ab253c65e80a971df9389a29941"} -{"@timestamp":"2026-06-26T08:00:11.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.3ms)","duration":"2038.3ms","level":"slow","span":"630a7987f0639856","trace":"151e7c35dc4c2a8a0d3870b69c127ee6"} -{"@timestamp":"2026-06-26T08:00:11.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.3ms","level":"info","span":"630a7987f0639856","trace":"151e7c35dc4c2a8a0d3870b69c127ee6"} -{"@timestamp":"2026-06-26T08:00:16.948+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.8ms)","duration":"2010.8ms","level":"slow","span":"2539296d3c477ca1","trace":"501775828ecb27a18be4cc34b5474270"} -{"@timestamp":"2026-06-26T08:00:16.948+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.8ms","level":"info","span":"2539296d3c477ca1","trace":"501775828ecb27a18be4cc34b5474270"} -{"@timestamp":"2026-06-26T08:00:22.021+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.2ms)","duration":"2070.2ms","level":"slow","span":"38673f8713343274","trace":"c6d3f43fe956cfc125d2d3bdbb5380d2"} -{"@timestamp":"2026-06-26T08:00:22.022+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.2ms","level":"info","span":"38673f8713343274","trace":"c6d3f43fe956cfc125d2d3bdbb5380d2"} -{"@timestamp":"2026-06-26T08:00:27.068+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.4ms)","duration":"2043.4ms","level":"slow","span":"da390d8398758abd","trace":"b49e2d9d22f7a82405ddd8471f690ac7"} -{"@timestamp":"2026-06-26T08:00:27.068+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.4ms","level":"info","span":"da390d8398758abd","trace":"b49e2d9d22f7a82405ddd8471f690ac7"} -{"@timestamp":"2026-06-26T08:00:32.110+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.0ms)","duration":"2039.0ms","level":"slow","span":"5fc77c577af4bbd2","trace":"3edaf29015200d3b8233e4f46292a0aa"} -{"@timestamp":"2026-06-26T08:00:32.110+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.0ms","level":"info","span":"5fc77c577af4bbd2","trace":"3edaf29015200d3b8233e4f46292a0aa"} -{"@timestamp":"2026-06-26T08:00:37.158+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.3ms)","duration":"2044.3ms","level":"slow","span":"7441bbc30ce81ac0","trace":"4095d9abdf4a22b78fc82f33fa09623a"} -{"@timestamp":"2026-06-26T08:00:37.158+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.3ms","level":"info","span":"7441bbc30ce81ac0","trace":"4095d9abdf4a22b78fc82f33fa09623a"} -{"@timestamp":"2026-06-26T08:00:42.241+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2080.1ms)","duration":"2080.1ms","level":"slow","span":"3c65916395972def","trace":"08342b09db2ac2a858558a2ab7c5d1fe"} -{"@timestamp":"2026-06-26T08:00:42.241+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2080.1ms","level":"info","span":"3c65916395972def","trace":"08342b09db2ac2a858558a2ab7c5d1fe"} -{"@timestamp":"2026-06-26T08:00:47.321+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.4ms)","duration":"2076.4ms","level":"slow","span":"ab27b838299a3dbb","trace":"5150288cb40a1a9ddf4aaf371521492a"} -{"@timestamp":"2026-06-26T08:00:47.321+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.4ms","level":"info","span":"ab27b838299a3dbb","trace":"5150288cb40a1a9ddf4aaf371521492a"} -{"@timestamp":"2026-06-26T08:00:52.404+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2079.4ms)","duration":"2079.4ms","level":"slow","span":"283429adcca1de70","trace":"411178f31d839c1f862949bf3864f62b"} -{"@timestamp":"2026-06-26T08:00:52.404+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2079.4ms","level":"info","span":"283429adcca1de70","trace":"411178f31d839c1f862949bf3864f62b"} -{"@timestamp":"2026-06-26T08:00:57.437+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.0ms)","duration":"2031.0ms","level":"slow","span":"fad9918b23468f67","trace":"9d30a07720d4eef8feab4a4d1c0c321a"} -{"@timestamp":"2026-06-26T08:00:57.437+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.0ms","level":"info","span":"fad9918b23468f67","trace":"9d30a07720d4eef8feab4a4d1c0c321a"} -{"@timestamp":"2026-06-26T08:00:59.826+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=71.4Mi, Sys=19.6Mi, NumGC=39","level":"stat"} -{"@timestamp":"2026-06-26T08:00:59.843+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:01:02.457+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2052.4ms, med: 2065.3ms, 90th: 2080.0ms, 99th: 2080.0ms, 99.9th: 2080.0ms","level":"stat"} -{"@timestamp":"2026-06-26T08:01:02.478+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.0ms)","duration":"2038.0ms","level":"slow","span":"db7918ff865f99fd","trace":"0e06f7e793137035ae0f63bb209e33d8"} -{"@timestamp":"2026-06-26T08:01:02.479+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.0ms","level":"info","span":"db7918ff865f99fd","trace":"0e06f7e793137035ae0f63bb209e33d8"} -{"@timestamp":"2026-06-26T08:01:07.515+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"630afc353a59964b","trace":"9ea3cf7a9ef201923536b63e8d21974b"} -{"@timestamp":"2026-06-26T08:01:07.515+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"630afc353a59964b","trace":"9ea3cf7a9ef201923536b63e8d21974b"} -{"@timestamp":"2026-06-26T08:01:12.563+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.4ms)","duration":"2045.4ms","level":"slow","span":"c6280abafdcf7618","trace":"ad27eaa6660d7dfe72be2de8d1e40de0"} -{"@timestamp":"2026-06-26T08:01:12.564+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.4ms","level":"info","span":"c6280abafdcf7618","trace":"ad27eaa6660d7dfe72be2de8d1e40de0"} -{"@timestamp":"2026-06-26T08:01:17.610+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.2ms)","duration":"2043.2ms","level":"slow","span":"7a193ab09aeaa7c0","trace":"0d4c2b70959f097481b855884e1a3eac"} -{"@timestamp":"2026-06-26T08:01:17.610+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.2ms","level":"info","span":"7a193ab09aeaa7c0","trace":"0d4c2b70959f097481b855884e1a3eac"} -{"@timestamp":"2026-06-26T08:01:22.653+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.3ms)","duration":"2040.3ms","level":"slow","span":"10a858a93f14298d","trace":"214fd103e2dc0e3217b599aba6c7bef8"} -{"@timestamp":"2026-06-26T08:01:22.653+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.3ms","level":"info","span":"10a858a93f14298d","trace":"214fd103e2dc0e3217b599aba6c7bef8"} -{"@timestamp":"2026-06-26T08:01:27.706+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.7ms)","duration":"2049.7ms","level":"slow","span":"749b743a2ce858b6","trace":"00ed6d13592560d8735cdf20b749f760"} -{"@timestamp":"2026-06-26T08:01:27.706+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.7ms","level":"info","span":"749b743a2ce858b6","trace":"00ed6d13592560d8735cdf20b749f760"} -{"@timestamp":"2026-06-26T08:01:32.778+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.3ms)","duration":"2070.3ms","level":"slow","span":"af4704a55db8373e","trace":"c6a3bddbdd22a7f5488ceae1e99b1020"} -{"@timestamp":"2026-06-26T08:01:32.779+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.3ms","level":"info","span":"af4704a55db8373e","trace":"c6a3bddbdd22a7f5488ceae1e99b1020"} -{"@timestamp":"2026-06-26T08:01:37.806+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.0ms)","duration":"2025.0ms","level":"slow","span":"a1fc78dabe0c5fb2","trace":"b9e7070a09150e87176161cc3c6cbf30"} -{"@timestamp":"2026-06-26T08:01:37.806+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.0ms","level":"info","span":"a1fc78dabe0c5fb2","trace":"b9e7070a09150e87176161cc3c6cbf30"} -{"@timestamp":"2026-06-26T08:01:42.843+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.6ms)","duration":"2033.6ms","level":"slow","span":"a633e29a7f552a60","trace":"1840386c6f659f74d6b2cc28975d7601"} -{"@timestamp":"2026-06-26T08:01:42.844+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.6ms","level":"info","span":"a633e29a7f552a60","trace":"1840386c6f659f74d6b2cc28975d7601"} -{"@timestamp":"2026-06-26T08:01:47.878+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.2ms)","duration":"2031.2ms","level":"slow","span":"ff71fdd5952c98af","trace":"680024fcd46aaaac2ffcc60dc2644ea5"} -{"@timestamp":"2026-06-26T08:01:47.878+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.2ms","level":"info","span":"ff71fdd5952c98af","trace":"680024fcd46aaaac2ffcc60dc2644ea5"} -{"@timestamp":"2026-06-26T08:01:52.923+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.7ms)","duration":"2041.7ms","level":"slow","span":"db9578fbc71adcab","trace":"c57492a11c1ab3839a0404009afe0d93"} -{"@timestamp":"2026-06-26T08:01:52.923+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.7ms","level":"info","span":"db9578fbc71adcab","trace":"c57492a11c1ab3839a0404009afe0d93"} -{"@timestamp":"2026-06-26T08:01:58.008+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.1ms)","duration":"2081.1ms","level":"slow","span":"29b82dd0e05f68d6","trace":"6c72224fbc485ff9330393f3c211792f"} -{"@timestamp":"2026-06-26T08:01:58.008+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.1ms","level":"info","span":"29b82dd0e05f68d6","trace":"6c72224fbc485ff9330393f3c211792f"} -{"@timestamp":"2026-06-26T08:01:59.826+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.8Mi, TotalAlloc=71.6Mi, Sys=19.6Mi, NumGC=39","level":"stat"} -{"@timestamp":"2026-06-26T08:01:59.843+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:02:02.456+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.2ms, med: 2041.7ms, 90th: 2081.0ms, 99th: 2081.0ms, 99.9th: 2081.0ms","level":"stat"} -{"@timestamp":"2026-06-26T08:02:03.073+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.7ms)","duration":"2060.7ms","level":"slow","span":"5bb6659319ab9959","trace":"1d8ae7d2fe60c8d072f9649bd9bcddf1"} -{"@timestamp":"2026-06-26T08:02:03.073+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.7ms","level":"info","span":"5bb6659319ab9959","trace":"1d8ae7d2fe60c8d072f9649bd9bcddf1"} -{"@timestamp":"2026-06-26T08:02:08.137+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.7ms)","duration":"2060.7ms","level":"slow","span":"133a9356ef561b94","trace":"dec6aff83f8426f1ccea7c0f4714f997"} -{"@timestamp":"2026-06-26T08:02:08.138+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.7ms","level":"info","span":"133a9356ef561b94","trace":"dec6aff83f8426f1ccea7c0f4714f997"} -{"@timestamp":"2026-06-26T08:02:13.200+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.2ms)","duration":"2060.2ms","level":"slow","span":"dedf11aca10e8c3d","trace":"0dba9c4bc3cf0ea7691a568efbb6dc79"} -{"@timestamp":"2026-06-26T08:02:13.200+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.2ms","level":"info","span":"dedf11aca10e8c3d","trace":"0dba9c4bc3cf0ea7691a568efbb6dc79"} -{"@timestamp":"2026-06-26T08:02:18.260+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.6ms)","duration":"2055.6ms","level":"slow","span":"80d4a51f00e0e2c0","trace":"f330e8f607071c40149096dbb13842eb"} -{"@timestamp":"2026-06-26T08:02:18.260+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.6ms","level":"info","span":"80d4a51f00e0e2c0","trace":"f330e8f607071c40149096dbb13842eb"} -{"@timestamp":"2026-06-26T08:02:23.299+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.6ms)","duration":"2034.6ms","level":"slow","span":"3ec3cb3931a4add6","trace":"4ab622d4d3a1fc9bce7466cd6a021ea8"} -{"@timestamp":"2026-06-26T08:02:23.299+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.6ms","level":"info","span":"3ec3cb3931a4add6","trace":"4ab622d4d3a1fc9bce7466cd6a021ea8"} -{"@timestamp":"2026-06-26T08:02:28.338+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.9ms)","duration":"2035.9ms","level":"slow","span":"bd64300091d86024","trace":"bbdbd9b8fde1ece3a5cae7bad8fe6d47"} -{"@timestamp":"2026-06-26T08:02:28.338+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.9ms","level":"info","span":"bd64300091d86024","trace":"bbdbd9b8fde1ece3a5cae7bad8fe6d47"} -{"@timestamp":"2026-06-26T08:02:33.445+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2103.7ms)","duration":"2103.7ms","level":"slow","span":"b2a17fada292ba91","trace":"26eacc46a73880b5f5682a86e4a6be1d"} -{"@timestamp":"2026-06-26T08:02:33.445+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2103.7ms","level":"info","span":"b2a17fada292ba91","trace":"26eacc46a73880b5f5682a86e4a6be1d"} -{"@timestamp":"2026-06-26T08:02:38.483+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.0ms)","duration":"2034.0ms","level":"slow","span":"5b6ec417f5cc0292","trace":"1b6ceade18766faceb42964aedcfcad7"} -{"@timestamp":"2026-06-26T08:02:38.486+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.0ms","level":"info","span":"5b6ec417f5cc0292","trace":"1b6ceade18766faceb42964aedcfcad7"} -{"@timestamp":"2026-06-26T08:02:43.542+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.4ms)","duration":"2051.4ms","level":"slow","span":"71250ece1a275d49","trace":"aec8ad27f9b843b1523947cf941026d3"} -{"@timestamp":"2026-06-26T08:02:43.542+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.4ms","level":"info","span":"71250ece1a275d49","trace":"aec8ad27f9b843b1523947cf941026d3"} -{"@timestamp":"2026-06-26T08:02:48.582+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.9ms)","duration":"2036.9ms","level":"slow","span":"92f33b9b43ba3c22","trace":"e285a61dd50f790d488d68c15cc50667"} -{"@timestamp":"2026-06-26T08:02:48.583+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.9ms","level":"info","span":"92f33b9b43ba3c22","trace":"e285a61dd50f790d488d68c15cc50667"} -{"@timestamp":"2026-06-26T08:02:53.611+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.0ms)","duration":"2024.0ms","level":"slow","span":"829eb16def8ab80b","trace":"2de68f806c81f023e105c3b912780375"} -{"@timestamp":"2026-06-26T08:02:53.611+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.0ms","level":"info","span":"829eb16def8ab80b","trace":"2de68f806c81f023e105c3b912780375"} -{"@timestamp":"2026-06-26T08:02:58.641+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.2ms)","duration":"2027.2ms","level":"slow","span":"ec8eaa89c64ff513","trace":"161699c0c8e51948132247ffda5caedd"} -{"@timestamp":"2026-06-26T08:02:58.641+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.2ms","level":"info","span":"ec8eaa89c64ff513","trace":"161699c0c8e51948132247ffda5caedd"} -{"@timestamp":"2026-06-26T08:02:59.825+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=72.1Mi, Sys=19.6Mi, NumGC=40","level":"stat"} -{"@timestamp":"2026-06-26T08:02:59.842+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:03:02.456+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2048.6ms, med: 2051.3ms, 90th: 2103.6ms, 99th: 2103.6ms, 99.9th: 2103.6ms","level":"stat"} -{"@timestamp":"2026-06-26T08:03:03.686+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.8ms)","duration":"2042.8ms","level":"slow","span":"3a1a84758f646ab5","trace":"dcf9c33cc9cc26b13a0fcc9e1487e378"} -{"@timestamp":"2026-06-26T08:03:03.686+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.8ms","level":"info","span":"3a1a84758f646ab5","trace":"dcf9c33cc9cc26b13a0fcc9e1487e378"} -{"@timestamp":"2026-06-26T08:03:08.744+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.8ms)","duration":"2054.8ms","level":"slow","span":"cb1f9a16cdbb727f","trace":"a447c4a0e4d5651159e1059f798040c8"} -{"@timestamp":"2026-06-26T08:03:08.744+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.8ms","level":"info","span":"cb1f9a16cdbb727f","trace":"a447c4a0e4d5651159e1059f798040c8"} -{"@timestamp":"2026-06-26T08:03:13.823+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2075.6ms)","duration":"2075.6ms","level":"slow","span":"f57559e8a86aa1f6","trace":"fed5926b1c07705735b9cf57778b9acc"} -{"@timestamp":"2026-06-26T08:03:13.824+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2075.6ms","level":"info","span":"f57559e8a86aa1f6","trace":"fed5926b1c07705735b9cf57778b9acc"} -{"@timestamp":"2026-06-26T08:03:18.856+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.4ms)","duration":"2030.4ms","level":"slow","span":"ffa062f76f7c3066","trace":"9eb04b888be435ede496fcb96f103380"} -{"@timestamp":"2026-06-26T08:03:18.857+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.4ms","level":"info","span":"ffa062f76f7c3066","trace":"9eb04b888be435ede496fcb96f103380"} -{"@timestamp":"2026-06-26T08:03:23.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.7ms)","duration":"2051.7ms","level":"slow","span":"412ada4f61b81d47","trace":"2e6456ab085b951ed478f714721b7750"} -{"@timestamp":"2026-06-26T08:03:23.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.7ms","level":"info","span":"412ada4f61b81d47","trace":"2e6456ab085b951ed478f714721b7750"} -{"@timestamp":"2026-06-26T08:03:28.954+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.8ms)","duration":"2038.8ms","level":"slow","span":"d1b2e915e1e78f13","trace":"6e6bd104cb851c155f4f618ad3c4df6f"} -{"@timestamp":"2026-06-26T08:03:28.954+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.8ms","level":"info","span":"d1b2e915e1e78f13","trace":"6e6bd104cb851c155f4f618ad3c4df6f"} -{"@timestamp":"2026-06-26T08:03:34.005+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.9ms)","duration":"2046.9ms","level":"slow","span":"8ca9a1ad0596893a","trace":"ecb12e6874f40d78bdf2700a697fe649"} -{"@timestamp":"2026-06-26T08:03:34.005+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.9ms","level":"info","span":"8ca9a1ad0596893a","trace":"ecb12e6874f40d78bdf2700a697fe649"} -{"@timestamp":"2026-06-26T08:03:39.051+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.7ms)","duration":"2042.7ms","level":"slow","span":"4f5cf9a908742da2","trace":"542e691c2a423261101b2bc7b94ffd43"} -{"@timestamp":"2026-06-26T08:03:39.051+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.7ms","level":"info","span":"4f5cf9a908742da2","trace":"542e691c2a423261101b2bc7b94ffd43"} -{"@timestamp":"2026-06-26T08:03:44.094+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.2ms)","duration":"2040.2ms","level":"slow","span":"5dc17cb8f6d7dac6","trace":"8fe7a7f9ea43c195af97e64ecbafe2ab"} -{"@timestamp":"2026-06-26T08:03:44.094+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.2ms","level":"info","span":"5dc17cb8f6d7dac6","trace":"8fe7a7f9ea43c195af97e64ecbafe2ab"} -{"@timestamp":"2026-06-26T08:03:49.143+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.4ms)","duration":"2045.4ms","level":"slow","span":"7069114e3ce27293","trace":"9c6da244ade9d4205e1f8d7dbee92738"} -{"@timestamp":"2026-06-26T08:03:49.143+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.4ms","level":"info","span":"7069114e3ce27293","trace":"9c6da244ade9d4205e1f8d7dbee92738"} -{"@timestamp":"2026-06-26T08:03:54.217+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.6ms)","duration":"2069.6ms","level":"slow","span":"3350a9f9d044c88d","trace":"0404b024d2fbef3a11b6224f50789b3c"} -{"@timestamp":"2026-06-26T08:03:54.218+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.6ms","level":"info","span":"3350a9f9d044c88d","trace":"0404b024d2fbef3a11b6224f50789b3c"} -{"@timestamp":"2026-06-26T08:03:59.281+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.0ms)","duration":"2060.0ms","level":"slow","span":"6f10f474e48a4432","trace":"3efb4ee42eaa0870c922992292f63003"} -{"@timestamp":"2026-06-26T08:03:59.281+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.0ms","level":"info","span":"6f10f474e48a4432","trace":"3efb4ee42eaa0870c922992292f63003"} -{"@timestamp":"2026-06-26T08:03:59.825+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=72.3Mi, Sys=19.6Mi, NumGC=40","level":"stat"} -{"@timestamp":"2026-06-26T08:03:59.842+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:04:02.456+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2049.8ms, med: 2046.8ms, 90th: 2075.5ms, 99th: 2075.5ms, 99.9th: 2075.5ms","level":"stat"} -{"@timestamp":"2026-06-26T08:04:04.348+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2064.9ms)","duration":"2064.9ms","level":"slow","span":"b8450e00566cac97","trace":"6cfe03f54e4e461b4cf8f88837632613"} -{"@timestamp":"2026-06-26T08:04:04.348+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2064.9ms","level":"info","span":"b8450e00566cac97","trace":"6cfe03f54e4e461b4cf8f88837632613"} -{"@timestamp":"2026-06-26T08:04:09.402+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.1ms)","duration":"2052.1ms","level":"slow","span":"2002aaa171dd85de","trace":"f1f0ca12a312626a151f7df57f0b61b4"} -{"@timestamp":"2026-06-26T08:04:09.402+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.1ms","level":"info","span":"2002aaa171dd85de","trace":"f1f0ca12a312626a151f7df57f0b61b4"} -{"@timestamp":"2026-06-26T08:04:14.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"c05c649ad6768a70","trace":"427976394a8dee771f171a945c929d64"} -{"@timestamp":"2026-06-26T08:04:14.436+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"c05c649ad6768a70","trace":"427976394a8dee771f171a945c929d64"} -{"@timestamp":"2026-06-26T08:04:19.471+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.5ms)","duration":"2031.5ms","level":"slow","span":"c686c5d0abb078e1","trace":"76bfcb761ff2eca32a7a85d0b7704944"} -{"@timestamp":"2026-06-26T08:04:19.471+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.5ms","level":"info","span":"c686c5d0abb078e1","trace":"76bfcb761ff2eca32a7a85d0b7704944"} -{"@timestamp":"2026-06-26T08:04:24.549+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.1ms)","duration":"2073.1ms","level":"slow","span":"c13ab8a1ad10a585","trace":"8f58cd16cd7606cd52fff6edb0a1e3f1"} -{"@timestamp":"2026-06-26T08:04:24.549+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.1ms","level":"info","span":"c13ab8a1ad10a585","trace":"8f58cd16cd7606cd52fff6edb0a1e3f1"} -{"@timestamp":"2026-06-26T08:04:29.615+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.1ms)","duration":"2060.1ms","level":"slow","span":"8b8fc11df04b43c6","trace":"61302fe459a20e7d59a5421b77538aed"} -{"@timestamp":"2026-06-26T08:04:29.615+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.1ms","level":"info","span":"8b8fc11df04b43c6","trace":"61302fe459a20e7d59a5421b77538aed"} -{"@timestamp":"2026-06-26T08:04:34.686+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.1ms)","duration":"2066.1ms","level":"slow","span":"0f21af9d8be04168","trace":"612af32a54c8aba55492e41b76264d26"} -{"@timestamp":"2026-06-26T08:04:34.686+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.1ms","level":"info","span":"0f21af9d8be04168","trace":"612af32a54c8aba55492e41b76264d26"} -{"@timestamp":"2026-06-26T08:04:39.735+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.7ms)","duration":"2045.7ms","level":"slow","span":"3baeb7e16153b3e1","trace":"fcea3218062169da719f6ed084134b4b"} -{"@timestamp":"2026-06-26T08:04:39.735+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.7ms","level":"info","span":"3baeb7e16153b3e1","trace":"fcea3218062169da719f6ed084134b4b"} -{"@timestamp":"2026-06-26T08:04:44.785+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.2ms)","duration":"2046.2ms","level":"slow","span":"ad72f87a282d0b0a","trace":"3e9c657faf9e8d396610dde151b68511"} -{"@timestamp":"2026-06-26T08:04:44.785+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.2ms","level":"info","span":"ad72f87a282d0b0a","trace":"3e9c657faf9e8d396610dde151b68511"} -{"@timestamp":"2026-06-26T08:04:49.815+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.2ms)","duration":"2027.2ms","level":"slow","span":"b171146dc14fedc3","trace":"090520e2797749b7c499569c948c5eac"} -{"@timestamp":"2026-06-26T08:04:49.815+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.2ms","level":"info","span":"b171146dc14fedc3","trace":"090520e2797749b7c499569c948c5eac"} -{"@timestamp":"2026-06-26T08:04:54.881+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.1ms)","duration":"2063.1ms","level":"slow","span":"140597be24108d25","trace":"2f2bee01f85577649e908c12629e064c"} -{"@timestamp":"2026-06-26T08:04:54.881+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.1ms","level":"info","span":"140597be24108d25","trace":"2f2bee01f85577649e908c12629e064c"} -{"@timestamp":"2026-06-26T08:04:59.824+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=72.8Mi, Sys=19.6Mi, NumGC=41","level":"stat"} -{"@timestamp":"2026-06-26T08:04:59.841+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:04:59.936+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.9ms)","duration":"2052.9ms","level":"slow","span":"ad70f2cd1894dc5c","trace":"4b40862613cbaad0a90c4a166f0efe0a"} -{"@timestamp":"2026-06-26T08:04:59.936+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.9ms","level":"info","span":"ad70f2cd1894dc5c","trace":"4b40862613cbaad0a90c4a166f0efe0a"} -{"@timestamp":"2026-06-26T08:05:02.455+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2051.0ms, med: 2052.8ms, 90th: 2073.0ms, 99th: 2073.0ms, 99.9th: 2073.0ms","level":"stat"} -{"@timestamp":"2026-06-26T08:05:04.978+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.6ms)","duration":"2040.6ms","level":"slow","span":"649797bf07a8d418","trace":"8c60ea54ce3d93afdec3fda088c62425"} -{"@timestamp":"2026-06-26T08:05:04.978+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.6ms","level":"info","span":"649797bf07a8d418","trace":"8c60ea54ce3d93afdec3fda088c62425"} -{"@timestamp":"2026-06-26T08:05:10.036+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.8ms)","duration":"2055.8ms","level":"slow","span":"db9126f70d9093df","trace":"d6646d4030afeef6c1a796c51d753195"} -{"@timestamp":"2026-06-26T08:05:10.036+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.8ms","level":"info","span":"db9126f70d9093df","trace":"d6646d4030afeef6c1a796c51d753195"} -{"@timestamp":"2026-06-26T08:05:15.078+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.7ms)","duration":"2040.7ms","level":"slow","span":"0c3a1acf78a612df","trace":"b54aef5bf21b0b723f60d2a0a3fb2ca3"} -{"@timestamp":"2026-06-26T08:05:15.079+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.7ms","level":"info","span":"0c3a1acf78a612df","trace":"b54aef5bf21b0b723f60d2a0a3fb2ca3"} -{"@timestamp":"2026-06-26T08:05:20.098+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.1ms)","duration":"2017.1ms","level":"slow","span":"b88b62369f5e3981","trace":"633d7941e1c024fd15da048a6abd3c24"} -{"@timestamp":"2026-06-26T08:05:20.098+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.1ms","level":"info","span":"b88b62369f5e3981","trace":"633d7941e1c024fd15da048a6abd3c24"} -{"@timestamp":"2026-06-26T08:05:25.150+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.3ms)","duration":"2048.3ms","level":"slow","span":"0cb33a1256c25d2e","trace":"6b93172e9392ba5e68c3b5d59c960723"} -{"@timestamp":"2026-06-26T08:05:25.150+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.3ms","level":"info","span":"0cb33a1256c25d2e","trace":"6b93172e9392ba5e68c3b5d59c960723"} -{"@timestamp":"2026-06-26T08:05:30.206+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.1ms)","duration":"2053.1ms","level":"slow","span":"537f1868ce5a8b0c","trace":"d092995ae0b0406fbf6a50d3dd4bbd30"} -{"@timestamp":"2026-06-26T08:05:30.206+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.1ms","level":"info","span":"537f1868ce5a8b0c","trace":"d092995ae0b0406fbf6a50d3dd4bbd30"} -{"@timestamp":"2026-06-26T08:05:35.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.2ms)","duration":"2041.2ms","level":"slow","span":"aece937e047fa054","trace":"a4b4a6a5280810f89aec53283f9d6162"} -{"@timestamp":"2026-06-26T08:05:35.249+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.2ms","level":"info","span":"aece937e047fa054","trace":"a4b4a6a5280810f89aec53283f9d6162"} -{"@timestamp":"2026-06-26T08:05:40.305+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.8ms)","duration":"2053.8ms","level":"slow","span":"7b790dafec185fd2","trace":"a89f4855076f2dd8e4adb1f934051d4c"} -{"@timestamp":"2026-06-26T08:05:40.305+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.8ms","level":"info","span":"7b790dafec185fd2","trace":"a89f4855076f2dd8e4adb1f934051d4c"} -{"@timestamp":"2026-06-26T08:05:45.359+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.7ms)","duration":"2051.7ms","level":"slow","span":"adc8194ca59de677","trace":"e0873e90c2cd5c5c3c0a32711a12f1da"} -{"@timestamp":"2026-06-26T08:05:45.359+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.7ms","level":"info","span":"adc8194ca59de677","trace":"e0873e90c2cd5c5c3c0a32711a12f1da"} -{"@timestamp":"2026-06-26T08:05:50.413+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.7ms)","duration":"2050.7ms","level":"slow","span":"157e0923be0688b9","trace":"384e111edb5c826d526a1cf25c543cb2"} -{"@timestamp":"2026-06-26T08:05:50.413+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.7ms","level":"info","span":"157e0923be0688b9","trace":"384e111edb5c826d526a1cf25c543cb2"} -{"@timestamp":"2026-06-26T08:05:55.455+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.3ms)","duration":"2038.3ms","level":"slow","span":"85ff31ffc1797005","trace":"63735b5d24ee87d5d9930b880c8e54c6"} -{"@timestamp":"2026-06-26T08:05:55.455+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.3ms","level":"info","span":"85ff31ffc1797005","trace":"63735b5d24ee87d5d9930b880c8e54c6"} -{"@timestamp":"2026-06-26T08:05:59.824+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=73.0Mi, Sys=19.6Mi, NumGC=41","level":"stat"} -{"@timestamp":"2026-06-26T08:05:59.840+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:06:00.502+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.0ms)","duration":"2043.0ms","level":"slow","span":"6a9e1f7359638a41","trace":"c95ec87cedec00a7ac1b5e2204bb78f2"} -{"@timestamp":"2026-06-26T08:06:00.502+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.0ms","level":"info","span":"6a9e1f7359638a41","trace":"c95ec87cedec00a7ac1b5e2204bb78f2"} -{"@timestamp":"2026-06-26T08:06:02.455+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.4ms, med: 2048.1ms, 90th: 2055.7ms, 99th: 2055.7ms, 99.9th: 2055.7ms","level":"stat"} -{"@timestamp":"2026-06-26T08:06:05.560+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.4ms)","duration":"2055.4ms","level":"slow","span":"2e1ffcf95d995d4b","trace":"9567435ea6d13ffe1e7503f9b3bf56f6"} -{"@timestamp":"2026-06-26T08:06:05.560+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.4ms","level":"info","span":"2e1ffcf95d995d4b","trace":"9567435ea6d13ffe1e7503f9b3bf56f6"} -{"@timestamp":"2026-06-26T08:06:10.587+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"20cc6b94da2c04fb","trace":"fb345ba50aa46ac4115fd2bbcd2512a8"} -{"@timestamp":"2026-06-26T08:06:10.587+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"20cc6b94da2c04fb","trace":"fb345ba50aa46ac4115fd2bbcd2512a8"} -{"@timestamp":"2026-06-26T08:06:15.631+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.0ms)","duration":"2040.0ms","level":"slow","span":"c9687d48b00a1877","trace":"41fdb05ae6a5f7a2471e83da699f7b7e"} -{"@timestamp":"2026-06-26T08:06:15.631+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.0ms","level":"info","span":"c9687d48b00a1877","trace":"41fdb05ae6a5f7a2471e83da699f7b7e"} -{"@timestamp":"2026-06-26T08:06:20.693+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.8ms)","duration":"2058.8ms","level":"slow","span":"e9040e5ca4f3e0be","trace":"585050738287c8e9caa2dce1a63a8947"} -{"@timestamp":"2026-06-26T08:06:20.693+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.8ms","level":"info","span":"e9040e5ca4f3e0be","trace":"585050738287c8e9caa2dce1a63a8947"} -{"@timestamp":"2026-06-26T08:06:25.753+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.0ms)","duration":"2057.0ms","level":"slow","span":"765240942ad8cd65","trace":"d9ec776223e1a70a3adc485b69c59a57"} -{"@timestamp":"2026-06-26T08:06:25.754+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.0ms","level":"info","span":"765240942ad8cd65","trace":"d9ec776223e1a70a3adc485b69c59a57"} -{"@timestamp":"2026-06-26T08:06:30.814+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.1ms)","duration":"2059.1ms","level":"slow","span":"e30094d7006c86b0","trace":"1083cdc6c3ce522b3798c4a7524b2ce8"} -{"@timestamp":"2026-06-26T08:06:30.815+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.1ms","level":"info","span":"e30094d7006c86b0","trace":"1083cdc6c3ce522b3798c4a7524b2ce8"} -{"@timestamp":"2026-06-26T08:06:35.887+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.5ms)","duration":"2069.5ms","level":"slow","span":"6224ff851a295ad1","trace":"5ba2560eea1fd728c9017e36d41cdccf"} -{"@timestamp":"2026-06-26T08:06:35.888+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.5ms","level":"info","span":"6224ff851a295ad1","trace":"5ba2560eea1fd728c9017e36d41cdccf"} -{"@timestamp":"2026-06-26T08:06:40.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.7ms)","duration":"2041.7ms","level":"slow","span":"2a9d4949547ce2b8","trace":"8a483bcafb129dec7b7e2ee57ea5677e"} -{"@timestamp":"2026-06-26T08:06:40.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.7ms","level":"info","span":"2a9d4949547ce2b8","trace":"8a483bcafb129dec7b7e2ee57ea5677e"} -{"@timestamp":"2026-06-26T08:06:45.973+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.6ms)","duration":"2036.6ms","level":"slow","span":"2c2c8b3441bf1694","trace":"63c26f172b507973eb000d76a94ad6b4"} -{"@timestamp":"2026-06-26T08:06:45.973+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.6ms","level":"info","span":"2c2c8b3441bf1694","trace":"63c26f172b507973eb000d76a94ad6b4"} -{"@timestamp":"2026-06-26T08:06:51.033+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.8ms)","duration":"2056.8ms","level":"slow","span":"bfe9de318373ad1b","trace":"23a5783cf195e565585bc1bec07f1f39"} -{"@timestamp":"2026-06-26T08:06:51.033+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.8ms","level":"info","span":"bfe9de318373ad1b","trace":"23a5783cf195e565585bc1bec07f1f39"} -{"@timestamp":"2026-06-26T08:06:56.041+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.6ms)","duration":"2004.6ms","level":"slow","span":"fff43aee5a524f61","trace":"fa14418e5e7640d2968094382401bc83"} -{"@timestamp":"2026-06-26T08:06:56.041+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.6ms","level":"info","span":"fff43aee5a524f61","trace":"fa14418e5e7640d2968094382401bc83"} -{"@timestamp":"2026-06-26T08:06:59.823+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=73.5Mi, Sys=19.6Mi, NumGC=42","level":"stat"} -{"@timestamp":"2026-06-26T08:06:59.840+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:07:01.111+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.6ms)","duration":"2066.6ms","level":"slow","span":"6fbb0b0f65e33fa2","trace":"b8eb818947508510b80c1d38a11e9807"} -{"@timestamp":"2026-06-26T08:07:01.111+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.6ms","level":"info","span":"6fbb0b0f65e33fa2","trace":"b8eb818947508510b80c1d38a11e9807"} -{"@timestamp":"2026-06-26T08:07:02.453+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2047.2ms, med: 2056.7ms, 90th: 2069.4ms, 99th: 2069.4ms, 99.9th: 2069.4ms","level":"stat"} -{"@timestamp":"2026-06-26T08:07:06.144+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.3ms)","duration":"2029.3ms","level":"slow","span":"8bc6240b994b59f4","trace":"493b94baafb424fb1f9d44ca735f5d8a"} -{"@timestamp":"2026-06-26T08:07:06.144+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.3ms","level":"info","span":"8bc6240b994b59f4","trace":"493b94baafb424fb1f9d44ca735f5d8a"} -{"@timestamp":"2026-06-26T08:07:11.245+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2097.5ms)","duration":"2097.5ms","level":"slow","span":"1d9588cf77e74643","trace":"56ef18200cf9319cdd9ff73d703265c4"} -{"@timestamp":"2026-06-26T08:07:11.245+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2097.5ms","level":"info","span":"1d9588cf77e74643","trace":"56ef18200cf9319cdd9ff73d703265c4"} -{"@timestamp":"2026-06-26T08:07:16.295+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.1ms)","duration":"2047.1ms","level":"slow","span":"331f0d2809e2715d","trace":"47089706ef52f2234f15c9707478d3d3"} -{"@timestamp":"2026-06-26T08:07:16.295+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.1ms","level":"info","span":"331f0d2809e2715d","trace":"47089706ef52f2234f15c9707478d3d3"} -{"@timestamp":"2026-06-26T08:07:21.329+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.6ms)","duration":"2030.6ms","level":"slow","span":"bec07c46883388dd","trace":"3da492574e480e836950e94ec7c2362b"} -{"@timestamp":"2026-06-26T08:07:21.329+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.6ms","level":"info","span":"bec07c46883388dd","trace":"3da492574e480e836950e94ec7c2362b"} -{"@timestamp":"2026-06-26T08:07:26.376+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.0ms)","duration":"2044.0ms","level":"slow","span":"17b9c35d214a4d4a","trace":"21385ee7136f096469f16402bd63dda7"} -{"@timestamp":"2026-06-26T08:07:26.376+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.0ms","level":"info","span":"17b9c35d214a4d4a","trace":"21385ee7136f096469f16402bd63dda7"} -{"@timestamp":"2026-06-26T08:07:31.403+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.6ms)","duration":"2023.6ms","level":"slow","span":"b12c71caaf24ece3","trace":"7bffbf786fa455eb17903eb4a644e236"} -{"@timestamp":"2026-06-26T08:07:31.403+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.6ms","level":"info","span":"b12c71caaf24ece3","trace":"7bffbf786fa455eb17903eb4a644e236"} -{"@timestamp":"2026-06-26T08:07:36.451+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.5ms)","duration":"2044.5ms","level":"slow","span":"d87719ebfaba155c","trace":"7be5f77327a5a972b552893569d01651"} -{"@timestamp":"2026-06-26T08:07:36.451+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.5ms","level":"info","span":"d87719ebfaba155c","trace":"7be5f77327a5a972b552893569d01651"} -{"@timestamp":"2026-06-26T08:07:41.512+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.5ms)","duration":"2057.5ms","level":"slow","span":"6120b143d364c86b","trace":"31b0b035e06d491f2edd531c2b51c67c"} -{"@timestamp":"2026-06-26T08:07:41.512+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.5ms","level":"info","span":"6120b143d364c86b","trace":"31b0b035e06d491f2edd531c2b51c67c"} -{"@timestamp":"2026-06-26T08:07:46.559+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.3ms)","duration":"2044.3ms","level":"slow","span":"08fe8b50e8cfe87e","trace":"13e363d05cc6a6b2b8350b843d70d199"} -{"@timestamp":"2026-06-26T08:07:46.559+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.3ms","level":"info","span":"08fe8b50e8cfe87e","trace":"13e363d05cc6a6b2b8350b843d70d199"} -{"@timestamp":"2026-06-26T08:07:51.610+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.6ms)","duration":"2046.6ms","level":"slow","span":"ba504743568f5005","trace":"943de084107f59230324f8d20ce0774d"} -{"@timestamp":"2026-06-26T08:07:51.610+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.6ms","level":"info","span":"ba504743568f5005","trace":"943de084107f59230324f8d20ce0774d"} -{"@timestamp":"2026-06-26T08:07:56.648+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.6ms)","duration":"2034.6ms","level":"slow","span":"fdb4e63bc19eb672","trace":"4e63b6ab5ffc68e5dac6a1246661238a"} -{"@timestamp":"2026-06-26T08:07:56.648+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.6ms","level":"info","span":"fdb4e63bc19eb672","trace":"4e63b6ab5ffc68e5dac6a1246661238a"} -{"@timestamp":"2026-06-26T08:07:59.822+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=73.8Mi, Sys=19.6Mi, NumGC=42","level":"stat"} -{"@timestamp":"2026-06-26T08:07:59.839+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:08:01.687+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.5ms)","duration":"2035.5ms","level":"slow","span":"bb1eae79252bec5f","trace":"8e41794e193dbbb02ae30744a3e96f53"} -{"@timestamp":"2026-06-26T08:08:01.687+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.5ms","level":"info","span":"bb1eae79252bec5f","trace":"8e41794e193dbbb02ae30744a3e96f53"} -{"@timestamp":"2026-06-26T08:08:02.453+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.5ms, med: 2044.2ms, 90th: 2097.4ms, 99th: 2097.4ms, 99.9th: 2097.4ms","level":"stat"} -{"@timestamp":"2026-06-26T08:08:06.748+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.9ms)","duration":"2057.9ms","level":"slow","span":"f28bbbab8c410564","trace":"96a2ea2e4903066b7ef148b70f6cec28"} -{"@timestamp":"2026-06-26T08:08:06.748+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.9ms","level":"info","span":"f28bbbab8c410564","trace":"96a2ea2e4903066b7ef148b70f6cec28"} -{"@timestamp":"2026-06-26T08:08:11.778+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"543510e9687be804","trace":"127ee8314953ec1b2ebe36d8e67f6ea2"} -{"@timestamp":"2026-06-26T08:08:11.778+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"543510e9687be804","trace":"127ee8314953ec1b2ebe36d8e67f6ea2"} -{"@timestamp":"2026-06-26T08:08:16.790+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.1ms)","duration":"2008.1ms","level":"slow","span":"9c40199b19ca0191","trace":"be1d5e0f16555d4288382fc286064bb3"} -{"@timestamp":"2026-06-26T08:08:16.790+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.1ms","level":"info","span":"9c40199b19ca0191","trace":"be1d5e0f16555d4288382fc286064bb3"} -{"@timestamp":"2026-06-26T08:08:21.836+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.3ms)","duration":"2042.3ms","level":"slow","span":"4ebc9a9a382a5bd7","trace":"cd465a67564c039011ee20b46d2fcf79"} -{"@timestamp":"2026-06-26T08:08:21.836+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.3ms","level":"info","span":"4ebc9a9a382a5bd7","trace":"cd465a67564c039011ee20b46d2fcf79"} -{"@timestamp":"2026-06-26T08:08:26.873+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.3ms)","duration":"2034.3ms","level":"slow","span":"3cef22feb9fd1416","trace":"fc6be8046d34b9b2ced8239e0c568288"} -{"@timestamp":"2026-06-26T08:08:26.874+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.3ms","level":"info","span":"3cef22feb9fd1416","trace":"fc6be8046d34b9b2ced8239e0c568288"} -{"@timestamp":"2026-06-26T08:08:31.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.0ms)","duration":"2035.0ms","level":"slow","span":"95f07bc5766fe0b3","trace":"e73159697f3faf4974997794fa44eee1"} -{"@timestamp":"2026-06-26T08:08:31.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.0ms","level":"info","span":"95f07bc5766fe0b3","trace":"e73159697f3faf4974997794fa44eee1"} -{"@timestamp":"2026-06-26T08:08:36.938+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.2ms)","duration":"2022.2ms","level":"slow","span":"53fab1597f99ad52","trace":"1bf370d83a4b797dceef9d67b53d4114"} -{"@timestamp":"2026-06-26T08:08:36.938+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.2ms","level":"info","span":"53fab1597f99ad52","trace":"1bf370d83a4b797dceef9d67b53d4114"} -{"@timestamp":"2026-06-26T08:08:42.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.1ms)","duration":"2076.1ms","level":"slow","span":"67923a5f590cd5b4","trace":"b7ea575d60b62b04ecacd1d2faffd6c0"} -{"@timestamp":"2026-06-26T08:08:42.016+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.1ms","level":"info","span":"67923a5f590cd5b4","trace":"b7ea575d60b62b04ecacd1d2faffd6c0"} -{"@timestamp":"2026-06-26T08:08:47.061+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.3ms)","duration":"2042.3ms","level":"slow","span":"6bcffb7e6c1e747c","trace":"50a11b61ed0fba1c800b57e56b3b20a4"} -{"@timestamp":"2026-06-26T08:08:47.061+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.3ms","level":"info","span":"6bcffb7e6c1e747c","trace":"50a11b61ed0fba1c800b57e56b3b20a4"} -{"@timestamp":"2026-06-26T08:08:52.128+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.2ms)","duration":"2063.2ms","level":"slow","span":"078ec80fa4359c64","trace":"57ccb3cc814a9de2d844378e9bbaec92"} -{"@timestamp":"2026-06-26T08:08:52.128+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.2ms","level":"info","span":"078ec80fa4359c64","trace":"57ccb3cc814a9de2d844378e9bbaec92"} -{"@timestamp":"2026-06-26T08:08:57.153+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.9ms)","duration":"2021.9ms","level":"slow","span":"225ae14da79029f3","trace":"fd5cbb9efe60ab1d624533ab1a4a1f38"} -{"@timestamp":"2026-06-26T08:08:57.153+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.9ms","level":"info","span":"225ae14da79029f3","trace":"fd5cbb9efe60ab1d624533ab1a4a1f38"} -{"@timestamp":"2026-06-26T08:08:59.822+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=74.3Mi, Sys=19.6Mi, NumGC=43","level":"stat"} -{"@timestamp":"2026-06-26T08:08:59.839+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:09:02.203+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.8ms)","duration":"2045.8ms","level":"slow","span":"01d7149c9c8367d6","trace":"750454c5f2070018974ffe4c46c1d409"} -{"@timestamp":"2026-06-26T08:09:02.203+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.8ms","level":"info","span":"01d7149c9c8367d6","trace":"750454c5f2070018974ffe4c46c1d409"} -{"@timestamp":"2026-06-26T08:09:02.452+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2039.2ms, med: 2042.2ms, 90th: 2076.0ms, 99th: 2076.0ms, 99.9th: 2076.0ms","level":"stat"} -{"@timestamp":"2026-06-26T08:09:07.223+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.0ms)","duration":"2017.0ms","level":"slow","span":"1b29735426ec7234","trace":"527fc39718e7179e2ebb905ca7b69a01"} -{"@timestamp":"2026-06-26T08:09:07.223+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.0ms","level":"info","span":"1b29735426ec7234","trace":"527fc39718e7179e2ebb905ca7b69a01"} -{"@timestamp":"2026-06-26T08:09:12.258+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.5ms)","duration":"2032.5ms","level":"slow","span":"190b1d93ae785cbd","trace":"58e35636229ac1b6b77d89ba5c496166"} -{"@timestamp":"2026-06-26T08:09:12.258+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.5ms","level":"info","span":"190b1d93ae785cbd","trace":"58e35636229ac1b6b77d89ba5c496166"} -{"@timestamp":"2026-06-26T08:09:17.320+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.1ms)","duration":"2058.1ms","level":"slow","span":"ed2674f3dbfe6062","trace":"feb54eb72e618d01a705008e08df8bc4"} -{"@timestamp":"2026-06-26T08:09:17.320+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.1ms","level":"info","span":"ed2674f3dbfe6062","trace":"feb54eb72e618d01a705008e08df8bc4"} -{"@timestamp":"2026-06-26T08:09:22.394+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.7ms)","duration":"2070.7ms","level":"slow","span":"63d431ed8bea16c1","trace":"9b99b4abfe735d2ed57d513fc396158a"} -{"@timestamp":"2026-06-26T08:09:22.394+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.7ms","level":"info","span":"63d431ed8bea16c1","trace":"9b99b4abfe735d2ed57d513fc396158a"} -{"@timestamp":"2026-06-26T08:09:27.425+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.5ms)","duration":"2028.5ms","level":"slow","span":"985340e312e713c1","trace":"5ec8138bad3fb3f1083848d8578f1c55"} -{"@timestamp":"2026-06-26T08:09:27.425+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.5ms","level":"info","span":"985340e312e713c1","trace":"5ec8138bad3fb3f1083848d8578f1c55"} -{"@timestamp":"2026-06-26T08:09:32.483+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.5ms)","duration":"2054.5ms","level":"slow","span":"75933b4bbb582ed9","trace":"749d57199cd8ac71997f77cc72bc1612"} -{"@timestamp":"2026-06-26T08:09:32.483+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.5ms","level":"info","span":"75933b4bbb582ed9","trace":"749d57199cd8ac71997f77cc72bc1612"} -{"@timestamp":"2026-06-26T08:09:37.510+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.5ms)","duration":"2023.5ms","level":"slow","span":"1b86503b452cebbe","trace":"556ed094ed620f4097c1733bdc85a3b3"} -{"@timestamp":"2026-06-26T08:09:37.510+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.5ms","level":"info","span":"1b86503b452cebbe","trace":"556ed094ed620f4097c1733bdc85a3b3"} -{"@timestamp":"2026-06-26T08:09:42.576+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"2734167759b93ccc","trace":"c6fe03aaa57a81bf10e5dcc56405930d"} -{"@timestamp":"2026-06-26T08:09:42.576+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"2734167759b93ccc","trace":"c6fe03aaa57a81bf10e5dcc56405930d"} -{"@timestamp":"2026-06-26T08:09:47.595+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.2ms)","duration":"2016.2ms","level":"slow","span":"065b7262225ff87f","trace":"a7f35d626251d038310597077f1785a4"} -{"@timestamp":"2026-06-26T08:09:47.595+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.2ms","level":"info","span":"065b7262225ff87f","trace":"a7f35d626251d038310597077f1785a4"} -{"@timestamp":"2026-06-26T08:09:52.647+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.4ms)","duration":"2048.4ms","level":"slow","span":"3e9b4e13c0f6dfc2","trace":"cb28da1f51c72118f46e674f28bdb732"} -{"@timestamp":"2026-06-26T08:09:52.647+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.4ms","level":"info","span":"3e9b4e13c0f6dfc2","trace":"cb28da1f51c72118f46e674f28bdb732"} -{"@timestamp":"2026-06-26T08:09:57.741+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2092.4ms)","duration":"2092.4ms","level":"slow","span":"e9047c55df19513c","trace":"afe6ae366d96ca947c50c1e413b6d585"} -{"@timestamp":"2026-06-26T08:09:57.742+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2092.4ms","level":"info","span":"e9047c55df19513c","trace":"afe6ae366d96ca947c50c1e413b6d585"} -{"@timestamp":"2026-06-26T08:09:59.821+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=74.5Mi, Sys=19.6Mi, NumGC=43","level":"stat"} -{"@timestamp":"2026-06-26T08:09:59.838+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:10:02.452+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2045.7ms, med: 2054.4ms, 90th: 2092.3ms, 99th: 2092.3ms, 99.9th: 2092.3ms","level":"stat"} -{"@timestamp":"2026-06-26T08:10:02.795+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.2ms)","duration":"2048.2ms","level":"slow","span":"da6e73471cfe3f16","trace":"2800deb562fb39f4a8b16e8fe5334ccc"} -{"@timestamp":"2026-06-26T08:10:02.795+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.2ms","level":"info","span":"da6e73471cfe3f16","trace":"2800deb562fb39f4a8b16e8fe5334ccc"} -{"@timestamp":"2026-06-26T08:10:07.846+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.8ms)","duration":"2047.8ms","level":"slow","span":"d979c5725dd7b4b3","trace":"05639994871082309c064ee345b51d22"} -{"@timestamp":"2026-06-26T08:10:07.846+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.8ms","level":"info","span":"d979c5725dd7b4b3","trace":"05639994871082309c064ee345b51d22"} -{"@timestamp":"2026-06-26T08:10:12.905+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.2ms)","duration":"2056.2ms","level":"slow","span":"d511a646d6502250","trace":"dea3b5d6ec4bf6a243bbb948b89827a4"} -{"@timestamp":"2026-06-26T08:10:12.905+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.2ms","level":"info","span":"d511a646d6502250","trace":"dea3b5d6ec4bf6a243bbb948b89827a4"} -{"@timestamp":"2026-06-26T08:10:17.961+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.5ms)","duration":"2053.5ms","level":"slow","span":"3b45743587efa2b8","trace":"9b13f278e0f69d6ea9dde136920ee8ab"} -{"@timestamp":"2026-06-26T08:10:17.961+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.5ms","level":"info","span":"3b45743587efa2b8","trace":"9b13f278e0f69d6ea9dde136920ee8ab"} -{"@timestamp":"2026-06-26T08:10:22.990+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.1ms)","duration":"2027.1ms","level":"slow","span":"b3610d0824d1ac47","trace":"adbf1289262821f9d4e7caf3335e0af7"} -{"@timestamp":"2026-06-26T08:10:22.991+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.1ms","level":"info","span":"b3610d0824d1ac47","trace":"adbf1289262821f9d4e7caf3335e0af7"} -{"@timestamp":"2026-06-26T08:10:28.062+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.5ms)","duration":"2067.5ms","level":"slow","span":"84ecfee11c890a62","trace":"ad750b0651514b1d76d46fe12cd90b70"} -{"@timestamp":"2026-06-26T08:10:28.062+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.5ms","level":"info","span":"84ecfee11c890a62","trace":"ad750b0651514b1d76d46fe12cd90b70"} -{"@timestamp":"2026-06-26T08:10:33.106+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.5ms)","duration":"2041.5ms","level":"slow","span":"bdcec73455274d38","trace":"704c575953570edc89f23efa3779e9d6"} -{"@timestamp":"2026-06-26T08:10:33.107+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.5ms","level":"info","span":"bdcec73455274d38","trace":"704c575953570edc89f23efa3779e9d6"} -{"@timestamp":"2026-06-26T08:10:38.143+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.6ms)","duration":"2032.6ms","level":"slow","span":"53819c9c6080e1be","trace":"46452b8f18d776c0bd2727779af3c194"} -{"@timestamp":"2026-06-26T08:10:38.143+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.6ms","level":"info","span":"53819c9c6080e1be","trace":"46452b8f18d776c0bd2727779af3c194"} -{"@timestamp":"2026-06-26T08:10:43.185+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.7ms)","duration":"2038.7ms","level":"slow","span":"29e086dc76c067ac","trace":"49cfe54deec57d6b4ac23a5d822bc4e6"} -{"@timestamp":"2026-06-26T08:10:43.186+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.7ms","level":"info","span":"29e086dc76c067ac","trace":"49cfe54deec57d6b4ac23a5d822bc4e6"} -{"@timestamp":"2026-06-26T08:10:48.251+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.6ms)","duration":"2062.6ms","level":"slow","span":"ec84523bb08cb341","trace":"da40763716d08099320ebf267a9a6464"} -{"@timestamp":"2026-06-26T08:10:48.252+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.6ms","level":"info","span":"ec84523bb08cb341","trace":"da40763716d08099320ebf267a9a6464"} -{"@timestamp":"2026-06-26T08:10:53.324+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.6ms)","duration":"2069.6ms","level":"slow","span":"fa497a99b586d58c","trace":"335e494b43d6dbfdbe2fde16736ae4ed"} -{"@timestamp":"2026-06-26T08:10:53.324+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.6ms","level":"info","span":"fa497a99b586d58c","trace":"335e494b43d6dbfdbe2fde16736ae4ed"} -{"@timestamp":"2026-06-26T08:10:58.369+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.7ms)","duration":"2041.7ms","level":"slow","span":"c9f2367cdf2b4e18","trace":"b01b127ab465d6aefd0814d844e246c5"} -{"@timestamp":"2026-06-26T08:10:58.370+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.7ms","level":"info","span":"c9f2367cdf2b4e18","trace":"b01b127ab465d6aefd0814d844e246c5"} -{"@timestamp":"2026-06-26T08:10:59.821+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=74.9Mi, Sys=19.6Mi, NumGC=44","level":"stat"} -{"@timestamp":"2026-06-26T08:10:59.838+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:11:12.182+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2048.8ms, med: 2048.2ms, 90th: 2069.5ms, 99th: 2069.5ms, 99.9th: 2069.5ms","level":"stat"} -{"@timestamp":"2026-06-26T08:11:13.137+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(11764.0ms)","duration":"11764.0ms","level":"slow","span":"765349062a528c4a","trace":"e215ac4281650b30f355d7235cc4b555"} -{"@timestamp":"2026-06-26T08:11:13.137+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"11764.0ms","level":"info","span":"765349062a528c4a","trace":"e215ac4281650b30f355d7235cc4b555"} -{"@timestamp":"2026-06-26T08:11:18.235+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2092.7ms)","duration":"2092.7ms","level":"slow","span":"29ff10d18f6063b3","trace":"03b6736725145ab41e8505e8214d803f"} -{"@timestamp":"2026-06-26T08:11:18.235+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2092.7ms","level":"info","span":"29ff10d18f6063b3","trace":"03b6736725145ab41e8505e8214d803f"} -{"@timestamp":"2026-06-26T08:11:23.287+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.6ms)","duration":"2047.6ms","level":"slow","span":"cbff373590b8f309","trace":"251b7ddba94c930484d27292a1b57717"} -{"@timestamp":"2026-06-26T08:11:23.287+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.6ms","level":"info","span":"cbff373590b8f309","trace":"251b7ddba94c930484d27292a1b57717"} -{"@timestamp":"2026-06-26T08:11:28.303+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.1ms)","duration":"2010.1ms","level":"slow","span":"9f9c7f52e87ed8cc","trace":"836911065c89114893086e100621ae78"} -{"@timestamp":"2026-06-26T08:11:28.303+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.1ms","level":"info","span":"9f9c7f52e87ed8cc","trace":"836911065c89114893086e100621ae78"} -{"@timestamp":"2026-06-26T08:11:33.379+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.0ms)","duration":"2071.0ms","level":"slow","span":"1a43b8a5efa968ce","trace":"833eed8da8a2d38d9c8079f854ed98ad"} -{"@timestamp":"2026-06-26T08:11:33.379+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.0ms","level":"info","span":"1a43b8a5efa968ce","trace":"833eed8da8a2d38d9c8079f854ed98ad"} -{"@timestamp":"2026-06-26T08:11:38.421+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.7ms)","duration":"2036.7ms","level":"slow","span":"c2499780ac62f6d8","trace":"3851b08fad32dd632dcf70f1278faa66"} -{"@timestamp":"2026-06-26T08:11:38.421+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.7ms","level":"info","span":"c2499780ac62f6d8","trace":"3851b08fad32dd632dcf70f1278faa66"} -{"@timestamp":"2026-06-26T08:11:43.488+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.0ms)","duration":"2061.0ms","level":"slow","span":"c1bcb140bf26b439","trace":"16e9293de3ca8a9dc15501e04aa566cc"} -{"@timestamp":"2026-06-26T08:11:43.488+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.0ms","level":"info","span":"c1bcb140bf26b439","trace":"16e9293de3ca8a9dc15501e04aa566cc"} -{"@timestamp":"2026-06-26T08:11:48.531+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.3ms)","duration":"2037.3ms","level":"slow","span":"5849281bc936572c","trace":"8efcb6295f11761fbb70e240f1db6f6a"} -{"@timestamp":"2026-06-26T08:11:48.531+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.3ms","level":"info","span":"5849281bc936572c","trace":"8efcb6295f11761fbb70e240f1db6f6a"} -{"@timestamp":"2026-06-26T08:11:53.623+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2087.5ms)","duration":"2087.5ms","level":"slow","span":"f8706736b6751767","trace":"702bad5a3bf5c91918d0a59486509d24"} -{"@timestamp":"2026-06-26T08:11:53.623+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2087.5ms","level":"info","span":"f8706736b6751767","trace":"702bad5a3bf5c91918d0a59486509d24"} -{"@timestamp":"2026-06-26T08:11:58.703+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2074.6ms)","duration":"2074.6ms","level":"slow","span":"f1364ab1fba36339","trace":"e9937a60bbf8a72bcb32feaa0eee7b03"} -{"@timestamp":"2026-06-26T08:11:58.703+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2074.6ms","level":"info","span":"f1364ab1fba36339","trace":"e9937a60bbf8a72bcb32feaa0eee7b03"} -{"@timestamp":"2026-06-26T08:14:01.289+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.1ms)","duration":"2058.1ms","level":"slow","span":"4d71f717fd3927eb","trace":"718f9b6852094548eaa89829c5f42ed8"} -{"@timestamp":"2026-06-26T08:14:01.289+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.1ms","level":"info","span":"4d71f717fd3927eb","trace":"718f9b6852094548eaa89829c5f42ed8"} -{"@timestamp":"2026-06-26T08:14:07.080+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=75.1Mi, Sys=19.6Mi, NumGC=44","level":"stat"} -{"@timestamp":"2026-06-26T08:14:07.097+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T08:14:09.714+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2939.9ms, med: 2070.9ms, 90th: 11763.8ms, 99th: 11763.8ms, 99.9th: 11763.8ms","level":"stat"} -2026/06/26 08:14:12 job worker claim error: read tcp 127.0.0.1:51546->127.0.0.1:6379: i/o timeout -2026/06/26 08:14:13 job scheduler tick error: i/o timeout -2026/06/26 08:14:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:14:16.311+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(12014.1ms)","duration":"12014.1ms","level":"slow","span":"27b088a2c586361f","trace":"51bcc179e023119f5f2d6722c9e07384"} -{"@timestamp":"2026-06-26T08:14:16.311+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"12014.1ms","level":"error","span":"27b088a2c586361f","trace":"51bcc179e023119f5f2d6722c9e07384"} -2026/06/26 08:14:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:14:22.347+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.1ms)","duration":"3027.1ms","level":"slow","span":"91b220aa3e850dcf","trace":"9f126d0dbce1bf8df460a0e1fed50aa9"} -{"@timestamp":"2026-06-26T08:14:22.347+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.1ms","level":"error","span":"91b220aa3e850dcf","trace":"9f126d0dbce1bf8df460a0e1fed50aa9"} -2026/06/26 08:14:24 job worker claim error: i/o timeout -2026/06/26 08:14:28 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:14:28.383+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.0ms)","duration":"3028.0ms","level":"slow","span":"9b043d7508ca731d","trace":"25757b191e45a1d75588e04ef3e4a410"} -{"@timestamp":"2026-06-26T08:14:28.383+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.0ms","level":"error","span":"9b043d7508ca731d","trace":"25757b191e45a1d75588e04ef3e4a410"} -2026/06/26 08:14:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:14:34.411+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.5ms)","duration":"3020.5ms","level":"slow","span":"5ed6d9f2280f97a7","trace":"ef53b8273f5e68a4d1631f3c43562334"} -{"@timestamp":"2026-06-26T08:14:34.411+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.5ms","level":"error","span":"5ed6d9f2280f97a7","trace":"ef53b8273f5e68a4d1631f3c43562334"} -2026/06/26 08:14:36 job worker claim error: i/o timeout -2026/06/26 08:14:40 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:14:40.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.9ms)","duration":"3020.9ms","level":"slow","span":"e1191227c890278d","trace":"7bcb2f5bad79f00bd22047a5b5989952"} -{"@timestamp":"2026-06-26T08:14:40.438+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.9ms","level":"error","span":"e1191227c890278d","trace":"7bcb2f5bad79f00bd22047a5b5989952"} -2026/06/26 08:14:46 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:14:48.867+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(5421.9ms)","duration":"5421.9ms","level":"slow","span":"3bace35fc5523e75","trace":"936d99d683e5ae59332f1344a494c0fc"} -{"@timestamp":"2026-06-26T08:14:48.867+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"5421.9ms","level":"error","span":"3bace35fc5523e75","trace":"936d99d683e5ae59332f1344a494c0fc"} -2026/06/26 08:14:50 job worker claim error: i/o timeout -2026/06/26 08:14:54 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:14:54.886+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"85cac1bee45ac5db","trace":"00e9cc29d49feb5dea9c883a882eb85f"} -{"@timestamp":"2026-06-26T08:14:54.887+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"85cac1bee45ac5db","trace":"00e9cc29d49feb5dea9c883a882eb85f"} -2026/06/26 08:14:55 job reaper error: connection(127.0.0.1:27017[-258]) incomplete read of message header: read tcp 127.0.0.1:51541->127.0.0.1:27017: use of closed network connection -2026/06/26 08:14:58 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:15:00.905+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.8ms)","duration":"3012.8ms","level":"slow","span":"b8abcc19fe280039","trace":"430e8937f8d2e6c05000cf76bf362859"} -{"@timestamp":"2026-06-26T08:15:00.905+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.8ms","level":"error","span":"b8abcc19fe280039","trace":"430e8937f8d2e6c05000cf76bf362859"} -2026/06/26 08:15:02 job worker claim error: i/o timeout -2026/06/26 08:15:06 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:15:06.923+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.4ms)","duration":"3012.4ms","level":"slow","span":"371097499e94f11f","trace":"7ef0d2c4cb13f667ef878c210c9a53e4"} -{"@timestamp":"2026-06-26T08:15:06.923+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.4ms","level":"error","span":"371097499e94f11f","trace":"7ef0d2c4cb13f667ef878c210c9a53e4"} -{"@timestamp":"2026-06-26T08:15:09.493+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.8Mi, TotalAlloc=77.1Mi, Sys=19.6Mi, NumGC=45","level":"stat"} -{"@timestamp":"2026-06-26T08:15:09.509+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 8, pass: 9, drop: 0","level":"stat"} -2026/06/26 08:15:10 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:15:12.124+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 4285.4ms, med: 3026.9ms, 90th: 12014.0ms, 99th: 12014.0ms, 99.9th: 12014.0ms","level":"stat"} -2026/06/26 08:15:12 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T08:15:12.956+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.1ms)","duration":"3027.1ms","level":"slow","span":"e902c5aeb77290f9","trace":"c55d59232c6db5cae4583250bcd3f3ae"} -{"@timestamp":"2026-06-26T08:15:12.956+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.1ms","level":"error","span":"e902c5aeb77290f9","trace":"c55d59232c6db5cae4583250bcd3f3ae"} -2026/06/26 08:15:14 job worker claim error: i/o timeout -2026/06/26 08:15:18 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:15:18.985+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"2e7d5a95c33b3dba","trace":"55ca1f7709984e7aefd9c370ac150dae"} -{"@timestamp":"2026-06-26T08:15:18.985+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"2e7d5a95c33b3dba","trace":"55ca1f7709984e7aefd9c370ac150dae"} -2026/06/26 08:15:22 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:15:25.004+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.9ms)","duration":"3012.9ms","level":"slow","span":"c95275bba5e5b1a2","trace":"611255416c4660ec29fc544b115a59b3"} -{"@timestamp":"2026-06-26T08:15:25.004+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.9ms","level":"error","span":"c95275bba5e5b1a2","trace":"611255416c4660ec29fc544b115a59b3"} -2026/06/26 08:15:25 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-273]) incomplete read of message header: read tcp 127.0.0.1:54164->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-273]) incomplete read of message header: read tcp 127.0.0.1:54164->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 08:15:26 job worker claim error: i/o timeout -2026/06/26 08:30:36 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:30:36.246+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.3ms)","duration":"3012.3ms","level":"slow","span":"a9f72f4b7e6b0e01","trace":"d27c2c7e790fe7032e7b571c094a6faf"} -{"@timestamp":"2026-06-26T08:30:36.246+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.3ms","level":"error","span":"a9f72f4b7e6b0e01","trace":"d27c2c7e790fe7032e7b571c094a6faf"} -2026/06/26 08:45:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T08:46:01.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"86d98b39611c7372","trace":"26958463c4df418263dc5c424e52915e"} -{"@timestamp":"2026-06-26T08:46:01.017+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"86d98b39611c7372","trace":"26958463c4df418263dc5c424e52915e"} -2026/06/26 09:03:07 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:22:54.514+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1185766.0ms)","duration":"1185766.0ms","level":"slow","span":"13125cbead4dc5db","trace":"3a95b4c2385496fb5d41b559edbda254"} -{"@timestamp":"2026-06-26T09:22:54.514+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1185766.0ms","level":"error","span":"13125cbead4dc5db","trace":"3a95b4c2385496fb5d41b559edbda254"} -2026/06/26 09:22:54 job worker claim error: i/o timeout -2026/06/26 09:22:58 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:38:19.266+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(921747.1ms)","duration":"921747.1ms","level":"slow","span":"d119c464a62c0c7d","trace":"3fdc1ce392427f7c1e29189d59cbb02a"} -{"@timestamp":"2026-06-26T09:38:19.267+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"921747.1ms","level":"error","span":"d119c464a62c0c7d","trace":"3fdc1ce392427f7c1e29189d59cbb02a"} -2026/06/26 09:38:21 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:38:47.986+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(25713.2ms)","duration":"25713.2ms","level":"slow","span":"870ff86ea52345aa","trace":"972111919455cedba71ffe8a8c6d42d2"} -{"@timestamp":"2026-06-26T09:38:47.986+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"25713.2ms","level":"error","span":"870ff86ea52345aa","trace":"972111919455cedba71ffe8a8c6d42d2"} -2026/06/26 09:38:48 job worker claim error: i/o timeout -2026/06/26 09:38:48 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-279]) incomplete read of message header: read tcp 127.0.0.1:57306->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-279]) incomplete read of message header: read tcp 127.0.0.1:57306->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 09:38:52 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:38:54.005+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.1ms)","duration":"3015.1ms","level":"slow","span":"c1158a3c95f45278","trace":"f20608754244c9eb87cd826cdfefa6ad"} -{"@timestamp":"2026-06-26T09:38:54.006+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.1ms","level":"error","span":"c1158a3c95f45278","trace":"f20608754244c9eb87cd826cdfefa6ad"} -2026/06/26 09:38:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:39:00.029+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.8ms)","duration":"3016.8ms","level":"slow","span":"f95b75f65f6900b8","trace":"81ae9bd4f8fed96652df6408088c3720"} -{"@timestamp":"2026-06-26T09:39:00.029+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.8ms","level":"error","span":"f95b75f65f6900b8","trace":"81ae9bd4f8fed96652df6408088c3720"} -2026/06/26 09:39:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:39:02.362+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.6Mi, TotalAlloc=79.2Mi, Sys=19.6Mi, NumGC=46","level":"stat"} -{"@timestamp":"2026-06-26T09:39:02.379+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 09:39:04 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:39:04.993+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 215434.4ms, med: 3021.9ms, 90th: 1185765.9ms, 99th: 1185765.9ms, 99.9th: 1185765.9ms","level":"stat"} -2026/06/26 09:39:05 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T09:39:06.061+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"5f1082411174933f","trace":"763eb004ebb3aa96de35c7d106148d9f"} -{"@timestamp":"2026-06-26T09:39:06.061+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"5f1082411174933f","trace":"763eb004ebb3aa96de35c7d106148d9f"} -2026/06/26 09:39:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:39:12.095+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.9ms)","duration":"3027.9ms","level":"slow","span":"1c270dafa3865f41","trace":"dcf106f69eba2491a6952b5539bc5b3e"} -{"@timestamp":"2026-06-26T09:39:12.095+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.9ms","level":"error","span":"1c270dafa3865f41","trace":"dcf106f69eba2491a6952b5539bc5b3e"} -2026/06/26 09:39:12 job worker claim error: i/o timeout -2026/06/26 09:39:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:39:18.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.5ms)","duration":"3024.5ms","level":"slow","span":"1edf3a52e20cc1f4","trace":"3ea38ec31f642681759c4dd74886b2c5"} -{"@timestamp":"2026-06-26T09:39:18.125+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.5ms","level":"error","span":"1edf3a52e20cc1f4","trace":"3ea38ec31f642681759c4dd74886b2c5"} -2026/06/26 09:39:18 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-285]) incomplete read of message header: read tcp 127.0.0.1:60354->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-285]) incomplete read of message header: read tcp 127.0.0.1:60354->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 09:39:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:39:24.151+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.3ms)","duration":"3020.3ms","level":"slow","span":"cda7b62e6d854892","trace":"48cad4d40262ec35612979af1b1123a1"} -{"@timestamp":"2026-06-26T09:39:24.151+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.3ms","level":"error","span":"cda7b62e6d854892","trace":"48cad4d40262ec35612979af1b1123a1"} -2026/06/26 09:39:24 job worker claim error: i/o timeout -2026/06/26 09:39:28 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:39:30.181+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"e154dad50acb9ae6","trace":"86e6b163100c3966262ee12d432e1524"} -{"@timestamp":"2026-06-26T09:39:30.181+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"e154dad50acb9ae6","trace":"86e6b163100c3966262ee12d432e1524"} -2026/06/26 09:39:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T09:56:27.401+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"734f5374249338c5","trace":"4f8e7630e8554fc58bfab56766e9aa72"} -{"@timestamp":"2026-06-26T09:56:27.401+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"734f5374249338c5","trace":"4f8e7630e8554fc58bfab56766e9aa72"} -2026/06/26 09:56:27 job worker claim error: i/o timeout -2026/06/26 10:11:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:11:58.187+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"e0d940e31571381c","trace":"dbc919901e73a89b863e91f12d7fae41"} -{"@timestamp":"2026-06-26T10:11:58.187+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"e0d940e31571381c","trace":"dbc919901e73a89b863e91f12d7fae41"} -2026/06/26 10:12:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:12:04.211+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"a7216aab3bb6fea0","trace":"8ef21a8790f52e244f8016ba86f9c0d5"} -{"@timestamp":"2026-06-26T10:12:04.211+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"a7216aab3bb6fea0","trace":"8ef21a8790f52e244f8016ba86f9c0d5"} -2026/06/26 10:12:04 job worker claim error: i/o timeout -2026/06/26 10:12:04 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-291]) incomplete read of message header: read tcp 127.0.0.1:62371->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-291]) incomplete read of message header: read tcp 127.0.0.1:62371->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 10:12:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:12:10.230+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.9ms)","duration":"3012.9ms","level":"slow","span":"7b42fe32b7f18759","trace":"b70ec86624ac0b07ea14fc5e9254a008"} -{"@timestamp":"2026-06-26T10:12:10.230+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.9ms","level":"error","span":"7b42fe32b7f18759","trace":"b70ec86624ac0b07ea14fc5e9254a008"} -2026/06/26 10:12:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:12:16.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.8ms)","duration":"3012.8ms","level":"slow","span":"4a44ffcd744392fc","trace":"213150ad9904aaa0c017501bbf02d209"} -{"@timestamp":"2026-06-26T10:12:16.249+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.8ms","level":"error","span":"4a44ffcd744392fc","trace":"213150ad9904aaa0c017501bbf02d209"} -2026/06/26 10:12:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:12:18.202+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.8Mi, TotalAlloc=81.5Mi, Sys=19.6Mi, NumGC=47","level":"stat"} -{"@timestamp":"2026-06-26T10:12:18.217+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 10:12:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:12:20.832+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.4ms, med: 3024.2ms, 90th: 3027.7ms, 99th: 3027.7ms, 99.9th: 3027.7ms","level":"stat"} -2026/06/26 10:12:21 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T10:12:22.280+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.9ms)","duration":"3025.9ms","level":"slow","span":"9a5005be06c8b08c","trace":"1dd9ff188b0a5f1ceb0c5a7145b4223f"} -{"@timestamp":"2026-06-26T10:12:22.280+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.9ms","level":"error","span":"9a5005be06c8b08c","trace":"1dd9ff188b0a5f1ceb0c5a7145b4223f"} -2026/06/26 10:12:24 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:12:28.305+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.7ms)","duration":"3018.7ms","level":"slow","span":"c1357e55e9a404bd","trace":"049c85e2867042a1b486afd2988205a7"} -{"@timestamp":"2026-06-26T10:12:28.305+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.7ms","level":"error","span":"c1357e55e9a404bd","trace":"049c85e2867042a1b486afd2988205a7"} -2026/06/26 10:12:28 job worker claim error: i/o timeout -2026/06/26 10:12:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:12:34.330+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.0ms)","duration":"3019.0ms","level":"slow","span":"f5e8696e52b0d2c8","trace":"354a629d98e5b371f6f29e86972002be"} -{"@timestamp":"2026-06-26T10:12:34.330+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.0ms","level":"error","span":"f5e8696e52b0d2c8","trace":"354a629d98e5b371f6f29e86972002be"} -2026/06/26 10:12:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-297]) incomplete read of message header: read tcp 127.0.0.1:64837->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-297]) incomplete read of message header: read tcp 127.0.0.1:64837->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 10:12:36 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:29:36.820+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1019484.5ms)","duration":"1019484.5ms","level":"slow","span":"fb615245835f461d","trace":"7280b60fc9176548c66b651860f779c6"} -{"@timestamp":"2026-06-26T10:29:36.820+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1019484.5ms","level":"error","span":"fb615245835f461d","trace":"7280b60fc9176548c66b651860f779c6"} -2026/06/26 10:29:36 job worker claim error: i/o timeout -2026/06/26 10:46:36 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T10:46:38.576+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.2ms)","duration":"3016.2ms","level":"slow","span":"b86f5d1e219a7ada","trace":"68c49535f9666a8121f5fc5797174d23"} -{"@timestamp":"2026-06-26T10:46:38.576+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.2ms","level":"error","span":"b86f5d1e219a7ada","trace":"68c49535f9666a8121f5fc5797174d23"} -2026/06/26 11:03:06 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:03:10.340+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"cb8c2bced817d7cc","trace":"189d90c6d5fbfeb5546b257ebfbe5590"} -{"@timestamp":"2026-06-26T11:03:10.340+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"cb8c2bced817d7cc","trace":"189d90c6d5fbfeb5546b257ebfbe5590"} -2026/06/26 11:03:10 job worker claim error: i/o timeout -2026/06/26 11:12:57 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:12:59.120+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.5ms)","duration":"3026.5ms","level":"slow","span":"f76cc351fd7b6c9a","trace":"abb338374ccb2d2952d8a26fcfe0dd4d"} -{"@timestamp":"2026-06-26T11:12:59.121+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.5ms","level":"error","span":"f76cc351fd7b6c9a","trace":"abb338374ccb2d2952d8a26fcfe0dd4d"} -2026/06/26 11:13:01 job worker claim error: i/o timeout -2026/06/26 11:13:05 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-303]) incomplete read of message header: read tcp 127.0.0.1:50228->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-303]) incomplete read of message header: read tcp 127.0.0.1:50228->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T11:13:05.151+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"88f2bfe27c406dc2","trace":"11108fa1b89e79fbc0c20cf95d8b3367"} -{"@timestamp":"2026-06-26T11:13:05.151+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"88f2bfe27c406dc2","trace":"11108fa1b89e79fbc0c20cf95d8b3367"} -2026/06/26 11:13:05 job worker claim error: i/o timeout -2026/06/26 11:13:09 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:13:11.176+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"514c0fe6bba91666","trace":"1236d2e964abc67846a7d2a8e0f7e97a"} -{"@timestamp":"2026-06-26T11:13:11.176+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"514c0fe6bba91666","trace":"1236d2e964abc67846a7d2a8e0f7e97a"} -2026/06/26 11:13:13 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:13:17.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.0ms)","duration":"3014.0ms","level":"slow","span":"a27ea2a8aceb90f0","trace":"2f74b581d810536490aee28632c95d29"} -{"@timestamp":"2026-06-26T11:13:17.196+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.0ms","level":"error","span":"a27ea2a8aceb90f0","trace":"2f74b581d810536490aee28632c95d29"} -2026/06/26 11:13:17 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:13:18.903+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.5Mi, TotalAlloc=83.3Mi, Sys=19.6Mi, NumGC=47","level":"stat"} -{"@timestamp":"2026-06-26T11:13:18.920+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -2026/06/26 11:13:21 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:13:21.534+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 104666.3ms, med: 3018.9ms, 90th: 1019484.4ms, 99th: 1019484.4ms, 99.9th: 1019484.4ms","level":"stat"} -2026/06/26 11:13:21 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T11:13:23.227+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.8ms)","duration":"3026.8ms","level":"slow","span":"c5a83f7ad28cb100","trace":"3a3e68ee84d73865b2a1974a2d9c079c"} -{"@timestamp":"2026-06-26T11:13:23.228+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.8ms","level":"error","span":"c5a83f7ad28cb100","trace":"3a3e68ee84d73865b2a1974a2d9c079c"} -2026/06/26 11:13:25 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:13:29.260+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.4ms)","duration":"3026.4ms","level":"slow","span":"b132976d39c37976","trace":"635206e3da630bfb1eb73658e1ccbbc1"} -{"@timestamp":"2026-06-26T11:13:29.260+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.4ms","level":"error","span":"b132976d39c37976","trace":"635206e3da630bfb1eb73658e1ccbbc1"} -2026/06/26 11:13:29 job worker claim error: i/o timeout -2026/06/26 11:13:33 job worker claim error: i/o timeout -2026/06/26 11:13:35 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-309]) incomplete read of message header: read tcp 127.0.0.1:52353->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-309]) incomplete read of message header: read tcp 127.0.0.1:52353->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T11:13:35.285+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.7ms)","duration":"3018.7ms","level":"slow","span":"4b7b286a6734113c","trace":"6a10aded8fece074cdbc923d013847f1"} -{"@timestamp":"2026-06-26T11:13:35.285+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.7ms","level":"error","span":"4b7b286a6734113c","trace":"6a10aded8fece074cdbc923d013847f1"} -2026/06/26 11:13:37 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:17:28.380+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(230089.1ms)","duration":"230089.1ms","level":"slow","span":"06280e3d0eaacaf3","trace":"de977a45e7ffee8d868b479c516e86b9"} -{"@timestamp":"2026-06-26T11:17:28.380+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"230089.1ms","level":"error","span":"06280e3d0eaacaf3","trace":"de977a45e7ffee8d868b479c516e86b9"} -2026/06/26 11:17:28 job worker claim error: i/o timeout -2026/06/26 11:17:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:17:34.408+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.3ms)","duration":"3025.3ms","level":"slow","span":"5bf8038fc440b58f","trace":"0440c2c6c3c7bdc93621652e5f532acf"} -{"@timestamp":"2026-06-26T11:17:34.408+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.3ms","level":"error","span":"5bf8038fc440b58f","trace":"0440c2c6c3c7bdc93621652e5f532acf"} -2026/06/26 11:17:36 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:17:40.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"2effecaa4029b2d4","trace":"3485fb42d6d3423cf7c09856c83e14bf"} -{"@timestamp":"2026-06-26T11:17:40.436+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"2effecaa4029b2d4","trace":"3485fb42d6d3423cf7c09856c83e14bf"} -2026/06/26 11:17:40 job worker claim error: i/o timeout -2026/06/26 11:17:44 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:17:46.458+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"abebcb4194e43f0d","trace":"0a405ebeb4f28c9df69ac5e5429f3a49"} -{"@timestamp":"2026-06-26T11:17:46.458+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"abebcb4194e43f0d","trace":"0a405ebeb4f28c9df69ac5e5429f3a49"} -2026/06/26 11:17:48 job worker claim error: i/o timeout -2026/06/26 11:17:52 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-315]) incomplete read of message header: read tcp 127.0.0.1:54001->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-315]) incomplete read of message header: read tcp 127.0.0.1:54001->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T11:17:52.488+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.3ms)","duration":"3024.3ms","level":"slow","span":"fd5010b425e5fc46","trace":"2c2e8d472ebbd5092272ff70398f408a"} -{"@timestamp":"2026-06-26T11:17:52.488+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.3ms","level":"error","span":"fd5010b425e5fc46","trace":"2c2e8d472ebbd5092272ff70398f408a"} -2026/06/26 11:17:52 job worker claim error: i/o timeout -2026/06/26 11:17:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:17:58.511+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.5ms)","duration":"3017.5ms","level":"slow","span":"8e2a314760136140","trace":"e497c05331f5798940cba211347d5b32"} -{"@timestamp":"2026-06-26T11:17:58.511+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.5ms","level":"error","span":"8e2a314760136140","trace":"e497c05331f5798940cba211347d5b32"} -2026/06/26 11:18:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:18:04.538+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"e517b5faa997876b","trace":"039f31d8592ab7efb8a929913dee8332"} -{"@timestamp":"2026-06-26T11:18:04.538+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"e517b5faa997876b","trace":"039f31d8592ab7efb8a929913dee8332"} -2026/06/26 11:18:04 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:18:05.953+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.2Mi, TotalAlloc=85.5Mi, Sys=19.6Mi, NumGC=48","level":"stat"} -{"@timestamp":"2026-06-26T11:18:05.968+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T11:18:08.584+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 25728.5ms, med: 3024.2ms, 90th: 230089.0ms, 99th: 230089.0ms, 99.9th: 230089.0ms","level":"stat"} -2026/06/26 11:18:08 job worker claim error: i/o timeout -2026/06/26 11:18:08 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T11:18:10.572+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.7ms)","duration":"3027.7ms","level":"slow","span":"a8dab3b7f9b0c07f","trace":"c9fe66ad3d7c97e0d2a4abb9d2e8a56b"} -{"@timestamp":"2026-06-26T11:18:10.572+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.7ms","level":"error","span":"a8dab3b7f9b0c07f","trace":"c9fe66ad3d7c97e0d2a4abb9d2e8a56b"} -2026/06/26 11:18:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T11:35:09.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1016073.8ms)","duration":"1016073.8ms","level":"slow","span":"49d232d688475c41","trace":"50552483ffb1f4a1c7c19b686f62dbb2"} -{"@timestamp":"2026-06-26T11:35:09.652+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1016073.8ms","level":"error","span":"49d232d688475c41","trace":"50552483ffb1f4a1c7c19b686f62dbb2"} -2026/06/26 11:35:09 job worker claim error: i/o timeout -2026/06/26 11:50:56 job worker claim error: i/o timeout -2026/06/26 11:50:57 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-321]) incomplete read of message header: read tcp 127.0.0.1:55745->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-321]) incomplete read of message header: read tcp 127.0.0.1:55745->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T11:50:58.421+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(945764.4ms)","duration":"945764.4ms","level":"slow","span":"66e13c460c4f54c0","trace":"40991b44c0f8ee83c552005504d5a014"} -{"@timestamp":"2026-06-26T11:50:58.422+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"945764.4ms","level":"error","span":"66e13c460c4f54c0","trace":"40991b44c0f8ee83c552005504d5a014"} -2026/06/26 11:51:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:08:28.194+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1046765.2ms)","duration":"1046765.2ms","level":"slow","span":"5f78451ab4ada769","trace":"4e74284a99fcd87b5ec497244da208be"} -{"@timestamp":"2026-06-26T12:08:28.194+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1046765.2ms","level":"error","span":"5f78451ab4ada769","trace":"4e74284a99fcd87b5ec497244da208be"} -2026/06/26 12:08:28 job worker claim error: i/o timeout -2026/06/26 12:13:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:13:57.963+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.6ms)","duration":"3023.6ms","level":"slow","span":"130caa66f62df168","trace":"9ba36921e17030ce295f03449b2668b8"} -{"@timestamp":"2026-06-26T12:13:57.964+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.6ms","level":"error","span":"130caa66f62df168","trace":"9ba36921e17030ce295f03449b2668b8"} -2026/06/26 12:14:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:14:03.987+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.7ms)","duration":"3016.7ms","level":"slow","span":"597b1ccaa1df2453","trace":"4350ef86218eb914e289c3832b35eb8a"} -{"@timestamp":"2026-06-26T12:14:03.987+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.7ms","level":"error","span":"597b1ccaa1df2453","trace":"4350ef86218eb914e289c3832b35eb8a"} -2026/06/26 12:14:04 job worker claim error: i/o timeout -2026/06/26 12:14:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:14:10.012+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.3ms)","duration":"3018.3ms","level":"slow","span":"e854694f314aeaa6","trace":"21808f60292798dd0f25c68c1f772026"} -{"@timestamp":"2026-06-26T12:14:10.012+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.3ms","level":"error","span":"e854694f314aeaa6","trace":"21808f60292798dd0f25c68c1f772026"} -2026/06/26 12:14:12 job worker claim error: i/o timeout -2026/06/26 12:14:15 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-327]) incomplete read of message header: read tcp 127.0.0.1:57813->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-327]) incomplete read of message header: read tcp 127.0.0.1:57813->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T12:14:16.033+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.7ms)","duration":"3014.7ms","level":"slow","span":"a5dd2760d1cf2041","trace":"b029e537b2aaf093b0ab51a62713a176"} -{"@timestamp":"2026-06-26T12:14:16.034+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.7ms","level":"error","span":"a5dd2760d1cf2041","trace":"b029e537b2aaf093b0ab51a62713a176"} -2026/06/26 12:14:16 job worker claim error: i/o timeout -2026/06/26 12:14:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:14:22.054+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.7ms)","duration":"3014.7ms","level":"slow","span":"f6742aff0b4a4b78","trace":"61340478d568e68df20d4c20fed91413"} -{"@timestamp":"2026-06-26T12:14:22.054+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.7ms","level":"error","span":"f6742aff0b4a4b78","trace":"61340478d568e68df20d4c20fed91413"} -2026/06/26 12:14:24 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:14:28.073+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.2ms)","duration":"3014.2ms","level":"slow","span":"d2de8e1a1cd702ab","trace":"b2ba556804da6a3873f875f0ca77c557"} -{"@timestamp":"2026-06-26T12:14:28.074+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.2ms","level":"error","span":"d2de8e1a1cd702ab","trace":"b2ba556804da6a3873f875f0ca77c557"} -2026/06/26 12:14:28 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:14:29.228+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.5Mi, TotalAlloc=87.6Mi, Sys=19.6Mi, NumGC=49","level":"stat"} -{"@timestamp":"2026-06-26T12:14:29.245+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T12:14:31.859+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 302973.2ms, med: 3023.5ms, 90th: 1046765.1ms, 99th: 1046765.1ms, 99.9th: 1046765.1ms","level":"stat"} -2026/06/26 12:14:32 job scheduler tick error: i/o timeout -2026/06/26 12:14:32 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:14:34.098+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.1ms)","duration":"3019.1ms","level":"slow","span":"054e2f4689b71943","trace":"91d7d29f6b26049ee218403becfb0c79"} -{"@timestamp":"2026-06-26T12:14:34.098+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.1ms","level":"error","span":"054e2f4689b71943","trace":"91d7d29f6b26049ee218403becfb0c79"} -2026/06/26 12:14:36 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:14:40.133+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.5ms)","duration":"3028.5ms","level":"slow","span":"5725c3b97e1050f0","trace":"37c0fd410728306236411f04ffe9cca6"} -{"@timestamp":"2026-06-26T12:14:40.133+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.5ms","level":"error","span":"5725c3b97e1050f0","trace":"37c0fd410728306236411f04ffe9cca6"} -2026/06/26 12:14:40 job worker claim error: i/o timeout -2026/06/26 12:32:34 job worker claim error: i/o timeout -2026/06/26 12:32:35 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-333]) incomplete read of message header: read tcp 127.0.0.1:58761->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-333]) incomplete read of message header: read tcp 127.0.0.1:58761->127.0.0.1:27017: i/o timeout }, ] } -{"@timestamp":"2026-06-26T12:32:36.229+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.5ms)","duration":"3027.5ms","level":"slow","span":"efe9e562fdec2b02","trace":"7bd8add0a9cdc6c10877227a5fa8fd89"} -{"@timestamp":"2026-06-26T12:32:36.229+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.5ms","level":"error","span":"efe9e562fdec2b02","trace":"7bd8add0a9cdc6c10877227a5fa8fd89"} -2026/06/26 12:50:04 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T12:50:05.493+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.0ms)","duration":"3024.0ms","level":"slow","span":"37d82df9760ebc6c","trace":"12631215cf1377a02c1372b264bc9ab2"} -{"@timestamp":"2026-06-26T12:50:05.493+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.0ms","level":"error","span":"37d82df9760ebc6c","trace":"12631215cf1377a02c1372b264bc9ab2"} -2026/06/26 13:07:16 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:07:19.276+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.9ms)","duration":"3017.9ms","level":"slow","span":"ed6952e410a8646b","trace":"b388b05a4dbd78ba6996a9bbf4b73a64"} -{"@timestamp":"2026-06-26T13:07:19.277+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.9ms","level":"error","span":"ed6952e410a8646b","trace":"b388b05a4dbd78ba6996a9bbf4b73a64"} -2026/06/26 13:07:20 job worker claim error: i/o timeout -2026/06/26 13:14:57 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:14:59.060+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.8ms)","duration":"3024.8ms","level":"slow","span":"16a3b21ec60e44ab","trace":"fee25274acbe7640654267483ababdb7"} -{"@timestamp":"2026-06-26T13:14:59.061+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.8ms","level":"error","span":"16a3b21ec60e44ab","trace":"fee25274acbe7640654267483ababdb7"} -2026/06/26 13:15:01 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:15:05.083+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.1ms)","duration":"3019.1ms","level":"slow","span":"5d28d9957d232cec","trace":"e660474f726b1faa957b45a1464c5399"} -{"@timestamp":"2026-06-26T13:15:05.083+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.1ms","level":"error","span":"5d28d9957d232cec","trace":"e660474f726b1faa957b45a1464c5399"} -2026/06/26 13:15:06 job worker claim error: i/o timeout -2026/06/26 13:15:10 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:15:11.116+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.7ms)","duration":"3026.7ms","level":"slow","span":"35a5b907edd0cac1","trace":"9fda9264797dda5b313e7e46d7a011fe"} -{"@timestamp":"2026-06-26T13:15:11.116+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.7ms","level":"error","span":"35a5b907edd0cac1","trace":"9fda9264797dda5b313e7e46d7a011fe"} -2026/06/26 13:15:12 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-339]) incomplete read of message header: read tcp 127.0.0.1:60817->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-339]) incomplete read of message header: read tcp 127.0.0.1:60817->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 13:15:14 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:15:17.147+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.1ms)","duration":"3026.1ms","level":"slow","span":"ee839fc233f1d55a","trace":"c18cdf2092dda63efbccd2fc556a9dd6"} -{"@timestamp":"2026-06-26T13:15:17.147+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.1ms","level":"error","span":"ee839fc233f1d55a","trace":"c18cdf2092dda63efbccd2fc556a9dd6"} -2026/06/26 13:15:18 job worker claim error: i/o timeout -2026/06/26 13:15:22 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:15:23.173+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.7ms)","duration":"3019.7ms","level":"slow","span":"7c3e36bd004eea2f","trace":"51f65debff1bd542007f0948edaadde8"} -{"@timestamp":"2026-06-26T13:15:23.173+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.7ms","level":"error","span":"7c3e36bd004eea2f","trace":"51f65debff1bd542007f0948edaadde8"} -2026/06/26 13:15:26 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:15:26.564+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.1Mi, TotalAlloc=89.9Mi, Sys=19.6Mi, NumGC=50","level":"stat"} -{"@timestamp":"2026-06-26T13:15:26.580+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T13:15:29.195+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3023.2ms, med: 3024.7ms, 90th: 3028.4ms, 99th: 3028.4ms, 99.9th: 3028.4ms","level":"stat"} -{"@timestamp":"2026-06-26T13:15:29.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"2bf605d781493fd7","trace":"78040aec4422d766772a0ced0b9fc206"} -{"@timestamp":"2026-06-26T13:15:29.195+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"2bf605d781493fd7","trace":"78040aec4422d766772a0ced0b9fc206"} -2026/06/26 13:15:29 job scheduler tick error: i/o timeout -2026/06/26 13:15:30 job worker claim error: i/o timeout -2026/06/26 13:15:34 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:15:35.220+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"f8696a54d6595379","trace":"901bfeba0d4925434724f3d65f3e10d3"} -{"@timestamp":"2026-06-26T13:15:35.225+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"f8696a54d6595379","trace":"901bfeba0d4925434724f3d65f3e10d3"} -2026/06/26 13:15:38 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:31:10.353+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(932123.8ms)","duration":"932123.8ms","level":"slow","span":"e24b20e0fea0acfd","trace":"e64e8c65f240e4f1b072445c56089166"} -{"@timestamp":"2026-06-26T13:31:10.353+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"932123.8ms","level":"error","span":"e24b20e0fea0acfd","trace":"e64e8c65f240e4f1b072445c56089166"} -2026/06/26 13:31:11 job worker claim error: i/o timeout -2026/06/26 13:31:11 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-345]) incomplete read of message header: read tcp 127.0.0.1:62293->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-345]) incomplete read of message header: read tcp 127.0.0.1:62293->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 13:31:15 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T13:49:09.137+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1075779.9ms)","duration":"1075779.9ms","level":"slow","span":"06b9ad42b9a0490e","trace":"bd1257cac1924e05aed833ee1d524dc4"} -{"@timestamp":"2026-06-26T13:49:09.137+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1075779.9ms","level":"error","span":"06b9ad42b9a0490e","trace":"bd1257cac1924e05aed833ee1d524dc4"} -2026/06/26 13:49:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:07:04.930+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1072786.9ms)","duration":"1072786.9ms","level":"slow","span":"1da605071a86cece","trace":"0078cee758b5b48a9aed5ebf01df59d3"} -{"@timestamp":"2026-06-26T14:07:04.930+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1072786.9ms","level":"error","span":"1da605071a86cece","trace":"0078cee758b5b48a9aed5ebf01df59d3"} -2026/06/26 14:07:05 job worker claim error: i/o timeout -2026/06/26 14:15:55 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:15:56.712+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(528776.4ms)","duration":"528776.4ms","level":"slow","span":"6928de45c2fa19f2","trace":"c50fa27217364bf8367c16a91c66e949"} -{"@timestamp":"2026-06-26T14:15:56.712+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"528776.4ms","level":"error","span":"6928de45c2fa19f2","trace":"c50fa27217364bf8367c16a91c66e949"} -2026/06/26 14:15:59 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:16:02.732+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.1ms)","duration":"3014.1ms","level":"slow","span":"4df8533961d7d7d8","trace":"bb2f44a1506f2756db85b9b63b265405"} -{"@timestamp":"2026-06-26T14:16:02.732+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.1ms","level":"error","span":"4df8533961d7d7d8","trace":"bb2f44a1506f2756db85b9b63b265405"} -2026/06/26 14:16:03 job worker claim error: i/o timeout -2026/06/26 14:16:07 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:16:08.757+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"9b7e0c250bab01a8","trace":"b750acc2df3af0745e876a2ac12af14f"} -{"@timestamp":"2026-06-26T14:16:08.757+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"9b7e0c250bab01a8","trace":"b750acc2df3af0745e876a2ac12af14f"} -2026/06/26 14:16:10 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-351]) incomplete read of message header: read tcp 127.0.0.1:64090->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-351]) incomplete read of message header: read tcp 127.0.0.1:64090->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 14:16:11 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:16:14.785+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.2ms)","duration":"3023.2ms","level":"slow","span":"dd060a5da6adc154","trace":"e39146b5b8111906e82429278080e6cf"} -{"@timestamp":"2026-06-26T14:16:14.785+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.2ms","level":"error","span":"dd060a5da6adc154","trace":"e39146b5b8111906e82429278080e6cf"} -2026/06/26 14:16:15 job worker claim error: i/o timeout -2026/06/26 14:16:19 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:16:20.809+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"d3eac44bb34dbc17","trace":"6d52d9770aa54523985acc266a1d896e"} -{"@timestamp":"2026-06-26T14:16:20.809+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"d3eac44bb34dbc17","trace":"6d52d9770aa54523985acc266a1d896e"} -2026/06/26 14:16:23 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:16:23.891+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=92.2Mi, Sys=19.6Mi, NumGC=51","level":"stat"} -{"@timestamp":"2026-06-26T14:16:23.908+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T14:16:26.522+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 362757.5ms, med: 3023.1ms, 90th: 1075779.9ms, 99th: 1075779.9ms, 99.9th: 1075779.9ms","level":"stat"} -{"@timestamp":"2026-06-26T14:16:26.839+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.8ms)","duration":"3024.8ms","level":"slow","span":"671f3b5ddc077739","trace":"4b28106dee191c0f07a6afe388f29a34"} -{"@timestamp":"2026-06-26T14:16:26.839+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.8ms","level":"error","span":"671f3b5ddc077739","trace":"4b28106dee191c0f07a6afe388f29a34"} -2026/06/26 14:16:26 job scheduler tick error: i/o timeout -2026/06/26 14:16:27 job worker claim error: i/o timeout -2026/06/26 14:16:31 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:16:32.872+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.4ms)","duration":"3027.4ms","level":"slow","span":"4add76cd2ffc6f67","trace":"f403906b25216dfcd0e6c15204f408c7"} -{"@timestamp":"2026-06-26T14:16:32.872+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.4ms","level":"error","span":"4add76cd2ffc6f67","trace":"f403906b25216dfcd0e6c15204f408c7"} -2026/06/26 14:16:35 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:33:23.860+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1007982.5ms)","duration":"1007982.5ms","level":"slow","span":"b1ae69c73e721c1e","trace":"f4a4e5aefadca481ed316061d45d47c1"} -{"@timestamp":"2026-06-26T14:33:23.860+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1007982.5ms","level":"error","span":"b1ae69c73e721c1e","trace":"f4a4e5aefadca481ed316061d45d47c1"} -2026/06/26 14:33:24 job worker claim error: i/o timeout -2026/06/26 14:33:25 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-357]) incomplete read of message header: read tcp 127.0.0.1:49527->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-357]) incomplete read of message header: read tcp 127.0.0.1:49527->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 14:50:42 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T14:50:43.630+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1036764.4ms)","duration":"1036764.4ms","level":"slow","span":"57273833d34c2f16","trace":"151f4a42c9d3baa3740698a1ec051320"} -{"@timestamp":"2026-06-26T14:50:43.630+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1036764.4ms","level":"error","span":"57273833d34c2f16","trace":"151f4a42c9d3baa3740698a1ec051320"} -2026/06/26 14:50:46 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:08:36.373+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1069739.1ms)","duration":"1069739.1ms","level":"slow","span":"dee7a0bb7946787e","trace":"968b3a474b7282a4f125eb2098ce0aa2"} -{"@timestamp":"2026-06-26T15:08:36.373+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1069739.1ms","level":"error","span":"dee7a0bb7946787e","trace":"968b3a474b7282a4f125eb2098ce0aa2"} -2026/06/26 15:08:37 job worker claim error: i/o timeout -2026/06/26 15:16:56 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:16:57.117+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(497737.6ms)","duration":"497737.6ms","level":"slow","span":"21bad82d872036b9","trace":"b8389680ab3c65438327464586a798f4"} -{"@timestamp":"2026-06-26T15:16:57.117+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"497737.6ms","level":"error","span":"21bad82d872036b9","trace":"b8389680ab3c65438327464586a798f4"} -2026/06/26 15:17:00 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:17:03.136+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"4d8d86383ac2d474","trace":"0d56bd1ace0a8a2a240377dfe69a079e"} -{"@timestamp":"2026-06-26T15:17:03.136+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"4d8d86383ac2d474","trace":"0d56bd1ace0a8a2a240377dfe69a079e"} -2026/06/26 15:17:04 job worker claim error: i/o timeout -2026/06/26 15:17:08 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:17:09.164+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.6ms)","duration":"3023.6ms","level":"slow","span":"524fbebda39271c8","trace":"0dfff953a5d751726eeb386a2b7d4c8d"} -{"@timestamp":"2026-06-26T15:17:09.164+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.6ms","level":"error","span":"524fbebda39271c8","trace":"0dfff953a5d751726eeb386a2b7d4c8d"} -2026/06/26 15:17:10 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-363]) incomplete read of message header: read tcp 127.0.0.1:51196->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-363]) incomplete read of message header: read tcp 127.0.0.1:51196->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 15:17:12 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:17:15.186+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.5ms)","duration":"3018.5ms","level":"slow","span":"e294ff83b4a37894","trace":"e15ecf8c4df5cfbe781f19f516b0da61"} -{"@timestamp":"2026-06-26T15:17:15.186+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.5ms","level":"error","span":"e294ff83b4a37894","trace":"e15ecf8c4df5cfbe781f19f516b0da61"} -2026/06/26 15:17:16 job worker claim error: i/o timeout -2026/06/26 15:17:20 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:17:21.216+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"4be4b2ed8aa51628","trace":"0dee513b7ead86468df0eee0af68734f"} -{"@timestamp":"2026-06-26T15:17:21.216+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"4be4b2ed8aa51628","trace":"0dee513b7ead86468df0eee0af68734f"} -{"@timestamp":"2026-06-26T15:35:07.298+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 9, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T15:35:07.298+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.2Mi, TotalAlloc=93.8Mi, Sys=19.6Mi, NumGC=51","level":"stat"} -2026/06/26 15:35:07 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:35:09.864+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 363035.4ms, med: 3027.3ms, 90th: 1069739.0ms, 99th: 1069739.0ms, 99.9th: 1069739.0ms","level":"stat"} -2026/06/26 15:35:10 job scheduler tick error: i/o timeout -{"@timestamp":"2026-06-26T15:35:10.447+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.6ms)","duration":"3027.6ms","level":"slow","span":"f02e98b835d95a31","trace":"1e947aaffe477794a819419c8e061fa3"} -{"@timestamp":"2026-06-26T15:35:10.447+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.6ms","level":"error","span":"f02e98b835d95a31","trace":"1e947aaffe477794a819419c8e061fa3"} -2026/06/26 15:35:11 job worker claim error: i/o timeout -2026/06/26 15:52:04 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T15:52:05.234+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.8ms)","duration":"3027.8ms","level":"slow","span":"5d90ef2328c23fdd","trace":"42c05bdf2d13cf9757cc34310cc2cdac"} -{"@timestamp":"2026-06-26T15:52:05.234+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.8ms","level":"error","span":"5d90ef2328c23fdd","trace":"42c05bdf2d13cf9757cc34310cc2cdac"} -2026/06/26 16:07:27 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T16:07:30.112+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.4ms)","duration":"3023.4ms","level":"slow","span":"8ed9f0718a0f3039","trace":"dd92e776d90852578bdbcb82c7035957"} -{"@timestamp":"2026-06-26T16:07:30.112+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.4ms","level":"error","span":"8ed9f0718a0f3039","trace":"dd92e776d90852578bdbcb82c7035957"} -2026/06/26 16:07:31 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-369]) incomplete read of message header: read tcp 127.0.0.1:53335->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-369]) incomplete read of message header: read tcp 127.0.0.1:53335->127.0.0.1:27017: i/o timeout }, ] } -2026/06/26 16:07:31 job worker claim error: i/o timeout -2026/06/26 16:17:57 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T16:17:58.862+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.7ms)","duration":"3020.7ms","level":"slow","span":"4136d5d84c03d250","trace":"856021292ce826f3aef4e4d407a38d84"} -{"@timestamp":"2026-06-26T16:17:58.862+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.7ms","level":"error","span":"4136d5d84c03d250","trace":"856021292ce826f3aef4e4d407a38d84"} -2026/06/26 16:18:01 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T16:18:04.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.1ms)","duration":"3029.1ms","level":"slow","span":"e1c8c7cf12bb0bea","trace":"a08c6d3c5489b4fd3d29caed2ed51041"} -{"@timestamp":"2026-06-26T16:18:04.896+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.1ms","level":"error","span":"e1c8c7cf12bb0bea","trace":"a08c6d3c5489b4fd3d29caed2ed51041"} -2026/06/26 16:18:05 job worker claim error: i/o timeout -2026/06/26 16:18:09 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T16:18:10.926+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.5ms)","duration":"3024.5ms","level":"slow","span":"e7c953850f9810fb","trace":"66009a73e4b51fbee205cbf76334ea3d"} -{"@timestamp":"2026-06-26T16:18:10.926+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.5ms","level":"error","span":"e7c953850f9810fb","trace":"66009a73e4b51fbee205cbf76334ea3d"} -2026/06/26 16:18:14 job worker claim error: i/o timeout -{"@timestamp":"2026-06-26T16:25:00.911+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(406980.0ms)","duration":"406980.0ms","level":"slow","span":"4675282c15d6837c","trace":"85a4fd43207f6864c9a227a4cd9443bc"} -{"@timestamp":"2026-06-26T16:25:00.911+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"406980.0ms","level":"info","span":"4675282c15d6837c","trace":"85a4fd43207f6864c9a227a4cd9443bc"} -{"@timestamp":"2026-06-26T16:25:05.987+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2072.2ms)","duration":"2072.2ms","level":"slow","span":"9ce306693bd67b1b","trace":"37d4eff9ad72817506ba163ce9621f24"} -{"@timestamp":"2026-06-26T16:25:05.987+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2072.2ms","level":"info","span":"9ce306693bd67b1b","trace":"37d4eff9ad72817506ba163ce9621f24"} -{"@timestamp":"2026-06-26T16:25:09.131+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.1ms)","duration":"2058.1ms","level":"slow","span":"51a80e13ce520d65","trace":"ad38b7ce4a99c9cc87c99c6795f673ae"} -{"@timestamp":"2026-06-26T16:25:09.131+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.1ms","level":"info","span":"51a80e13ce520d65","trace":"ad38b7ce4a99c9cc87c99c6795f673ae"} -{"@timestamp":"2026-06-26T16:25:14.209+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.0ms)","duration":"2073.0ms","level":"slow","span":"6268904dea49f417","trace":"bec74581cd18e11d588bd064f36a75b2"} -{"@timestamp":"2026-06-26T16:25:14.209+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.0ms","level":"info","span":"6268904dea49f417","trace":"bec74581cd18e11d588bd064f36a75b2"} -{"@timestamp":"2026-06-26T16:25:18.410+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.3Mi, TotalAlloc=95.6Mi, Sys=19.6Mi, NumGC=52","level":"stat"} -{"@timestamp":"2026-06-26T16:25:18.427+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:25:19.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.6ms)","duration":"2038.6ms","level":"slow","span":"1413a9d8cc7a5304","trace":"b732651e4220321a6fea04c74930d6e3"} -{"@timestamp":"2026-06-26T16:25:19.249+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.6ms","level":"info","span":"1413a9d8cc7a5304","trace":"b732651e4220321a6fea04c74930d6e3"} -{"@timestamp":"2026-06-26T16:25:21.051+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 39397.5ms, med: 3024.4ms, 90th: 406980.0ms, 99th: 406980.0ms, 99.9th: 406980.0ms","level":"stat"} -{"@timestamp":"2026-06-26T16:25:24.288+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"d3e14238ccf4db00","trace":"c4fbe7d2163cef5c96cc19f4593d8dfb"} -{"@timestamp":"2026-06-26T16:25:24.288+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"d3e14238ccf4db00","trace":"c4fbe7d2163cef5c96cc19f4593d8dfb"} -{"@timestamp":"2026-06-26T16:25:29.327+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.7ms)","duration":"2034.7ms","level":"slow","span":"2c83a396e0f0b401","trace":"a9f18bef2bdbcd2ed7d53e36350092aa"} -{"@timestamp":"2026-06-26T16:25:29.327+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.7ms","level":"info","span":"2c83a396e0f0b401","trace":"a9f18bef2bdbcd2ed7d53e36350092aa"} -{"@timestamp":"2026-06-26T16:25:34.373+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.6ms)","duration":"2043.6ms","level":"slow","span":"322cdd1dbb257f85","trace":"7e214f63662a10fb7f9bdb1e7f7d97a3"} -{"@timestamp":"2026-06-26T16:25:34.374+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.6ms","level":"info","span":"322cdd1dbb257f85","trace":"7e214f63662a10fb7f9bdb1e7f7d97a3"} -{"@timestamp":"2026-06-26T16:25:39.397+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.8ms)","duration":"2019.8ms","level":"slow","span":"6924f901a2f46272","trace":"00c2b581bed7f13db116e03c3cb5f840"} -{"@timestamp":"2026-06-26T16:25:39.397+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.8ms","level":"info","span":"6924f901a2f46272","trace":"00c2b581bed7f13db116e03c3cb5f840"} -{"@timestamp":"2026-06-26T16:25:44.472+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.8ms)","duration":"2071.8ms","level":"slow","span":"7c2f0e289666a20e","trace":"72eeb5770786cff5bca5de4085951918"} -{"@timestamp":"2026-06-26T16:25:44.472+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.8ms","level":"info","span":"7c2f0e289666a20e","trace":"72eeb5770786cff5bca5de4085951918"} -{"@timestamp":"2026-06-26T16:25:49.539+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.6ms)","duration":"2063.6ms","level":"slow","span":"b8a086c2f4f09dbf","trace":"f8f46489dcd22f68ae1a7721f944aae2"} -{"@timestamp":"2026-06-26T16:25:49.539+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.6ms","level":"info","span":"b8a086c2f4f09dbf","trace":"f8f46489dcd22f68ae1a7721f944aae2"} -{"@timestamp":"2026-06-26T16:25:54.610+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2068.3ms)","duration":"2068.3ms","level":"slow","span":"013305431f656a74","trace":"85df184f76adc2a8c816c799d8025c30"} -{"@timestamp":"2026-06-26T16:25:54.610+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2068.3ms","level":"info","span":"013305431f656a74","trace":"85df184f76adc2a8c816c799d8025c30"} -{"@timestamp":"2026-06-26T16:25:59.659+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.1ms)","duration":"2044.1ms","level":"slow","span":"277fcc95fe073a1d","trace":"5691c5d5b246daba401ca8baea3b1978"} -{"@timestamp":"2026-06-26T16:25:59.659+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.1ms","level":"info","span":"277fcc95fe073a1d","trace":"5691c5d5b246daba401ca8baea3b1978"} -{"@timestamp":"2026-06-26T16:26:04.702+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.5ms)","duration":"2040.5ms","level":"slow","span":"8120e5f69e3949ef","trace":"634b501aa8dcadb408112d47ff5f2230"} -{"@timestamp":"2026-06-26T16:26:04.702+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.5ms","level":"info","span":"8120e5f69e3949ef","trace":"634b501aa8dcadb408112d47ff5f2230"} -{"@timestamp":"2026-06-26T16:26:09.755+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.1ms)","duration":"2051.1ms","level":"slow","span":"1a4e4b04b9f2abcc","trace":"079c6ef50e09e591f34cdc89a21a2c80"} -{"@timestamp":"2026-06-26T16:26:09.756+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.1ms","level":"info","span":"1a4e4b04b9f2abcc","trace":"079c6ef50e09e591f34cdc89a21a2c80"} -{"@timestamp":"2026-06-26T16:26:14.829+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.8ms)","duration":"2069.8ms","level":"slow","span":"914ba2a6415765d7","trace":"fd0723e9f5872e891f49f8c586abf7be"} -{"@timestamp":"2026-06-26T16:26:14.829+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.8ms","level":"info","span":"914ba2a6415765d7","trace":"fd0723e9f5872e891f49f8c586abf7be"} -{"@timestamp":"2026-06-26T16:26:18.406+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.5Mi, TotalAlloc=95.8Mi, Sys=19.6Mi, NumGC=52","level":"stat"} -{"@timestamp":"2026-06-26T16:26:18.423+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:26:19.860+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.8ms)","duration":"2028.8ms","level":"slow","span":"d82c6109ac8f2094","trace":"b178730f82259f9df3a34a9a0b3c8e0d"} -{"@timestamp":"2026-06-26T16:26:19.860+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.8ms","level":"info","span":"d82c6109ac8f2094","trace":"b178730f82259f9df3a34a9a0b3c8e0d"} -{"@timestamp":"2026-06-26T16:26:21.037+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2047.4ms, med: 2044.0ms, 90th: 2071.7ms, 99th: 2071.7ms, 99.9th: 2071.7ms","level":"stat"} -{"@timestamp":"2026-06-26T16:26:24.926+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.5ms)","duration":"2062.5ms","level":"slow","span":"ac79368d3e6beda7","trace":"d1759b2dbe3d954e8c89dd4707996894"} -{"@timestamp":"2026-06-26T16:26:24.926+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.5ms","level":"info","span":"ac79368d3e6beda7","trace":"d1759b2dbe3d954e8c89dd4707996894"} -{"@timestamp":"2026-06-26T16:26:29.962+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.8ms)","duration":"2027.8ms","level":"slow","span":"6cf3ead48119dfae","trace":"a558faed2537438f9826209fd04ef7f4"} -{"@timestamp":"2026-06-26T16:26:29.962+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.8ms","level":"info","span":"6cf3ead48119dfae","trace":"a558faed2537438f9826209fd04ef7f4"} -{"@timestamp":"2026-06-26T16:26:35.003+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.7ms)","duration":"2036.7ms","level":"slow","span":"3163d4951482a534","trace":"3cb0c6fa8851a36879a64c07fd557213"} -{"@timestamp":"2026-06-26T16:26:35.003+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.7ms","level":"info","span":"3163d4951482a534","trace":"3cb0c6fa8851a36879a64c07fd557213"} -{"@timestamp":"2026-06-26T16:26:40.036+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.8ms)","duration":"2029.8ms","level":"slow","span":"c4bbcd43d2babad7","trace":"9106323ff8089d70e0ae520af215e5e7"} -{"@timestamp":"2026-06-26T16:26:40.036+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.8ms","level":"info","span":"c4bbcd43d2babad7","trace":"9106323ff8089d70e0ae520af215e5e7"} -{"@timestamp":"2026-06-26T16:26:45.077+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.8ms)","duration":"2037.8ms","level":"slow","span":"6f91d343dd49ecf3","trace":"56309ed5c724fd6d58183d3eff2d4232"} -{"@timestamp":"2026-06-26T16:26:45.077+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.8ms","level":"info","span":"6f91d343dd49ecf3","trace":"56309ed5c724fd6d58183d3eff2d4232"} -{"@timestamp":"2026-06-26T16:26:50.146+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.4ms)","duration":"2065.4ms","level":"slow","span":"f7b47613ffadb6df","trace":"c2ccbd218648f1911ea66650e23a67eb"} -{"@timestamp":"2026-06-26T16:26:50.146+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.4ms","level":"info","span":"f7b47613ffadb6df","trace":"c2ccbd218648f1911ea66650e23a67eb"} -{"@timestamp":"2026-06-26T16:26:55.210+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.1ms)","duration":"2059.1ms","level":"slow","span":"45d7a990e69d48f2","trace":"0f9b72b452a579278f5ab5c923320fd3"} -{"@timestamp":"2026-06-26T16:26:55.210+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.1ms","level":"info","span":"45d7a990e69d48f2","trace":"0f9b72b452a579278f5ab5c923320fd3"} -{"@timestamp":"2026-06-26T16:27:00.259+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.0ms)","duration":"2045.0ms","level":"slow","span":"dc91a9ff6ea44678","trace":"43e085c156b750eaa35f1717572560c0"} -{"@timestamp":"2026-06-26T16:27:00.259+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.0ms","level":"info","span":"dc91a9ff6ea44678","trace":"43e085c156b750eaa35f1717572560c0"} -{"@timestamp":"2026-06-26T16:27:05.289+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"d4ca8217bc585c14","trace":"ab8d47faecc7bae3ce740d3100eaa8cf"} -{"@timestamp":"2026-06-26T16:27:05.289+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"d4ca8217bc585c14","trace":"ab8d47faecc7bae3ce740d3100eaa8cf"} -{"@timestamp":"2026-06-26T16:27:10.330+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.0ms)","duration":"2037.0ms","level":"slow","span":"6aae64f427f86ff7","trace":"00da45983e36443e9f777f30a585b4b1"} -{"@timestamp":"2026-06-26T16:27:10.330+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.0ms","level":"info","span":"6aae64f427f86ff7","trace":"00da45983e36443e9f777f30a585b4b1"} -{"@timestamp":"2026-06-26T16:27:15.378+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.0ms)","duration":"2045.0ms","level":"slow","span":"6b8087400d46fdbe","trace":"e88440e4c5d40115ab778df7f891b63b"} -{"@timestamp":"2026-06-26T16:27:15.379+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.0ms","level":"info","span":"6b8087400d46fdbe","trace":"e88440e4c5d40115ab778df7f891b63b"} -{"@timestamp":"2026-06-26T16:27:18.403+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=96.5Mi, Sys=19.6Mi, NumGC=53","level":"stat"} -{"@timestamp":"2026-06-26T16:27:18.419+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:27:20.419+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.3ms)","duration":"2036.3ms","level":"slow","span":"2d12fc9931648644","trace":"fe8666bd3a3e3e655c6d80294372f311"} -{"@timestamp":"2026-06-26T16:27:20.419+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.3ms","level":"info","span":"2d12fc9931648644","trace":"fe8666bd3a3e3e655c6d80294372f311"} -{"@timestamp":"2026-06-26T16:27:21.034+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2042.3ms, med: 2037.8ms, 90th: 2065.4ms, 99th: 2065.4ms, 99.9th: 2065.4ms","level":"stat"} -{"@timestamp":"2026-06-26T16:27:25.455+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"760feb25bd3bc2e2","trace":"0a145688b7836383ea043b7fdd5818ed"} -{"@timestamp":"2026-06-26T16:27:25.455+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"760feb25bd3bc2e2","trace":"0a145688b7836383ea043b7fdd5818ed"} -{"@timestamp":"2026-06-26T16:27:30.475+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.0ms)","duration":"2016.0ms","level":"slow","span":"7f65be47a6d0c437","trace":"7e41cb3a1262ee545e81941d1aa9dd42"} -{"@timestamp":"2026-06-26T16:27:30.475+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.0ms","level":"info","span":"7f65be47a6d0c437","trace":"7e41cb3a1262ee545e81941d1aa9dd42"} -{"@timestamp":"2026-06-26T16:27:35.525+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.9ms)","duration":"2047.9ms","level":"slow","span":"142274e2384ded08","trace":"ce3021eac420f8466f3e9179012898aa"} -{"@timestamp":"2026-06-26T16:27:35.526+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.9ms","level":"info","span":"142274e2384ded08","trace":"ce3021eac420f8466f3e9179012898aa"} -{"@timestamp":"2026-06-26T16:27:40.574+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.6ms)","duration":"2044.6ms","level":"slow","span":"383ad6e47a9fbfa1","trace":"37b6f4974db7f834dde97c6b979eb0d4"} -{"@timestamp":"2026-06-26T16:27:40.574+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.6ms","level":"info","span":"383ad6e47a9fbfa1","trace":"37b6f4974db7f834dde97c6b979eb0d4"} -{"@timestamp":"2026-06-26T16:27:45.613+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.2ms)","duration":"2037.2ms","level":"slow","span":"4b21fe84bfed6f22","trace":"28bece1d7394b13e3bb81ea26a944b41"} -{"@timestamp":"2026-06-26T16:27:45.613+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.2ms","level":"info","span":"4b21fe84bfed6f22","trace":"28bece1d7394b13e3bb81ea26a944b41"} -{"@timestamp":"2026-06-26T16:27:50.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.9ms)","duration":"2035.9ms","level":"slow","span":"df2138e6ecb5407e","trace":"aaf91c8994056711c08e7e9bd526e173"} -{"@timestamp":"2026-06-26T16:27:50.652+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.9ms","level":"info","span":"df2138e6ecb5407e","trace":"aaf91c8994056711c08e7e9bd526e173"} -{"@timestamp":"2026-06-26T16:27:55.712+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.2ms)","duration":"2058.2ms","level":"slow","span":"ecd6626e7214afb1","trace":"342aaae8b0f4ef04835db9908bff2ab0"} -{"@timestamp":"2026-06-26T16:27:55.712+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.2ms","level":"info","span":"ecd6626e7214afb1","trace":"342aaae8b0f4ef04835db9908bff2ab0"} -{"@timestamp":"2026-06-26T16:28:00.802+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2086.8ms)","duration":"2086.8ms","level":"slow","span":"9d1ad654a864bd63","trace":"b196f7ee03ea176c4cbb375506642c3b"} -{"@timestamp":"2026-06-26T16:28:00.803+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2086.8ms","level":"info","span":"9d1ad654a864bd63","trace":"b196f7ee03ea176c4cbb375506642c3b"} -{"@timestamp":"2026-06-26T16:28:05.840+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.4ms)","duration":"2033.4ms","level":"slow","span":"46be56f159994912","trace":"28aaa235d351114c9473d8ea858ee909"} -{"@timestamp":"2026-06-26T16:28:05.840+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.4ms","level":"info","span":"46be56f159994912","trace":"28aaa235d351114c9473d8ea858ee909"} -{"@timestamp":"2026-06-26T16:28:10.868+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.4ms)","duration":"2026.4ms","level":"slow","span":"038cc73a29d3dee3","trace":"64c161f9357e0d714e329f25f0776f8e"} -{"@timestamp":"2026-06-26T16:28:10.868+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.4ms","level":"info","span":"038cc73a29d3dee3","trace":"64c161f9357e0d714e329f25f0776f8e"} -{"@timestamp":"2026-06-26T16:28:15.953+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.4ms)","duration":"2081.4ms","level":"slow","span":"f9dd1cc3c1d0076b","trace":"1a91ca859c41d75c0b2eb8c2939282f0"} -{"@timestamp":"2026-06-26T16:28:15.954+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.4ms","level":"info","span":"f9dd1cc3c1d0076b","trace":"1a91ca859c41d75c0b2eb8c2939282f0"} -{"@timestamp":"2026-06-26T16:28:18.398+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=96.6Mi, Sys=19.6Mi, NumGC=53","level":"stat"} -{"@timestamp":"2026-06-26T16:28:18.416+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:28:20.992+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.0ms)","duration":"2035.0ms","level":"slow","span":"b12127d5a8aa91c4","trace":"fcc2f7077c8d5aa107372855e8f6fc32"} -{"@timestamp":"2026-06-26T16:28:20.992+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.0ms","level":"info","span":"b12127d5a8aa91c4","trace":"fcc2f7077c8d5aa107372855e8f6fc32"} -{"@timestamp":"2026-06-26T16:28:21.030+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.5ms, med: 2037.1ms, 90th: 2086.7ms, 99th: 2086.7ms, 99.9th: 2086.7ms","level":"stat"} -{"@timestamp":"2026-06-26T16:28:26.046+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.7ms)","duration":"2050.7ms","level":"slow","span":"169a68512d5dfdd2","trace":"c138d8807c20b554279cf948641a864f"} -{"@timestamp":"2026-06-26T16:28:26.046+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.7ms","level":"info","span":"169a68512d5dfdd2","trace":"c138d8807c20b554279cf948641a864f"} -{"@timestamp":"2026-06-26T16:28:31.145+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2095.6ms)","duration":"2095.6ms","level":"slow","span":"cea862b633ead120","trace":"c167136eb24f5e6ff2f8eea9b100b6f3"} -{"@timestamp":"2026-06-26T16:28:31.145+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2095.6ms","level":"info","span":"cea862b633ead120","trace":"c167136eb24f5e6ff2f8eea9b100b6f3"} -{"@timestamp":"2026-06-26T16:28:36.222+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.6ms)","duration":"2071.6ms","level":"slow","span":"983a32a6855daab7","trace":"4a2ddb22d5250efde27ac7eda9fdaa68"} -{"@timestamp":"2026-06-26T16:28:36.222+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.6ms","level":"info","span":"983a32a6855daab7","trace":"4a2ddb22d5250efde27ac7eda9fdaa68"} -{"@timestamp":"2026-06-26T16:28:41.297+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.1ms)","duration":"2073.1ms","level":"slow","span":"64691003a79c80aa","trace":"cd90dc075cfa88bf6aaf51e09c857e57"} -{"@timestamp":"2026-06-26T16:28:41.297+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.1ms","level":"info","span":"64691003a79c80aa","trace":"cd90dc075cfa88bf6aaf51e09c857e57"} -{"@timestamp":"2026-06-26T16:28:46.370+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.1ms)","duration":"2069.1ms","level":"slow","span":"6bb55e72dd6af6a4","trace":"bd76d0b2b64ef1f4079cfeb59eebaa67"} -{"@timestamp":"2026-06-26T16:28:46.370+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.1ms","level":"info","span":"6bb55e72dd6af6a4","trace":"bd76d0b2b64ef1f4079cfeb59eebaa67"} -{"@timestamp":"2026-06-26T16:28:51.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2064.0ms)","duration":"2064.0ms","level":"slow","span":"9b346fb0611e3156","trace":"db1fd71a47fbea9a0748a83097464c31"} -{"@timestamp":"2026-06-26T16:28:51.439+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2064.0ms","level":"info","span":"9b346fb0611e3156","trace":"db1fd71a47fbea9a0748a83097464c31"} -{"@timestamp":"2026-06-26T16:28:56.525+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2083.4ms)","duration":"2083.4ms","level":"slow","span":"91b1316259153ed0","trace":"06e8b6b8818dba37f0c448755b022e88"} -{"@timestamp":"2026-06-26T16:28:56.525+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2083.4ms","level":"info","span":"91b1316259153ed0","trace":"06e8b6b8818dba37f0c448755b022e88"} -{"@timestamp":"2026-06-26T16:29:01.562+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.5ms)","duration":"2033.5ms","level":"slow","span":"e46d9d1f129d2cd8","trace":"6cfc3ab84f534b0508819fa1040b2299"} -{"@timestamp":"2026-06-26T16:29:01.562+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.5ms","level":"info","span":"e46d9d1f129d2cd8","trace":"6cfc3ab84f534b0508819fa1040b2299"} -{"@timestamp":"2026-06-26T16:29:06.621+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.5ms)","duration":"2055.5ms","level":"slow","span":"c7605a8ffecd22f2","trace":"76bf2b90f0df51d8596a9b4fc9a0f447"} -{"@timestamp":"2026-06-26T16:29:06.621+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.5ms","level":"info","span":"c7605a8ffecd22f2","trace":"76bf2b90f0df51d8596a9b4fc9a0f447"} -{"@timestamp":"2026-06-26T16:29:11.704+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.0ms)","duration":"2081.0ms","level":"slow","span":"9f808bed70b55a92","trace":"384457950382faea29809b7db134cb2b"} -{"@timestamp":"2026-06-26T16:29:11.704+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.0ms","level":"info","span":"9f808bed70b55a92","trace":"384457950382faea29809b7db134cb2b"} -{"@timestamp":"2026-06-26T16:29:16.783+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2075.1ms)","duration":"2075.1ms","level":"slow","span":"1643f4c3ca322912","trace":"a019e08b4b107293d38e853e108e58d7"} -{"@timestamp":"2026-06-26T16:29:16.784+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2075.1ms","level":"info","span":"1643f4c3ca322912","trace":"a019e08b4b107293d38e853e108e58d7"} -{"@timestamp":"2026-06-26T16:29:18.396+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=97.2Mi, Sys=19.6Mi, NumGC=54","level":"stat"} -{"@timestamp":"2026-06-26T16:29:18.412+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:29:21.026+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2068.3ms, med: 2073.0ms, 90th: 2095.4ms, 99th: 2095.4ms, 99.9th: 2095.4ms","level":"stat"} -{"@timestamp":"2026-06-26T16:29:21.816+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.0ms)","duration":"2027.0ms","level":"slow","span":"4bf63e40ffe72914","trace":"8e30c569f54a16a274a8fa7bc92dea6d"} -{"@timestamp":"2026-06-26T16:29:21.816+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.0ms","level":"info","span":"4bf63e40ffe72914","trace":"8e30c569f54a16a274a8fa7bc92dea6d"} -{"@timestamp":"2026-06-26T16:29:26.890+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.0ms)","duration":"2069.0ms","level":"slow","span":"167f7f7f6d92bfa5","trace":"407ee544c479c129b0cf3c781a5dec8e"} -{"@timestamp":"2026-06-26T16:29:26.891+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.0ms","level":"info","span":"167f7f7f6d92bfa5","trace":"407ee544c479c129b0cf3c781a5dec8e"} -{"@timestamp":"2026-06-26T16:29:31.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.8ms)","duration":"2039.8ms","level":"slow","span":"ce19415e846a6f32","trace":"03625bdc33b0693a369c98f5841b5d34"} -{"@timestamp":"2026-06-26T16:29:31.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.8ms","level":"info","span":"ce19415e846a6f32","trace":"03625bdc33b0693a369c98f5841b5d34"} -{"@timestamp":"2026-06-26T16:29:36.989+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.1ms)","duration":"2053.1ms","level":"slow","span":"5cb39908b73dabed","trace":"e3b9d563e8b54d7a297b39b31fb77e79"} -{"@timestamp":"2026-06-26T16:29:36.989+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.1ms","level":"info","span":"5cb39908b73dabed","trace":"e3b9d563e8b54d7a297b39b31fb77e79"} -{"@timestamp":"2026-06-26T16:29:42.074+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.4ms)","duration":"2081.4ms","level":"slow","span":"b75d2534f57064a6","trace":"3eec4fb36af3b7b28451e595a82b1543"} -{"@timestamp":"2026-06-26T16:29:42.074+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.4ms","level":"info","span":"b75d2534f57064a6","trace":"3eec4fb36af3b7b28451e595a82b1543"} -{"@timestamp":"2026-06-26T16:29:47.114+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.6ms)","duration":"2035.6ms","level":"slow","span":"aa0c83a7c874817a","trace":"57ff16f8719e8b27d1a1b120f071cbb7"} -{"@timestamp":"2026-06-26T16:29:47.114+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.6ms","level":"info","span":"aa0c83a7c874817a","trace":"57ff16f8719e8b27d1a1b120f071cbb7"} -{"@timestamp":"2026-06-26T16:29:52.219+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2100.0ms)","duration":"2100.0ms","level":"slow","span":"958b76938b2a1288","trace":"b00ea6b015c769e83a8257db6ae2849b"} -{"@timestamp":"2026-06-26T16:29:52.219+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2100.0ms","level":"info","span":"958b76938b2a1288","trace":"b00ea6b015c769e83a8257db6ae2849b"} -{"@timestamp":"2026-06-26T16:29:57.300+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.3ms)","duration":"2076.3ms","level":"slow","span":"fc1e10c4b643541b","trace":"b83ea9fa1b4c45a31c87f7308aff2507"} -{"@timestamp":"2026-06-26T16:29:57.300+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.3ms","level":"info","span":"fc1e10c4b643541b","trace":"b83ea9fa1b4c45a31c87f7308aff2507"} -{"@timestamp":"2026-06-26T16:30:02.395+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2090.7ms)","duration":"2090.7ms","level":"slow","span":"72853ca4ad4d4c13","trace":"9cbe19d5badb15cf28f03fafba1d8ed5"} -{"@timestamp":"2026-06-26T16:30:02.395+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2090.7ms","level":"info","span":"72853ca4ad4d4c13","trace":"9cbe19d5badb15cf28f03fafba1d8ed5"} -{"@timestamp":"2026-06-26T16:30:07.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.3ms)","duration":"2039.3ms","level":"slow","span":"351cc75e58ef560a","trace":"3b49cd7601de9b91214fe0c4f3b9400b"} -{"@timestamp":"2026-06-26T16:30:07.437+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.3ms","level":"info","span":"351cc75e58ef560a","trace":"3b49cd7601de9b91214fe0c4f3b9400b"} -{"@timestamp":"2026-06-26T16:30:12.481+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.2ms)","duration":"2041.2ms","level":"slow","span":"fe14bd7edacc123b","trace":"75c43a72f9c6c57b75b33a88e4536355"} -{"@timestamp":"2026-06-26T16:30:12.481+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.2ms","level":"info","span":"fe14bd7edacc123b","trace":"75c43a72f9c6c57b75b33a88e4536355"} -{"@timestamp":"2026-06-26T16:30:17.552+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.0ms)","duration":"2066.0ms","level":"slow","span":"1fc3cefb2140e0b3","trace":"137ae67ca6af96f4e399327e41f07555"} -{"@timestamp":"2026-06-26T16:30:17.552+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.0ms","level":"info","span":"1fc3cefb2140e0b3","trace":"137ae67ca6af96f4e399327e41f07555"} -{"@timestamp":"2026-06-26T16:30:18.392+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=97.4Mi, Sys=19.6Mi, NumGC=54","level":"stat"} -{"@timestamp":"2026-06-26T16:30:18.408+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:30:21.023+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2059.8ms, med: 2065.9ms, 90th: 2099.9ms, 99th: 2099.9ms, 99.9th: 2099.9ms","level":"stat"} -{"@timestamp":"2026-06-26T16:30:22.602+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.3ms)","duration":"2045.3ms","level":"slow","span":"74145824038985db","trace":"ed5c5994f8eb85b23cb1314ce9a31200"} -{"@timestamp":"2026-06-26T16:30:22.602+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.3ms","level":"info","span":"74145824038985db","trace":"ed5c5994f8eb85b23cb1314ce9a31200"} -{"@timestamp":"2026-06-26T16:30:27.615+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.6ms)","duration":"2008.6ms","level":"slow","span":"7ba37cc211faa83a","trace":"48329f5b4d5c1e59b900c06df6a7e0dd"} -{"@timestamp":"2026-06-26T16:30:27.615+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.6ms","level":"info","span":"7ba37cc211faa83a","trace":"48329f5b4d5c1e59b900c06df6a7e0dd"} -{"@timestamp":"2026-06-26T16:30:32.700+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.6ms)","duration":"2081.6ms","level":"slow","span":"b39dc365701b2356","trace":"6aa6a6ffec5f98286e27ef65e9b611d6"} -{"@timestamp":"2026-06-26T16:30:32.700+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.6ms","level":"info","span":"b39dc365701b2356","trace":"6aa6a6ffec5f98286e27ef65e9b611d6"} -{"@timestamp":"2026-06-26T16:30:37.756+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.4ms)","duration":"2051.4ms","level":"slow","span":"726427d14ef96459","trace":"c5e170d25301e6d49282f50da9b1415a"} -{"@timestamp":"2026-06-26T16:30:37.756+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.4ms","level":"info","span":"726427d14ef96459","trace":"c5e170d25301e6d49282f50da9b1415a"} -{"@timestamp":"2026-06-26T16:30:42.837+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.8ms)","duration":"2076.8ms","level":"slow","span":"9c8205fe7715d036","trace":"d2c5600e0621725f81d2c9386df2181d"} -{"@timestamp":"2026-06-26T16:30:42.837+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.8ms","level":"info","span":"9c8205fe7715d036","trace":"d2c5600e0621725f81d2c9386df2181d"} -{"@timestamp":"2026-06-26T16:30:47.876+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.7ms)","duration":"2035.7ms","level":"slow","span":"43b86448a33fb1f9","trace":"6979a3cff2dbcafdb7975d6537a923f2"} -{"@timestamp":"2026-06-26T16:30:47.876+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.7ms","level":"info","span":"43b86448a33fb1f9","trace":"6979a3cff2dbcafdb7975d6537a923f2"} -{"@timestamp":"2026-06-26T16:30:52.899+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.9ms)","duration":"2018.9ms","level":"slow","span":"c4c246dc53319ebc","trace":"8f27fbf814bc03243c84404e9a195754"} -{"@timestamp":"2026-06-26T16:30:52.900+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.9ms","level":"info","span":"c4c246dc53319ebc","trace":"8f27fbf814bc03243c84404e9a195754"} -{"@timestamp":"2026-06-26T16:30:57.971+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.3ms)","duration":"2066.3ms","level":"slow","span":"b445e096fbf91005","trace":"031b80d7bfcdf25d7fb1137bb6cd8c97"} -{"@timestamp":"2026-06-26T16:30:57.971+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.3ms","level":"info","span":"b445e096fbf91005","trace":"031b80d7bfcdf25d7fb1137bb6cd8c97"} -{"@timestamp":"2026-06-26T16:31:03.035+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.9ms)","duration":"2059.9ms","level":"slow","span":"2d7b3a8f7e16a7f0","trace":"6a1cab8a1700763c491e09b98b9ae18d"} -{"@timestamp":"2026-06-26T16:31:03.035+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.9ms","level":"info","span":"2d7b3a8f7e16a7f0","trace":"6a1cab8a1700763c491e09b98b9ae18d"} -{"@timestamp":"2026-06-26T16:31:08.111+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.5ms)","duration":"2073.5ms","level":"slow","span":"ab45af367d074c9c","trace":"a313fc2c441d9cdee836c0fd37b8dd95"} -{"@timestamp":"2026-06-26T16:31:08.111+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.5ms","level":"info","span":"ab45af367d074c9c","trace":"a313fc2c441d9cdee836c0fd37b8dd95"} -{"@timestamp":"2026-06-26T16:31:13.185+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.5ms)","duration":"2071.5ms","level":"slow","span":"38fb7bac65b71630","trace":"23c405a00c7b2c02772bc1a3e96052f0"} -{"@timestamp":"2026-06-26T16:31:13.185+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.5ms","level":"info","span":"38fb7bac65b71630","trace":"23c405a00c7b2c02772bc1a3e96052f0"} -{"@timestamp":"2026-06-26T16:31:18.270+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2080.8ms)","duration":"2080.8ms","level":"slow","span":"3f02ba91ae4101f3","trace":"99b86ff94f764f311e8ba4a1ba94ca76"} -{"@timestamp":"2026-06-26T16:31:18.270+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2080.8ms","level":"info","span":"3f02ba91ae4101f3","trace":"99b86ff94f764f311e8ba4a1ba94ca76"} -{"@timestamp":"2026-06-26T16:31:18.388+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=97.9Mi, Sys=19.6Mi, NumGC=55","level":"stat"} -{"@timestamp":"2026-06-26T16:31:18.405+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:31:21.019+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2055.7ms, med: 2066.1ms, 90th: 2081.5ms, 99th: 2081.5ms, 99.9th: 2081.5ms","level":"stat"} -{"@timestamp":"2026-06-26T16:31:23.533+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2260.0ms)","duration":"2260.0ms","level":"slow","span":"c7e372c368d355a5","trace":"a904d4779306896b3d1e41fb0057c82f"} -{"@timestamp":"2026-06-26T16:31:23.534+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2260.0ms","level":"info","span":"c7e372c368d355a5","trace":"a904d4779306896b3d1e41fb0057c82f"} -{"@timestamp":"2026-06-26T16:31:28.744+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2206.2ms)","duration":"2206.2ms","level":"slow","span":"592ef905ca7324f2","trace":"cdfbbd30e0704fccd3962d4ee31cf606"} -{"@timestamp":"2026-06-26T16:31:28.744+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2206.2ms","level":"info","span":"592ef905ca7324f2","trace":"cdfbbd30e0704fccd3962d4ee31cf606"} -{"@timestamp":"2026-06-26T16:31:33.943+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2194.2ms)","duration":"2194.2ms","level":"slow","span":"6af2bc0cd1e8f439","trace":"6258eae5fe6f5257e88240f5fbfcc5d6"} -{"@timestamp":"2026-06-26T16:31:33.943+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2194.2ms","level":"info","span":"6af2bc0cd1e8f439","trace":"6258eae5fe6f5257e88240f5fbfcc5d6"} -{"@timestamp":"2026-06-26T16:31:39.153+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2206.0ms)","duration":"2206.0ms","level":"slow","span":"c9f5b387b51edae0","trace":"323b23118e2533b1e36b55b42c974b23"} -{"@timestamp":"2026-06-26T16:31:39.153+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2206.0ms","level":"info","span":"c9f5b387b51edae0","trace":"323b23118e2533b1e36b55b42c974b23"} -{"@timestamp":"2026-06-26T16:31:44.351+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2194.2ms)","duration":"2194.2ms","level":"slow","span":"d7e030f8237d186a","trace":"09103848f5feb4eea0f35e1381926ab2"} -{"@timestamp":"2026-06-26T16:31:44.351+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2194.2ms","level":"info","span":"d7e030f8237d186a","trace":"09103848f5feb4eea0f35e1381926ab2"} -{"@timestamp":"2026-06-26T16:31:49.399+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.3ms)","duration":"2045.3ms","level":"slow","span":"10054041b1c606af","trace":"f387cb84cb712de12bf111d4ff4010dc"} -{"@timestamp":"2026-06-26T16:31:49.399+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.3ms","level":"info","span":"10054041b1c606af","trace":"f387cb84cb712de12bf111d4ff4010dc"} -{"@timestamp":"2026-06-26T16:31:54.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.1ms)","duration":"2034.1ms","level":"slow","span":"4b6d3156bba6a48e","trace":"b4ee23577054d262476d403575d64532"} -{"@timestamp":"2026-06-26T16:31:54.436+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.1ms","level":"info","span":"4b6d3156bba6a48e","trace":"b4ee23577054d262476d403575d64532"} -{"@timestamp":"2026-06-26T16:31:59.476+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"dbf6d062ca709f7c","trace":"aa6f776078319322f684ae8a2bf2edc1"} -{"@timestamp":"2026-06-26T16:31:59.476+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"dbf6d062ca709f7c","trace":"aa6f776078319322f684ae8a2bf2edc1"} -{"@timestamp":"2026-06-26T16:32:04.569+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2086.8ms)","duration":"2086.8ms","level":"slow","span":"0c82d753b1f7260a","trace":"b92a3cc449f428b6eafe60adc946e968"} -{"@timestamp":"2026-06-26T16:32:04.569+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2086.8ms","level":"info","span":"0c82d753b1f7260a","trace":"b92a3cc449f428b6eafe60adc946e968"} -{"@timestamp":"2026-06-26T16:32:09.633+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.5ms)","duration":"2060.5ms","level":"slow","span":"25eac505cef53d6e","trace":"e908481265cc593b86149ceb301f56cc"} -{"@timestamp":"2026-06-26T16:32:09.633+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.5ms","level":"info","span":"25eac505cef53d6e","trace":"e908481265cc593b86149ceb301f56cc"} -{"@timestamp":"2026-06-26T16:32:14.682+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.9ms)","duration":"2044.9ms","level":"slow","span":"74307c7851d88ba7","trace":"7dba01655b3f241d8f75b727966a9a17"} -{"@timestamp":"2026-06-26T16:32:14.682+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.9ms","level":"info","span":"74307c7851d88ba7","trace":"7dba01655b3f241d8f75b727966a9a17"} -{"@timestamp":"2026-06-26T16:32:18.385+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=98.0Mi, Sys=19.6Mi, NumGC=55","level":"stat"} -{"@timestamp":"2026-06-26T16:32:18.401+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:32:19.745+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.7ms)","duration":"2059.7ms","level":"slow","span":"bef12da237b78fef","trace":"286e8c18541b89e185104dce69231ac0"} -{"@timestamp":"2026-06-26T16:32:19.745+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.7ms","level":"info","span":"bef12da237b78fef","trace":"286e8c18541b89e185104dce69231ac0"} -{"@timestamp":"2026-06-26T16:32:21.015+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2118.7ms, med: 2086.7ms, 90th: 2259.9ms, 99th: 2259.9ms, 99.9th: 2259.9ms","level":"stat"} -{"@timestamp":"2026-06-26T16:32:24.810+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.3ms)","duration":"2060.3ms","level":"slow","span":"4e3b42c844b45082","trace":"8be02509dfcce05482e39e2a2eeba02a"} -{"@timestamp":"2026-06-26T16:32:24.810+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.3ms","level":"info","span":"4e3b42c844b45082","trace":"8be02509dfcce05482e39e2a2eeba02a"} -{"@timestamp":"2026-06-26T16:32:29.852+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.6ms)","duration":"2038.6ms","level":"slow","span":"4d1d35a9ec2d91ae","trace":"81224e0ecd11eb62f05e2865dd614520"} -{"@timestamp":"2026-06-26T16:32:29.853+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.6ms","level":"info","span":"4d1d35a9ec2d91ae","trace":"81224e0ecd11eb62f05e2865dd614520"} -{"@timestamp":"2026-06-26T16:32:34.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.7ms)","duration":"2038.7ms","level":"slow","span":"c3505f0df6603dba","trace":"53f511cc0976f5ae08764a8112a959d9"} -{"@timestamp":"2026-06-26T16:32:34.896+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.7ms","level":"info","span":"c3505f0df6603dba","trace":"53f511cc0976f5ae08764a8112a959d9"} -{"@timestamp":"2026-06-26T16:32:39.931+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.5ms)","duration":"2031.5ms","level":"slow","span":"40aed877f5912dd1","trace":"368cf73126889db6fde99b51b526484a"} -{"@timestamp":"2026-06-26T16:32:39.931+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.5ms","level":"info","span":"40aed877f5912dd1","trace":"368cf73126889db6fde99b51b526484a"} -{"@timestamp":"2026-06-26T16:32:44.976+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.7ms)","duration":"2040.7ms","level":"slow","span":"f97e97027270f045","trace":"15fe840ab3221075f9bb6fc278e63270"} -{"@timestamp":"2026-06-26T16:32:44.976+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.7ms","level":"info","span":"f97e97027270f045","trace":"15fe840ab3221075f9bb6fc278e63270"} -{"@timestamp":"2026-06-26T16:32:50.041+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.3ms)","duration":"2061.3ms","level":"slow","span":"4f384784c48a77a5","trace":"cb8e5954a08e76e10c0ff3f2845a4961"} -{"@timestamp":"2026-06-26T16:32:50.042+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.3ms","level":"info","span":"4f384784c48a77a5","trace":"cb8e5954a08e76e10c0ff3f2845a4961"} -{"@timestamp":"2026-06-26T16:32:55.119+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.9ms)","duration":"2073.9ms","level":"slow","span":"b7cce025d3acf42d","trace":"efae06cf98a58f0151797f100fb5166c"} -{"@timestamp":"2026-06-26T16:32:55.119+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.9ms","level":"info","span":"b7cce025d3acf42d","trace":"efae06cf98a58f0151797f100fb5166c"} -{"@timestamp":"2026-06-26T16:33:00.202+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2078.9ms)","duration":"2078.9ms","level":"slow","span":"d7f9e80d5f00ba66","trace":"4a1e5b0b0fbb9ed3cd05839e99b32943"} -{"@timestamp":"2026-06-26T16:33:00.202+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2078.9ms","level":"info","span":"d7f9e80d5f00ba66","trace":"4a1e5b0b0fbb9ed3cd05839e99b32943"} -{"@timestamp":"2026-06-26T16:33:05.241+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.6ms)","duration":"2034.6ms","level":"slow","span":"c9ae3f1afc429c51","trace":"16852fc0fec6616e28be4a61e90d5274"} -{"@timestamp":"2026-06-26T16:33:05.241+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.6ms","level":"info","span":"c9ae3f1afc429c51","trace":"16852fc0fec6616e28be4a61e90d5274"} -{"@timestamp":"2026-06-26T16:33:10.292+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.7ms)","duration":"2047.7ms","level":"slow","span":"83cb07ea9be9bba4","trace":"651d24247cfbd62bf654b65608467356"} -{"@timestamp":"2026-06-26T16:33:10.292+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.7ms","level":"info","span":"83cb07ea9be9bba4","trace":"651d24247cfbd62bf654b65608467356"} -{"@timestamp":"2026-06-26T16:33:15.367+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.1ms)","duration":"2070.1ms","level":"slow","span":"824d0a81c0488cbe","trace":"a3b7c0e1df8b4a3bcf92f37fa46b1c7f"} -{"@timestamp":"2026-06-26T16:33:15.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.1ms","level":"info","span":"824d0a81c0488cbe","trace":"a3b7c0e1df8b4a3bcf92f37fa46b1c7f"} -{"@timestamp":"2026-06-26T16:33:18.381+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=98.6Mi, Sys=19.6Mi, NumGC=56","level":"stat"} -{"@timestamp":"2026-06-26T16:33:18.398+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:33:20.405+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.4ms)","duration":"2034.4ms","level":"slow","span":"b4f3548245326533","trace":"503f0e05cbb457fa4e5c6397ee842338"} -{"@timestamp":"2026-06-26T16:33:20.405+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.4ms","level":"info","span":"b4f3548245326533","trace":"503f0e05cbb457fa4e5c6397ee842338"} -{"@timestamp":"2026-06-26T16:33:21.012+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2050.8ms, med: 2047.6ms, 90th: 2078.7ms, 99th: 2078.7ms, 99.9th: 2078.7ms","level":"stat"} -{"@timestamp":"2026-06-26T16:33:25.431+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.9ms)","duration":"2021.9ms","level":"slow","span":"5097303d1b5dd758","trace":"27d4b88de9dde69e22202336c3c2638e"} -{"@timestamp":"2026-06-26T16:33:25.432+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.9ms","level":"info","span":"5097303d1b5dd758","trace":"27d4b88de9dde69e22202336c3c2638e"} -{"@timestamp":"2026-06-26T16:33:30.484+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.3ms)","duration":"2049.3ms","level":"slow","span":"6c1d32088a0205b2","trace":"d0b37caf322de61927b279a41bf3074f"} -{"@timestamp":"2026-06-26T16:33:30.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.3ms","level":"info","span":"6c1d32088a0205b2","trace":"d0b37caf322de61927b279a41bf3074f"} -{"@timestamp":"2026-06-26T16:33:35.548+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.7ms)","duration":"2061.7ms","level":"slow","span":"d98edb5f541daeee","trace":"e54242e93005a61c461da92a02f09b6b"} -{"@timestamp":"2026-06-26T16:33:35.548+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.7ms","level":"info","span":"d98edb5f541daeee","trace":"e54242e93005a61c461da92a02f09b6b"} -{"@timestamp":"2026-06-26T16:33:40.615+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"a5a4a0910933f764","trace":"9bf36cb2f52e376c581e2d34759178f6"} -{"@timestamp":"2026-06-26T16:33:40.615+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"a5a4a0910933f764","trace":"9bf36cb2f52e376c581e2d34759178f6"} -{"@timestamp":"2026-06-26T16:33:45.629+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.4ms)","duration":"2011.4ms","level":"slow","span":"2e15a2a3f5276917","trace":"5460e12bfa6222c9c70a29e8536189ce"} -{"@timestamp":"2026-06-26T16:33:45.629+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.4ms","level":"info","span":"2e15a2a3f5276917","trace":"5460e12bfa6222c9c70a29e8536189ce"} -{"@timestamp":"2026-06-26T16:33:50.712+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2078.6ms)","duration":"2078.6ms","level":"slow","span":"fe26ed180c4f093a","trace":"079417b14db9db0d31a9459d869f4215"} -{"@timestamp":"2026-06-26T16:33:50.712+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2078.6ms","level":"info","span":"fe26ed180c4f093a","trace":"079417b14db9db0d31a9459d869f4215"} -{"@timestamp":"2026-06-26T16:33:55.747+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"f7b333194925a8e3","trace":"5dcfbe1bb69c07b973f0eedd5fd6e093"} -{"@timestamp":"2026-06-26T16:33:55.747+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"f7b333194925a8e3","trace":"5dcfbe1bb69c07b973f0eedd5fd6e093"} -{"@timestamp":"2026-06-26T16:34:00.818+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.8ms)","duration":"2067.8ms","level":"slow","span":"c846967b2154d694","trace":"3c69c11221d5ae131c0e7f9e372b3ead"} -{"@timestamp":"2026-06-26T16:34:00.818+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.8ms","level":"info","span":"c846967b2154d694","trace":"3c69c11221d5ae131c0e7f9e372b3ead"} -{"@timestamp":"2026-06-26T16:34:05.850+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.9ms)","duration":"2027.9ms","level":"slow","span":"e9fda9212f9f39e9","trace":"890e1c5ef983075c364b1a481e0d3eca"} -{"@timestamp":"2026-06-26T16:34:05.850+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.9ms","level":"info","span":"e9fda9212f9f39e9","trace":"890e1c5ef983075c364b1a481e0d3eca"} -{"@timestamp":"2026-06-26T16:34:10.908+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.2ms)","duration":"2055.2ms","level":"slow","span":"4a467d1095a546e6","trace":"a5f28da5bf3c998216d53ab99954371f"} -{"@timestamp":"2026-06-26T16:34:10.909+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.2ms","level":"info","span":"4a467d1095a546e6","trace":"a5f28da5bf3c998216d53ab99954371f"} -{"@timestamp":"2026-06-26T16:34:15.970+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.4ms)","duration":"2057.4ms","level":"slow","span":"396b2ec4ee6c8a01","trace":"dab54e6a56774729067e5ff6eb833aa0"} -{"@timestamp":"2026-06-26T16:34:15.971+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.4ms","level":"info","span":"396b2ec4ee6c8a01","trace":"dab54e6a56774729067e5ff6eb833aa0"} -{"@timestamp":"2026-06-26T16:34:18.377+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=98.7Mi, Sys=19.6Mi, NumGC=56","level":"stat"} -{"@timestamp":"2026-06-26T16:34:18.394+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:34:21.008+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2047.5ms, med: 2057.2ms, 90th: 2078.5ms, 99th: 2078.5ms, 99.9th: 2078.5ms","level":"stat"} -{"@timestamp":"2026-06-26T16:34:21.027+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.8ms)","duration":"2052.8ms","level":"slow","span":"f58ee85275fffc8d","trace":"3baf52014123b4733e5fef92be32bd59"} -{"@timestamp":"2026-06-26T16:34:21.027+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.8ms","level":"info","span":"f58ee85275fffc8d","trace":"3baf52014123b4733e5fef92be32bd59"} -{"@timestamp":"2026-06-26T16:34:26.082+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.2ms)","duration":"2051.2ms","level":"slow","span":"658f53f1e99ae33f","trace":"7681071c8137457bdb8a6a2a7258242e"} -{"@timestamp":"2026-06-26T16:34:26.082+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.2ms","level":"info","span":"658f53f1e99ae33f","trace":"7681071c8137457bdb8a6a2a7258242e"} -{"@timestamp":"2026-06-26T16:34:31.117+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.1ms)","duration":"2031.1ms","level":"slow","span":"b71272fe73b9c192","trace":"7554eccc31e426fe92f4775e740742ac"} -{"@timestamp":"2026-06-26T16:34:31.117+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.1ms","level":"info","span":"b71272fe73b9c192","trace":"7554eccc31e426fe92f4775e740742ac"} -{"@timestamp":"2026-06-26T16:34:36.180+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.8ms)","duration":"2058.8ms","level":"slow","span":"f0a549033a7364c9","trace":"57f11b8c35aa8b446afe5151cea075ae"} -{"@timestamp":"2026-06-26T16:34:36.181+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.8ms","level":"info","span":"f0a549033a7364c9","trace":"57f11b8c35aa8b446afe5151cea075ae"} -{"@timestamp":"2026-06-26T16:34:41.237+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.0ms)","duration":"2052.0ms","level":"slow","span":"b9af5e61083906c7","trace":"7a215521352417575784636f6c46c9fe"} -{"@timestamp":"2026-06-26T16:34:41.237+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.0ms","level":"info","span":"b9af5e61083906c7","trace":"7a215521352417575784636f6c46c9fe"} -{"@timestamp":"2026-06-26T16:34:46.296+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.1ms)","duration":"2054.1ms","level":"slow","span":"3a6ec2fe1fea636b","trace":"9550f2962a01379cd02ac0cde27c227e"} -{"@timestamp":"2026-06-26T16:34:46.296+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.1ms","level":"info","span":"3a6ec2fe1fea636b","trace":"9550f2962a01379cd02ac0cde27c227e"} -{"@timestamp":"2026-06-26T16:34:51.363+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"0e5966f9cf0aedba","trace":"40849dbec535d573e7a8c9fc0819f341"} -{"@timestamp":"2026-06-26T16:34:51.363+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"0e5966f9cf0aedba","trace":"40849dbec535d573e7a8c9fc0819f341"} -{"@timestamp":"2026-06-26T16:34:56.455+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2087.4ms)","duration":"2087.4ms","level":"slow","span":"f852785c23549d78","trace":"ccb0357f19282c7d8788fd35cc3b6f4e"} -{"@timestamp":"2026-06-26T16:34:56.455+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2087.4ms","level":"info","span":"f852785c23549d78","trace":"ccb0357f19282c7d8788fd35cc3b6f4e"} -{"@timestamp":"2026-06-26T16:35:01.514+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.6ms)","duration":"2056.6ms","level":"slow","span":"3d48548b73164b1e","trace":"f0caf8657ce79bc056636a9479e862e6"} -{"@timestamp":"2026-06-26T16:35:01.515+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.6ms","level":"info","span":"3d48548b73164b1e","trace":"f0caf8657ce79bc056636a9479e862e6"} -{"@timestamp":"2026-06-26T16:35:06.580+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.9ms)","duration":"2061.9ms","level":"slow","span":"627aff1ec96140b8","trace":"9d57c99d78d5430486b7838b31c48d1f"} -{"@timestamp":"2026-06-26T16:35:06.580+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.9ms","level":"info","span":"627aff1ec96140b8","trace":"9d57c99d78d5430486b7838b31c48d1f"} -{"@timestamp":"2026-06-26T16:35:11.655+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.4ms)","duration":"2071.4ms","level":"slow","span":"03d916f2c930fca3","trace":"8c3487cc080b274fff1af3c4a72a6fc9"} -{"@timestamp":"2026-06-26T16:35:11.656+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.4ms","level":"info","span":"03d916f2c930fca3","trace":"8c3487cc080b274fff1af3c4a72a6fc9"} -{"@timestamp":"2026-06-26T16:35:16.692+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.0ms)","duration":"2032.0ms","level":"slow","span":"ef8c556c9347c05c","trace":"f951fcb7e39057daa3816d6a42b3ed05"} -{"@timestamp":"2026-06-26T16:35:16.692+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.0ms","level":"info","span":"ef8c556c9347c05c","trace":"f951fcb7e39057daa3816d6a42b3ed05"} -{"@timestamp":"2026-06-26T16:35:18.374+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=99.3Mi, Sys=19.6Mi, NumGC=57","level":"stat"} -{"@timestamp":"2026-06-26T16:35:18.391+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:35:21.005+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2055.8ms, med: 2056.5ms, 90th: 2087.3ms, 99th: 2087.3ms, 99.9th: 2087.3ms","level":"stat"} -{"@timestamp":"2026-06-26T16:35:21.780+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2083.3ms)","duration":"2083.3ms","level":"slow","span":"8228838464f0f37b","trace":"f131fe56eb395579afff62189aabef14"} -{"@timestamp":"2026-06-26T16:35:21.780+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2083.3ms","level":"info","span":"8228838464f0f37b","trace":"f131fe56eb395579afff62189aabef14"} -{"@timestamp":"2026-06-26T16:35:26.836+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.8ms)","duration":"2051.8ms","level":"slow","span":"2fd7dac48bff9ed1","trace":"eb2f7717cb655753f7968eee38d21844"} -{"@timestamp":"2026-06-26T16:35:26.836+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.8ms","level":"info","span":"2fd7dac48bff9ed1","trace":"eb2f7717cb655753f7968eee38d21844"} -{"@timestamp":"2026-06-26T16:35:31.911+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.1ms)","duration":"2070.1ms","level":"slow","span":"ef32134b8aafdbbc","trace":"8f03f660b26e2e297f9593429955f805"} -{"@timestamp":"2026-06-26T16:35:31.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.1ms","level":"info","span":"ef32134b8aafdbbc","trace":"8f03f660b26e2e297f9593429955f805"} -{"@timestamp":"2026-06-26T16:35:36.993+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2077.7ms)","duration":"2077.7ms","level":"slow","span":"a7e4b76e38fb60a2","trace":"d7dcb592892205de706670fa74f9941a"} -{"@timestamp":"2026-06-26T16:35:36.993+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2077.7ms","level":"info","span":"a7e4b76e38fb60a2","trace":"d7dcb592892205de706670fa74f9941a"} -{"@timestamp":"2026-06-26T16:35:42.076+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2079.4ms)","duration":"2079.4ms","level":"slow","span":"45e6a31b95dfe947","trace":"9853b5b9c0e27a1d4f28fd1efb806fa7"} -{"@timestamp":"2026-06-26T16:35:42.077+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2079.4ms","level":"info","span":"45e6a31b95dfe947","trace":"9853b5b9c0e27a1d4f28fd1efb806fa7"} -{"@timestamp":"2026-06-26T16:35:47.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.2ms)","duration":"2044.2ms","level":"slow","span":"4929e0009477bd56","trace":"8ecbcf91525054ba77bf91110e6667b7"} -{"@timestamp":"2026-06-26T16:35:47.126+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.2ms","level":"info","span":"4929e0009477bd56","trace":"8ecbcf91525054ba77bf91110e6667b7"} -{"@timestamp":"2026-06-26T16:35:52.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.2ms)","duration":"2065.2ms","level":"slow","span":"76e5bf34c95afd8a","trace":"b17fe789f4253d41e99a5aa1df566f93"} -{"@timestamp":"2026-06-26T16:35:52.195+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.2ms","level":"info","span":"76e5bf34c95afd8a","trace":"b17fe789f4253d41e99a5aa1df566f93"} -{"@timestamp":"2026-06-26T16:35:57.229+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.1ms)","duration":"2029.1ms","level":"slow","span":"4cc5a3000b1c2b5b","trace":"c09e1ce30c2456567044979a46988024"} -{"@timestamp":"2026-06-26T16:35:57.229+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.1ms","level":"info","span":"4cc5a3000b1c2b5b","trace":"c09e1ce30c2456567044979a46988024"} -{"@timestamp":"2026-06-26T16:36:02.326+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2093.3ms)","duration":"2093.3ms","level":"slow","span":"9b6d44a902105334","trace":"fa4e3c773b9653ae23c66876ecfbfa33"} -{"@timestamp":"2026-06-26T16:36:02.326+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2093.3ms","level":"info","span":"9b6d44a902105334","trace":"fa4e3c773b9653ae23c66876ecfbfa33"} -{"@timestamp":"2026-06-26T16:36:07.351+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.1ms)","duration":"2021.1ms","level":"slow","span":"312f17f74f4a710c","trace":"284bd1edb0e91e409806029b5098761b"} -{"@timestamp":"2026-06-26T16:36:07.351+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.1ms","level":"info","span":"312f17f74f4a710c","trace":"284bd1edb0e91e409806029b5098761b"} -{"@timestamp":"2026-06-26T16:36:12.421+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2068.0ms)","duration":"2068.0ms","level":"slow","span":"76c1bd70f675898a","trace":"117d1135a1ac87050345d16de7c37aa4"} -{"@timestamp":"2026-06-26T16:36:12.421+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2068.0ms","level":"info","span":"76c1bd70f675898a","trace":"117d1135a1ac87050345d16de7c37aa4"} -{"@timestamp":"2026-06-26T16:36:17.499+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.8ms)","duration":"2073.8ms","level":"slow","span":"7735dc2dd00128d1","trace":"5ce769ad65f630a6959f221c8fbcd4eb"} -{"@timestamp":"2026-06-26T16:36:17.499+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.8ms","level":"info","span":"7735dc2dd00128d1","trace":"5ce769ad65f630a6959f221c8fbcd4eb"} -{"@timestamp":"2026-06-26T16:36:18.370+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=99.4Mi, Sys=19.6Mi, NumGC=57","level":"stat"} -{"@timestamp":"2026-06-26T16:36:18.387+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"} -{"@timestamp":"2026-06-26T16:36:21.001+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2062.9ms, med: 2070.0ms, 90th: 2093.1ms, 99th: 2093.1ms, 99.9th: 2093.1ms","level":"stat"} -{"@timestamp":"2026-06-26T16:36:22.558+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.0ms)","duration":"2054.0ms","level":"slow","span":"a463bf145aaee075","trace":"e4d45689d85fea8c81c367ba814614a7"} -{"@timestamp":"2026-06-26T16:36:22.558+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.0ms","level":"info","span":"a463bf145aaee075","trace":"e4d45689d85fea8c81c367ba814614a7"} -{"@timestamp":"2026-06-26T16:36:27.589+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.7ms)","duration":"2027.7ms","level":"slow","span":"8a70738e682d06f6","trace":"ba28e697b236d1707107beb8f48fdb42"} -{"@timestamp":"2026-06-26T16:36:27.590+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.7ms","level":"info","span":"8a70738e682d06f6","trace":"ba28e697b236d1707107beb8f48fdb42"} -{"@timestamp":"2026-06-26T16:36:32.618+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.7ms)","duration":"2025.7ms","level":"slow","span":"348e057e169b3225","trace":"f4d2a07fd2f4de42ea92cc8e00a1f1d0"} -{"@timestamp":"2026-06-26T16:36:32.618+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.7ms","level":"info","span":"348e057e169b3225","trace":"f4d2a07fd2f4de42ea92cc8e00a1f1d0"} -{"@timestamp":"2026-06-26T16:36:37.637+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.0ms)","duration":"2016.0ms","level":"slow","span":"b44ed843ac16733c","trace":"290804501a3746861f263fddd0399b59"} -{"@timestamp":"2026-06-26T16:36:37.637+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.0ms","level":"info","span":"b44ed843ac16733c","trace":"290804501a3746861f263fddd0399b59"} -{"@timestamp":"2026-06-26T16:36:42.668+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.4ms)","duration":"2027.4ms","level":"slow","span":"d10f4ae5da211974","trace":"c858658e77fb6fe2ac02d2026452f5b0"} -{"@timestamp":"2026-06-26T16:36:42.668+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.4ms","level":"info","span":"d10f4ae5da211974","trace":"c858658e77fb6fe2ac02d2026452f5b0"} -{"@timestamp":"2026-06-26T16:36:47.688+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.8ms)","duration":"2017.8ms","level":"slow","span":"a19f5590f8b76824","trace":"68f2690f8692afddbdaf9c233e178b00"} -{"@timestamp":"2026-06-26T16:36:47.688+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.8ms","level":"info","span":"a19f5590f8b76824","trace":"68f2690f8692afddbdaf9c233e178b00"} -{"@timestamp":"2026-06-26T16:36:52.710+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.9ms)","duration":"2018.9ms","level":"slow","span":"3d40c0280d4e338f","trace":"fa719cc659ac3139baa977982a430582"} -{"@timestamp":"2026-06-26T16:36:52.710+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.9ms","level":"info","span":"3d40c0280d4e338f","trace":"fa719cc659ac3139baa977982a430582"} -{"@timestamp":"2026-06-26T16:36:57.738+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.7ms)","duration":"2025.7ms","level":"slow","span":"203ea16969150534","trace":"f67148e2946a7d2e77f3bff9d01125e0"} -{"@timestamp":"2026-06-26T16:36:57.738+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.7ms","level":"info","span":"203ea16969150534","trace":"f67148e2946a7d2e77f3bff9d01125e0"} diff --git a/.run/logs/web.log b/.run/logs/web.log deleted file mode 100644 index 3ee12d0..0000000 --- a/.run/logs/web.log +++ /dev/null @@ -1,9 +0,0 @@ - -> haixun-web@0.1.0 dev -> vite - - - VITE v6.4.3 ready in 187 ms - - ➜ Local: http://localhost:5173/ - ➜ Network: use --host to expose diff --git a/.run/logs/worker.log b/.run/logs/worker.log deleted file mode 100644 index 45c7031..0000000 --- a/.run/logs/worker.log +++ /dev/null @@ -1,1217 +0,0 @@ - -> haixun-master@0.1.0 worker:style-8d -> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts - -[8d-worker] started id=local-style-8d-node-72804 api=http://127.0.0.1:8890 -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) -[8d-worker] loop error Error: internal server error - at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11) - at process.processTicksAndRejections (node:internal/process/task_queues:105:5) - at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19) diff --git a/.run/web.pid b/.run/web.pid deleted file mode 100644 index 56f69ec..0000000 --- a/.run/web.pid +++ /dev/null @@ -1 +0,0 @@ -72713 diff --git a/.run/worker.pid b/.run/worker.pid deleted file mode 100644 index 63d0b24..0000000 --- a/.run/worker.pid +++ /dev/null @@ -1 +0,0 @@ -72714 diff --git a/AGENTS.md b/AGENTS.md index 76d5958..8cdad13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ ## 設計文件 - `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。 +- `docs/post-project-plan.md`:發文計畫(文案任務 Flow A 砍掉重做)— 主題發想、風格分析、矩陣產 N 篇草稿、定時發送。 - `docs/scan-placement-plan.md`:海巡獲客(流程 B)— 知識圖譜、Brave 擴展、雙軌爬取(7 天重點 / 30 天補充)、產品匹配、島民交接。 ## 新增 API 流程 diff --git a/Makefile b/Makefile index ae3fdee..d322066 100644 --- a/Makefile +++ b/Makefile @@ -1,200 +1,128 @@ -# 巡樓 monorepo Makefile -# 兩種模式: -# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev 在本機跑) -# docker - 全棧一鍵啟動(Mongo/Redis/gateway/worker/node-worker/web 全在 docker) -# -# 常用: -# make up # 一鍵:build + 啟動整套(含前後端、worker、DB) -# make down # 停掉整套 -# make logs # 追全部日誌 -# make dev-infra # 本機開發:只起 Mongo/Redis -# make dev-backend # 本機開發:go run gateway (:8890) -# make dev-frontend # 本機開發:vite dev (:5173) - SHELL := /bin/bash -# --- 路徑 --- -BACKEND_DIR := backend -FRONTEND_DIR := frontend +BACKEND_DIR := backend +WEB_DIR := backend/web +INFRA_DIR := infra +DEPLOY_DIR := deploy -# --- docker compose(prod 全棧)--- -COMPOSE_FILE := deploy/docker-compose.yml -ENV_FILE := deploy/.env -COMPOSE := docker compose -f $(COMPOSE_FILE) --env-file $(ENV_FILE) - -# --- docker compose(本地測試;base + dev override + dev env)--- -DEV_ENV_FILE := deploy/.env.dev -DEV_COMPOSE := docker compose -f $(COMPOSE_FILE) -f deploy/docker-compose.dev.yml --env-file $(DEV_ENV_FILE) -# go worker 用 gateway 內嵌,dev 不啟動獨立 worker 容器 -DEV_SERVICES := mongo redis init gateway node-worker web - -# --- dev 用 worker secret(對應 etc/gateway.yaml 的 InternalWorker.Secret)--- -DEV_WORKER_SECRET := haixun-dev-worker-secret -DEV_BACKEND_URL := http://127.0.0.1:8890 +INFRA_DEV_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.dev -f $(INFRA_DIR)/docker-compose.yml +INFRA_PROD_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(INFRA_DIR)/docker-compose.yml +DEPLOY_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(DEPLOY_DIR)/docker-compose.yml .DEFAULT_GOAL := help .PHONY: help help: ## 顯示可用指令 - @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + @grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' -$(ENV_FILE): - @test -f $(ENV_FILE) || (cp deploy/.env.example $(ENV_FILE) && echo "已從 deploy/.env.example 建立 $(ENV_FILE),請填入密碼與 secret 後再 make up") - -# ============================================================ -# DOCKER(全棧一鍵) -# ============================================================ - -.PHONY: up -up: $(ENV_FILE) ## [docker] 一鍵啟動整套(build + up,背景) - $(COMPOSE) up -d --build - @echo "" - @echo "=== 啟動中 ===" - @echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)" - @echo " 健康檢查: curl http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)/api/v1/health" - @echo " 看狀態: make ps 看日誌: make logs" - -.PHONY: down -down: ## [docker] 停掉整套(保留資料 volume) - $(COMPOSE) down - -.PHONY: down-clean -down-clean: ## [docker] 停掉整套並刪除資料 volume(清空 Mongo/Redis) - $(COMPOSE) down -v - -.PHONY: build -build: $(ENV_FILE) ## [docker] 只 build image,不啟動 - $(COMPOSE) build - -.PHONY: rebuild -rebuild: $(ENV_FILE) ## [docker] 無快取重新 build 並重啟 - $(COMPOSE) build --no-cache - $(COMPOSE) up -d - -.PHONY: restart -restart: ## [docker] 重啟所有服務 - $(COMPOSE) restart - -.PHONY: ps -ps: ## [docker] 看服務狀態 - $(COMPOSE) ps - -.PHONY: logs -logs: ## [docker] 追全部日誌(Ctrl-C 離開) - $(COMPOSE) logs -f - -.PHONY: logs-gateway -logs-gateway: ## [docker] 只看 gateway 日誌 - $(COMPOSE) logs -f gateway - -.PHONY: init -init: $(ENV_FILE) ## [docker] 重跑一次 DB 初始化(索引/權限/admin,冪等) - $(COMPOSE) run --rm init - -.PHONY: config -config: $(ENV_FILE) ## [docker] 驗證 compose 設定 - $(COMPOSE) config >/dev/null && echo "docker compose config OK" - -# ============================================================ -# DEV(本地測試,全走 docker) -# ============================================================ - -.PHONY: dev-up -dev-up: ## [dev] 一鍵:本地 docker 測試(gateway 開 :8890,web 開 :8080,go worker 內嵌) - $(DEV_COMPOSE) up -d --build $(DEV_SERVICES) - @echo "" - @echo "=== 本地測試啟動中 ===" - @echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(DEV_ENV_FILE) | cut -d= -f2)" - @echo " API: http://localhost:8890/api/v1/health" - @echo " DevHeaderFallback 已開(可用 X-Tenant-ID / X-UID 模擬登入)" - -.PHONY: dev-down -dev-down: ## [dev] 停掉本地 docker(保留資料) - $(DEV_COMPOSE) down - -.PHONY: dev-down-clean -dev-down-clean: ## [dev] 停掉本地 docker 並清空資料 volume - $(DEV_COMPOSE) down -v - -.PHONY: dev-rebuild -dev-rebuild: ## [dev] 改 code 後重新 build 並重啟本地 docker - $(DEV_COMPOSE) up -d --build $(DEV_SERVICES) - -.PHONY: dev-restart -dev-restart: ## [dev] 只重啟(改了 etc/*.yaml 後不用 rebuild,掛載即生效) - $(DEV_COMPOSE) restart gateway node-worker - -.PHONY: dev-ps -dev-ps: ## [dev] 看本地 docker 狀態 - $(DEV_COMPOSE) ps - -.PHONY: dev-logs -dev-logs: ## [dev] 追本地 docker 日誌 - $(DEV_COMPOSE) logs -f - -.PHONY: dev-init -dev-init: ## [dev] 重跑一次 DB 初始化(索引/權限/admin,冪等) - $(DEV_COMPOSE) run --rm init - -.PHONY: dev -dev: ## [dev] 本地 docker 測試說明 - @echo "本地測試(全 docker):" - @echo " make dev-up # 一鍵起整套(前端 :8080、API :8890)" - @echo " make dev-logs # 看日誌" - @echo " make dev-restart # 改 etc/*.yaml 後重啟(免 rebuild)" - @echo " make dev-rebuild # 改 Go/前端 code 後重 build" - @echo " make dev-down # 停(保留資料)" - -# --- 進階:不進 docker,直接本機 go run / vite(需自備 Go/Node)--- -.PHONY: local-infra -local-infra: ## [local] 只起 Mongo + Redis(docker),其餘本機跑 - $(DEV_COMPOSE) up -d mongo redis - $(DEV_COMPOSE) ps - -.PHONY: local-backend -local-backend: ## [local] 本機 go run gateway (:8890) - cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml - -.PHONY: local-worker -local-worker: ## [local] 本機 go run Go job worker (:8891) - cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml - -.PHONY: local-node-worker -local-node-worker: ## [local] 本機跑 Node playwright worker (style-8d) - cd $(BACKEND_DIR)/worker && npm install && \ - HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d - -.PHONY: local-frontend -local-frontend: ## [local] 本機跑前端 dev server (:5173) - cd $(FRONTEND_DIR) && npm install && npm run dev - -.PHONY: local-init -local-init: ## [local] 本機初始化 DB(索引/權限)並建立 admin - cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml - -# ============================================================ -# 驗證 / 維護 -# ============================================================ - -.PHONY: tidy -tidy: ## go mod tidy - cd $(BACKEND_DIR) && go mod tidy +.PHONY: setup-env +setup-env: ## 互動式產生 deploy/.env.prod(自動 URL-encode MongoDB 密碼) + @$(DEPLOY_DIR)/setup.sh .PHONY: gen-api -gen-api: ## 用 goctl 從 .api 重新產生 handler/logic/types/routes - cd $(BACKEND_DIR) && goctl api go -api generate/api/gateway.api -dir . -home generate/goctl -style go_zero +gen-api: ## 重新產生後端 API handler / logic / types + $(MAKE) -C $(BACKEND_DIR) gen-api .PHONY: fmt -fmt: ## gofmt 後端 - cd $(BACKEND_DIR) && gofmt -w . +fmt: ## 格式化後端 Go 程式 + $(MAKE) -C $(BACKEND_DIR) fmt .PHONY: test -test: ## 跑後端測試 - cd $(BACKEND_DIR) && go test ./... +test: ## 執行後端測試 + $(MAKE) -C $(BACKEND_DIR) test + +.PHONY: init +init: ## 初始化系統(建索引、種權限、建立 admin,讀 deploy/.env.prod) + $(MAKE) -C $(BACKEND_DIR) ENV_FILE=../deploy/.env.prod init + +.PHONY: run +run: ## 啟動後端 API + $(MAKE) -C $(BACKEND_DIR) run + +.PHONY: dev-all +dev-all: ## 啟動本機完整 dev stack(API、Go workers、Node workers、web) + ./scripts/dev-all.sh + +.PHONY: dev-all-stop +dev-all-stop: ## 停止本機 dev stack(釋放 8890 / 9101 / 9102 / web 等埠) + ./scripts/dev-all-stop.sh + +.PHONY: web-dev +web-dev: ## 啟動正式前端 dev server + cd $(WEB_DIR) && npm install && npm run dev + +.PHONY: web-build +web-build: ## 建置正式前端 + cd $(WEB_DIR) && npm install && npm run build .PHONY: verify -verify: $(ENV_FILE) ## 後端 build/test + 前端 build + compose 語法 - cd $(BACKEND_DIR) && go build ./... && go test ./... - cd $(FRONTEND_DIR) && npm ci && npm run build - $(COMPOSE) config >/dev/null && echo "docker compose config OK" +verify: test web-build ## 後端測試與前端建置 + +.PHONY: dev-infra +dev-infra: ## 啟動本機 dev 資料服務(Mongo / Redis 容器,讀 deploy/.env.dev) + @test -f $(DEPLOY_DIR)/.env.dev || cp $(DEPLOY_DIR)/.env.dev.example $(DEPLOY_DIR)/.env.dev + @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.dev 再執行後續指令" >&2 + $(INFRA_DEV_COMPOSE) up -d + $(INFRA_DEV_COMPOSE) ps + +.PHONY: dev-infra-down +dev-infra-down: ## 關閉本機 dev 資料服務(保留資料 volume) + $(INFRA_DEV_COMPOSE) down + +.PHONY: dev-infra-recreate +dev-infra-recreate: ## 清除 dev 資料 volume 並重啟(Mongo / Redis 全新狀態) + $(INFRA_DEV_COMPOSE) down -v + $(INFRA_DEV_COMPOSE) up -d + $(INFRA_DEV_COMPOSE) ps + +.PHONY: prod-infra +prod-infra: ## 啟動 prod 資料服務(Mongo / Redis,讀 deploy/.env.prod) + @test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod + @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2 + $(INFRA_PROD_COMPOSE) up -d + $(INFRA_PROD_COMPOSE) ps + +.PHONY: prod-infra-down +prod-infra-down: ## 關閉 prod 資料服務(保留資料 volume) + $(INFRA_PROD_COMPOSE) down + +.PHONY: prod-infra-recreate +prod-infra-recreate: ## 清除 prod 資料 volume 並重啟(Mongo / Redis 全新狀態) + $(INFRA_PROD_COMPOSE) down -v + $(INFRA_PROD_COMPOSE) up -d + $(INFRA_PROD_COMPOSE) ps + +.PHONY: prod-install +prod-install: ## 一鍵部署 prod:複製 .env.prod、build 本地 image、啟動 app、init(需先啟動 infra) + @test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod + @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2 + @echo ">>> 請確認 infra 已啟動(make prod-infra)" >&2 + $(DEPLOY_COMPOSE) build + $(DEPLOY_COMPOSE) up -d + $(DEPLOY_COMPOSE) --profile init run --rm init + $(DEPLOY_COMPOSE) ps + +.PHONY: prod-rebuild +prod-rebuild: ## 重新 build 本地 image 並重建 prod app stack(infra 不影響) + $(DEPLOY_COMPOSE) build + $(DEPLOY_COMPOSE) up -d + $(DEPLOY_COMPOSE) ps + +.PHONY: prod-stop +prod-stop: ## 關閉 prod app stack(保留 infra 與資料) + $(DEPLOY_COMPOSE) down + +.PHONY: prod-stop-all +prod-stop-all: ## 關閉 prod app + infra(保留資料 volume) + $(DEPLOY_COMPOSE) down + $(INFRA_PROD_COMPOSE) down + +.PHONY: prod-recreate +prod-recreate: ## 完整清除 prod 全部資料 volume + 重建啟動(app + infra + init) + $(DEPLOY_COMPOSE) down -v + $(INFRA_PROD_COMPOSE) down -v + $(INFRA_PROD_COMPOSE) up -d + $(DEPLOY_COMPOSE) up -d + $(DEPLOY_COMPOSE) --profile init run --rm init + $(DEPLOY_COMPOSE) ps diff --git a/ai_threads_auto_account_ops.md b/ai_threads_auto_account_ops.md new file mode 100644 index 0000000..45d2a89 --- /dev/null +++ b/ai_threads_auto_account_ops.md @@ -0,0 +1,1678 @@ +# AI 自動經營 Threads 帳號系統規格書 + +> 目標:建立一套可以自動經營 Threads 帳號的 AI 系統。 +> 系統不是單純產文,而是能夠「找話題、判斷切角、生成內容、檢查品質、發布排程、回收成效、分析原因、更新策略」的閉環內容營運系統。 + +--- + +## 0. 核心概念 + +這套系統的核心不是「AI 幫我寫一篇文」,而是: + +```text +AI 自動經營 Threads 帳號 = +話題雷達 ++ 帳號大腦 ++ 內容公式池 ++ 切角推理 ++ 品質閘門 ++ 自動排程/發布 ++ 成效回收 ++ 策略自我更新 +``` + +系統要做到: + +1. 自己發現新話題。 +2. 自己判斷話題適不適合帳號。 +3. 自己選擇內容任務,例如漲粉、互動、收藏、信任、人設。 +4. 自己從公式池選擇適合的開頭、中段、結尾、CTA。 +5. 自己產出多版本草稿。 +6. 自己檢查是否太 AI、太公式化、太像競品、太高風險。 +7. 自己排程或進入人工審核。 +8. 自己回收發文後的成效。 +9. 自己分析哪裡有效、哪裡沒效。 +10. 自己更新下一輪發文策略。 + +--- + +## 1. 系統總流程 + +```mermaid +flowchart TD + A[資料來源收集] --> B[資料清洗與解析] + B --> C[帳號大腦 Brand Brain] + C --> D[話題雷達 Topic Radar] + D --> E[話題評分與分類] + E --> F[內容策略決策器] + F --> G[公式池與切角引擎] + G --> H[AI 產文工廠] + H --> I[品質閘門 Gatekeeper] + I --> J{是否可發布} + J -->|高信心| K[自動排程/發布] + J -->|中信心| L[人工審核] + J -->|低信心| M[退回重寫] + L --> K + M --> H + K --> N[成效回收] + N --> O[成效分析] + O --> P[策略自我更新] + P --> D +``` + +--- + +## 2. 系統模組拆分 + +```mermaid +flowchart LR + A[Ingestion Service] --> B[Knowledge Brain Service] + B --> C[Topic Radar Service] + C --> D[Strategy Planner] + D --> E[Formula Engine] + E --> F[Content Generator] + F --> G[Quality Gatekeeper] + G --> H[Scheduler & Publisher] + H --> I[Insight Collector] + I --> J[Learning Engine] + J --> B +``` + +### 2.1 Ingestion Service + +負責收集資料。 + +資料來源包含: + +| 來源 | 用途 | 優先級 | +|---|---|---:| +| 使用者上傳 Excel / Markdown / PDF | 建立帳號知識庫與風格資料 | 高 | +| 自己歷史貼文 | 學習帳號語氣、主題、成效 | 高 | +| Threads 關鍵字搜尋 | 發現新話題 | 高 | +| 指定競品帳號 | 拆解內容 DNA,不直接複製 | 高 | +| Brave Search / Google Search | 補充外部資料與查證 | 中 | +| RSS / 官方網站 | 專業帳號資料來源 | 中 | +| 手動輸入筆記 | 補充人設、想法、禁忌 | 中 | +| 留言與回覆 | 找粉絲真正關心的問題 | 高 | + +### 2.2 Knowledge Brain Service + +負責維護帳號大腦。 + +包含: + +1. Brand Memory:帳號設定、人設、語氣、禁用規則。 +2. Knowledge Memory:文件、主題、知識點、可引用資料。 +3. Style Memory:句型、開頭、結尾、CTA、語氣節奏。 +4. Formula Memory:不同任務對應的內容公式。 +5. Feedback Memory:成效、成功模式、失敗模式、人工評分。 + +### 2.3 Topic Radar Service + +負責找話題、去重、分類、評分。 + +### 2.4 Strategy Planner + +負責決定: + +1. 今天要發什麼主題。 +2. 這篇文的任務是什麼。 +3. 要用什麼切角。 +4. 要用什麼情緒槓桿。 +5. 要不要進人工審核。 + +### 2.5 Formula Engine + +負責從公式池中挑出: + +1. 開頭方式。 +2. 中段結構。 +3. 情緒槓桿。 +4. 結尾方式。 +5. CTA 類型。 + +### 2.6 Content Generator + +負責產出多版本草稿,並根據帳號語氣改寫。 + +### 2.7 Quality Gatekeeper + +負責檢查: + +1. AI 感。 +2. 公式感。 +3. 相似度。 +4. 事實風險。 +5. 醫療、金融、法律等高風險內容。 +6. 帳號一致性。 +7. 發文頻率與疲乏度。 + +### 2.8 Scheduler & Publisher + +負責排程與發布。 + +支援: + +1. Threads API 發布。 +2. Web Intent 半自動發文。 +3. 一鍵複製手動發文。 + +### 2.9 Insight Collector + +負責回收成效。 + +收集: + +1. impressions。 +2. likes。 +3. replies。 +4. reposts。 +5. quotes。 +6. follows gained。 +7. profile visits。 +8. reply sentiment。 +9. manual rating。 + +### 2.10 Learning Engine + +負責分析成效並更新策略。 + +--- + +## 3. 資料收集流程 + +```mermaid +flowchart TD + A[資料來源] --> B[Raw Data Pool] + B --> C[清洗 HTML / 雜訊] + C --> D[切 Chunk] + D --> E[抽取主題 Topic] + D --> F[抽取實體 Entity] + D --> G[抽取觀點 Claim] + D --> H[抽取語氣 Style] + D --> I[抽取公式 Pattern] + E --> J[Knowledge Brain] + F --> J + G --> J + H --> J + I --> J + J --> K[向量索引] + J --> L[關係資料] +``` + +### 3.1 匯入文件 + +支援格式: + +```text +.xlsx +.csv +.md +.txt +.pdf +.docx +.zip, for Obsidian vault +``` + +### 3.2 Excel 匯入邏輯 + +如果使用者上傳類似「小日子長大中.xlsx」這種內容檔案,可以這樣解析: + +| Sheet 類型 | 解析用途 | +|---|---| +| 發文 | 當作歷史貼文範例、語氣樣本、主題樣本 | +| 產圖 | 當作 IG 輪播與視覺規格 | +| 脆文 | 當作 Threads prompt 與內容風格樣本 | + +每一列轉成: + +```json +{ + "source_type": "excel", + "sheet_name": "發文", + "title": "備孕搜尋紀錄", + "content": "開始備孕後,Google 或 AI 可能比我本人更了解我的身體狀況...", + "topics": ["備孕", "焦慮", "搜尋", "共鳴"], + "content_type": "情緒共鳴文", + "style_tags": ["溫柔", "真實", "口語", "不焦慮推進"] +} +``` + +### 3.3 Obsidian 匯入邏輯 + +Obsidian 不要當核心資料庫,只當匯入與輸出入口。 + +解析內容: + +```text +frontmatter +# headings +[[internal links]] +#tags +tables +paragraphs +attachments references +``` + +轉成: + +```json +{ + "note_title": "第一次看生殖門診", + "tags": ["備孕", "生殖門診"], + "links": ["AMH", "FSH", "試管流程"], + "chunks": [], + "topics": [], + "claims": [], + "source_path": "/vault/備孕/第一次看生殖門診.md" +} +``` + +--- + +## 4. 帳號大腦 Brand Brain + +```mermaid +flowchart TD + A[帳號基本設定] --> F[Brand Brain] + B[歷史貼文] --> F + C[上傳文件] --> F + D[競品分析] --> F + E[成效回饋] --> F + F --> G[Brand Memory] + F --> H[Knowledge Memory] + F --> I[Style Memory] + F --> J[Formula Memory] + F --> K[Feedback Memory] +``` + +### 4.1 Brand Memory + +記住帳號是誰。 + +```json +{ + "account_name": "小日子長大中", + "persona": "拾日", + "positioning": "備孕、試管、孕育日常心境分享", + "tone": ["溫柔", "真實", "陪伴", "不焦慮推進"], + "avoid": ["太AI", "太醫療", "太雞湯", "保證效果", "過度正能量"], + "content_pillars": [ + "備孕心情", + "試管流程", + "生殖門診", + "伴侶陪伴", + "等待焦慮", + "幽默共鳴" + ], + "signature_lines": [ + "不是只有成功那天,這段路才算數", + "把這些小小的日子,一點一點拾起來" + ] +} +``` + +### 4.2 Knowledge Memory + +記住專業知識與資料來源。 + +```json +{ + "topic": "第一次看生殖門診", + "knowledge_points": [ + "可先整理月經週期", + "可準備過去檢查報告", + "可列出想問醫生的問題" + ], + "risk_level": "medium", + "requires_fact_check": true, + "source_ids": ["doc_001", "doc_002"] +} +``` + +### 4.3 Style Memory + +記住帳號怎麼說話。 + +```json +{ + "opening_patterns": ["其實我一直覺得", "有時候不是", "今天突然想到"], + "sentence_style": { + "short_sentence_ratio": 0.65, + "paragraph_spacing": "loose", + "emoji_usage": "low" + }, + "common_endings": ["今天先這樣也沒關係", "慢慢來也算有在往前"], + "avoid_phrases": ["在這個快節奏的時代", "總而言之", "讓我們一起"] +} +``` + +### 4.4 Formula Memory + +記住不同內容任務的公式。 + +```json +{ + "mission": "情緒共鳴文", + "formula": { + "opening": "真實情緒開場", + "body": "生活細節堆疊", + "emotion": "被理解感", + "ending": "溫柔但不雞湯", + "cta": "輕提問" + }, + "use_case": ["等待焦慮", "月經來", "看到別人懷孕"], + "avoid": ["不要寫成衛教文", "不要硬塞解方"] +} +``` + +### 4.5 Feedback Memory + +記住什麼有效。 + +```json +{ + "insight": "反差開場的 reply rate 高於帳號平均 42%", + "action": "提高反差開場權重", + "confidence": 0.78, + "applied_to_strategy_version": "v3" +} +``` + +--- + +## 5. 話題雷達 Topic Radar + +```mermaid +flowchart TD + A[每日定時掃描] --> B[收集候選話題] + B --> C[去重] + C --> D[分類] + D --> E[熱度分數] + D --> F[帳號適配分數] + D --> G[互動潛力] + D --> H[可延伸性] + D --> I[風險分數] + E --> J[Topic Score] + F --> J + G --> J + H --> J + I --> J + J --> K[候選題庫] +``` + +### 5.1 話題評分公式 + +```text +Topic Score = +熱度分數 * 0.25 ++ 帳號適配度 * 0.30 ++ 可延伸性 * 0.15 ++ 互動潛力 * 0.15 ++ 資料可信度 * 0.10 +- 風險分數 * 0.20 +``` + +### 5.2 話題分類 + +| 類型 | 用途 | 範例 | +|---|---|---| +| 熱點話題 | 跟上討論,提高曝光 | 最近大家在討論 AMH | +| 長尾知識 | 建立專業與收藏 | 第一次看生殖門診準備 | +| 情緒共鳴 | 增加轉發與留言 | 等待開獎真的很累 | +| 互動提問 | 養留言 | 大家第一次看診最怕什麼 | +| 人設生活 | 讓帳號像真人 | 今天只是好好吃飯 | +| 系列主題 | 長期內容資產 | 備孕小字典系列 | + +### 5.3 Candidate Topic Schema + +```json +{ + "id": "topic_001", + "account_id": "account_001", + "name": "第一次看生殖門診,可以準備什麼?", + "source": "keyword_search", + "category": "長尾知識", + "heat_score": 72, + "fit_score": 94, + "interaction_score": 68, + "extend_score": 85, + "risk_score": 35, + "final_score": 78.5, + "recommended_missions": ["收藏文", "互動文", "情緒共鳴文"] +} +``` + +--- + +## 6. 內容策略決策器 Strategy Planner + +系統每次產文前,要先決定「這篇文的任務」。 + +```mermaid +flowchart TD + A[候選話題] --> B[讀取帳號策略] + B --> C[讀取最近貼文] + C --> D[讀取成效記憶] + D --> E[決定內容任務] + E --> F[決定切角] + F --> G[產生 Content Plan] +``` + +### 6.1 內容任務分類 + +| 任務 | 目的 | 適合公式 | +|---|---|---| +| 漲粉文 | 擴散、追蹤 | 共鳴、反差、痛點、金句 | +| 互動文 | 引留言 | 提問、二選一、經驗募集 | +| 收藏文 | 提高保存 | 清單、步驟、懶人包 | +| 信任文 | 建立專業與真實感 | 經驗、觀察、案例 | +| 人設文 | 讓帳號有個性 | 生活、價值觀、吐槽 | +| 轉換文 | 引導私訊/連結/資源 | 模板、工具、服務 | +| 系列文 | 長期經營 | 連載、週更、固定單元 | + +### 6.2 Content Plan Schema + +```json +{ + "topic": "第一次看生殖門診", + "mission": "互動文", + "target_audience": "準備第一次去生殖門診的備孕女性", + "angle": "診間會腦袋空白,所以先問大家都準備什麼", + "opening_type": "具體問題開場", + "body_type": "列出 3 個自己想到但不確定的問題", + "emotion": "緊張但想準備好", + "ending_type": "邀請補充", + "cta_type": "經驗募集型", + "risk_level": "medium", + "requires_human_review": true, + "avoid": [ + "不要寫成衛教文", + "不要太完整,保留留言空間", + "不要溫柔金句收尾", + "不要使用最近 7 天重複用過的開頭" + ] +} +``` + +--- + +## 7. 公式池與切角引擎 Formula Engine + +```mermaid +flowchart TD + A[Content Plan] --> B[選擇開頭公式] + A --> C[選擇中段結構] + A --> D[選擇情緒槓桿] + A --> E[選擇結尾方式] + A --> F[選擇 CTA] + B --> G[組成產文指令] + C --> G + D --> G + E --> G + F --> G +``` + +### 7.1 開頭公式池 + +| 類型 | 用途 | 範例 | +|---|---|---| +| 情緒直球 | 快速共鳴 | 其實我一直覺得,備孕最累的是等待。 | +| 反差開場 | 提高停留 | 以前以為看診最難,後來發現最難的是不知道要問什麼。 | +| 場景開場 | 增加真人感 | 今天在診間等叫號的時候,腦袋突然一片空白。 | +| 衝突觀點 | 建立觀點 | 我不太喜歡別人說「放輕鬆就好」。 | +| 問題開場 | 引互動 | 第一次看生殖門診,大家最怕的是什麼? | +| 自嘲開場 | 增加親近感 | 備孕後,我的 Google 搜尋紀錄比我本人還像醫生。 | + +### 7.2 中段結構池 + +| 類型 | 用途 | +|---|---| +| 生活細節堆疊 | 情緒共鳴文 | +| 三點整理 | 收藏文、知識文 | +| 自問自答 | 觀點文、焦慮文 | +| 故事推進 | 人設文、信任文 | +| 對比以前和現在 | 成長型內容 | +| 錯誤觀念澄清 | 專業文 | +| 故意不講滿 | 互動文 | + +### 7.3 情緒槓桿池 + +| 情緒 | 適合內容 | +|---|---| +| 被理解感 | 共鳴文 | +| 小小委屈 | 備孕、職場、生活 | +| 無奈但不崩潰 | Threads 短文 | +| 自嘲 | 反差、幽默 | +| 溫柔補償 | 陪伴帳號 | +| 不想說破的疲憊 | 深夜文 | +| 終於有人講出來 | 轉發型內容 | + +### 7.4 結尾公式池 + +| 類型 | 用途 | +|---|---| +| 溫柔陪伴 | 情緒文 | +| 反諷補刀 | 幽默文 | +| 開放提問 | 互動文 | +| 收藏提醒 | 知識文 | +| 留白不收 | 日記文 | +| 系列預告 | 系列內容 | + +### 7.5 CTA 公式池 + +| CTA 類型 | 範例 | +|---|---| +| 共鳴型 | 你也有這種感覺嗎? | +| 經驗募集型 | 第一次看診你最慶幸自己有準備什麼? | +| 補充型 | 有漏掉的也歡迎補充。 | +| 收藏型 | 可以先存起來,下次看診前翻一下。 | +| 轉發型 | 轉給那個一直叫你放輕鬆的人。 | +| 無 CTA | 讓文章自然停在情緒裡。 | + +--- + +## 8. 防膩系統 Anti-Fatigue Engine + +避免每篇都長一樣。 + +```mermaid +flowchart TD + A[新草稿] --> B[讀取最近 7/14/30 天貼文] + B --> C[檢查開頭重複] + B --> D[檢查結尾重複] + B --> E[檢查 CTA 重複] + B --> F[檢查任務比例] + B --> G[檢查句型相似度] + B --> H[計算膩感分數] + H --> I{是否過高} + I -->|是| J[退回重寫] + I -->|否| K[通過] +``` + +### 8.1 防重複規則 + +| 檢查項目 | 規則 | +|---|---| +| 開頭類型 | 連續 3 篇不能同類型 | +| 結尾類型 | 連續 3 篇不能都是溫柔金句 | +| CTA | 不能每篇都「你也會這樣嗎」 | +| 文章長度 | 長短文要交錯 | +| 情緒走向 | 不能每天都是焦慮到安慰 | +| 內容任務 | 不能連續 5 篇都是共鳴文 | +| 句型相似度 | 超過閾值退回重寫 | +| 主題密度 | 同一主題設定冷卻期 | + +### 8.2 Fatigue Score + +```text +Fatigue Score = +Opening Similarity * 0.20 ++ Ending Similarity * 0.20 ++ CTA Similarity * 0.15 ++ Structure Similarity * 0.25 ++ Topic Repetition * 0.20 +``` + +建議門檻: + +```text +0 - 40:安全 +41 - 70:需要微調 +71 - 100:退回重寫 +``` + +--- + +## 9. AI 產文工廠 Content Generator + +```mermaid +flowchart TD + A[Content Plan] --> B[生成初稿 A] + A --> C[生成初稿 B] + A --> D[生成初稿 C] + B --> E[帳號語氣改寫] + C --> E + D --> E + E --> F[真人感修稿] + F --> G[Threads 格式優化] + G --> H[版本評分] + H --> I[選出最佳版本] +``` + +### 9.1 生成版本 + +每次至少產 3 個版本: + +| 版本 | 目的 | +|---|---| +| A 版 | 穩定帳號風格 | +| B 版 | 更有共鳴 | +| C 版 | 更容易互動 | +| D 版 optional | 更短、更 Threads | +| E 版 optional | 更幽默、更反差 | + +### 9.2 產文指令格式 + +```text +請產出一篇 Threads 文。 + +帳號:小日子長大中 +人設:拾日 +語氣:溫柔、真實、陪伴、不焦慮推進 +主題:第一次看生殖門診 +內容任務:互動文 +切角:診間會腦袋空白,所以先問大家都準備什麼 +開頭方式:具體問題開場 +中段方式:列出 3 個自己想到但不確定的問題 +情緒:緊張但想準備好 +結尾方式:邀請補充 +CTA:經驗募集型 + +禁止: +- 不要寫成衛教文 +- 不要太像 AI +- 不要使用「在這個快節奏的時代」 +- 不要溫柔金句收尾 +- 不要超過 500 字 +- 不要直接給醫療建議 +``` + +--- + +## 10. 品質閘門 Quality Gatekeeper + +```mermaid +flowchart TD + A[草稿] --> B[AI 感檢查] + A --> C[公式感檢查] + A --> D[競品相似度檢查] + A --> E[事實查證] + A --> F[風險檢查] + A --> G[帳號一致性檢查] + A --> H[發布頻率檢查] + B --> I[總分] + C --> I + D --> I + E --> I + F --> I + G --> I + H --> I + I --> J{決策} + J -->|高分| K[可自動排程] + J -->|中分| L[人工審核] + J -->|低分| M[退回重寫] +``` + +### 10.1 檢查分數 + +| 分數 | 說明 | +|---|---| +| ai_score | 越高代表越像 AI,需降低 | +| formula_score | 越高代表公式感越重 | +| brand_fit_score | 越高代表越符合帳號 | +| risk_score | 越高代表越危險 | +| similarity_score | 越高代表越像競品或過去貼文 | +| engagement_potential | 預測互動潛力 | +| freshness_score | 新鮮感 | + +### 10.2 發布決策 + +```text +總分 >= 85 且 risk_score < 30:可自動排程 +總分 70 - 84:進人工審核 +總分 < 70:退回重寫 +risk_score >= 60:強制人工審核 +similarity_score >= 75:退回重寫 +ai_score >= 70:退回真人感修稿 +``` + +### 10.3 高風險內容 + +以下內容預設不能全自動發布: + +1. 醫療建議。 +2. 金融投資建議。 +3. 法律判斷。 +4. 政治爭議。 +5. 指控個人或公司。 +6. 可能造成恐慌的資訊。 +7. 未查證的數據。 + +--- + +## 11. 排程與發布 Scheduler & Publisher + +```mermaid +flowchart TD + A[通過品質閘門] --> B[選擇發布時間] + B --> C[檢查頻率限制] + C --> D[建立排程] + D --> E{發布方式} + E -->|API| F[Threads API 發布] + E -->|Web Intent| G[開啟半自動發文] + E -->|Manual| H[一鍵複製] + F --> I[記錄 post_id] + G --> I + H --> I + I --> J[等待成效回收] +``` + +### 11.1 發布策略 + +| 模式 | 用途 | +|---|---| +| API 自動發 | 權限完整、低風險內容 | +| Web Intent | API 權限不足時 | +| 一鍵複製 | 初期 MVP 或人工操作 | + +### 11.2 排程策略 + +系統需要根據過去成效找出最佳時間。 + +第一版可先用簡單設定: + +```json +{ + "posting_windows": ["12:00-13:30", "20:30-23:00"], + "max_posts_per_day": 3, + "min_gap_minutes": 180, + "high_risk_requires_review": true +} +``` + +--- + +## 12. 成效回收 Insight Collector + +```mermaid +flowchart TD + A[已發布貼文] --> B[1 小時成效] + A --> C[6 小時成效] + A --> D[24 小時成效] + A --> E[72 小時成效] + B --> F[Performance DB] + C --> F + D --> F + E --> F + F --> G[成效分析器] +``` + +### 12.1 回收時間點 + +| 時間 | 用途 | +|---|---| +| 1 小時 | 初速判斷 | +| 6 小時 | 當日表現 | +| 24 小時 | 主要成效 | +| 72 小時 | 長尾成效 | +| 7 天 optional | 長期資料 | + +### 12.2 指標 + +```json +{ + "post_id": "post_001", + "collected_at": "2026-07-02T21:00:00+08:00", + "impressions": 12000, + "likes": 580, + "replies": 72, + "reposts": 31, + "quotes": 8, + "follows_gained": 45, + "profile_visits": 320, + "engagement_rate": 0.057, + "reply_sentiment": "positive" +} +``` + +--- + +## 13. 成效分析 Performance Analyzer + +```mermaid +flowchart TD + A[Performance DB] --> B[按主題分析] + A --> C[按公式分析] + A --> D[按開頭分析] + A --> E[按 CTA 分析] + A --> F[按情緒分析] + A --> G[按發布時間分析] + B --> H[洞察] + C --> H + D --> H + E --> H + F --> H + G --> H +``` + +### 13.1 分析問題 + +系統每次分析要回答: + +1. 哪個主題表現最好? +2. 哪種開頭最容易讓人回覆? +3. 哪種 CTA 真的有效? +4. 哪種情緒最容易被轉發? +5. 哪種內容最容易漲粉? +6. 哪種內容收藏價值最高? +7. 哪個發文時間效果最好? +8. 哪些內容應該減少? +9. 哪些內容可以延伸成系列? + +### 13.2 分析結果範例 + +```json +{ + "account_id": "account_001", + "period": "last_14_days", + "insights": [ + { + "type": "opening_pattern", + "finding": "反差開場的平均 reply rate 比帳號平均高 42%", + "recommendation": "下週提高反差開場權重,但避免連續使用", + "confidence": 0.82 + }, + { + "type": "content_mission", + "finding": "收藏文的 follows gained 較低,但 profile visits 較高", + "recommendation": "收藏文結尾加入更明確的人設介紹或系列預告", + "confidence": 0.74 + } + ] +} +``` + +--- + +## 14. 策略自我更新 Learning Engine + +```mermaid +flowchart TD + A[成效分析] --> B[更新主題權重] + A --> C[更新公式權重] + A --> D[更新 CTA 權重] + A --> E[更新發布時間] + A --> F[更新禁用規則] + A --> G[更新內容比例] + B --> H[Strategy Version] + C --> H + D --> H + E --> H + F --> H + G --> H + H --> I[下一輪產文使用] +``` + +### 14.1 策略權重更新 + +第一版不要 fine-tune,先更新權重。 + +```json +{ + "strategy_version": "v3", + "content_mix": { + "emotion_posts": 30, + "knowledge_posts": 30, + "interaction_posts": 20, + "life_posts": 10, + "conversion_posts": 10 + }, + "opening_weights": { + "反差開場": 35, + "情緒直球": 25, + "問題開場": 20, + "場景開場": 15, + "金句開場": 5 + }, + "cta_weights": { + "經驗募集型": 35, + "收藏型": 25, + "共鳴型": 20, + "轉發型": 10, + "無CTA": 10 + } +} +``` + +### 14.2 策略版本化 + +每次更新都要記錄原因。 + +```json +{ + "from_version": "v2", + "to_version": "v3", + "changes": [ + { + "field": "content_mix.interaction_posts", + "from": 15, + "to": 20, + "reason": "過去 14 天互動文 reply rate 高於帳號平均 38%" + }, + { + "field": "opening_weights.金句開場", + "from": 15, + "to": 5, + "reason": "金句開場連續三週互動下降,且 AI 感評分偏高" + } + ] +} +``` + +--- + +## 15. Dashboard 設計 + +首頁應該像「AI 經營戰情室」。 + +### 15.1 首頁區塊 + +```text +今日建議發文 +- 推薦主題 +- 推薦任務 +- 推薦切角 +- 推薦時間 +- 風險提醒 + +草稿工廠 +- 待審核 +- 可排程 +- 需重寫 +- 已發布 + +昨日成效 +- 最佳貼文 +- 最差貼文 +- 主要原因 +- 下一步建議 + +策略更新 +- 本週提高什麼比例 +- 本週降低什麼比例 +- 新增禁用規則 +``` + +### 15.2 單篇貼文分析頁 + +```text +貼文內容 +內容任務:互動文 +主題:第一次看生殖門診 +開頭類型:問題開場 +中段類型:故意不講滿 +CTA:經驗募集型 + +成效: +- impressions +- likes +- replies +- reposts +- follows gained + +AI 分析: +- 為什麼有效 +- 哪裡可以更好 +- 是否適合延伸成系列 +- 下次建議用什麼變奏 +``` + +--- + +## 16. DB Schema 草案 + +### 16.1 accounts + +```sql +CREATE TABLE accounts ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + platform TEXT NOT NULL DEFAULT 'threads', + persona TEXT, + positioning TEXT, + tone_config JSONB, + avoid_config JSONB, + content_pillars JSONB, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.2 sources + +```sql +CREATE TABLE sources ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + type TEXT NOT NULL, + title TEXT, + url TEXT, + file_path TEXT, + raw_content TEXT, + parsed_status TEXT DEFAULT 'pending', + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.3 knowledge_chunks + +```sql +CREATE TABLE knowledge_chunks ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + source_id UUID REFERENCES sources(id), + content TEXT NOT NULL, + topics JSONB, + entities JSONB, + claims JSONB, + style_tags JSONB, + risk_level TEXT, + embedding VECTOR, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.4 topics + +```sql +CREATE TABLE topics ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + name TEXT NOT NULL, + source TEXT, + category TEXT, + heat_score NUMERIC, + fit_score NUMERIC, + interaction_score NUMERIC, + extend_score NUMERIC, + risk_score NUMERIC, + final_score NUMERIC, + status TEXT DEFAULT 'candidate', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.5 formula_pools + +```sql +CREATE TABLE formula_pools ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + type TEXT NOT NULL, + name TEXT NOT NULL, + pattern TEXT, + use_case JSONB, + avoid JSONB, + weight NUMERIC DEFAULT 1, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.6 content_plans + +```sql +CREATE TABLE content_plans ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + topic_id UUID REFERENCES topics(id), + mission TEXT NOT NULL, + target_audience TEXT, + angle TEXT, + opening_type TEXT, + body_type TEXT, + emotion TEXT, + ending_type TEXT, + cta_type TEXT, + risk_level TEXT, + requires_human_review BOOLEAN DEFAULT FALSE, + avoid JSONB, + status TEXT DEFAULT 'planned', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.7 drafts + +```sql +CREATE TABLE drafts ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + content_plan_id UUID REFERENCES content_plans(id), + content TEXT NOT NULL, + version_name TEXT, + ai_score NUMERIC, + formula_score NUMERIC, + brand_fit_score NUMERIC, + risk_score NUMERIC, + similarity_score NUMERIC, + freshness_score NUMERIC, + status TEXT DEFAULT 'draft', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.8 published_posts + +```sql +CREATE TABLE published_posts ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + draft_id UUID REFERENCES drafts(id), + platform_post_id TEXT, + published_at TIMESTAMP, + publish_mode TEXT, + status TEXT DEFAULT 'published', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.9 post_metrics + +```sql +CREATE TABLE post_metrics ( + id UUID PRIMARY KEY, + post_id UUID REFERENCES published_posts(id), + collected_at TIMESTAMP NOT NULL, + impressions INT, + likes INT, + replies INT, + reposts INT, + quotes INT, + follows_gained INT, + profile_visits INT, + engagement_rate NUMERIC, + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.10 strategy_versions + +```sql +CREATE TABLE strategy_versions ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + version TEXT NOT NULL, + content_mix JSONB, + opening_weights JSONB, + cta_weights JSONB, + formula_weights JSONB, + posting_windows JSONB, + change_reason JSONB, + is_active BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.11 learning_events + +```sql +CREATE TABLE learning_events ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + post_id UUID REFERENCES published_posts(id), + insight_type TEXT, + finding TEXT, + recommendation TEXT, + confidence NUMERIC, + applied_status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +--- + +## 17. API Endpoint 草案 + +### 17.1 Account + +```text +POST /api/accounts +GET /api/accounts/:id +PATCH /api/accounts/:id +GET /api/accounts/:id/brain +POST /api/accounts/:id/rebuild-brain +``` + +### 17.2 Source / Ingestion + +```text +POST /api/accounts/:id/sources/upload +POST /api/accounts/:id/sources/url +POST /api/accounts/:id/sources/obsidian-import +GET /api/accounts/:id/sources +POST /api/sources/:source_id/parse +``` + +### 17.3 Topic Radar + +```text +POST /api/accounts/:id/topic-radar/scan +GET /api/accounts/:id/topics +PATCH /api/topics/:topic_id +POST /api/topics/:topic_id/create-plan +``` + +### 17.4 Content Plan / Draft + +```text +POST /api/accounts/:id/content-plans +GET /api/accounts/:id/content-plans +POST /api/content-plans/:id/generate-drafts +GET /api/content-plans/:id/drafts +PATCH /api/drafts/:id +POST /api/drafts/:id/quality-check +POST /api/drafts/:id/approve +POST /api/drafts/:id/reject +``` + +### 17.5 Publishing + +```text +POST /api/drafts/:id/schedule +POST /api/drafts/:id/publish +GET /api/accounts/:id/calendar +``` + +### 17.6 Insights / Learning + +```text +POST /api/published-posts/:id/collect-metrics +GET /api/accounts/:id/performance +POST /api/accounts/:id/analyze-performance +POST /api/accounts/:id/update-strategy +GET /api/accounts/:id/strategy-versions +``` + +--- + +## 18. Worker / Cron Jobs + +### 18.1 每日 Job + +```text +每日 08:00 +- 掃描話題 +- 更新候選題庫 +- 產生今日內容建議 +``` + +```text +每日 10:00 +- 根據今日內容建議產生草稿 +- 執行品質檢查 +- 高信心進排程,中信心進審核 +``` + +```text +每日 12:00 / 21:00 +- 執行排程發文 +``` + +### 18.2 成效回收 Job + +```text +每小時 +- 找出已發布但未收 1h / 6h / 24h / 72h 成效的貼文 +- 呼叫平台 API 收集 insights +- 寫入 post_metrics +``` + +### 18.3 每週學習 Job + +```text +每週一 09:00 +- 分析過去 7 / 14 天成效 +- 產生 learning_events +- 產生 strategy_version draft +- 若 confidence 高,自動套用低風險策略更新 +- 若 confidence 中,進人工確認 +``` + +--- + +## 19. MVP 開發階段 + +### MVP 1:AI 社群助理 + +目標:先證明「找題 + 產文 + 檢查」有價值。 + +功能: + +1. 建立帳號。 +2. 上傳 Excel / Markdown。 +3. 建立 Brand Brain。 +4. 手動輸入關鍵字。 +5. AI 產生候選話題。 +6. AI 產生 Content Plan。 +7. AI 產 3 版草稿。 +8. AI 感與公式感檢查。 +9. 人工核准。 +10. 一鍵複製發文。 + +驗收: + +```text +使用者可以在 10 分鐘內: +建立帳號 → 匯入資料 → 產出 5 篇可用 Threads 草稿。 +``` + +### MVP 2:半自動營運 + +目標:開始排程與成效回收。 + +功能: + +1. Topic Radar 每日自動掃描。 +2. 自動產生每日 3-5 篇候選草稿。 +3. 人工審核。 +4. 排程發文。 +5. 手動或 API 回收成效。 +6. 產生週報。 + +驗收: + +```text +使用者每週只要審核草稿,系統能自動維持一週發文節奏。 +``` + +### MVP 3:低風險自動發布 + +目標:低風險內容自動發,高風險內容人工審。 + +功能: + +1. 自動發布低風險草稿。 +2. 高風險草稿強制人工審核。 +3. 發文後自動回收成效。 +4. 自動更新部分策略權重。 + +驗收: + +```text +系統能連續 14 天自動經營帳號,且不出現高風險錯誤內容。 +``` + +### MVP 4:自我改善閉環 + +目標:讓系統真的越跑越準。 + +功能: + +1. 每週自動分析成效。 +2. 自動調整內容比例。 +3. 自動調整公式權重。 +4. 自動產生內容節奏表。 +5. 生成策略版本與變更原因。 + +驗收: + +```text +系統能說明: +這週為什麼提高互動文比例, +為什麼降低某種開頭, +下週策略和上週有何不同。 +``` + +--- + +## 20. Coding Agent 開發任務建議 + +### Phase 1:資料模型與帳號大腦 + +```text +1. 建立 DB schema。 +2. 建立 Account CRUD。 +3. 建立 Source upload API。 +4. 實作 Excel parser。 +5. 實作 Markdown parser。 +6. 建立 Brand Brain builder。 +7. 將 parsed content 寫入 knowledge_chunks。 +``` + +### Phase 2:話題與策略 + +```text +1. 建立 Topic model。 +2. 實作手動 keyword topic import。 +3. 實作 Topic Score。 +4. 建立 Content Plan generator。 +5. 建立 Formula Pool seed data。 +``` + +### Phase 3:產文與品質檢查 + +```text +1. 建立 Draft generator。 +2. 每個 Content Plan 產出 3 版草稿。 +3. 實作 AI 感檢查。 +4. 實作公式感檢查。 +5. 實作相似度檢查。 +6. 實作人工 approve / reject。 +``` + +### Phase 4:排程與發布 + +```text +1. 建立 Schedule table。 +2. 實作草稿排程。 +3. 實作一鍵複製模式。 +4. 實作 Web Intent fallback。 +5. 預留 Threads API publisher adapter。 +``` + +### Phase 5:成效與學習 + +```text +1. 建立 Published Posts。 +2. 建立 Post Metrics。 +3. 實作手動成效輸入。 +4. 實作 performance analyzer。 +5. 產生 learning_events。 +6. 建立 strategy_versions。 +``` + +--- + +## 21. 產品原則 + +### 21.1 不要做洗稿工具 + +系統可以分析競品,但不能直接複製競品內容。 + +允許: + +```text +分析主題 +分析切角 +分析公式 +分析情緒槓桿 +分析 CTA +分析互動方式 +``` + +禁止: + +```text +直接複製原文 +高度相似改寫 +批量搬運他人貼文 +規避平台規範的自動化抓取 +``` + +### 21.2 人設一致,但形式要變 + +```text +語氣一致 +主題一致 +價值觀一致 + +但: +開頭要變 +結尾要變 +CTA 要變 +長短要變 +內容任務要變 +``` + +### 21.3 高風險內容要保守 + +醫療、金融、法律類內容,預設要查證與人工審核。 + +### 21.4 自我改善要可解釋 + +AI 每次調整策略,都要說明: + +```text +調整了什麼 +為什麼調整 +依據哪段成效 +信心分數多少 +是否需要人工確認 +``` + +--- + +## 22. 最終產品定位 + +這不是一般 AI 發文工具。 + +更精準的定位是: + +```text +AI Threads Account Operator +``` + +或: + +```text +AI Content Brain for Threads +``` + +一句話描述: + +```text +讓 AI 像內容編輯一樣,替每個 Threads 帳號找題、選切角、產文、排程、分析成效,並根據結果持續調整經營策略。 +``` + +產品真正的價值不是「會寫文」,而是: + +```text +知道為什麼要發這篇, +知道這篇打中誰, +知道發完好不好, +知道下一篇要怎麼改。 +``` + +--- + +## 23. 最終閉環摘要 + +```mermaid +flowchart TD + A[輸入帳號資料與知識] --> B[建立帳號大腦] + B --> C[每日找話題] + C --> D[挑選適合帳號的主題] + D --> E[決定內容任務] + E --> F[選公式與切角] + F --> G[產生多版本草稿] + G --> H[品質檢查] + H --> I[排程或人工審核] + I --> J[發布] + J --> K[回收成效] + K --> L[分析有效原因] + L --> M[更新策略權重] + M --> C +``` + +--- + +## 24. 建議第一版畫面 + +### 24.1 帳號大腦頁 + +```text +帳號名稱 +人設 +定位 +語氣 +禁用語 +主題池 +公式池 +知識來源 +歷史貼文 +成效記憶 +``` + +### 24.2 今日話題雷達 + +```text +今日推薦主題 +- 熱度 +- 帳號適配度 +- 互動潛力 +- 風險分數 +- 推薦內容任務 +``` + +### 24.3 草稿工廠 + +```text +草稿列表 +- 內容任務 +- 主題 +- 公式組合 +- AI 感分數 +- 公式感分數 +- 風險分數 +- 狀態 +``` + +### 24.4 成效分析頁 + +```text +已發布貼文 +成效數據 +AI 分析 +下次建議 +是否延伸系列 +``` + +### 24.5 策略版本頁 + +```text +目前策略版本 +內容比例 +公式權重 +CTA 權重 +發布時間 +本次調整原因 +``` + +--- + +## 25. 開發時的最小可交付結果 + +第一個可交付版本只需要做到: + +```text +1. 建立一個帳號。 +2. 上傳一份 Excel。 +3. 系統解析出帳號大腦。 +4. 手動輸入一個主題。 +5. 系統產出 Content Plan。 +6. 系統產出 3 篇 Threads 草稿。 +7. 系統檢查 AI 感與公式感。 +8. 使用者可以 approve / reject。 +9. 系統記住這次人工回饋。 +``` + +這樣就可以開始驗證核心價值。 + +不要一開始就把所有 API、圖譜、全自動發布都做完。 +先讓 AI 真的能寫出「像這個帳號」的內容,再讓它慢慢接管營運。 diff --git a/backend/.DS_Store b/backend/.DS_Store deleted file mode 100644 index 3397521d4d5052c563c944fb23e80fffc04c6401..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM%}*Og6o1c9ScfJo2_%3T$%-$P5~Y;VN>vo$7*LC<2!s)ZeAHR52eV~8W9_bS z03rFDoRWKc&o!q?z4X{iuf0|M6WVJ}&6^K{y)MTRl+2T6esAZ!H?#9wznPsi0AR6M z{S;sl030e3-4Uw(L!@1nKFOFCmk=o)gkK3og|1|a70ZBSz%pPNunbrR&Lab~XG?KT z+4r^8)|LUwzxDN`UKgMS9{Dk75$?lnx)!OJm#G+sd>C*l zk&f;4&&VaA`;PnkE3LR;zEJ#BX@o(X%*_0bnSsF%hK8MCXT*72+7Yc%;w8i5GAdsequ$$mP+u0k4WyQ8alqR`opY&dtr- z%6&dNb95_r_~k6=bDw>2bd+@_@}JyUEANJNE}qdGNYE^Ea8X|$*Uw4on^)lPnR(%F zoq7G~x}BM2FN}P6@zSU}cKOQK_}Ik6#MNt)Q`6HQmE9{9&)=;@@{SioD57H6WDjdm zP%Sk)Kj5{^ggs44!2v6~ms755toq$a)B1jce8x{2RCixRjB(Z9%Jz25lFcrCtL%=( zek8(hRm4FO2)^|wCcc*wR$u1Zf=1jUu2&k;6mPI7ChUvZ-14Hx<26>?e;j%h7OwE3 zND@((p0YPz@YRUPo;Cy0O2_$69eAuN5MByrUV!{4d@UrLS|rSvH$5 ztQwO8H{l-KhYi?;19$;Hz$^F}euY2aFZc(CaSCtXO`OBKIFDcBGCn{bcQC;3b$q7t zu&mTU>W%T8Nk8C(xTk|JMSUsA?1s8 zj>;1%Qlwv7qY9x)y2y5Q949>f!;tixu@wxqHIjwo%s&LsEB*O+jqlkO?EY`}f0Yks GRlNZuN33oD diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..883b3ab --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.24-alpine AS build + +WORKDIR /src +RUN apk add --no-cache ca-certificates git +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/gateway ./gateway.go \ + && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker \ + && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tool ./cmd/tool + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S haixun \ + && adduser -S -G haixun -h /app haixun +WORKDIR /app +COPY --from=build /out/gateway /app/gateway +COPY --from=build /out/worker /app/worker +COPY --from=build /out/tool /app/tool +COPY backend/etc /app/etc +USER haixun +EXPOSE 8890 +CMD ["/app/gateway", "-f", "/app/etc/gateway.prod.yaml"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..d9ca395 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,47 @@ +.PHONY: gen-api fmt test run web-dev web-build + +API_FILE := generate/api/gateway.api +GOCTL_HOME := generate/goctl +GOCTL ?= $(shell go env GOPATH)/bin/goctl + +ENV_FILE ?= ../deploy/.env.dev + +ifneq ($(wildcard $(ENV_FILE)),) +# 把 deploy/.env.dev 的值 export 出來(給 make run / native binary 用;dev-all.sh 自行 source 故不走這條) +include $(ENV_FILE) +export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD +export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET +export HAIXUN_SECRETS_KEY HAIXUN_BACKEND_URL +export HAIXUN_WORKER_POLL_MS HAIXUN_8D_TARGET_SAMPLES PLAYWRIGHT_HEADLESS +export INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME +endif + +export HAIXUN_JOB_WORKER_TYPE ?= go-demo +export HAIXUN_JOB_WORKER_ID ?= local-go-demo + +# 修改 generate/api/*.api 後執行,重新產生 routes / types / handler(logic 已存在則保留實作) +gen-api: + $(GOCTL) api go -api $(API_FILE) -dir . -style go_zero -home $(GOCTL_HOME) + +fmt: + go fmt ./... + +test: + go test ./... + +run: + mkdir -p bin + go build -o bin/gateway gateway.go + ./bin/gateway -f etc/gateway.yaml + +run-dev: + go run gateway.go -f etc/gateway.yaml + +init: + go run ./cmd/tool/main.go init -f etc/gateway.yaml + +web-dev: + cd web && npm install && npm run dev + +web-build: + cd web && npm install && npm run build diff --git a/backend/README.md b/backend/README.md index b77593e..96879cf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,6 +12,7 @@ - `auth`:native email/password 登入、JWT access/refresh token、logout revoke。 - `member`:目前登入會員的 profile 讀寫。 - `permission`:permission catalog 與目前會員權限查詢。 +- `threads automation`:Threads 帳號、OAuth/API 診斷、發文 queue、補庫存、智慧時段、頻率護欄、成效追蹤與語調庫。 暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。 @@ -23,6 +24,13 @@ go mod download make run ``` +正式前端: + +```bash +make web-dev # Vite dev server :5173,proxy 到 :8890 +make web-build # TypeScript + Vite build +``` + 預設服務: ```text @@ -61,10 +69,10 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高 前端在人設詳情頁按「開始 8D 分析」後,任務會進入: ```text -確認連線 -> 抓取樣本 -> AI 8D -> 儲存策略 +準備爬蟲 -> 抓取公開樣本 -> AI 8D -> 儲存策略 ``` -目前 Node worker 先用 Playwright 抓 Threads 公開頁樣本並產生可驗證的 8D 結構;若公開頁無法讀到足夠樣本,job 會標記為 `failed` 並顯示原因,不會停在等待狀態。 +人設 8D 分析**不需** Chrome extension session;Node worker 以匿名 Playwright 讀取對標帳號的公開個人頁。若公開頁無法讀到足夠樣本,job 會標記為 `failed` 並顯示原因,不會停在等待狀態。AI 分析使用會員設定頁的研究用 provider / API key(有選定經營帳號時沿用該帳號設定)。 ## 專案結構 @@ -72,6 +80,8 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高 haixun-backend/ gateway.go # go-zero server 入口 Makefile # gen-api / fmt / test / run + web/ # 正式巡樓 Console(Vite + React + TypeScript) + dev-console/ # 本機診斷用最小開發面板,不是產品 UI etc/ # runtime config generate/ api/ # goctl .api 定義 @@ -87,6 +97,11 @@ haixun-backend/ auth/ # JWT token issue/refresh/logout + Redis revoke store member/ # Native member profile + password hash permission/ # Permission catalog + role permission mapping + publish_queue/ # Threads 發文 queue、guarded transition、retry/missed + publish_inventory/ # 自動補庫存 policy + publish_guard/ # 發文頻率護欄與 pause/resume + publish_queue_event/ # Queue 狀態轉移與告警事件 + style_preset/ # 人設語調 preset、CTA、禁用詞 worker/ # 常駐背景 worker / scheduler / reaper library/ # 最小 runtime library response/ # 統一 JSON response envelope diff --git a/backend/cmd/tool/main.go b/backend/cmd/tool/main.go index 837a0dc..c6b8c35 100644 --- a/backend/cmd/tool/main.go +++ b/backend/cmd/tool/main.go @@ -15,6 +15,8 @@ import ( ) func main() { + bootstrap.LoadInfraEnv() + if len(os.Args) < 2 { printUsage() os.Exit(1) @@ -37,7 +39,7 @@ func runInit(args []string) error { configFile := fs.String("f", "etc/gateway.yaml", "config file") tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions") email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@30cm.net"), "bootstrap admin email") - password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Fafafa54088"), "bootstrap admin password") + password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", ""), "bootstrap admin password") displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name") if err := fs.Parse(args); err != nil { return err diff --git a/backend/cmd/worker/main.go b/backend/cmd/worker/main.go index e80b632..d54f0a0 100644 --- a/backend/cmd/worker/main.go +++ b/backend/cmd/worker/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "syscall" + "haixun-backend/internal/bootstrap" "haixun-backend/internal/config" "haixun-backend/internal/svc" @@ -18,11 +19,12 @@ var configFile = flag.String("f", "etc/gateway.worker.yaml", "config file") func main() { flag.Parse() + bootstrap.LoadInfraEnv() var c config.Config conf.MustLoad(*configFile, &c, conf.UseEnv()) - if !c.JobWorker.Enabled { - fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true") + if !c.JobWorker.Enabled && !c.JobScheduler.Enabled && !c.JobReaper.Enabled && !c.ThreadsTokenRefresh.Enabled && !c.ThreadsPublishQueue.Enabled { + fmt.Fprintln(os.Stderr, "[worker] at least one worker, scheduler, reaper, token sweeper, or publish queue sweeper must be enabled") os.Exit(1) } @@ -30,10 +32,13 @@ func main() { defer sc.Close(context.Background()) fmt.Printf( - "[worker] started type=%s (scheduler=%v reaper=%v)\n", + "[worker] started type=%s (job=%v scheduler=%v reaper=%v token_sweeper=%v publish_sweeper=%v)\n", c.JobWorker.WorkerType, + c.JobWorker.Enabled, c.JobScheduler.Enabled, c.JobReaper.Enabled, + c.ThreadsTokenRefresh.Enabled, + c.ThreadsPublishQueue.Enabled, ) ch := make(chan os.Signal, 1) diff --git a/backend/dev-console/README.md b/backend/dev-console/README.md new file mode 100644 index 0000000..3ff7126 --- /dev/null +++ b/backend/dev-console/README.md @@ -0,0 +1,37 @@ +# 巡樓 Dev Console + +重做正式前端前的**最小開發面板**:登入、人設 CRUD、8D 公開爬蟲任務與 job 輪詢。 + +## 啟動 + +```bash +# 1. 後端 API(另開終端) +cd backend +go run gateway.go -f etc/gateway.yaml + +# 2. Node 8D worker(另開終端,人設 8D 需要) +cd backend/worker +npm install +npm run style-8d + +# 3. Dev Console +cd backend/dev-console +npm install +npm run dev +``` + +瀏覽器開啟 http://127.0.0.1:5173 。API 透過 Vite proxy 轉到 `http://127.0.0.1:8890`,無需處理 CORS。 + +## 使用順序 + +1. 註冊或登入(tenant 預設 `default`) +2. **AI 設定**:選研究 provider → 貼 API key → **讀取模型列表** → 選 model → **儲存** +3. 建立人設 → 點選列表中的一筆 +4. 填對標 Threads 帳號 → **開始 8D** +5. 自動輪詢 job;完成後下方人設詳情會出現 `style_profile` + +## 注意 + +- 8D 分析使用**研究用** provider / model + API key(與 Chrome session 無關) +- 若尚無經營帳號,Dev Console 會自動建立「Dev Console」帳號(AI key 實際存會員 scope) +- 僅供本機開發,不是產品 UI \ No newline at end of file diff --git a/backend/dev-console/dist/assets/index-56hNfpGK.js b/backend/dev-console/dist/assets/index-56hNfpGK.js new file mode 100644 index 0000000..f55f5b3 --- /dev/null +++ b/backend/dev-console/dist/assets/index-56hNfpGK.js @@ -0,0 +1,3 @@ +(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))i(o);new MutationObserver(o=>{for(const r of o)if(r.type==="childList")for(const f of r.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&i(f)}).observe(document,{childList:!0,subtree:!0});function s(o){const r={};return o.integrity&&(r.integrity=o.integrity),o.referrerPolicy&&(r.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?r.credentials="include":o.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(o){if(o.ep)return;o.ep=!0;const r=s(o);fetch(o.href,r)}})();const I=102e3,v="haixun.dev-console",t=e=>document.getElementById(e),a=w();function w(){try{return JSON.parse(localStorage.getItem(v)||"{}")}catch{return{}}}function d(){localStorage.setItem(v,JSON.stringify(a))}function c(e,n){const s=t("log"),i=n===void 0?String(e):`${e} ${JSON.stringify(n,null,2)}`;s.textContent=`${new Date().toLocaleTimeString()} ${i} + +`+s.textContent}async function l(e,{method:n="GET",body:s,auth:i=!1}={}){const o={"Content-Type":"application/json"};i&&a.accessToken&&(o.Authorization=`Bearer ${a.accessToken}`);const r=await fetch(e,{method:n,headers:o,body:s===void 0?void 0:JSON.stringify(s)}),f=await r.text();let m;try{m=JSON.parse(f)}catch{throw new Error(`非 JSON 回應 ${e}: HTTP ${r.status}`)}if(m.code!==I)throw new Error(m.message||`API 錯誤 ${m.code}`);return m.data}function u(e,n){const s=t("authStatus");s.textContent=e,s.className="status"+(n?" ok":n===!1?" err":"")}function T(e){return`${e||"—"}`}async function $(){try{await l("/api/v1/health"),t("connStatus").textContent="API 連線正常",t("connStatus").className="status ok"}catch(e){t("connStatus").textContent="API 離線:"+e.message,t("connStatus").className="status err"}}function S(e){const n=t("personaList");if(n.innerHTML="",!(e!=null&&e.length)){n.innerHTML='
  • 尚無人設
  • ';return}for(const s of e){const i=document.createElement("li");i.className=s.id===a.selectedPersonaId?"active":"",i.innerHTML=`${s.display_name||s.id}${s.style_benchmark?"@"+s.style_benchmark:"未分析"}`,i.onclick=()=>g(s.id),n.appendChild(i)}}async function h(){const e=await l("/api/v1/personas",{auth:!0});a.personas=e.list||[],S(a.personas),d(),c("人設列表已更新",{count:a.personas.length})}function g(e){a.selectedPersonaId=e,d(),S(a.personas||[]),t("btnStart8d").disabled=!e,t("btnReloadPersona").disabled=!e,y()}async function y(){const e=a.selectedPersonaId;if(!e)return;const n=await l(`/api/v1/personas/${encodeURIComponent(e)}`,{auth:!0});t("personaMeta").textContent=`${n.display_name||e} · benchmark @${n.style_benchmark||"—"}`,t("personaDetail").value=JSON.stringify(n,null,2),c("人設詳情",{id:e,style_benchmark:n.style_benchmark})}async function C(){const e=await l("/api/v1/auth/login",{method:"POST",body:{tenant_id:t("tenantId").value.trim(),email:t("email").value.trim(),password:t("password").value}});a.accessToken=e.access_token,a.refreshToken=e.refresh_token,a.uid=e.uid,d(),u(`已登入 uid=${e.uid}`,!0),c("登入成功",{uid:e.uid}),await h(),a.selectedPersonaId&&g(a.selectedPersonaId)}async function N(){const e=await l("/api/v1/auth/register",{method:"POST",body:{tenant_id:t("tenantId").value.trim(),email:t("email").value.trim(),password:t("password").value,display_name:t("email").value.split("@")[0]}});a.accessToken=e.access_token,a.refreshToken=e.refresh_token,a.uid=e.uid,d(),u(`註冊並登入 uid=${e.uid}`,!0),c("註冊成功",{uid:e.uid}),await h()}function O(){a.accessToken="",a.refreshToken="",a.uid="",d(),u("已登出",!1),t("personaList").innerHTML="",t("personaDetail").value="",t("btnStart8d").disabled=!0,t("btnReloadPersona").disabled=!0,c("已登出")}async function _(){const e=t("displayName").value.trim()||"新人設",n=await l("/api/v1/personas",{method:"POST",auth:!0,body:{display_name:e}});t("displayName").value="",await h(),g(n.id),c("建立人設",{id:n.id})}async function L(){const e=a.selectedPersonaId,n=t("benchmark").value.trim().replace(/^@/,"");if(!e||!n)throw new Error("請選人設並填對標帳號");const s=await l(`/api/v1/personas/${encodeURIComponent(e)}/style-analysis`,{method:"POST",auth:!0,body:{benchmark_username:n}});t("jobId").value=s.job_id,t("jobHint").textContent=s.message||"任務已建立",t("jobHint").className="status ok",c("8D 任務已建立",s),await b(),k()}function x(e){var s,i,o;if(!e)return;const n=((s=e.progress)==null?void 0:s.steps)||[];t("jobSteps").innerHTML=n.map(r=>`
    ${T(r.status)} ${r.id} — ${r.message||""}
    `).join(""),t("jobDetail").textContent=JSON.stringify({id:e.id,status:e.status,phase:e.phase,percentage:(i=e.progress)==null?void 0:i.percentage,summary:(o=e.progress)==null?void 0:o.summary,error:e.error,result:e.result},null,2),["succeeded","completed","failed","cancelled"].includes(e.status)&&(P(),(e.status==="succeeded"||e.status==="completed")&&y().catch(()=>{}))}async function b(){const e=t("jobId").value.trim();if(!e)return;const n=await l(`/api/v1/jobs/${encodeURIComponent(e)}`,{auth:!0});return x(n),c("Job 狀態",{id:n.id,status:n.status,phase:n.phase}),n}let p=null;function k(){P(),p=setInterval(()=>b().catch(e=>c("輪詢失敗",e.message)),3e3),t("btnAutoPoll").textContent="停止輪詢"}function P(){p&&clearInterval(p),p=null,t("btnAutoPoll").textContent="自動輪詢"}t("btnLogin").onclick=()=>C().catch(e=>{u(e.message,!1),c("登入失敗",e.message)});t("btnRegister").onclick=()=>N().catch(e=>{u(e.message,!1),c("註冊失敗",e.message)});t("btnLogout").onclick=O;t("btnCreatePersona").onclick=()=>_().catch(e=>c("建立失敗",e.message));t("btnRefreshPersonas").onclick=()=>h().catch(e=>c("載入失敗",e.message));t("btnReloadPersona").onclick=()=>y().catch(e=>c("載入失敗",e.message));t("btnStart8d").onclick=()=>L().catch(e=>{t("jobHint").textContent=e.message,t("jobHint").className="status err",c("8D 失敗",e.message)});t("btnPollJob").onclick=()=>b().catch(e=>c("查詢失敗",e.message));t("btnAutoPoll").onclick=()=>p?P():k();a.tenantId&&(t("tenantId").value=a.tenantId);t("tenantId").onchange=()=>{a.tenantId=t("tenantId").value,d()};a.accessToken&&(u(`token 已還原 uid=${a.uid||"?"}`,!0),h().then(()=>{a.selectedPersonaId&&g(a.selectedPersonaId)}).catch(()=>u("token 可能過期,請重新登入",!1)));$(); diff --git a/backend/dev-console/dist/index.html b/backend/dev-console/dist/index.html new file mode 100644 index 0000000..29c0d8d --- /dev/null +++ b/backend/dev-console/dist/index.html @@ -0,0 +1,225 @@ + + + + + + 巡樓 Dev Console + + + + +
    +
    +

    巡樓 Dev Console

    +

    暫用開發面板 · 人設 + 8D 公開爬蟲 · 重做前端前夠用就好

    +
    +
    尚未連線
    +
    + +
    +
    +

    1. 登入

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + + +
    +
    + +
    +

    2. 人設

    +
    + + + + +
    +
      +

      點選一筆人設後,下方可跑 8D 分析。

      +
      + +
      +

      3. 8D 風格分析(公開爬蟲,不需 session)

      +
      + + + +
      +

      需先啟動 Go API(:8890)與 Node worker(style-8d)。設定頁要有研究用 AI API key。

      +
      +
      + + + + +
      +
      +
      (尚無任務)
      +
      + +
      +

      4. 人設詳情

      +
      + + +
      + +
      + +
      +

      Log

      +
      
      +      
      +
      + + + \ No newline at end of file diff --git a/backend/dev-console/index.html b/backend/dev-console/index.html new file mode 100644 index 0000000..7607a45 --- /dev/null +++ b/backend/dev-console/index.html @@ -0,0 +1,2188 @@ + + + + + + 巡樓 Dev Console + + + + + + +
      +
      +

      巡樓 Dev Console

      +

      暫用開發面板 · 人設 + 8D 公開爬蟲 · 重做前端前夠用就好

      +
      +
      尚未連線
      +
      + +
      +
      +

      1. 登入

      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + + + +
      +
      + +
      +

      2. Threads API 連線(多帳號)

      +

      每位會員可綁定多個 Threads 帳號;token 存 Mongo,系統會在到期前自動 refresh

      +

      選中帳號:—

      +
      + + + + + + +
      +
        +
        + OAuth 除錯 log + + 授權後若仍顯示未連線,先看這裡的 stage +
        +
        (載入中…)
        +

        Meta Redirect URI:載入中…

        +
        + 產品連線設定 + + + + +
        +
        + Threads 權限煙霧測試 + + 讀取類 API 自動測;發文/刪文/隱藏留言需手動 +
        +
          +
          + +
          +
          +
          +
          +

          貼文成效追蹤

          +

          瀏覽、互動與帳號洞察一次看完。數字來自 Threads Graph API,需帳號已 OAuth 連線。

          +
          +
          + + + + +
          +
          + +
          +
          + + +
          + 各 API scope 能追蹤什麼(展開) +
          +
          +
          +
          + +
          選中已連線帳號,按「載入成效」
          +
          +
          +
          +
          + +
          +

          3. 發文佇列

          +

          排程或立即發文;到期由後端 sweeper 透過 Threads API 發佈,並自動排 +1h / +24h / +7d 成效檢查。

          +
          +
          + + +
          +
          + + +
          + + +
          + +
          • 選中帳號後載入佇列
          +
          + +
          +

          4. 發文健康度

          +

          已發佈貼文與成效 checkpoint 彙總(來自 publish_queue + publish_analytics)。

          +
          + + +
          + +
          • 按「載入健康度」
          +
          + +
          +

          2b. Threads API 手動測試台

          +

          針對選中帳號逐項呼叫 Graph API;寫入類(發文/刪文)會真的改 Threads 上的內容。

          +
          +
          +

          個人資料 /me

          +
          threads_basic
          +
          +
          +
          +

          我的貼文列表

          +
          threads_basic
          +
          +
          +
          +

          關鍵字搜尋

          +
          threads_keyword_search
          +
          +
          +
          +
          +

          帳號查詢

          +
          threads_profile_discovery
          +
          +
          +
          +
          +

          提及 mentions

          +
          threads_manage_mentions
          +
          +
          +
          +

          帳號洞察 insights

          +
          threads_manage_insights
          +
          +
          +
          +

          地點搜尋

          +
          threads_location_tagging
          +
          +
          +
          +
          +

          貼文統計

          +
          threads_manage_insights · GET /{media-id}/insights
          +
          +
          +
          +
          +

          貼文洞察 insights

          +
          threads_manage_insights
          +
          +
          +
          +
          +

          貼文留言

          +
          threads_read_replies
          +
          +
          +
          +
          +

          發文(真的會發)

          +
          threads_content_publish
          +
          +
          +
          +
          +

          回覆留言(真的會發)

          +
          threads_content_publish
          +
          +
          +
          +
          +
          +

          刪除貼文

          +
          threads_delete
          +
          +
          +
          +
          +

          隱藏 / 顯示留言

          +
          threads_manage_replies
          +
          +
          +
          +
          +
          +

          同步到 Instagram

          +
          threads_share_to_instagram
          +
          +
          +
          +
          +

          刷新 long-lived token

          +
          token refresh
          +
          +
          +
          +

          結果會顯示在下方;成功後可把 media_id 複製到其他卡片繼續測。

          +
          (尚無結果)
          +
          + +
          +

          3. AI 設定(8D 研究用)

          +

          登入後自動載入經營帳號與 AI 設定

          +
          + + +
          +
          + + +
          +
          + + + + +
          +
          + + + +
          +

          8D 分析使用研究用 provider / model。Key 存於後端(會員 scope),Dev Console 不會把完整 key 留在 localStorage。

          +
          + +
          +

          4. 人設

          +
          + + + + +
          +
            +

            點選一筆人設後,下方可跑 8D 分析。

            +
            + +
            +

            5. 8D 風格分析(公開爬蟲,不需 session)

            +
            + + + +
            +

            需先完成上方 AI 設定,並啟動 Go API + Node worker(style-8d)。

            + +
            +
            + + + + +
            +
            +
            (尚無任務)
            +
            + +
            +

            6. 人設詳情

            +
            + + +
            +
            +

            選中人設後顯示內容

            +
            +
            + +
            +

            Log

            +
            
            +      
            +
            + + + + \ No newline at end of file diff --git a/backend/dev-console/package-lock.json b/backend/dev-console/package-lock.json new file mode 100644 index 0000000..915a3d4 --- /dev/null +++ b/backend/dev-console/package-lock.json @@ -0,0 +1,1141 @@ +{ + "name": "haixun-dev-console", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "haixun-dev-console", + "devDependencies": { + "vite": "^6.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/backend/dev-console/package.json b/backend/dev-console/package.json new file mode 100644 index 0000000..65989f4 --- /dev/null +++ b/backend/dev-console/package.json @@ -0,0 +1,12 @@ +{ + "name": "haixun-dev-console", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/backend/dev-console/vite.config.ts similarity index 63% rename from frontend/vite.config.ts rename to backend/dev-console/vite.config.ts index 857218e..c753b7c 100644 --- a/frontend/vite.config.ts +++ b/backend/dev-console/vite.config.ts @@ -1,10 +1,8 @@ import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' export default defineConfig({ - plugins: [react(), tailwindcss()], server: { + host: '127.0.0.1', port: 5173, proxy: { '/api': { diff --git a/backend/docs/phase1-methods.md b/backend/docs/phase1-methods.md deleted file mode 100644 index a91ebe4..0000000 --- a/backend/docs/phase1-methods.md +++ /dev/null @@ -1,176 +0,0 @@ -# 全 Phase 新增功能說明 - -## Phase 1 — TA 精準度 - -### 1. Semantic Embedding Reranker - -**ScanPost entity 新增欄位**(`internal/model/scan_post/domain/entity/post.go`): - -| 欄位 | 型別 | 用途 | -|------|------|------| -| `Embedding` | `[]float32` | OpenAI text-embedding-3-small 向量 | -| `SemanticScore` | `int` | 語意相似度分數(0–100) | -| `EngagementPredicted` | `int` | 預測互動率 | -| `AudienceQualityScore` | `int` | 受眾品質分數(0–100) | -| `AuthorID` | `string` | 作者 ID(Threads user ID) | -| `AuthorAvatar` | `string` | 作者頭像 URL | -| `AuthorFollowers` | `int` | 作者粉絲數 | - -`ScanCandidate` struct 同步新增對應欄位(`internal/library/placement/dual_track.go`)。 - -### 2. Embedding Client - -```go -// internal/library/embedding/client.go -func NewClient(apiKey, model string) *Client -func (c *Client) Generate(ctx context.Context, text string) ([]float32, error) -``` - -- 預設使用 OpenAI `text-embedding-3-small`(可降級 local BGE-M3) -- Scorer: `CosineSimilarity(a, b []float32) float64` - -### 3. Placement Score V2 - -```go -// internal/library/placement/dual_track.go -func computePlacementScore(post *ScanCandidate, member productContext, brand productMeta, - semanticScore, predictedEngagement, audienceQuality *int) int -``` - -整合三項 pointer 參數(nil 表示欄位不存在,跳過該項): - -- **語意相似度**(`SemanticScore`):Embedding cosine → 0–100 -- **預測互動率**(`EngagementPredicted`):like+reply → 對數正規化 0–100 -- **受眾品質**(`AudienceQualityScore`):`computeAudienceQuality()` heuristic - -權重:語意 20% / 互動 30% / 品質 10% / 舊有 productFit + solvedBy + recency 40%。 - -```go -func computeAudienceQuality(followers int) int -``` - -Heuristic:followers 在 500–50k 之間品質最佳,過低或過高遞減。 - -### 4. Creator Lookalike - -**端點**: `POST /api/v1/scan-post/lookalike`(需 JWT) - -```json -{ "source_post_id": "uuid", "brand_id": "...", "limit": 10 } - -→ { "list": [{ "post_id", "author_id", "author_name", "similarity", ... }] } -``` - -**演算法**: cosine similarity(門檻 ≥0.3),同 brand 內找 embedding 最接近的貼文回傳。 - -| 檔案 | | -|------|--| -| Handler | `internal/handler/scan_post/lookalike_handler.go` | -| Logic | `internal/logic/scan_post/lookalike_logic.go` | -| `cosineSimilarity` | `internal/logic/scan_post/lookalike_logic.go` — 全 Go 實作 | - ---- - -## Phase 2 — 營運流程 - -### 2.1 Creator CRM - -**完整四層 model** `crm_contact`: - -``` -internal/model/crm_contact/ - domain/entity/entity.go - domain/repository/repository.go - domain/usecase/usecase.go - usecase/usecase.go - repository/mongo.go -``` - -**端點**: `/api/v1/crm/contacts`(需 JWT) - -| Method | Path | 用途 | -|--------|------|------| -| GET | `/` | List(`page`/`pageSize`/`brand_id`/`status`/`tag`) | -| POST | `/` | 新增聯絡人(`author_id` + `author_name`) | -| GET | `/:id` | 取得單筆 | -| PATCH | `/:id` | 更新(`notes`/`tags`/`status`) | -| DELETE | `/:id` | 刪除 | - -**CRMContact 狀態機**: `lead` → `contacted` → `responded` → `converted` / `archived` - -### 2.2 Publish Analytics - -**完整四層 model** `publish_analytics`: - -**Checkpoint 常數**(`internal/model/publish_analytics/usecase/usecase.go`): `+1h`、`+24h`、`+7d` - -**方法**: -```go -RecordCheckpoint(ctx, tenantID, ownerUID, summary) (*PublishAnalyticsSummary, error) -ListByMedia(ctx, tenantID, ownerUID, mediaID) ([]PublishAnalyticsSummary, error) -``` - -**Worker handler** `publish-analytics`(step `check`): - -呼叫 Threads API `GET /{media-id}?fields=like_count,reply_count,repost_count,quote_count` → 存入 `publish_analytics`。 - -### 2.3 Threads API Token 刷新 - -**底層 API**(`internal/library/threadsapi/media.go`): -```go -RefreshAccessToken(ctx, currentToken) (*TokenRefreshResult, error) -// GET /v1.0/refresh_access_token?grant_type=th_refresh_token -``` - -**儲存 Token**: -```go -// Repository -SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*entity.Secrets, error) -// Usecase -SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*SecretsData, error) -``` - -**Worker handler** `refresh-threads-token`(step `refresh`):讀取 secrets → refresh → 儲存。 - -### 2.4 Rate Limiter - -```go -// internal/library/ratelimit/token_bucket.go -func NewTokenBucket(rate float64, burst int) *TokenBucket -func (tb *TokenBucket) Allow() bool -func (tb *TokenBucket) AllowN(n int) bool -``` - -- In-memory token bucket(非 Redis),thread-safe -- `rate` = tokens/sec,`burst` = 最大 burst - ---- - -## Infrastructure - -### Job Templates - -`internal/model/job/usecase/usecase.go` 新增兩個 template constants + 對應 `Ensure*` methods + `template()` functions: - -| Template Type | Steps | Timeout | Retry | -|---------------|-------|---------|-------| -| `refresh-threads-token` | `refresh`(go) | 120s | 3 次(30/120/300s) | -| `publish-analytics` | `check`(go) | 120s | 2 次(60s) | - -### Worker Handlers 註冊 - -`internal/svc/service_context.go` 中註冊: - -```go -jobworker.RegisterRefreshThreadsTokenHandler(runner, RefreshThreadsTokenDeps{...}) -jobworker.RegisterPublishAnalyticsHandler(runner, PublishAnalyticsDeps{...}) -``` - -### Worker Helper - -```go -// internal/worker/job/expand_graph.go -func int64Field(payload map[string]any, key string) int64 -``` - -從 job payload 讀 `int64`,支援 `float64` / `int64` / `int` / `json.Number`。 diff --git a/backend/docs/scan-placement-plan.md b/backend/docs/scan-placement-plan.md deleted file mode 100644 index c38f09b..0000000 --- a/backend/docs/scan-placement-plan.md +++ /dev/null @@ -1,512 +0,0 @@ -# 海巡獲客計畫:知識圖譜 + 雙軌爬取 + 島民交接 - -> 在既有「背景 Job + 島民交接」上,新增 Topic Knowledge Graph(Brave 驅動)與**雙維度 Tag**(相關 + 近期)+ **雙軌海巡**,強化流程 B 的痛點發現、關鍵字精準度與**產品-痛點匹配**驗證。 - -## 北極星 - -海巡找到的貼文/留言,**你的產品是否真的解得了那個問題**(可置入、可回覆、可追蹤)。 - -流程 B 主線: - -```text -Brave 知識圖譜擴散(周邊延伸) - → 衍生雙維度搜尋 tag(相關詞 + 近期求助詞) - → 使用者手動勾選 - → 每個 tag 雙軌爬 Threads(相關軌 + 近期軌,7d 優先 / 30d 補充) - → productFitScore 篩選 - → 島民協助撰寫獲客留言 -``` - -## 已拍板決策 - -| 項目 | 決策 | -|------|------| -| 圖譜深度 | **3 層、範圍廣**:核心 → 成因/症狀 → 相鄰情境 | -| 進海巡的 tag | **使用者手動勾選**(圖譜 UI 多選;島民可 toggle,不預設全選) | -| 近期窗口 | **7 天內為重點**;不足時補充至 **30 天**;超過 30 天排除 | -| Brave 預算 | 中等,每輪知識擴展 **10–15 次查詢**(不足可 supplemental 1 輪) | -| 痛點 tag 候選 | 圖譜衍生 **≥12 候選**,其中痛點/求助類 **≥8** | -| 流程 A | 保留 `style-8d` 捷徑;matrix + 留言收集疊加於 Phase 2 | - ---- - -## 根因診斷(舊系統痛點少、關鍵字不準) - -對照舊 Next.js([`lib/ai/prompts/research-map-placement.ts`](../../lib/ai/prompts/research-map-placement.ts)、[`lib/services/scan-tasks.ts`](../../lib/services/scan-tasks.ts)、[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)): - -| 現象 | 根因 | 新系統對策 | -|------|------|------------| -| 痛點只抓到 1–2 個 | Placement 壓 `suggestedTags` 至 **2~4**;`PLACEMENT_QUERY_MAX = 8` | 圖譜衍生 ≥8 痛點 tag + supplemental 補充迴圈 | -| 關鍵字不夠精準 | AI 憑種子詞推測,無外部知識 | Brave `knowledge_expand` 建 TKG,節點附 `evidence[]` | -| 只有「最相關」 | Recency 只是加分,無獨立近期軌 | **Tag 層**分 `relevance` / `recency`;**Crawl 層**雙軌必跑 | -| 沒有周邊延伸 | Brave 只做 `site:threads.net` | `knowledge_expand` 做領域知識(成因、懷孕、換季…)再衍生 tag | - -新後端原則見 [`AGENTS.md`](../AGENTS.md):**複製模式,不複製舊業務**——移植 Playwright/過濾規則,用 Mongo + Job 重建。 - ---- - -## 架構總覽 - -```mermaid -flowchart TB - subgraph input [輸入] - Seed["種子詞"] - ProductBrief["product_brief"] - Persona["人設"] - end - - subgraph tkg [知識圖譜] - ExpandJob["expand-graph job"] - BraveK["Brave knowledge_expand"] - TKG["topic_knowledge_graphs"] - end - - subgraph derive [Tag衍生] - DeriveFn["deriveSearchTagsFromGraph"] - RelQ["relevanceQueries"] - RecQ["recencyQueries"] - end - - subgraph select [使用者選擇] - GraphUI["圖譜 UI 勾選節點/tag"] - end - - subgraph scan [海巡] - ScanJob["scan job 每tag雙軌"] - Posts["scan_posts"] - end - - subgraph outcome [驗收] - Fit["productFitScore"] - Outreach["outreach + 島民留言"] - end - - Seed --> ExpandJob - ProductBrief --> ExpandJob - Persona --> ExpandJob - ExpandJob --> BraveK --> TKG - TKG --> DeriveFn - DeriveFn --> RelQ - DeriveFn --> RecQ - RelQ --> GraphUI - RecQ --> GraphUI - GraphUI --> ScanJob --> Posts --> Fit --> Outreach -``` - ---- - -## Tag 產生完整流水線 - -Tag **不是** AI 一次吐 2~4 個,而是五段流水線產出: - -```mermaid -flowchart LR - S["1 種子詞+brief"] --> A["2 AI核心地圖"] - A --> B["3 Brave knowledge_expand"] - B --> G["4 合成TKG三層"] - G --> D["5 deriveSearchTagsFromGraph"] - D --> R["relevanceQueries"] - D --> C["recencyQueries"] -``` - -| 步驟 | 做什麼 | 產出 | -|------|--------|------| -| 1 | 讀 `seed_query`、`product_brief`、`target_audience` | 輸入包 | -| 2 | AI 產核心 questions/pillars/exclusions | 研究地圖骨架 | -| 3 | Brave 10–15 次**一般網搜**(非 threadsOnly) | snippets → 候選節點 | -| 4 | AI 合成 TKG(L0/L1/L2)+ `productFitScore` + `evidence[]` | `topic_knowledge_graphs` | -| 5 | 每節點壓成 2~8 字真人搜尋詞,分兩套 | `derivedTags` | - -### 雙維度 Tag(相關 + 近期都要) - -每個圖譜節點衍生: - -| 維度 | 用途 | 寫法範例 | -|------|------|----------| -| **`relevanceQueries`** | 相關軌:短詞、高命中 | `敏感肌`、`屏障受損` | -| **`recencyQueries`** | 近期軌:求助語境 + 時間窗 | `敏感肌 請問`、`換季泛紅 推薦` | - -- `recencyQueries` 在 Brave `threads_discover` 時加 `after:{7天前日期}`(參考舊 [`scan-web-discover.ts`](../../lib/services/scan-web-discover.ts) `buildPlacementKeywordQueries`) -- 候選總量:**≥12 tag**(痛點/求助類 **≥8**);**使用者勾選後才 crawl** - ---- - -## 痛點 Tag 保底機制 - -解決「只抓到一兩個痛點」: - -```text -expand-graph 完成 → deriveSearchTagsFromGraph - IF 痛點/求助類 tag 數 < 8: - → supplemental_round(最多 1 次,Brave +5 查詢) - → 追加查詢例:{seed} 困擾、{seed} 求助、{L2節點} 請問、{seed} 推薦 - → AI 補節點 + 補 derivedTags - IF 仍 < 8: - → job 標 warning;UI + 島民提示「可重跑 expand 或手動加種子詞」 -``` - -| 指標 | 舊系統 | 新系統 | -|------|--------|--------| -| Placement suggestedTags | 2~4 | 不沿用此上限 | -| 搜尋任務上限 | 8 | 候選 ≥12,實 crawl = 勾選數 | -| 痛點類最低 | 無保證 | **≥8**(含 supplemental) | - ---- - -## Topic Knowledge Graph(TKG) - -### Mongo collection:`topic_knowledge_graphs` - -綁 `persona_id` + `seed_query`。 - -```json -{ - "seed": "敏感肌", - "nodes": [ - { - "id": "n1", - "label": "敏感肌", - "nodeKind": "pain", - "type": "core", - "layer": 0, - "placementValue": "high", - "productFitScore": 95, - "selectedForScan": false, - "evidence": [], - "derivedTags": { - "relevance": ["敏感肌"], - "recency": ["敏感肌 請問", "敏感肌 推薦"] - } - }, - { - "id": "n2", - "label": "懷孕嗅覺敏感", - "nodeKind": "cause", - "type": "cause", - "layer": 2, - "relation": "可能成因", - "placementValue": "medium", - "productFitScore": 40, - "selectedForScan": false, - "evidence": [{ "url": "...", "snippet": "..." }], - "derivedTags": { - "relevance": ["懷孕皮膚癢", "嗅覺敏感"], - "recency": ["懷孕 皮膚 癢 請益"] - } - }, - { - "id": "n3", - "label": "屏障修復原理", - "nodeKind": "knowledge", - "type": "mechanism", - "layer": 1, - "productFitScore": 70, - "selectedForScan": false, - "derivedTags": { - "relevance": ["屏障受損"], - "recency": ["屏障受損 怎麼辦"] - } - } - ], - "edges": [ - { "from": "n1", "to": "n2", "relation": "可能因" }, - { "from": "n1", "to": "n3", "relation": "機制" } - ], - "braveSources": [{ "query": "敏感肌 懷孕 原因", "snippet": "...", "url": "..." }], - "painTagCount": 9, - "generatedAt": 0 -} -``` - -### 三層擴散 - -```text -L0 核心:敏感肌 -L1 直接相關:屏障受損、換季泛紅、刺癢 -L2 周邊情境:懷孕荷爾蒙、嗅覺敏感、壓力熬夜、換洗臉產品過敏 … -``` - -### 節點語意 - -| 欄位 | 說明 | -|------|------| -| `nodeKind` | `pain`(痛點/求助)、`knowledge`(科普延伸)、`cause`、`symptom` | -| `placementValue` | 建議優先級,**不決定是否海巡** | -| `selectedForScan` | 使用者勾選後 `true`,才進 `scan` payload | -| `productFitScore` | 依 `product_brief`:產品解不解得了 | -| `derivedTags` | `relevance` + `recency` 兩套查詢詞 | -| `evidence[]` | L1/L2 必填(Brave snippet 可追溯) | - -- `knowledge` 節點:延伸話題/科普靈感,**預設不勾選**;若 snippet 含求助語境可升級為 `pain` -- `knowledge` 不強制進 placement crawl,除非使用者勾選且 `productFitScore` 達標 - ---- - -## Brave 雙模式 - -| 模式 | `threadsOnly` | 用途 | -|------|---------------|------| -| `knowledge_expand` | `false` | 建 TKG,找成因/周邊/知識 | -| `threads_discover` | `true` | 海巡時找 Threads 貼文 | - -### L0/L1 查詢模板(plan_queries,上限 15/輪) - -```text -{seed} 常見原因 -{seed} 什麼情況會 -{seed} 初期 症狀 -{seed} 怎麼改善 困擾 -{seed} 求助 推薦 -``` - -### L2 周邊擴散查詢池(從 brief/受眾推導) - -```text -{seed} 懷孕 相關 -{seed} 壓力 熬夜 -{seed} 換產品 過敏 -{seed} 與 {受眾場景} 的關係 -{L1節點} 原因 -{L1節點} 困擾 -``` - -Brave 回傳 title/snippet/url → AI 萃取節點與邊 → 寫入 TKG。實作:`internal/library/knowledge/` + Brave adapter(`BRAVE_SEARCH_API_KEY`;參考舊 [`lib/services/web-search.ts`](../../lib/services/web-search.ts))。 - ---- - -## 完整範例:敏感肌 Walkthrough - -**輸入** - -- 種子詞:`敏感肌` -- product_brief:溫和修護、無香料、適合敏感/屏障受損肌 - -**Brave knowledge_expand(節錄)** - -| 查詢 | snippet 線索 | 圖譜節點 | -|------|--------------|----------| -| `敏感肌 常見原因` | 屏障受損、過度清潔 | L1 symptom `屏障受損` | -| `敏感肌 懷孕` | 荷爾蒙、嗅覺/皮膚變敏感 | L2 cause `懷孕嗅覺敏感` | -| `換季 皮膚 泛紅` | 季節性刺激 | L1 symptom `換季泛紅` | - -**衍生 tag(候選,勾選前不 crawl)** - -| 節點 | relevanceQuery | recencyQuery | productFit | -|------|----------------|--------------|------------| -| 敏感肌 | `敏感肌` | `敏感肌 請問` | 95 | -| 屏障受損 | `屏障受損` | `屏障受損 推薦` | 90 | -| 換季泛紅 | `換季泛紅` | `換季泛紅 請問` | 88 | -| 懷孕皮膚癢 | `懷孕皮膚癢` | `懷孕 皮膚 癢 請益` | 視產品而定 | - -**使用者**:勾選 productFit 高的 4 個節點(可不勾懷孕若產品不適用) - -**startScan**:每個勾選節點的 relevance + recency 詞都跑雙軌 - -| 軌道 | 行為 | 本例預期 | -|------|------|----------| -| 相關軌 | sort=relevance, limit≈12 | 高互動痛點貼文 | -| 近期軌 | 7d 優先,不足補 30d | 一週內求助帖 | - -**合併** → gold / recent / relevant → `productFitScore` → 獲客台 → 島民 `generateOutreachReply` + fill - ---- - -## 近期窗口 - -| 窗口 | 天數 | 行為 | -|------|------|------| -| **重點** | 7 天內 | 優先爬取、優先顯示、排序最高 | -| **補充** | 8~30 天 | 7 天內不足時才補,排序較低 | -| **排除** | >30 天 | 不進海巡與獲客清單 | - -策略: - -1. 每個勾選 tag 的**近期軌**先抓滿 7 天名額 -2. 全輪痛點貼文不足目標時,自動放寬至 30 天 -3. 獲客台預設篩「7 天內」,可切「含 30 天內補充」 - ---- - -## 雙軌海巡(Tag + Crawl + UI 三層對齊) - -**近期軌不是相關軌的副產品**——每個勾選 tag 的 relevance 與 recency 查詢都**必跑**。 - -| 層級 | 相關 | 近期 | -|------|------|------| -| **Tag** | `derivedTags.relevance` 短詞高命中 | `derivedTags.recency` 求助語境 + after 日期 | -| **Crawl** | 相關軌 sort=relevance, limit≈12 | 近期軌 7d 滿額 → 30d 補 | -| **UI** | 可篩 `priority=relevant` | 預設 7d + `priority=gold` 置頂 | - -合併優先級: - -1. 兩軌皆有 → `gold` -2. 僅近期軌 → `recent` -3. 僅相關軌 → `relevant` - -過濾:移植 `hasPlacementIntent`、`looksLikeCasualChat`(舊 [`lib/topic-anchor.ts`](../../lib/topic-anchor.ts)、[`lib/scan-recency.ts`](../../lib/scan-recency.ts))。 - -### scan_posts 擴充欄位 - -- `placement_score`、`priority`(gold/recent/relevant) -- `product_fit_score`、`solved_by_product` -- `posted_at`、`search_tag`、`query_dimension`(relevance/recency) -- `graph_node_id` -- `replies[]`(可選,`scrape_replies: true`) - ---- - -## 產品匹配驗收 - -每篇海巡結果: - -- **`productFitScore`**:痛點 vs `product_brief` -- **`solvedByProduct`**:獲客留言是否對應產品能力(生成時強制檢查) - -獲客台 UI: - -- 預設排序:7 天內 + 產品能解決 -- 標示:可置入 / 需人工 / 超出產品範圍 -- 獲客留言:**島民 fill 全文,不自動送出** - ---- - -## 島民交接 - -### job.result.handoff - -```json -{ - "handoff": { - "flow": "placement", - "persona_id": "...", - "pain_tag_count": 9, - "summary": "12 候選 tag → 勾選 6 節點 → 38 篇;痛點 10(核心 6 + 周邊 4);7 天內 8 篇", - "pain_breakdown": { "core": 6, "peripheral": 4, "recent_7d": 8 }, - "top_peripheral_hits": ["懷孕皮膚癢", "換季泛紅"], - "next_route": "/personas/:id/outreach", - "needs_supplemental_expand": false, - "connection_required": false - } -} -``` - -JobMonitor → `islanderHandoffStore`;[`buildIslanderContext`](../web/src/lib/islander/buildIslanderContext.ts) 注入【近期海巡交接】。 - -### Custom actions - -| Action | 用途 | -|--------|------| -| `expandKnowledgeGraph` | 觸發 `expand-graph`;`supplemental=true` 補充迴圈 | -| `toggleGraphNode` | 勾選/取消節點 | -| `startScan` | `dual_track=true`,只爬 `selectedForScan` 節點 | -| `generateOutreachReply` | 產獲客留言 | -| `applyDraft` | fill 留言欄位 | - -### 對話路徑(流程 B) - -```text -「幫我找敏感肌的痛點」 - → expand-graph(Brave knowledge_expand + AI 合成 TKG) - → IF pain_tag_count < 8 → 島民:「要再補一輪 Brave 嗎?」→ supplemental_round - → 研究頁:圖譜 + 雙維度 tag + productFitScore - → 使用者手動勾選節點 - → startScan(每詞雙軌:相關 + 近期,7d/30d) - → outreach → highlight gold/recent → generateOutreachReply + fill -``` - ---- - -## Job 模板 - -| Template | Steps | worker | -|----------|-------|--------| -| `expand-graph` | plan_queries → brave_knowledge → ai_synth → derive_tags → [supplemental?] → persist_tkg | go | -| `scan` | session → crawl_dual_track → replies? → store → filter → ai_fit → persist | node + go | -| `style-8d` | (既有) | node + go | - -執行順序:**expand-graph → 勾選 tag → scan**。 - ---- - -## API 草案 - -```text -POST /api/v1/personas/:id/knowledge-graph/expand # ?supplemental=true -GET /api/v1/personas/:id/knowledge-graph -PATCH /api/v1/personas/:id/knowledge-graph/nodes # selectedForScan - -POST /api/v1/personas/:id/scan-jobs # graph_id, selected_node_ids, dual_track -GET /api/v1/personas/:id/scan-posts # recent_7d, product_fit_min, priority - -POST /api/v1/personas/:id/outreach-drafts/generate -``` - -Internal worker:`POST /workers/scan-posts/batch`、Brave/AI 內部端點。 - ---- - -## 前端頁面 - -| 路徑 | 用途 | 島民 label | -|------|------|------------| -| `/personas/:id/research` | 圖譜、雙維度 tag、勾選 | 加入海巡、Brave 再擴展 | -| `/personas/:id/outreach` | 獲客貼文 + 留言 | 獲客留言、標記已處理 | -| `/personas/:id/matrix` | 流程 A(Phase 2) | 草稿內容 | - -研究頁每節點展示:`relevanceQueries`、`recencyQueries`、`productFitScore`、勾選框、上次命中數。 - ---- - -## 實作分期 - -### Phase 0a — 知識圖譜 + Tag 流水線 - -- [ ] Go Brave adapter(`knowledge_expand` / `threads_discover`) -- [ ] `expand-graph` job:plan_queries → brave → ai_synth → derive_tags -- [ ] `supplemental_round`(痛點 tag < 8) -- [ ] Mongo `topic_knowledge_graphs`(含 `derivedTags`、`painTagCount`) -- [ ] `deriveSearchTagsFromGraph`(relevance + recency 雙陣列) -- [ ] API expand / get / patch nodes - -### Phase 0b — 島民 handoff - -- [ ] handoff(含 `pain_tag_count`、`needs_supplemental_expand`) -- [ ] JobMonitor bridge -- [ ] custom actions + `ai.islander.system.md` 海巡專章 - -### Phase 1 — 雙軌 scan + 流程 B - -- [ ] Node `crawl_dual_track`(每 tag 相關+近期,7d/30d) -- [ ] `productFitScore` + outreach UI -- [ ] **驗收**:敏感肌 → L2(懷孕等)→ 候選痛點 tag ≥8 → 勾選後貼文痛點 ≥8 → 7d 內 ≥5 - -### Phase 2 — 流程 A - -- [ ] matrix + 留言收集 + 島民 fill - -### Phase 3 — 自動化 - -- [ ] job_schedules、Brave 熔斷、Meta API 發留言 - ---- - -## 風險 - -| 議題 | 對策 | -|------|------| -| Brave 幻覺 | 節點必須有 `evidence[]` | -| 圖譜跑題 | exclusions + `productFitScore` | -| 查詢爆炸 | Brave ≤15/輪;supplemental ≤5;衍生 ≤20;只爬勾選 | -| 醫療敏感 | `disclaimer`;留言不自動發 | -| 周邊節點產品不符 | 低 productFit 預設不勾;獲客台標 ✗ | - ---- - -## 參考 - -- 舊海巡:[`lib/services/scan.ts`](../../lib/services/scan.ts) -- 舊網搜:[`lib/services/scan-web-discover.ts`](../../lib/services/scan-web-discover.ts) -- 舊研究地圖:[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts) -- Job 系統:[`docs/job-system-plan.md`](./job-system-plan.md) -- 島民:[`internal/library/prompt/files/ai.islander.system.md`](../internal/library/prompt/files/ai.islander.system.md) -- 既有 8D:[`worker/style-8d-worker.ts`](../worker/style-8d-worker.ts) \ No newline at end of file diff --git a/backend/docs/similar-accounts-ta-plan.md b/backend/docs/similar-accounts-ta-plan.md deleted file mode 100644 index bb51acf..0000000 --- a/backend/docs/similar-accounts-ta-plan.md +++ /dev/null @@ -1,307 +0,0 @@ -# 相似帳號 / 對標帳號 / 找 TA — 改進計畫 - -## ⚠ 換手進度(2026-06-28) - -### ✅ Phase 1 已完成(全部測試通過) -`make fmt && make test` 全 ok;`make build-frontend` 通過。`go build ./...` 通過。 - -已完成項目: -- **1.1 名額放寬**:`MaxSimilarAccounts` 5 → 10(`backend/internal/library/viral/discover_accounts.go:15`)。 -- **1.2 停止清空**:`analyze_copy_mission.go` 不再 `SimilarAccounts = nil`,改用 `similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts)` 保留前次(`backend/internal/worker/job/analyze_copy_mission.go:128`)。 -- **1.3 合併取代覆蓋**:`scan_viral.go` 改 `libviral.MergeSimilarAccounts(prev, new)`(`backend/internal/worker/job/scan_viral.go:195`)。 -- **1.4 排序權重抽出**:`reference_accounts.go` 新增 `ReferenceRankWeights` + `DefaultReferenceRankWeights` + `rankScore`(log-scale follower bucket 1k/10k/100k/1M、topicHits 加權),排序用加權和;`postTopicRelevant` 改為 `topicTopicHits` 回傳 hits 並用 `normalisedTopicTerms` lower-case 取代 `topicTerms`。 -- **1.5 web search fallback**:`scan_viral.go:170` `BuildReferenceAccountsFromScan` 後若 `len(referenceAccounts) < MaxSimilarAccounts` 且 `placement.WebSearchAvailable(research)` → 呼 `supplementSimilarAccountsFromWeb`(`scan_viral.go:369`),透過 `websearch.New(memberCtx.WebSearchConfig())` 建 client → `DiscoverSimilarAccounts` → `MergeSimilarAccounts` 串接。失敗安全:web search 失敗不讓 job fail。 -- **1.6 ProfileURL helper**:新檔 `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink(permalink, username)`,優先用 permalink 反推、fallback `https://www.threads.com/@`(`.com` 為 canonical host)。`reference_accounts.go:137`、`enrich_accounts.go`、`discover_accounts.go` 已改用。 -- **1.7 Brave StartPublishedDate**:`brave.SearchOptions` 加 `StartPublishedDate string` 欄位(forward-compat,目前 brave client 尚未傳給 API);`websearch/client.go` braveAdapter 把 `opts.StartPublishedDate` 塞進 libbrave.SearchOptions。Exa 原本就有支援。 -- **1.8 前端顯示**:`frontend/src/types/copyMission.ts` `CopySimilarAccountData` 加 `matched_source?: string[]` + `similarAccountSourceTier`/`similarAccountSourceLabel` helper;`frontend/src/lib/viralSignals.ts` 加 `similarAccountTone`;`CopyMissionDetailPage.tsx:837-878` row 加「來源」badge(scan+web/sky/success、web/sky、scan/brand)、「置信度」小字。 -- **schema**:`missionentity.SimilarAccount` 加 `MatchedSource []string`(`backend/internal/model/copy_mission/domain/entity/mission.go:27`);`domusecase.SimilarAccountSummary` 同步;`usecase.toSummary` 帶 matched;`types.CopySimilarAccountData` 同步;`logic/copy_mission/mapper.go` entity↔API 兩處對映帶 matched;`generate/api/copy_mission.api` 的 `CopySimilarAccountData` 同步(手動補,**未跑 `make gen-api`**——必要時下一手可跑 gen-api 重新產生以對齊)。 -- 新檔: - - `backend/internal/library/viral/merge_accounts.go` `MergeSimilarAccounts` + `unionMatchedSource`。 - - `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink`。 - - `backend/internal/worker/job/similar_accounts.go` `similarAccountsFromSummary`(summary→entity 轉換 helper,給 worker 跨 rerun 保留 Previous)。 - -### 未做、給下一手 -- **Phase 2**:相似帳號變一等公民(`Status`/`TopicRelevance`/`LastSeenAt` schema + PATCH API + worker 尊重 excluded/pinned + 前端釘選/排除按鈕 + Placement 維持不動)。詳見下方 Phase 2 章節。 -- **Phase 3**:TA 樣本(留言作者採樣,`AudienceSample` + `audience.go` + worker 接入 + PATCH API + 前端「TA 樣本」區)。詳見下方 Phase 3 章節。 -- **Phase 4**:跨任務記憶(後續,未規劃細節)。 -- **重新 gen-api**:`generate/api/copy_mission.api` 只有 `CopySimilarAccountData` 加 `matched_source` 一處,types.go 已手動同步。若下一手要擴 API schema(Phase 2),建議跑 `make gen-api` 重新產 handler/logic/types 確保對齊。 -- **新測試**:Phase 1 沒額外加新 test case。建議下一手補:`viral/merge_accounts_test.go`(`MergeSimilarAccounts` merges、preserves prev、truncates to limit、unionMatchedSource dedup)、`viral/reference_accounts_test.go` 新排序 case(topicHits 加權、log-scale follower bucket)、`worker/job/scan_viral_test.go` web fallback case(mock websearch client)。這些 test 是 Phase 1 的「補強」層,不阻塞 Phase 2 進行。 - -### 已動的檔案清單(Phase 1) -**Go 後端** -- `backend/internal/library/viral/discover_accounts.go`(常數、ProfileURL helper、accountCandidate.permalink) -- `backend/internal/library/viral/reference_accounts.go`(ReferenceRankWeights、topicHits、MatchedSource、ProfileURL helper、刪 topicTerms) -- `backend/internal/library/viral/enrich_accounts.go`(MatchedSource、ProfileURL helper、authorScore.permalink) -- `backend/internal/library/viral/merge_accounts.go`(**新檔**) -- `backend/internal/library/threadsapi/url.go`(**新檔**) -- `backend/internal/library/websearch/client.go`(braveAdapter 傳 StartPublishedDate) -- `backend/internal/library/brave/client.go`(SearchOptions 加 StartPublishedDate) -- `backend/internal/worker/job/scan_viral.go`(web fallback + MergeSimilarAccounts + supplementSimilarAccountsFromWeb) -- `backend/internal/worker/job/analyze_copy_mission.go`(停止清空 SimilarAccounts) -- `backend/internal/worker/job/similar_accounts.go`(**新檔**:similarAccountsFromSummary) -- `backend/internal/model/copy_mission/domain/entity/mission.go`(SimilarAccount 加 MatchedSource) -- `backend/internal/model/copy_mission/domain/usecase/usecase.go`(SimilarAccountSummary 加 MatchedSource) -- `backend/internal/model/copy_mission/usecase/usecase.go`(toSummary 帶 matched) -- `backend/internal/types/types.go`(CopySimilarAccountData 加 matched_source) -- `backend/generate/api/copy_mission.api`(CopySimilarAccountData 加 matched_source) -- `backend/internal/logic/copy_mission/mapper.go`(兩處對映帶 matched) - -**前端** -- `frontend/src/types/copyMission.ts`(CopySimilarAccountData 加 matched_source + helper) -- `frontend/src/lib/viralSignals.ts`(similarAccountTone) -- `frontend/src/pages/CopyMissionDetailPage.tsx`(row 顯示 source badge、置信度小字) - -### 設計選擇備忘 -- `similarAccountsFromSummary` 只帶 summary 可見欄位(沒 `TopicRelevance/LastSeenAt/Status`,那些是 Phase 2)。Phase 1 schema 只補 `MatchedSource`,其他 Phase 2 欄位別在 Phase 1 先加,避免污染。 -- 排序權重改成 log-scale follower bucket(1k/10k/100k/1M cut),避免 mega 帳號壓過 niche 高相關作者。`ReferenceRankWeights` 結構在 `reference_accounts.go` 內部,未注入 input,未來要調整再加。 -- `ProfileURL` 從 `https://www.threads.net/@` 改 `https://www.threads.com/@`(threads.com 現在是 canonical host,前端 `frontend/src/lib/threadsLinks.ts` 用 `threadsProfileUrl(username, profileUrl)` 已能接受 `profile_url` injected,不需改)。 -- `MergeSimilarAccounts` 串接 priority:新(scan+web)> 舊(保舊不被清掉),命中兩條 source 的 username `MatchedSource` 會 union 含 scan 與 web,但 **confidence 不會自動升 high**(confidence 由 `BuildReferenceAccountsFromScan` 內 strict/relaxed gate + verified + bestEngagement 算出來)。若未來要把「跨來源」當 high confidence 的訊號,需在 `MergeSimilarAccounts` 或 mapper 後處理;目前只透過前端 `MatchedSource` badge 視覺呈現。 -- web search fallback 只在 `< MaxSimilarAccounts(=10)` 時觸發,避免每次 scan 都額外打 web 浪費 quota;也避免 web 來的帳號擠掉 scan 來的(因為 new 排前面)。 -- Brave `StartPublishedDate` 欄位加了但**沒 map 到 Brave 的 `freshness` 參數**(Brave API freshness 接受 `d/w/m/y` 而非 ISO date,要做轉換)。Phase 1 只做資訊不丟失;實際生效需要第二階段處理 Brave adapter 的 `freshness` mapping。Exa 已有支援。 - -### 沒動的檔案(確認 boundary) -- Placement / Persona / Brand schema 完全沒動。 -- 沒碰 `internal/handler/routes.go`、`internal/svc/service_context.go`、`internal/response`、`internal/middleware`。 -- 沒動 `internal/model/copy_mission/repository`、`internal/model/copy_mission/domain/repository`。 - ---- - -> 範圍:僅 CopyMission flow。Placement(B 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 schema。 -> 上限:`MaxSimilarAccounts` 自 5 放寬到 10。 -> 遵循:`code/message/data` envelope、`page/pageSize`、`SSCCCDDD` 錯誤碼、UTC+0 nanoseconds、Redis `workerID` lock、不裸 `Update` job 狀態。 -> 驗證:`go mod tidy && make fmt && go test ./...`;前端動到另執行 `make web-build`。 - ---- - -## 背景:threads-api-skill repo 對位 - -參考 repo `madebypan/threads-api-skill` 是**純發布工具包**(容器→publish 30s wait、reply_to_id 串貼、catbox.moe 圖床、60 天 token refresh、Chrome 自動化 setup),**沒有「找相似帳號 / 找對標 / 找 TA」功能**。它整理的 Threads Graph API endpoint 與踩坑,可用來校正下列事實: - -- `me?fields=id,name,username,...` 取 self 資訊 → 目前 `threadsapi/client.go` 完全沒用,無法驗 token 對應 username。 -- `keyword_search`(TOP/RECENT,50/page,無 cursor)→ 已實作;目前沒做翻頁,召回量受限。 -- `media/{id}/replies` → 已實作,目前只當 outreach 標的,**沒把留言作者彙整成 TA 樣本**。 -- Threads Graph API **沒有 endpoint 查任意別人的 follower / 受眾輪廓** → 「找 TA」必須繞道:把留言作者/互動者彙整成潛在受眾樣本。 - ---- - -## 目前狀態(viral 流程摸整) - -- 唯一活著的相似帳號產生路徑:`scan_viral.go` job → `libviral.RunDiscover`(抓 threads 貼文)→ `BuildReferenceAccountsFromScan`(by author 聚合 + verified/follower/engagement 排序,strict gate→relaxed fallback)→ 寫入 `Mission.ResearchMap.SimilarAccounts`(`reference_accounts.go:43`)。 -- `DiscoverSimilarAccounts` / `EnrichSimilarAccounts` / `ToEntitySimilarAccounts` 是 dead code(只有測試呼叫)。 -- `analyze_copy_mission.go:138` 顯式 `researchMap.SimilarAccounts = nil` → 重新跑研究地圖 job 會清空相似帳號,前端沒警告。 -- API 對相似帳號純唯讀:`UpdateCopyMissionReq`(`copy_mission.api:60-72`)沒 `similar_accounts` 欄位,使用者無法排除/釘選。 -- 「找 TA」目前只是 LLM 產出的一段文字 `AudienceSummary`,**沒有任何「找出受眾樣本帳號」的機制**,連結構都沒有。 -- 驗證缺位:`ProfileURL` 一律 `https://www.threads.net/@` 拼字串(`reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119`),不驗真。 -- 純 Threads API 模式下 `AuthorVerified=false, FollowerCount=0` 恆成立,排序權重失衡。 -- 排序權重寫死:`reference_accounts.go:108-119` 固定 verified→follower→engagement,沒有主題 pillar 相似度、沒有跨任務記憶、沒有使用者可調。 -- `postTopicRelevant`(`reference_accounts.go:166-180`)用裸 `strings.Contains`,中文長 label 命中精度低。 - ---- - -## Phase 1 — 資料品質低風險修正(先做) - -### 1.1 名額放寬 -- `backend/internal/library/viral/discover_accounts.go:14`:`MaxSimilarAccounts = 5` → `10`。 -- `scan_viral.go:177`:`Limit: libviral.MaxSimilarAccounts` 維持引用常數。 -- 前端已用 list render,不需改。 - -### 1.2 停止 `analyze_copy_mission` 清空相似帳號 -- `backend/internal/worker/job/analyze_copy_mission.go:138`:刪 `researchMap.SimilarAccounts = nil`。 -- 確認 `model/copy_mission/usecase/usecase.go` dot-path 更新其他 research_map 欄位時不會覆蓋 `similar_accounts`(worker 才會用 `Patch.ResearchMap` 整包,且 worker 現在會保留 SimilarAccounts)。 - -### 1.3 scan 結果改合併而非覆蓋 -- `scan_viral.go:189-197`:`SimilarAccounts: referenceAccounts` → `SimilarAccounts: mergeSimilarAccounts(mission.ResearchMap.SimilarAccounts, referenceAccounts)`。 -- 新檔 `backend/internal/library/viral/merge_accounts.go`:by username 合併,新來的更新統計、舊有保留;Phase 1 schema 還沒 `Status`,先純保舊不被清掉。 -- `mergeSimilarAccounts(prev, new []missionentity.SimilarAccount) []missionentity.SimilarAccount`: - - key = lowercase username;新>舊(更新統計欄位),舊沒被新覆蓋就保留。 - - 排序:保留新來秩序在前 N 個,舊的接在後面直到達 `MaxSimilarAccounts`。 - -### 1.4 排序權重抽出 + 主題相關改用命中詞數 -- `reference_accounts.go:108-119`:抽出 `ReferenceRankWeights` struct(`VerifiedW/FollowerW/TotalEngagementW/BestEngagementW/TopicRelevanceW`,預設 `4/2/1/1/2`),排序 key = 加權和。 -- `reference_accounts.go:166-180` `postTopicRelevant`:仍維持「命中詞數 >= 1 才納入」,但額外回傳 `topicHitCount` 給排序用。 -- `referenceAuthorAgg` 加 `topicHits int`;`BuildReferenceAccountsFromScan` 內部迴圈累加。 --硼切詞暫不引入 jieba:對 CJK 用 `strings.Fields` + 拆字元組(>=2 字才當 term)即可,避免新依賴。 - -### 1.5 web search 補洞(復活 dead code 線) -- `scan_viral.go:172` `BuildReferenceAccountsFromScan` 之後,若 `len(referenceAccounts) < 5` 且 `memberCtx.WebSearchEnabled()`: - ```go - extra, err := libviral.DiscoverSimilarAccounts(ctx, websearchClient, libviral.DiscoverAccountsInput{ - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - Pillars: mission.ResearchMap.Pillars, - }) - if err == nil && len(extra) > 0 { - referenceAccounts = libviral.EnrichSimilarAccounts(referenceAccounts, nil, libviral.MaxSimilarAccounts) - // 把 web 找到的也 by username 合併進去 - } - ``` -- `enrich_accounts.go`:`Source` 改成集合概念:`viral.SimilarAccount` 與 `missionentity.SimilarAccount` 都加 `MatchedSource []string`,命中 web 也命中 scan = `confidence="high"`。 -- web search 失敗**不可讓 scan job 失敗**,只跳過並 log。 - -### 1.6 threadsapi profile URL helper -- 新檔 `backend/internal/library/threadsapi/url.go`:`ProfileURLFromPermalink(permalink, username) string`: - - regex `threads\.(?:com|net)/@([^/]+)/post/` 從 permalink 抓 author root `https://www.threads.com/@/`。 - - fallback `https://www.threads.com/@`(注意 `www.threads.com` 而非 `www.threads.net` 較新且穩)。 -- `reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119` 改用此 helper。 - -### 1.7 Brave adapter 補 StartPublishedDate -- `backend/internal/library/websearch/client.go:114-127` Brave adapter:把 `SearchOptions.StartPublishedDate`(即便 Brave API 不支援)也讀進 `libbrave.SearchOptions`,讓未來 Brave 端支援時自動生效。實作細節:libbrave.SearchOptions 若沒對應欄位就先無效果,但頂層 client 不能丟失資訊。 - -### 1.8 前端顯示新增資訊 -- `frontend/src/pages/CopyMissionDetailPage.tsx:837-875` row:加 `source` badge(已有 `AccountSource` 文字時顯示 `brand`/`neutral`)、`confidence` 用 `text-muted` 小字。 -- `frontend/src/lib/viralSignals.ts:42-48` `viralAccountRowClass`:依 Phase 1 schema 補 `MatchedSource` → 範含 `scan` 與 `web` 用 `--verified-hot` 樣式。 -- `frontend/src/types/copyMission.ts`:`SimilarAccount` 加 `matched_source?: string[]`(Phase 1 只補這欄位,舊資料缺少不影響渲染)。 -- 執行 `make web-build`。 - ---- - -## Phase 2 — 相似帳號變一等公民(動 API,CopyMission only) - -### 2.1 schema 變更 -`backend/internal/model/copy_mission/domain/entity/mission.go:23-35` `SimilarAccount` 加: -```go -Status string `bson:"status,omitempty" json:"status,omitempty"` // recommended|pinned|excluded|promoted -TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"` -LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"` -MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"` -``` -`internal/types/types.go` 同步(重新 gen-api)。 - -### 2.2 API 新增 -`backend/generate/api/copy_mission.api` 加: -``` -@handler patchCopyMissionSimilarAccount -patch /personas/:persona_id/copy-missions/:copy_mission_id/similar-accounts/:username (PatchSimilarAccountReq) returns (CommonRes) - -type PatchSimilarAccountReq { - Status string `json:"status"` -} -``` -跑 `make gen-api` 重新產 handler/logic/types。logic 走 dot-path: -```go -out[fmt.Sprintf("research_map.similar_accounts.%s.status", username)] = strings.TrimSpace(req.Status) -``` -錯誤碼:CopyMission 目前沒有專屬 scope,**Phase 2 用 `Facade` (10)** 暫時頂著,待新增 `CopyMission Scope=41` 後再回頭改。**禁止裸寫數字**,一律 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`。 - -### 2.3 worker 尊重 status -- `reference_accounts.go:54` 開頭:`if isExcluded(in.ExcludedUsernames, post.Author) { continue }`。 -- `scan_viral.go:170`:把 `mission.ResearchMap.SimilarAccounts` 中 `status=excluded` 的 username 傳進 `ReferenceAccountInput.ExcludedUsernames`。 -- `merge_accounts.go`:`status=pinned` 永遠置頂,本次沒掃到也保留。 - -### 2.4 前端互動 -- `CopyMissionDetailPage.tsx` row 加兩按鈕「釘選」「排除」,呼 `api.patch(.../similar-accounts/:username, {status})`,本地 optimistic update。 -- 新檔 `frontend/src/lib/copyMissionSimilarAccounts.ts` 包 API client。 -- `CopyMissionResearchOverview.tsx` 加「相似帳號」子區(從 detail 頁整合進來)。 - ---- - -## Phase 3 — TA 樣本(留言作者採樣,全新功能) - - Threads API 拿不到任意別人的受眾 → 繞道:用 `MediaReplies` 的 `ReplyCandidate.Author` 聚合成潛在受眾樣本。 - -### 3.1 schema 變更 -`backend/internal/model/copy_mission/domain/entity/mission.go` `ResearchMap` 加: -```go -AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"` -``` -新 struct: -```go -type AudienceSample struct { - Username string `bson:"username" json:"username"` - SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"` - SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"` - ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"` - Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"` - FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"` - LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"` - Status string `bson:"status,omitempty" json:"status,omitempty"` -} -``` -`generate/api/copy_mission.api` 在 `CopyResearchMap` 加 `AudienceSamples []CopyAudienceSampleData`;`make gen-api`。 - -### 3.2 新 library -`backend/internal/library/viral/audience.go`: -```go -func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample -``` -- `appearance = by username.Count` -- 過濾:自己 mission 的 author(即 SimilarAccount source)排除、純連結/數字罐頭 reply 文字過濾(`len < 5 chars` or URL-only)。 -- 排序 `appearance desc → replyLikeCount desc → postedAt desc`,前 15 筆。 -- confidence(前端呈現用,不入 struct):`appearance >= 3` high、`= 2` medium、`= 1` low。 - -### 3.3 worker 接入 -`scan_viral.go` 在 `AttachReplies` 完成後: -```go -var allReplies []placement.ReplyCandidate -for _, p := range postsWithReplies { - allReplies = append(allReplies, p.Replies...) -} -samples := libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{ - Max: 15, - ExcludeAuthors: similarAccountUsernames, -}) -researchMap.AudienceSamples = mergeAudienceSamples(prev.Samples, samples) -``` -合併邏輯與 `merge_accounts.go` 同款。 - -### 3.4 API -- `GET /copy-missions/:id` 已含(在 `research_map.audience_samples`)。 -- `PATCH /copy-missions/:id/audience-samples/:username` body `{status}`,dot-path 更新 `research_map.audience_samples..status`。 -- `make gen-api` + 邏輯同 2.2。 - -### 3.5 前端 -- `CopyMissionResearchOverview.tsx` 新區「TA 樣本」(在「相似帳號」之後): - - 每筆 `@username`(連 `threadsProfileUrl`)、出現 N 篇、最近一次樣本文字。 - - 按鈕「收藏為長期受眾」(status=pinned)、「忽略」(status=excluded)。 -- 新檔 `frontend/src/lib/copyMissionAudience.ts` 包 API client。 -- `frontend/src/index.css` 加 `.hx-audience-samples*` token,沿用 `hx-copy-similar-accounts` 結構。 - -### 3.6 Phase 3 驗收 -- `make web-build` 過、`go test ./...` 過。 -- E2E:開 Threads API 連線 → 跑 scan job → detail page 顯示「TA 樣本」最多 15 筆、@username 連結不 404、可釘選後重跑 scan 不消失。 - ---- - -## Phase 4 — 跨任務記憶(後續,本計畫不實作) - -`ThreadsAccount` 層加 `KnownAccounts map[username]AccountProfile{ FirstSeenAt, LastSeenAt, Missions[], PersonaIds[], Tags[], AvgEngagement, Status }`,scan job 結束 upsert。同一 username 在多 mission 出現 → confidence 累積、auto-suggest 為 persona benchmark;已 excluded 跨任務繼承。 - ---- - -## 移交檢查表 - -### 要動的檔案(Phase 1) -**Go 後端** -- `internal/library/viral/discover_accounts.go`(常數、ProfileURL helper) -- `internal/library/viral/reference_accounts.go`(排序權重、topicHits) -- `internal/library/viral/enrich_accounts.go`(MatchedSource 集合) -- `internal/library/viral/merge_accounts.go`(新檔) -- `internal/library/threadsapi/url.go`(新檔,profileURL helper) -- `internal/library/websearch/client.go`(Brave 補 StartPublishedDate) -- `internal/worker/job/scan_viral.go`(web 補洞、merge 取代覆蓋) -- `internal/worker/job/analyze_copy_mission.go:138`(停止清空) -- `internal/model/copy_mission/domain/entity/mission.go`(SimilarAccount 加 MatchedSource) -- `internal/types/types.go` 同步(gen-api 或手動加) - -**前端(Phase 1.8)** -- `src/pages/CopyMissionDetailPage.tsx`(顯示 source/confidence) -- `src/lib/viralSignals.ts`(MatchedSource badge 對應) -- `src/types/copyMission.ts`(加 matched_source) - -### 要動的檔案(Phase 2-3,後續 agent) -- `model/copy_mission/domain/entity/mission.go`(Status、TopicRelevance、LastSeenAt、AudienceSample、AudienceSamples) -- `generate/api/copy_mission.api`(兩支 PATCH endpoint + types) -- `internal/logic/copy_mission/patch_similar_account_logic.go` + `patch_audience_sample_logic.go`(gen 產出後手寫) -- `internal/library/viral/audience.go`(新檔) -- `internal/worker/job/scan_viral.go`(excluded 傳遞、audience samples、pinned 保留) -- `frontend/src/components/CopyMissionResearchOverview.tsx`(整合區) -- `frontend/src/lib/copyMissionSimilarAccounts.ts`、`copyMissionAudience.ts`(新) -- `frontend/src/index.css`(`.hx-audience-samples*`) - -### 錯誤碼配置 -- Phase 2 patch similar 錯誤:CopyMission 無專屬 scope → 先用 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`;待新增 `CopyMission=41` 後再回頭改。**禁止裸寫數字**。 - -### 失敗安全 -- 任何 web search 呼叫失敗不能讓 scan job fail,只跳過。 -- `mergeSimilarAccounts` 必須處理 prev=nil。 -- `AudienceSamples`(Phase 3)在無 Threads API token 時直接跳過整段,不報錯。 \ No newline at end of file diff --git a/backend/docs/threads-automation-gap-plan.md b/backend/docs/threads-automation-gap-plan.md new file mode 100644 index 0000000..23cd0b9 --- /dev/null +++ b/backend/docs/threads-automation-gap-plan.md @@ -0,0 +1,133 @@ +# Threads 自動營運補功能計畫 + +## 目前狀態 + +P1/P2 已落地到後端 API、worker 與正式前端: + +- 自動補庫存:`publish_inventory_policies` + `refill-publish-inventory` job template/worker。 +- 智慧時段:`GET /publish-slot-insights` 從 publish health / checkpoints 聚合推薦時段。 +- 草稿批量排程:Persona 與 Copy Mission 草稿可批量排到 `publish_queue`,草稿回寫 `scheduled + publish_queue_id`。 +- Retry / 告警 / 漏發:`publish_queue_events` 記錄狀態轉移;sweeper 會標記 missed;failed/cancelled/missed 可 retry。 +- 頻率護欄:`publish_guard_policies` 支援每日上限、最小間隔、連續失敗 pause / resume。 +- P2:內容日曆 date range query、多帳號 dashboard summary、style preset 語調庫、OAuth/API diagnostics。 + +## 目標 + +把現有「海巡找題、AI 文案、手動排程、成效追蹤」串成自動營運閉環,讓巡樓 Console 不只是一個排程工具,而是可持續補庫存、控制風險、追蹤成效的 Threads 工作台。 + +## P1:自動補庫存 + +### 功能 + +- 每個 Threads account 可設定每日/每週目標篇數。 +- 可設定 weekday/time slots 與 timezone;timezone 僅用於 cron/排程解讀,儲存仍用 unix nanoseconds UTC。 +- 指定內容來源:persona、copy mission、brand scan posts、manual seed。 +- 當 `publish_queue` 未來 N 天庫存低於門檻時,自動建立 copy generation job,完成後排入 queue。 + +### 後端草案 + +- 新增 model:`internal/model/publish_inventory/`。 +- 新增設定 entity: + - `account_id` + - `target_daily_count` + - `low_stock_threshold` + - `lookahead_days` + - `timezone` + - `slots` + - `source_refs` + - `enabled` +- 新增 job step:`refill_publish_inventory`。 +- 補 API: + - `GET /api/v1/threads-accounts/:accountId/publish-inventory-policy` + - `PUT /api/v1/threads-accounts/:accountId/publish-inventory-policy` + - `POST /api/v1/threads-accounts/:accountId/publish-inventory/refill-jobs` + +## P1:智慧時段 + +### 功能 + +- 第一版以規則為主:帳號的 weekday/time slots + 最小間隔。 +- 用 publish health 的 1h/24h/7d checkpoints 回填每個 slot 的平均互動。 +- 前端顯示「推薦時段」與「低成效時段」。 + +### 後端草案 + +- 新增 summary usecase,從 `publish_analytics` 聚合 account slot performance。 +- 補 API: + - `GET /api/v1/threads-accounts/:accountId/publish-slot-insights` +- 回傳: + - `slots[]` + - `avg_likes_1h/24h/7d` + - `avg_replies_1h/24h/7d` + - `sample_size` + - `recommendation` + +## P1:草稿批量排程 + +### 功能 + +- 使用者在 copy drafts 中選多篇,一次排到未來 slots。 +- 支援根據智慧時段自動分配 `scheduled_at`。 +- draft 排入 queue 後狀態改為 `scheduled`,保留 queue item id。 + +### 後端草案 + +- 補 API: + - `POST /api/v1/personas/:personaId/copy-drafts/schedule` + - `POST /api/v1/personas/:personaId/copy-missions/:missionId/copy-drafts/schedule` +- request: + - `account_id` + - `draft_ids` + - `start_at` + - `timezone` + - `slots` + - `mode`: `manual` / `recommended` + +## P1:發文 retry / 告警 / 漏發偵測 + +### 功能 + +- failed queue item 可重試,保留失敗歷史。 +- sweeper 偵測到超過 grace period 的 scheduled item 未處理,標記為 missed 或重新 enqueue。 +- 連續失敗超過門檻時 pause account publish,避免 token 或 rate limit 問題擴大。 + +### 後端草案 + +- `publish_queue_events` 記錄 queue 狀態轉移與錯誤分類。 +- `retry` 使用 guarded update,只允許 `failed/cancelled` 回到 `scheduled`。 +- 補 API: + - `POST /api/v1/threads-accounts/:accountId/publish-queue/:queueId/retry` + - `GET /api/v1/threads-accounts/:accountId/publish-queue/:queueId/events` + - `GET /api/v1/threads-accounts/:accountId/publish-alerts` + +## P1:頻率護欄 + +### 功能 + +- 每帳號每日上限。 +- 最小發文間隔。 +- 連續失敗自動 pause。 +- 手動解除 pause。 + +### 後端草案 + +- 擴充 Threads account connection prefs 或新增 `publish_guard_policy`。 +- sweeper dispatch 前檢查 policy;違反則延後排程,不直接發文。 +- 補 API: + - `GET /api/v1/threads-accounts/:accountId/publish-guard-policy` + - `PUT /api/v1/threads-accounts/:accountId/publish-guard-policy` + - `POST /api/v1/threads-accounts/:accountId/publish-guard/resume` + +## P2 + +- 內容日曆:補 queue date range query,前端做週/月視圖。 +- 多帳號 dashboard:補跨帳號 summary endpoint,避免前端 N+1 requests。 +- 語調庫:把 `style_profile`、CTA、禁用詞、品牌口吻抽成 reusable preset。 +- OAuth/API 診斷:把 smoke test、token expiry、permission scope 做成標準診斷報告。 + +## 驗證策略 + +- 所有列表維持 `page/pageSize` 與 `pagination/list`。 +- 所有時間欄位寫入 unix nanoseconds。 +- queue/job 狀態轉移都使用 guarded update。 +- 長任務必須 heartbeat,取消走既有 cooperative cancel 語意。 diff --git a/backend/etc/gateway.dev.example.yaml b/backend/etc/gateway.dev.example.yaml deleted file mode 100644 index 0b423bf..0000000 --- a/backend/etc/gateway.dev.example.yaml +++ /dev/null @@ -1,45 +0,0 @@ -Name: haixun-backend -Host: 0.0.0.0 -Port: 8890 -Timeout: 120000 - -# 本地「docker 測試」設定(make dev-up 使用)。 -# 連線走 compose service name(mongo / redis),密碼對齊 deploy/.env.dev。 -# 純本機 go run(不進 docker)請改用 etc/gateway.yaml(連 127.0.0.1)。 -Mongo: - URI: mongodb://haixun:haixun-dev-pass@mongo:27017/?authSource=admin - Database: haixun - TimeoutSeconds: 10 - -Redis: - Addr: redis:6379 - Password: haixun-dev-redis - DB: 0 - -Auth: - AccessSecret: haixun-dev-access-secret-change-me - RefreshSecret: haixun-dev-refresh-secret-change-me - AccessExpireSeconds: 900 - RefreshExpireSeconds: 2592000 - # 僅本地開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須 false。 - DevHeaderFallback: true - -Secrets: - # 留空 = 機敏資料不加密(本地測試方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。 - EncryptionKey: "" - -InternalWorker: - Secret: haixun-dev-worker-secret - -# dev 用 gateway 內嵌 go worker(勿另開 worker 容器,避免兩個 worker 搶 job)。 -JobWorker: - Enabled: true - WorkerType: go - -JobScheduler: - Enabled: true - IntervalSeconds: 60 - -JobReaper: - Enabled: true - IntervalSeconds: 30 diff --git a/backend/etc/gateway.prod.yaml b/backend/etc/gateway.prod.yaml index e6ca1e3..580f5aa 100644 --- a/backend/etc/gateway.prod.yaml +++ b/backend/etc/gateway.prod.yaml @@ -3,8 +3,6 @@ Host: 0.0.0.0 Port: 8890 Timeout: 120000 -# 連線字串與所有 secret 都從環境變數注入(systemd EnvironmentFile=/opt/haixun/etc/haixun.env)。 -# go-zero 以 conf.UseEnv() + os.ExpandEnv 展開 ${VAR};未設定的變數會展開為空字串並讓服務 fail fast。 Mongo: URI: ${HAIXUN_MONGO_URI} Database: ${HAIXUN_MONGO_DB} @@ -30,12 +28,28 @@ InternalWorker: JobWorker: Enabled: false - WorkerType: go + WorkerType: disabled JobScheduler: - Enabled: true + Enabled: false IntervalSeconds: 60 JobReaper: - Enabled: true + Enabled: false IntervalSeconds: 30 + +ThreadsOAuth: + AppID: "${THREADS_APP_ID}" + AppSecret: "${THREADS_APP_SECRET}" + RedirectURI: "${THREADS_REDIRECT_URI}" + SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}" + +ThreadsTokenRefresh: + Enabled: false + IntervalSeconds: 3600 + LeadDays: 7 + +ThreadsPublishQueue: + Enabled: false + IntervalSeconds: 60 + BatchSize: 20 diff --git a/backend/etc/gateway.worker.prod.yaml b/backend/etc/gateway.worker.prod.yaml index 9491084..bd40c65 100644 --- a/backend/etc/gateway.worker.prod.yaml +++ b/backend/etc/gateway.worker.prod.yaml @@ -27,13 +27,30 @@ InternalWorker: Secret: ${HAIXUN_WORKER_SECRET} JobWorker: - Enabled: true - WorkerType: go + Enabled: ${HAIXUN_JOB_WORKER_ENABLED} + WorkerType: ${HAIXUN_JOB_WORKER_TYPE} + WorkerID: ${HAIXUN_JOB_WORKER_ID} JobScheduler: - Enabled: false - IntervalSeconds: 60 + Enabled: ${HAIXUN_JOB_SCHEDULER_ENABLED} + IntervalSeconds: ${HAIXUN_JOB_SCHEDULER_INTERVAL_SECONDS} JobReaper: - Enabled: false - IntervalSeconds: 30 + Enabled: ${HAIXUN_JOB_REAPER_ENABLED} + IntervalSeconds: ${HAIXUN_JOB_REAPER_INTERVAL_SECONDS} + +ThreadsOAuth: + AppID: "${THREADS_APP_ID}" + AppSecret: "${THREADS_APP_SECRET}" + RedirectURI: "${THREADS_REDIRECT_URI}" + SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}" + +ThreadsTokenRefresh: + Enabled: ${HAIXUN_THREADS_TOKEN_REFRESH_ENABLED} + IntervalSeconds: ${HAIXUN_THREADS_TOKEN_REFRESH_INTERVAL_SECONDS} + LeadDays: ${HAIXUN_THREADS_TOKEN_REFRESH_LEAD_DAYS} + +ThreadsPublishQueue: + Enabled: ${HAIXUN_THREADS_PUBLISH_QUEUE_ENABLED} + IntervalSeconds: ${HAIXUN_THREADS_PUBLISH_QUEUE_INTERVAL_SECONDS} + BatchSize: ${HAIXUN_THREADS_PUBLISH_QUEUE_BATCH_SIZE} diff --git a/backend/etc/gateway.worker.yaml b/backend/etc/gateway.worker.yaml index 05dfaba..af7dcc2 100644 --- a/backend/etc/gateway.worker.yaml +++ b/backend/etc/gateway.worker.yaml @@ -5,31 +5,32 @@ Timeout: 120000 # 本機開發 worker 設定(go worker)。搭配 `make dev-infra` 的 Mongo/Redis。 Mongo: - URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin - Database: haixun + URI: ${HAIXUN_MONGO_URI} + Database: ${HAIXUN_MONGO_DB} TimeoutSeconds: 10 Redis: - Addr: 127.0.0.1:6379 - Password: change-me-redis-pass + Addr: ${HAIXUN_REDIS_ADDR} + Password: ${HAIXUN_REDIS_PASSWORD} DB: 0 Auth: - AccessSecret: haixun-dev-access-secret-change-me - RefreshSecret: haixun-dev-refresh-secret-change-me + AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET} + RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET} AccessExpireSeconds: 900 RefreshExpireSeconds: 2592000 DevHeaderFallback: false Secrets: - EncryptionKey: "" + EncryptionKey: ${HAIXUN_SECRETS_KEY} InternalWorker: - Secret: haixun-dev-worker-secret + Secret: ${HAIXUN_WORKER_SECRET} JobWorker: Enabled: true - WorkerType: go + WorkerType: ${HAIXUN_JOB_WORKER_TYPE} + WorkerID: ${HAIXUN_JOB_WORKER_ID} JobScheduler: Enabled: false @@ -38,3 +39,13 @@ JobScheduler: JobReaper: Enabled: false IntervalSeconds: 30 + +ThreadsTokenRefresh: + Enabled: false + IntervalSeconds: 3600 + LeadDays: 7 + +ThreadsPublishQueue: + Enabled: false + IntervalSeconds: 60 + BatchSize: 20 diff --git a/backend/etc/gateway.yaml b/backend/etc/gateway.yaml index 399b90b..d9858a3 100644 --- a/backend/etc/gateway.yaml +++ b/backend/etc/gateway.yaml @@ -3,36 +3,35 @@ Host: 0.0.0.0 Port: 8890 Timeout: 120000 -# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml)跑的 Mongo/Redis, -# 帳密對應 infra/.env.example 的預設值。若你改了 .env 密碼,這裡也要同步。 +# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml,讀 deploy/.env.dev)跑的 Mongo/Redis。 +# 帳密對應 deploy/.env.dev 的值;若改 .env.dev 密碼,這裡不需動(${} 會重讀)。 Mongo: - URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin - Database: haixun + URI: ${HAIXUN_MONGO_URI} + Database: ${HAIXUN_MONGO_DB} TimeoutSeconds: 10 Redis: - Addr: 127.0.0.1:6379 - Password: change-me-redis-pass + Addr: ${HAIXUN_REDIS_ADDR} + Password: ${HAIXUN_REDIS_PASSWORD} DB: 0 Auth: - AccessSecret: haixun-dev-access-secret-change-me - RefreshSecret: haixun-dev-refresh-secret-change-me + AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET} + RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET} AccessExpireSeconds: 900 RefreshExpireSeconds: 2592000 # 僅本機開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須為 false。 DevHeaderFallback: true Secrets: - # 留空 = 不加密(本機開發方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。 - EncryptionKey: "" + EncryptionKey: ${HAIXUN_SECRETS_KEY} InternalWorker: - Secret: haixun-dev-worker-secret + Secret: ${HAIXUN_WORKER_SECRET} JobWorker: - Enabled: true - WorkerType: go + Enabled: false + WorkerType: disabled JobScheduler: Enabled: true @@ -41,3 +40,23 @@ JobScheduler: JobReaper: Enabled: true IntervalSeconds: 30 + +# Meta Threads OAuth(App Dashboard → 用戶端 OAuth 設定須登記 Redirect URI) +ThreadsOAuth: + AppID: "${THREADS_APP_ID}" + AppSecret: "${THREADS_APP_SECRET}" + RedirectURI: "${THREADS_REDIRECT_URI}" + # OAuth 完成後瀏覽器導回(現在指向 https://threads-tool.30cm.net ) + SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}" + +# 系統任務:在 token 到期前自動 enqueue refresh-threads-token job +ThreadsTokenRefresh: + Enabled: true + IntervalSeconds: 3600 + LeadDays: 7 + +# 排程發文佇列 sweeper(掃描 scheduled_at 到期項目並 PublishText) +ThreadsPublishQueue: + Enabled: true + IntervalSeconds: 60 + BatchSize: 20 diff --git a/backend/gateway.go b/backend/gateway.go index 78a8bd2..130f7f7 100644 --- a/backend/gateway.go +++ b/backend/gateway.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" + "haixun-backend/internal/bootstrap" "haixun-backend/internal/config" "haixun-backend/internal/handler" "haixun-backend/internal/svc" @@ -18,6 +19,8 @@ var configFile = flag.String("f", "etc/gateway.yaml", "config file") func main() { flag.Parse() + bootstrap.LoadInfraEnv() + var c config.Config conf.MustLoad(*configFile, &c, conf.UseEnv()) @@ -28,6 +31,7 @@ func main() { defer sc.Close(context.Background()) handler.RegisterHandlers(server, sc) + handler.RegisterExtraHandlers(server, sc) fmt.Printf("Starting backend backend at %s:%d...\n", c.Host, c.Port) server.Start() diff --git a/backend/generate/api/brand.api b/backend/generate/api/brand.api index b4102de..d4658e1 100644 --- a/backend/generate/api/brand.api +++ b/backend/generate/api/brand.api @@ -36,6 +36,7 @@ type ( ID string `json:"id"` Label string `json:"label"` ProductContext string `json:"product_context"` + PlacementURL string `json:"placement_url,omitempty"` MatchTags []string `json:"match_tags,omitempty"` CreateAt int64 `json:"create_at"` UpdateAt int64 `json:"update_at"` @@ -65,12 +66,14 @@ type ( CreateBrandProductReq { Label string `json:"label" validate:"required"` ProductContext string `json:"product_context" validate:"required"` + PlacementURL string `json:"placement_url,optional"` MatchTags []string `json:"match_tags,optional"` } UpdateBrandProductReq { Label *string `json:"label,optional"` ProductContext *string `json:"product_context,optional"` + PlacementURL string `json:"placement_url,optional"` MatchTags []string `json:"match_tags,optional"` } @@ -183,6 +186,7 @@ type ( NodeIDs []string `json:"node_ids,optional"` DualTrack bool `json:"dual_track,optional"` PatrolMode bool `json:"patrol_mode,optional"` + TestPatrol bool `json:"test_patrol,optional"` PatrolKeywords []string `json:"patrol_keywords,optional"` } @@ -219,6 +223,7 @@ type ( PublishedPermalink string `json:"published_permalink,omitempty"` OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"` PostedAt string `json:"posted_at,omitempty"` + RecencyDays int `json:"recency_days,omitempty"` Replies []ScanReplyData `json:"replies,omitempty"` LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"` CreateAt int64 `json:"create_at"` @@ -235,6 +240,7 @@ type ( Count int `json:"count,optional"` VoicePersonaID string `json:"voice_persona_id,optional"` ProductID string `json:"product_id,optional"` + Regenerate bool `json:"regenerate,optional"` } OutreachDraftItemData { @@ -258,6 +264,11 @@ type ( Confirm bool `json:"confirm"` } + UpdateOutreachDraftReq { + DraftIndex int `json:"draft_index"` + Text string `json:"text" validate:"required"` + } + PublishOutreachDraftData { ScanPostID string `json:"scan_post_id"` ReplyID string `json:"reply_id"` @@ -355,6 +366,12 @@ type ( PublishOutreachDraftReq } + UpdateOutreachDraftHandlerReq { + BrandPath + DraftID string `path:"draftId" validate:"required"` + UpdateOutreachDraftReq + } + PatchScanPostOutreachHandlerReq { BrandPath PostID string `path:"postId"` @@ -428,6 +445,9 @@ service gateway { @handler publishOutreachDraft post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData) + @handler updateOutreachDraft + patch /:id/outreach-drafts/:draftId (UpdateOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData) + @handler patchScanPostOutreach patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData) diff --git a/backend/generate/api/copy_mission.api b/backend/generate/api/copy_mission.api index 9e8ab69..cb60235 100644 --- a/backend/generate/api/copy_mission.api +++ b/backend/generate/api/copy_mission.api @@ -165,7 +165,10 @@ type ( } CopyMissionInspirationReq { - Keyword string `json:"keyword,optional"` + Keyword string `json:"keyword,optional"` + ContentDirection string `json:"content_direction,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + AvoidTopics []string `json:"avoid_topics,optional"` } CopyMissionInspirationHandlerReq { @@ -267,6 +270,26 @@ type ( Message string `json:"message"` } + CopyMissionScheduleCopyDraftsReq { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` + } + + ScheduleCopyMissionDraftsHandlerReq { + CopyMissionPath + CopyMissionScheduleCopyDraftsReq + } + + CopyMissionScheduleCopyDraftsData { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` + } + CopyMissionInspirationSourceData { Query string `json:"query,omitempty"` Title string `json:"title,omitempty"` @@ -275,11 +298,22 @@ type ( } CopyMissionInspirationData { + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + ContentPlanID string `json:"content_plan_id,omitempty"` Label string `json:"label"` SeedQuery string `json:"seed_query"` Brief string `json:"brief"` TrendReason string `json:"trend_reason,omitempty"` TrendKeywords []string `json:"trend_keywords,omitempty"` + Angles []string `json:"angles,omitempty"` + Mission string `json:"mission,omitempty"` + TargetAudience string `json:"target_audience,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Avoid []string `json:"avoid,omitempty"` Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"` WebSearchUsed bool `json:"web_search_used"` Message string `json:"message"` @@ -336,6 +370,9 @@ service gateway { @handler deleteCopyMissionMatrixDrafts post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData) + @handler scheduleCopyMissionDrafts + post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (CopyMissionScheduleCopyDraftsData) + @handler getCopyMissionScanSchedule get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData) diff --git a/backend/generate/api/member.api b/backend/generate/api/member.api index 5f2f546..4df8067 100644 --- a/backend/generate/api/member.api +++ b/backend/generate/api/member.api @@ -39,6 +39,7 @@ type ( BraveSearchLang string `json:"brave_search_lang"` ExaUserLocation string `json:"exa_user_location"` ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid + DevModeEnabled bool `json:"dev_mode_enabled"` } UpdateMemberPlacementSettingsReq { @@ -49,6 +50,7 @@ type ( BraveSearchLang *string `json:"brave_search_lang,optional"` ExaUserLocation *string `json:"exa_user_location,optional"` ExpandStrategy *string `json:"expand_strategy,optional"` + DevModeEnabled *bool `json:"dev_mode_enabled,optional"` } MemberCapabilitiesData { diff --git a/backend/generate/api/persona.api b/backend/generate/api/persona.api index 4b364a1..e363ff0 100644 --- a/backend/generate/api/persona.api +++ b/backend/generate/api/persona.api @@ -64,6 +64,23 @@ type ( StartPersonaStyleAnalysisReq } + StartPersonaStyleAnalysisFromTextReq { + ReferenceTexts []string `json:"reference_texts,optional"` + RawText string `json:"raw_text,optional"` + SourceLabel string `json:"source_label,optional"` + } + + StartPersonaStyleAnalysisFromTextData { + Persona PersonaData `json:"persona"` + PostCount int `json:"post_count"` + Message string `json:"message"` + } + + StartPersonaStyleAnalysisFromTextHandlerReq { + PersonaPath + StartPersonaStyleAnalysisFromTextReq + } + StartPersonaViralScanJobReq { Keywords []string `json:"keywords,optional"` } @@ -113,17 +130,29 @@ type ( CopyDraftData { ID string `json:"id"` PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` CopyMissionID string `json:"copy_mission_id,omitempty"` ScanPostID string `json:"scan_post_id,omitempty"` + FormulaID string `json:"formula_id,omitempty"` DraftType string `json:"draft_type"` SortOrder int `json:"sort_order,omitempty"` Text string `json:"text"` + TopicTag string `json:"topic_tag,omitempty"` Angle string `json:"angle,omitempty"` Hook string `json:"hook,omitempty"` Rationale string `json:"rationale,omitempty"` ReferenceNotes string `json:"reference_notes,omitempty"` Sources []string `json:"sources,omitempty"` + AiScore int `json:"ai_score,omitempty"` + FormulaScore int `json:"formula_score,omitempty"` + BrandFitScore int `json:"brand_fit_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + SimilarityScore int `json:"similarity_score,omitempty"` + EngagementPotential int `json:"engagement_potential,omitempty"` + FreshnessScore int `json:"freshness_score,omitempty"` + ReviewSuggestion string `json:"review_suggestion,omitempty"` Status string `json:"status,omitempty"` + PublishQueueID string `json:"publish_queue_id,omitempty"` PublishedMediaID string `json:"published_media_id,omitempty"` PublishedPermalink string `json:"published_permalink,omitempty"` PublishedAt int64 `json:"published_at,omitempty"` @@ -155,10 +184,202 @@ type ( } UpdateCopyDraftReq { - Text *string `json:"text,optional"` - Hook *string `json:"hook,optional"` - Angle *string `json:"angle,optional"` - Status *string `json:"status,optional"` + Text *string `json:"text,optional"` + TopicTag *string `json:"topic_tag,optional"` + Hook *string `json:"hook,optional"` + Angle *string `json:"angle,optional"` + Rationale *string `json:"rationale,optional"` + Status *string `json:"status,optional"` + } + + TopicCandidateData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Source string `json:"source,omitempty"` + Category string `json:"category,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + TrendReason string `json:"trend_reason,omitempty"` + TrendKeywords []string `json:"trend_keywords,omitempty"` + HeatScore int `json:"heat_score,omitempty"` + FitScore int `json:"fit_score,omitempty"` + InteractionScore int `json:"interaction_score,omitempty"` + ExtendScore int `json:"extend_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + FinalScore float64 `json:"final_score,omitempty"` + RecommendedMissions []string `json:"recommended_missions,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + ContentPlanData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + Topic string `json:"topic"` + Mission string `json:"mission"` + TargetAudience string `json:"target_audience,omitempty"` + Angle string `json:"angle,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + EndingType string `json:"ending_type,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + RequiresHumanReview bool `json:"requires_human_review,omitempty"` + Avoid []string `json:"avoid,omitempty"` + SelectedKnowledge []string `json:"selected_knowledge,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + FeedbackEventData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Decision string `json:"decision"` + Note string `json:"note,omitempty"` + Snapshot string `json:"snapshot,omitempty"` + CreateAt int64 `json:"create_at"` + } + + KnowledgeSourceData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceType string `json:"source_type"` + Title string `json:"title,omitempty"` + Filename string `json:"filename,omitempty"` + ParsedStatus string `json:"parsed_status,omitempty"` + ChunkCount int `json:"chunk_count,omitempty"` + CreateAt int64 `json:"create_at"` + } + + KnowledgeChunkData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceID string `json:"source_id"` + Content string `json:"content"` + Topics []string `json:"topics,omitempty"` + StyleTags []string `json:"style_tags,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + CreateAt int64 `json:"create_at"` + } + + FormulaPoolData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Type string `json:"type"` + Name string `json:"name"` + Pattern string `json:"pattern,omitempty"` + UseCases []string `json:"use_cases,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Weight int `json:"weight,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + CreateKnowledgeSourceReq { + SourceType string `json:"source_type" validate:"required"` + Title string `json:"title,optional"` + Filename string `json:"filename,optional"` + Content string `json:"content,optional"` + ContentBase64 string `json:"content_base64,optional"` + } + + CreateKnowledgeSourceHandlerReq { + PersonaPath + CreateKnowledgeSourceReq + } + + ListKnowledgeSourcesData { List []KnowledgeSourceData `json:"list"` } + ListKnowledgeChunksData { List []KnowledgeChunkData `json:"list"` } + ListFormulaPoolsData { List []FormulaPoolData `json:"list"` } + + CreateFormulaPoolReq { + Type string `json:"type" validate:"required"` + Name string `json:"name" validate:"required"` + Pattern string `json:"pattern,optional"` + UseCases []string `json:"use_cases,optional"` + Avoid []string `json:"avoid,optional"` + Weight int `json:"weight,optional"` + } + + CreateFormulaPoolHandlerReq { + PersonaPath + CreateFormulaPoolReq + } + + ListTopicCandidatesData { + List []TopicCandidateData `json:"list"` + } + + ListContentPlansData { + List []ContentPlanData `json:"list"` + } + + CreateContentPlanReq { + TopicCandidateID string `json:"topic_candidate_id,optional"` + Topic string `json:"topic" validate:"required"` + Mission string `json:"mission" validate:"required"` + TargetAudience string `json:"target_audience,optional"` + Angle string `json:"angle,optional"` + OpeningType string `json:"opening_type,optional"` + BodyType string `json:"body_type,optional"` + Emotion string `json:"emotion,optional"` + EndingType string `json:"ending_type,optional"` + CtaType string `json:"cta_type,optional"` + RiskLevel string `json:"risk_level,optional"` + RequiresHumanReview bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` + } + + CreateContentPlanHandlerReq { + PersonaPath + CreateContentPlanReq + } + + ContentPlanPath { + ID string `path:"id" validate:"required"` + ContentPlanID string `path:"contentPlanId" validate:"required"` + } + + UpdateContentPlanReq { + Topic *string `json:"topic,optional"` + Mission *string `json:"mission,optional"` + TargetAudience *string `json:"target_audience,optional"` + Angle *string `json:"angle,optional"` + OpeningType *string `json:"opening_type,optional"` + BodyType *string `json:"body_type,optional"` + Emotion *string `json:"emotion,optional"` + EndingType *string `json:"ending_type,optional"` + CtaType *string `json:"cta_type,optional"` + RiskLevel *string `json:"risk_level,optional"` + RequiresHumanReview *bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` + Status *string `json:"status,optional"` + } + + UpdateContentPlanHandlerReq { + ContentPlanPath + UpdateContentPlanReq + } + + CreateFeedbackEventReq { + ContentPlanID string `json:"content_plan_id,optional"` + DraftID string `json:"draft_id,optional"` + Decision string `json:"decision" validate:"required"` + Note string `json:"note,optional"` + Snapshot string `json:"snapshot,optional"` + } + + CreateFeedbackEventHandlerReq { + PersonaPath + CreateFeedbackEventReq } UpdateCopyDraftHandlerReq { @@ -167,8 +388,9 @@ type ( } PublishCopyDraftReq { - Text string `json:"text,optional"` - Confirm bool `json:"confirm"` + Text string `json:"text,optional"` + TopicTag string `json:"topic_tag,optional"` + Confirm bool `json:"confirm"` } PublishCopyDraftHandlerReq { @@ -184,10 +406,216 @@ type ( Message string `json:"message"` } + ScheduleCopyDraftsReq { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` + } + + ScheduleCopyDraftsData { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` + } + + SchedulePersonaDraftsHandlerReq { + PersonaPath + ScheduleCopyDraftsReq + } + + StylePresetData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Tone string `json:"tone,omitempty"` + CTA []string `json:"cta,omitempty"` + BannedWords []string `json:"banned_words,omitempty"` + Notes string `json:"notes,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + ListStylePresetsData { + List []StylePresetData `json:"list"` + } + + UpsertStylePresetReq { + Name string `json:"name" validate:"required"` + Tone string `json:"tone,optional"` + CTA []string `json:"cta,optional"` + BannedWords []string `json:"banned_words,optional"` + Notes string `json:"notes,optional"` + Apply bool `json:"apply,optional"` + } + + StylePresetPath { + ID string `path:"id" validate:"required"` + PresetID string `path:"presetId" validate:"required"` + } + + UpsertStylePresetHandlerReq { + StylePresetPath + UpsertStylePresetReq + } + DeleteCopyDraftData { DraftID string `json:"draft_id"` Message string `json:"message"` } + + PrunePersonaCopyDraftsReq { + Keep int `json:"keep,optional"` + } + + PrunePersonaCopyDraftsHandlerReq { + PersonaPath + PrunePersonaCopyDraftsReq + } + + PrunePersonaCopyDraftsData { + Deleted int64 `json:"deleted"` + Kept int `json:"kept"` + Message string `json:"message"` + } + + ListPersonaContentInboxReq { + Page int64 `form:"page,optional"` + PageSize int64 `form:"pageSize,optional"` + Status string `form:"status,optional"` + RangeStart int64 `form:"rangeStart,optional"` + RangeEnd int64 `form:"rangeEnd,optional"` + } + + ListPersonaContentInboxHandlerReq { + PersonaPath + ListPersonaContentInboxReq + } + + ContentInboxItemData { + CopyDraftData + ScheduledAt int64 `json:"scheduled_at,omitempty"` + Lifecycle string `json:"lifecycle"` + GroupDate int64 `json:"group_date"` + FormulaLabel string `json:"formula_label,omitempty"` + } + + ListPersonaContentInboxData { + Pagination PaginationData `json:"pagination"` + List []ContentInboxItemData `json:"list"` + } + + SchedulePersonaCopyDraftReq { + AccountID string `json:"account_id" validate:"required"` + TopicTag string `json:"topic_tag,optional"` + ScheduledAt int64 `json:"scheduled_at,optional"` + } + + SchedulePersonaCopyDraftHandlerReq { + CopyDraftPath + SchedulePersonaCopyDraftReq + } + + ContentFormulaPath { + ID string `path:"id" validate:"required"` + FormulaID string `path:"formulaId" validate:"required"` + } + + GenerateFromContentFormulaReq { + AccountID string `json:"account_id" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` + } + + GenerateFromContentFormulaHandlerReq { + ContentFormulaPath + GenerateFromContentFormulaReq + } + + GenerateFromContentFormulaData { + List []CopyDraftData `json:"list"` + Message string `json:"message"` + } + + GeneratePersonaTopicMatrixReq { + Topic string `json:"topic" validate:"required"` + ContentPlanID string `json:"content_plan_id,optional"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` + } + + GeneratePersonaTopicMatrixHandlerReq { + PersonaPath + GeneratePersonaTopicMatrixReq + } + + GeneratePersonaTopicMatrixData { + List []CopyDraftData `json:"list"` + Message string `json:"message"` + } + + StartPersonaTopicMatrixJobHandlerReq { + PersonaPath + GeneratePersonaTopicMatrixReq + } + + StartPersonaTopicMatrixJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` + } + + StartPersonaFormulaDraftJobReq { + AccountID string `json:"account_id" validate:"required"` + FormulaID string `json:"formula_id" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` + } + + StartPersonaFormulaDraftJobHandlerReq { + PersonaPath + StartPersonaFormulaDraftJobReq + } + + StartPersonaFormulaDraftJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` + } + + StartPersonaRewriteDraftJobReq { + AccountID string `json:"account_id" validate:"required"` + ReferenceText string `json:"reference_text" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + SaveLabel string `json:"save_label,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` + } + + StartPersonaRewriteDraftJobHandlerReq { + PersonaPath + StartPersonaRewriteDraftJobReq + } + + StartPersonaRewriteDraftJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` + } ) @server( @@ -216,6 +644,9 @@ service gateway { @handler startPersonaStyleAnalysis post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData) + @handler startPersonaStyleAnalysisFromText + post /:id/style-analysis-from-text (StartPersonaStyleAnalysisFromTextHandlerReq) returns (StartPersonaStyleAnalysisFromTextData) + @handler startPersonaViralScanJob post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData) @@ -225,6 +656,39 @@ service gateway { @handler listPersonaCopyDrafts get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData) + @handler listPersonaContentInbox + get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData) + + @handler listTopicCandidates + get /:id/topic-candidates (PersonaPath) returns (ListTopicCandidatesData) + + @handler listContentPlans + get /:id/content-plans (PersonaPath) returns (ListContentPlansData) + + @handler createContentPlan + post /:id/content-plans (CreateContentPlanHandlerReq) returns (ContentPlanData) + + @handler updateContentPlan + patch /:id/content-plans/:contentPlanId (UpdateContentPlanHandlerReq) returns (ContentPlanData) + + @handler createFeedbackEvent + post /:id/feedback-events (CreateFeedbackEventHandlerReq) returns (FeedbackEventData) + + @handler createKnowledgeSource + post /:id/knowledge-sources (CreateKnowledgeSourceHandlerReq) returns (KnowledgeSourceData) + + @handler listKnowledgeSources + get /:id/knowledge-sources (PersonaPath) returns (ListKnowledgeSourcesData) + + @handler listKnowledgeChunks + get /:id/knowledge-chunks (PersonaPath) returns (ListKnowledgeChunksData) + + @handler listFormulaPools + get /:id/formula-pools (PersonaPath) returns (ListFormulaPoolsData) + + @handler createFormulaPool + post /:id/formula-pools (CreateFormulaPoolHandlerReq) returns (FormulaPoolData) + @handler generatePersonaCopyDraft post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData) @@ -234,6 +698,39 @@ service gateway { @handler publishPersonaCopyDraft post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData) + @handler schedulePersonaDrafts + post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData) + + @handler schedulePersonaCopyDraft + post /:id/copy-drafts/:draftId/schedule (SchedulePersonaCopyDraftHandlerReq) returns (ScheduleCopyDraftsData) + + @handler generateFromContentFormula + post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData) + + @handler generatePersonaTopicMatrix + post /:id/topic-matrix/generate (GeneratePersonaTopicMatrixHandlerReq) returns (GeneratePersonaTopicMatrixData) + + @handler startPersonaTopicMatrixJob + post /:id/topic-matrix/jobs (StartPersonaTopicMatrixJobHandlerReq) returns (StartPersonaTopicMatrixJobData) + + @handler startPersonaFormulaDraftJob + post /:id/formula-draft/jobs (StartPersonaFormulaDraftJobHandlerReq) returns (StartPersonaFormulaDraftJobData) + + @handler startPersonaRewriteDraftJob + post /:id/rewrite-draft/jobs (StartPersonaRewriteDraftJobHandlerReq) returns (StartPersonaRewriteDraftJobData) + @handler deletePersonaCopyDraft delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData) -} \ No newline at end of file + + @handler prunePersonaCopyDrafts + post /:id/copy-drafts/prune (PrunePersonaCopyDraftsHandlerReq) returns (PrunePersonaCopyDraftsData) + + @handler listStylePresets + get /:id/style-presets (PersonaPath) returns (ListStylePresetsData) + + @handler upsertStylePreset + put /:id/style-presets/:presetId (UpsertStylePresetHandlerReq) returns (StylePresetData) + + @handler deleteStylePreset + delete /:id/style-presets/:presetId (StylePresetPath) +} diff --git a/backend/generate/api/placement_topic.api b/backend/generate/api/placement_topic.api index a6d66a1..a0b12d9 100644 --- a/backend/generate/api/placement_topic.api +++ b/backend/generate/api/placement_topic.api @@ -83,6 +83,12 @@ type ( PublishOutreachDraftReq } + UpdatePlacementTopicOutreachDraftHandlerReq { + PlacementTopicPath + DraftID string `path:"draftId" validate:"required"` + UpdateOutreachDraftReq + } + PatchPlacementTopicScanPostOutreachHandlerReq { PlacementTopicPath PostID string `path:"postId"` @@ -116,6 +122,25 @@ type ( PlacementTopicPath UpsertBrandScanScheduleReq } + + StartPlacementTopicOutreachDraftJobReq { + ScanPostID string `json:"scan_post_id,optional"` + ScanPostIDs []string `json:"scan_post_ids,optional"` + Count int `json:"count,optional"` + VoicePersonaID string `json:"voice_persona_id,optional"` + ProductID string `json:"product_id,optional"` + Regenerate bool `json:"regenerate,optional"` + } + + StartPlacementTopicOutreachDraftJobHandlerReq { + PlacementTopicPath + StartPlacementTopicOutreachDraftJobReq + } + + StartPlacementTopicOutreachDraftJobsData { + Jobs []StartBrandScanJobData `json:"jobs"` + Message string `json:"message"` + } ) @server( @@ -159,9 +184,15 @@ service gateway { @handler generatePlacementTopicOutreachDrafts post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData) + @handler startPlacementTopicOutreachDraftJob + post /:id/outreach-draft-jobs (StartPlacementTopicOutreachDraftJobHandlerReq) returns (StartPlacementTopicOutreachDraftJobsData) + @handler publishPlacementTopicOutreachDraft post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData) + @handler updatePlacementTopicOutreachDraft + patch /:id/outreach-drafts/:draftId (UpdatePlacementTopicOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData) + @handler patchPlacementTopicScanPostOutreach patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData) diff --git a/backend/generate/api/threads_account.api b/backend/generate/api/threads_account.api index 93396a3..da1d081 100644 --- a/backend/generate/api/threads_account.api +++ b/backend/generate/api/threads_account.api @@ -8,8 +8,9 @@ type ( ThreadsUserID string `json:"threads_user_id,omitempty"` PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account BrowserConnected bool `json:"browser_connected"` - ApiConnected bool `json:"api_connected"` - Status string `json:"status"` + ApiConnected bool `json:"api_connected"` + APITokenExpiresAt int64 `json:"api_token_expires_at,omitempty"` + Status string `json:"status"` CreateAt int64 `json:"create_at"` UpdateAt int64 `json:"update_at"` } @@ -19,6 +20,12 @@ type ( ActiveAccountID string `json:"active_account_id"` } + DeleteThreadsAccountData { + DeletedID string `json:"deleted_id"` + ActiveAccountID string `json:"active_account_id,omitempty"` + Message string `json:"message"` + } + CreateThreadsAccountReq { DisplayName string `json:"display_name,optional"` Activate *bool `json:"activate,optional"` @@ -137,6 +144,711 @@ type ( Streams bool `json:"streams"` Error string `json:"error,optional"` } + + StartThreadsOAuthQuery { + AccountID string `form:"account_id,optional"` + } + + StartThreadsOAuthData { + AuthorizeURL string `json:"authorize_url"` + State string `json:"state"` + AccountID string `json:"account_id"` + } + + ThreadsOAuthCallbackQuery { + Code string `form:"code,optional"` + State string `form:"state,optional"` + Error string `form:"error,optional"` + ErrorReason string `form:"error_reason,optional"` + ErrorDescription string `form:"error_description,optional"` + } + + ThreadsOAuthLogItem { + At int64 `json:"at"` + Level string `json:"level"` + Stage string `json:"stage"` + AccountID string `json:"account_id,omitempty"` + Message string `json:"message"` + } + + ListThreadsOAuthLogsData { + List []ThreadsOAuthLogItem `json:"list"` + } + + ThreadsOAuthConfigData { + RedirectURI string `json:"redirect_uri"` + SuccessRedirect string `json:"success_redirect"` + Enabled bool `json:"enabled"` + RequiresHTTPS bool `json:"requires_https"` + } + + ThreadsAPISmokeTestItem { + Scope string `json:"scope"` + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` + Count int `json:"count"` + } + + RunThreadsAPISmokeTestData { + List []ThreadsAPISmokeTestItem `json:"list"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Total int `json:"total"` + } + + RunThreadsAPIPlaygroundReq { + Action string `json:"action" validate:"required"` + Query string `json:"query,optional"` + Username string `json:"username,optional"` + MediaID string `json:"media_id,optional"` + ReplyID string `json:"reply_id,optional"` + ReplyToID string `json:"reply_to_id,optional"` + Text string `json:"text,optional"` + Permalink string `json:"permalink,optional"` + HintText string `json:"hint_text,optional"` + SkipPrime bool `json:"skip_prime,optional"` + LocationQuery string `json:"location_query,optional"` + Limit int `json:"limit,optional"` + Hide bool `json:"hide,optional"` + SearchType string `json:"search_type,optional"` + } + + RunThreadsAPIPlaygroundHandlerReq { + ThreadsAccountPath + RunThreadsAPIPlaygroundReq + } + + ThreadsAPIPlaygroundData { + Action string `json:"action"` + Ok bool `json:"ok"` + Message string `json:"message"` + Data string `json:"data,optional"` + } + + ListThreadsPostPerformanceQuery { + Limit int `form:"limit,optional"` + } + + ListThreadsPostPerformanceHandlerReq { + ThreadsAccountPath + ListThreadsPostPerformanceQuery + } + + ThreadsPostMetricCapability { + Scope string `json:"scope"` + Name string `json:"name"` + Metrics []string `json:"metrics"` + Trackable bool `json:"trackable"` + Note string `json:"note,optional"` + } + + ThreadsInsightMetricItem { + Name string `json:"name"` + Value int `json:"value"` + } + + ThreadsAccountInsightsBlock { + Status string `json:"status"` + Message string `json:"message,optional"` + Metrics []ThreadsInsightMetricItem `json:"metrics,optional"` + } + + ThreadsPostPerformanceItem { + MediaID string `json:"media_id"` + Text string `json:"text,optional"` + Permalink string `json:"permalink,optional"` + Timestamp string `json:"timestamp,optional"` + MediaType string `json:"media_type,optional"` + MediaURL string `json:"media_url,optional"` + ThumbnailURL string `json:"thumbnail_url,optional"` + TopicTag string `json:"topic_tag,optional"` + Shortcode string `json:"shortcode,optional"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Views int `json:"views,optional"` + Shares int `json:"shares,optional"` + InsightsStatus string `json:"insights_status,optional"` + InsightsMessage string `json:"insights_message,optional"` + } + + ThreadsPostPerformanceData { + AccountID string `json:"account_id"` + Username string `json:"username,optional"` + ThreadsUserID string `json:"threads_user_id,optional"` + FetchedAt int64 `json:"fetched_at"` + Capabilities []ThreadsPostMetricCapability `json:"capabilities"` + AccountInsights ThreadsAccountInsightsBlock `json:"account_insights"` + Posts []ThreadsPostPerformanceItem `json:"posts"` + } + + PublishQueueItemPath { + ID string `path:"id" validate:"required"` + QID string `path:"qid" validate:"required"` + } + + ListPublishQueueQuery { + Status string `form:"status,optional"` + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` + } + + ListPublishQueueHandlerReq { + ThreadsAccountPath + ListPublishQueueQuery + } + + CreatePublishQueueReq { + Text string `json:"text" validate:"required"` + TopicTag string `json:"topic_tag,optional"` + ScheduledAt int64 `json:"scheduled_at,optional"` + } + + CreatePublishQueueHandlerReq { + ThreadsAccountPath + CreatePublishQueueReq + } + + PatchPublishQueueReq { + Text *string `json:"text,optional"` + TopicTag *string `json:"topic_tag,optional"` + ScheduledAt *int64 `json:"scheduled_at,optional"` + } + + PatchPublishQueueHandlerReq { + PublishQueueItemPath + PatchPublishQueueReq + } + + PublishQueueItemData { + ID string `json:"id"` + AccountID string `json:"account_id"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + CopyDraftID string `json:"copy_draft_id,omitempty"` + Text string `json:"text"` + TopicTag string `json:"topic_tag,omitempty"` + ScheduledAt int64 `json:"scheduled_at"` + Status string `json:"status"` + MediaID string `json:"media_id,optional"` + Permalink string `json:"permalink,optional"` + PublishedAt int64 `json:"published_at,optional"` + ErrorMessage string `json:"error_message,optional"` + RetryCount int `json:"retry_count,omitempty"` + LastAttemptAt int64 `json:"last_attempt_at,omitempty"` + NextAttemptAt int64 `json:"next_attempt_at,omitempty"` + MissedAt int64 `json:"missed_at,omitempty"` + PausedReason string `json:"paused_reason,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + PublishQueueListData { + Pagination PaginationData `json:"pagination"` + List []PublishQueueItemData `json:"list"` + } + + ListThreadsPublishHealthQuery { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` + } + + ListThreadsPublishHealthHandlerReq { + ThreadsAccountPath + ListThreadsPublishHealthQuery + } + + ThreadsPublishHealthSummary { + PendingScheduled int64 `json:"pending_scheduled"` + FailedCount int64 `json:"failed_count"` + Published7d int64 `json:"published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` + } + + ThreadsPublishHealthCheckpoint { + Checkpoint string `json:"checkpoint"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` + Views int `json:"views,optional"` + CheckedAt int64 `json:"checked_at"` + } + + ThreadsPublishHealthItem { + MediaID string `json:"media_id"` + Permalink string `json:"permalink,optional"` + Text string `json:"text,optional"` + PublishedAt int64 `json:"published_at"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Views int `json:"views,optional"` + Shares int `json:"shares,optional"` + Checkpoints []ThreadsPublishHealthCheckpoint `json:"checkpoints"` + } + + ThreadsPublishHealthData { + Summary ThreadsPublishHealthSummary `json:"summary"` + Pagination PaginationData `json:"pagination"` + List []ThreadsPublishHealthItem `json:"list"` + } + + PublishSlotData { + Weekday int `json:"weekday"` + Time string `json:"time"` + } + + PublishSourceRefData { + Type string `json:"type"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + ScanPostID string `json:"scan_post_id,omitempty"` + ManualSeed string `json:"manual_seed,omitempty"` + } + + PublishInventoryPolicyData { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count"` + LowStockThreshold int `json:"low_stock_threshold"` + LookaheadDays int `json:"lookahead_days"` + Timezone string `json:"timezone"` + Slots []PublishSlotData `json:"slots"` + SourceRefs []PublishSourceRefData `json:"source_refs"` + UpdateAt int64 `json:"update_at"` + } + + UpsertPublishInventoryPolicyReq { + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count,optional"` + LowStockThreshold int `json:"low_stock_threshold,optional"` + LookaheadDays int `json:"lookahead_days,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + SourceRefs []PublishSourceRefData `json:"source_refs,optional"` + } + + UpsertPublishInventoryPolicyHandlerReq { + ThreadsAccountPath + UpsertPublishInventoryPolicyReq + } + + StartPublishInventoryRefillJobReq { + Count int `json:"count,optional"` + } + + StartPublishInventoryRefillJobHandlerReq { + ThreadsAccountPath + StartPublishInventoryRefillJobReq + } + + StartPublishInventoryRefillJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + Message string `json:"message"` + } + + PublishSlotInsightData { + Weekday int `json:"weekday"` + Time string `json:"time"` + AvgLikes1h int `json:"avg_likes_1h"` + AvgReplies1h int `json:"avg_replies_1h"` + AvgLikes24h int `json:"avg_likes_24h"` + AvgReplies24h int `json:"avg_replies_24h"` + AvgLikes7d int `json:"avg_likes_7d"` + AvgReplies7d int `json:"avg_replies_7d"` + SampleSize int `json:"sample_size"` + Recommendation string `json:"recommendation"` + } + + PublishSlotInsightsData { + AccountID string `json:"account_id"` + Timezone string `json:"timezone"` + Slots []PublishSlotInsightData `json:"slots"` + } + + PublishGuardPolicyData { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts"` + MinIntervalMinutes int `json:"min_interval_minutes"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"` + Paused bool `json:"paused"` + PausedReason string `json:"paused_reason,omitempty"` + UpdateAt int64 `json:"update_at"` + } + + UpsertPublishGuardPolicyReq { + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts,optional"` + MinIntervalMinutes int `json:"min_interval_minutes,optional"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"` + Paused *bool `json:"paused,optional"` + PausedReason string `json:"paused_reason,optional"` + } + + UpsertPublishGuardPolicyHandlerReq { + ThreadsAccountPath + UpsertPublishGuardPolicyReq + } + + PublishQueueEventData { + ID string `json:"id"` + QueueID string `json:"queue_id"` + EventType string `json:"event_type"` + FromStatus string `json:"from_status,omitempty"` + ToStatus string `json:"to_status,omitempty"` + Message string `json:"message,omitempty"` + CreateAt int64 `json:"create_at"` + } + + PublishQueueEventsData { + List []PublishQueueEventData `json:"list"` + } + + PublishAlertData { + Type string `json:"type"` + Severity string `json:"severity"` + Message string `json:"message"` + QueueID string `json:"queue_id,omitempty"` + AccountID string `json:"account_id"` + CreateAt int64 `json:"create_at"` + } + + PublishAlertsData { + List []PublishAlertData `json:"list"` + } + + PublishQueueRangeQuery { + StartAt int64 `form:"startAt,optional"` + EndAt int64 `form:"endAt,optional"` + Status string `form:"status,optional"` + } + + PublishQueueRangeHandlerReq { + ThreadsAccountPath + PublishQueueRangeQuery + } + + PublishDashboardAccountSummary { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + PendingScheduled int64 `json:"pending_scheduled"` + FailedCount int64 `json:"failed_count"` + Published7d int64 `json:"published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` + Paused bool `json:"paused"` + BestSlot string `json:"best_slot,omitempty"` + LowSlot string `json:"low_slot,omitempty"` + } + + PublishDashboardSummaryData { + List []PublishDashboardAccountSummary `json:"list"` + TotalPending int64 `json:"total_pending"` + TotalFailed int64 `json:"total_failed"` + TotalPublished7d int64 `json:"total_published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` + } + + GenerateOwnPostReplyDraftReq { + MediaID string `json:"media_id"` + PersonaID string `json:"persona_id,optional"` + ReplyToID string `json:"reply_to_id,optional"` + PostText string `json:"post_text,optional"` + ReplyText string `json:"reply_text,optional"` + ThreadPostText string `json:"thread_post_text,optional"` + ParentReplyText string `json:"parent_reply_text,optional"` + } + + GenerateOwnPostReplyDraftHandlerReq { + ThreadsAccountPath + GenerateOwnPostReplyDraftReq + } + + GenerateOwnPostReplyDraftData { + Text string `json:"text"` + } + + MentionInboxItemData { + MediaID string `json:"media_id"` + AuthorUsername string `json:"author_username,omitempty"` + Text string `json:"text,omitempty"` + Permalink string `json:"permalink,omitempty"` + Shortcode string `json:"shortcode,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + MediaType string `json:"media_type,omitempty"` + IsReply bool `json:"is_reply"` + IsQuotePost bool `json:"is_quote_post"` + HasReplies bool `json:"has_replies"` + RootPostID string `json:"root_post_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` + ThreadPostText string `json:"thread_post_text,omitempty"` + ParentReplyText string `json:"parent_reply_text,omitempty"` + ReplyReady bool `json:"reply_ready"` + ReplyReadyMsg string `json:"reply_ready_msg,omitempty"` + SyncedAt int64 `json:"synced_at"` + } + + SyncMentionInboxReq { + Limit int `json:"limit,optional"` + MaxPages int `json:"max_pages,optional"` + } + + SyncMentionInboxHandlerReq { + ThreadsAccountPath + SyncMentionInboxReq + } + + SyncMentionInboxData { + Synced int `json:"synced"` + Ready int `json:"ready"` + Total int64 `json:"total"` + } + + ListMentionInboxQuery { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` + } + + ListMentionInboxHandlerReq { + ThreadsAccountPath + ListMentionInboxQuery + } + + ListMentionInboxData { + List []MentionInboxItemData `json:"list"` + Pagination PaginationData `json:"pagination"` + ReadyTotal int64 `json:"ready_total"` + NewestSyncedAt int64 `json:"newest_synced_at,omitempty"` + } + + PublishMentionReplyReq { + Text string `json:"text"` + } + + PublishMentionReplyPath { + ID string `path:"id" validate:"required"` + MediaID string `path:"media_id" validate:"required"` + } + + PublishMentionReplyHandlerReq { + PublishMentionReplyPath + PublishMentionReplyReq + } + + PublishMentionReplyData { + MediaID string `json:"media_id,omitempty"` + Permalink string `json:"permalink,omitempty"` + } + + GenerateOwnPostFormulaReq { + MediaID string `json:"media_id"` + PersonaID string `json:"persona_id,optional"` + PostText string `json:"post_text,optional"` + TopicTag string `json:"topic_tag,optional"` + MediaType string `json:"media_type,optional"` + Timestamp string `json:"timestamp,optional"` + LikeCount int `json:"like_count,optional"` + ReplyCount int `json:"reply_count,optional"` + Views int `json:"views,optional"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Shares int `json:"shares,optional"` + Force bool `json:"force,optional"` + } + + GenerateOwnPostFormulaHandlerReq { + ThreadsAccountPath + GenerateOwnPostFormulaReq + } + + OwnPostFormulaReviewData { + MediaID string `json:"media_id"` + ReviewedAt int64 `json:"reviewed_at"` + FromCache bool `json:"from_cache,omitempty"` + Summary string `json:"summary"` + Wins []string `json:"wins"` + Improvements []string `json:"improvements"` + Formula string `json:"formula"` + PostTemplate string `json:"post_template"` + HookPattern string `json:"hook_pattern"` + Structure string `json:"structure"` + ReplicationTips []string `json:"replication_tips"` + Avoid []string `json:"avoid"` + } + + GenerateOwnPostFormulaData { + OwnPostFormulaReviewData + } + + ListOwnPostFormulasHandlerReq { + ThreadsAccountPath + } + + ListOwnPostFormulasData { + List []OwnPostFormulaReviewData `json:"list"` + } + + ThreadsDiagnosticsData { + AccountID string `json:"account_id"` + CheckedAt int64 `json:"checked_at"` + ApiConnected bool `json:"api_connected"` + TokenExpiresAt int64 `json:"token_expires_at,omitempty"` + Scopes []string `json:"scopes"` + Items []ThreadsAPISmokeTestItem `json:"items"` + Message string `json:"message"` + } + + ContentFormulaSourceMetricsData { + LikeCount int `json:"like_count,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + Views int `json:"views,omitempty"` + RepostCount int `json:"repost_count,omitempty"` + QuoteCount int `json:"quote_count,omitempty"` + Shares int `json:"shares,omitempty"` + } + + ContentFormulaData { + ID string `json:"id"` + AccountID string `json:"account_id"` + Label string `json:"label"` + SourceType string `json:"source_type"` + SourceRef string `json:"source_ref,omitempty"` + SourcePostText string `json:"source_post_text,omitempty"` + SourceAuthor string `json:"source_author,omitempty"` + SourcePermalink string `json:"source_permalink,omitempty"` + SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"` + Summary string `json:"summary"` + Wins []string `json:"wins,omitempty"` + Improvements []string `json:"improvements,omitempty"` + Formula string `json:"formula"` + PostTemplate string `json:"post_template"` + HookPattern string `json:"hook_pattern"` + Structure string `json:"structure"` + ReplicationTips []string `json:"replication_tips,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Tags []string `json:"tags,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + ListContentFormulasReq { + Page int64 `form:"page,optional"` + PageSize int64 `form:"pageSize,optional"` + SourceType string `form:"source_type,optional"` + Tag string `form:"tag,optional"` + } + + ListContentFormulasHandlerReq { + ThreadsAccountPath + ListContentFormulasReq + } + + ListContentFormulasData { + Pagination PaginationData `json:"pagination"` + List []ContentFormulaData `json:"list"` + } + + ContentFormulaItemPath { + ID string `path:"id" validate:"required"` + FormulaID string `path:"formulaId" validate:"required"` + } + + GetContentFormulaHandlerReq { + ContentFormulaItemPath + } + + AnalyzeContentFormulaReq { + SourceType string `json:"source_type" validate:"required"` + PersonaID string `json:"persona_id,optional"` + PostText string `json:"post_text,optional"` + MediaID string `json:"media_id,optional"` + ScanPostID string `json:"scan_post_id,optional"` + Keyword string `json:"keyword,optional"` + SaveLabel string `json:"save_label,optional"` + AuthorName string `json:"author_name,optional"` + Permalink string `json:"permalink,optional"` + LikeCount int `json:"like_count,optional"` + ReplyCount int `json:"reply_count,optional"` + Views int `json:"views,optional"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Shares int `json:"shares,optional"` + ForceRefresh bool `json:"force_refresh,optional"` + } + + AnalyzeContentFormulaHandlerReq { + ThreadsAccountPath + AnalyzeContentFormulaReq + } + + AnalyzeContentFormulaData { + Formula ContentFormulaData `json:"formula"` + Message string `json:"message"` + } + + SearchContentFormulaPostsReq { + Keyword string `json:"keyword" validate:"required"` + SearchType string `json:"search_type,optional"` + Limit int `json:"limit,optional"` + } + + SearchContentFormulaPostsHandlerReq { + ThreadsAccountPath + SearchContentFormulaPostsReq + } + + ContentFormulaSearchPostData { + Text string `json:"text"` + Author string `json:"author"` + Permalink string `json:"permalink,omitempty"` + MediaID string `json:"media_id,omitempty"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + EngagementScore int `json:"engagement_score"` + } + + SearchContentFormulaPostsData { + List []ContentFormulaSearchPostData `json:"list"` + Message string `json:"message"` + } + + PatchContentFormulaReq { + Label *string `json:"label,optional"` + Formula *string `json:"formula,optional"` + PostTemplate *string `json:"post_template,optional"` + HookPattern *string `json:"hook_pattern,optional"` + Structure *string `json:"structure,optional"` + Tags []string `json:"tags,optional"` + } + + PatchContentFormulaHandlerReq { + ContentFormulaItemPath + PatchContentFormulaReq + } + + DeleteContentFormulaData { + FormulaID string `json:"formula_id"` + Message string `json:"message"` + } + + ImportOwnPostFormulaReq { + MediaID string `json:"media_id" validate:"required"` + Label string `json:"label,optional"` + } + + ImportOwnPostFormulaHandlerReq { + ThreadsAccountPath + ImportOwnPostFormulaReq + } ) @server( @@ -159,6 +871,9 @@ service gateway { @handler updateThreadsAccount patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData) + @handler deleteThreadsAccount + delete /:id (ThreadsAccountPath) returns (DeleteThreadsAccountData) + @handler activateThreadsAccount post /:id/activate (ThreadsAccountPath) @@ -179,4 +894,135 @@ service gateway { @handler listThreadsAccountAiProviderModels post /:id/ai-settings/providers/:provider/models (ThreadsAccountAiProviderModelsHandlerReq) returns (ThreadsAccountAiProviderModelsData) -} \ No newline at end of file + + @handler startThreadsOauth + get /oauth/start (StartThreadsOAuthQuery) returns (StartThreadsOAuthData) + + @handler listThreadsOauthLogs + get /oauth/logs returns (ListThreadsOAuthLogsData) + + @handler getThreadsOauthConfig + get /oauth/config returns (ThreadsOAuthConfigData) + + @handler disconnectThreadsApi + post /:id/api/disconnect (ThreadsAccountPath) returns (ThreadsAccountData) + + @handler runThreadsApiSmokeTest + post /:id/api/smoke-test (ThreadsAccountPath) returns (RunThreadsAPISmokeTestData) + + @handler runThreadsApiPlayground + post /:id/api/playground (RunThreadsAPIPlaygroundHandlerReq) returns (ThreadsAPIPlaygroundData) + + @handler listThreadsPostPerformance + get /:id/post-performance (ListThreadsPostPerformanceHandlerReq) returns (ThreadsPostPerformanceData) + + @handler createPublishQueueItem + post /:id/publish-queue (CreatePublishQueueHandlerReq) returns (PublishQueueItemData) + + @handler listPublishQueueItems + get /:id/publish-queue (ListPublishQueueHandlerReq) returns (PublishQueueListData) + + @handler getPublishQueueItem + get /:id/publish-queue/:qid (PublishQueueItemPath) returns (PublishQueueItemData) + + @handler patchPublishQueueItem + patch /:id/publish-queue/:qid (PatchPublishQueueHandlerReq) returns (PublishQueueItemData) + + @handler cancelPublishQueueItem + post /:id/publish-queue/:qid/cancel (PublishQueueItemPath) returns (PublishQueueItemData) + + @handler deletePublishQueueItem + delete /:id/publish-queue/:qid (PublishQueueItemPath) + + @handler listThreadsPublishHealth + get /:id/publish-health (ListThreadsPublishHealthHandlerReq) returns (ThreadsPublishHealthData) + + @handler getPublishInventoryPolicy + get /:id/publish-inventory-policy (ThreadsAccountPath) returns (PublishInventoryPolicyData) + + @handler upsertPublishInventoryPolicy + put /:id/publish-inventory-policy (UpsertPublishInventoryPolicyHandlerReq) returns (PublishInventoryPolicyData) + + @handler startPublishInventoryRefillJob + post /:id/publish-inventory/refill-jobs (StartPublishInventoryRefillJobHandlerReq) returns (StartPublishInventoryRefillJobData) + + @handler getPublishSlotInsights + get /:id/publish-slot-insights (ThreadsAccountPath) returns (PublishSlotInsightsData) + + @handler getPublishGuardPolicy + get /:id/publish-guard-policy (ThreadsAccountPath) returns (PublishGuardPolicyData) + + @handler upsertPublishGuardPolicy + put /:id/publish-guard-policy (UpsertPublishGuardPolicyHandlerReq) returns (PublishGuardPolicyData) + + @handler resumePublishGuard + post /:id/publish-guard/resume (ThreadsAccountPath) returns (PublishGuardPolicyData) + + @handler retryPublishQueueItem + post /:id/publish-queue/:qid/retry (PublishQueueItemPath) returns (PublishQueueItemData) + + @handler listPublishQueueEvents + get /:id/publish-queue/:qid/events (PublishQueueItemPath) returns (PublishQueueEventsData) + + @handler listPublishAlerts + get /:id/publish-alerts (ThreadsAccountPath) returns (PublishAlertsData) + + @handler listPublishQueueRange + get /:id/publish-calendar (PublishQueueRangeHandlerReq) returns (PublishQueueListData) + + @handler getThreadsDiagnostics + get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData) + + @handler generateOwnPostReplyDraft + post /:id/own-post-reply-draft (GenerateOwnPostReplyDraftHandlerReq) returns (GenerateOwnPostReplyDraftData) + + @handler syncMentionInbox + post /:id/mention-inbox/sync (SyncMentionInboxHandlerReq) returns (SyncMentionInboxData) + + @handler listMentionInbox + get /:id/mention-inbox (ListMentionInboxHandlerReq) returns (ListMentionInboxData) + + @handler publishMentionReply + post /:id/mention-inbox/:media_id/reply (PublishMentionReplyHandlerReq) returns (PublishMentionReplyData) + + @handler generateOwnPostFormula + post /:id/own-post-formula (GenerateOwnPostFormulaHandlerReq) returns (GenerateOwnPostFormulaData) + + @handler listOwnPostFormulas + get /:id/own-post-formulas (ListOwnPostFormulasHandlerReq) returns (ListOwnPostFormulasData) + + @handler getPublishDashboardSummary + get /publish-dashboard-summary returns (PublishDashboardSummaryData) + + @handler listContentFormulas + get /:id/content-formulas (ListContentFormulasHandlerReq) returns (ListContentFormulasData) + + @handler getContentFormula + get /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (ContentFormulaData) + + @handler analyzeContentFormula + post /:id/content-formulas/analyze (AnalyzeContentFormulaHandlerReq) returns (AnalyzeContentFormulaData) + + @handler searchContentFormulaPosts + post /:id/content-formulas/search-posts (SearchContentFormulaPostsHandlerReq) returns (SearchContentFormulaPostsData) + + @handler patchContentFormula + patch /:id/content-formulas/:formulaId (PatchContentFormulaHandlerReq) returns (ContentFormulaData) + + @handler deleteContentFormula + delete /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (DeleteContentFormulaData) + + @handler importOwnPostToContentFormula + post /:id/content-formulas/import-own-post (ImportOwnPostFormulaHandlerReq) returns (AnalyzeContentFormulaData) +} + +@server( + group: threads_account + prefix: /api/v1/threads-accounts + tags: "ThreadsAccount" + summary: "Threads OAuth callback (public redirect from Meta)." +) +service gateway { + @handler threadsOauthCallback + get /oauth/callback (ThreadsOAuthCallbackQuery) +} diff --git a/backend/go.mod b/backend/go.mod index 288e93a..4bae144 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/go-playground/validator/v10 v10.27.0 + github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/redis/go-redis/v9 v9.14.0 @@ -27,7 +28,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect - github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/golang/snappy v1.0.0 // indirect github.com/grafana/pyroscope-go v1.2.7 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0f9b29a..6b31483 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -100,6 +100,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/backend/internal/bootstrap/admin.go b/backend/internal/bootstrap/admin.go index a820eae..2ab36d3 100644 --- a/backend/internal/bootstrap/admin.go +++ b/backend/internal/bootstrap/admin.go @@ -36,6 +36,18 @@ func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminO return nil, false, err } existing.Roles = []string{"admin"} + + if err := bcrypt.CompareHashAndPassword([]byte(existing.PasswordHash), []byte(opts.Password)); err != nil { + hash, hashErr := bcrypt.GenerateFromPassword([]byte(opts.Password), bcrypt.DefaultCost) + if hashErr != nil { + return nil, false, app.For(code.Member).SysInternal("rehash password failed").WithCause(hashErr) + } + if updateErr := repo.UpdatePassword(ctx, tenantID, existing.UID, string(hash)); updateErr != nil { + return nil, false, updateErr + } + existing.PasswordHash = string(hash) + } + return existing, false, nil } if e := app.FromError(err); e == nil || e.Category() != code.ResNotFound { diff --git a/backend/internal/bootstrap/admin_test.go b/backend/internal/bootstrap/admin_test.go index b36df77..06b0f20 100644 --- a/backend/internal/bootstrap/admin_test.go +++ b/backend/internal/bootstrap/admin_test.go @@ -68,6 +68,16 @@ func (m *memoryMemberRepo) SetActiveThreadsAccountID(_ context.Context, tenantID return app.For(code.Member).ResNotFound("member not found") } +func (m *memoryMemberRepo) UpdatePassword(_ context.Context, tenantID, uid, passwordHash string) error { + for _, item := range m.byEmail { + if item.TenantID == tenantID && item.UID == uid { + item.PasswordHash = passwordHash + return nil + } + } + return app.For(code.Member).ResNotFound("member not found") +} + func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, roles []string) error { for _, item := range m.byEmail { if item.TenantID == tenantID && item.UID == uid { @@ -123,4 +133,72 @@ func TestEnsureAdminMemberUpgradesExisting(t *testing.T) { if len(member.Roles) != 1 || member.Roles[0] != "admin" { t.Fatalf("roles=%v", member.Roles) } + if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Admin-Pass-1!")); err != nil { + t.Fatalf("password should have been synced: %v", err) + } +} + +func TestEnsureAdminMemberSyncsPasswordWhenChanged(t *testing.T) { + oldHash, err := bcrypt.GenerateFromPassword([]byte("Old-Pass-1!"), bcrypt.DefaultCost) + if err != nil { + t.Fatal(err) + } + repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{ + memberKey("default", "admin@haixun.local"): { + TenantID: "default", + UID: "uid-1", + Email: "admin@haixun.local", + Roles: []string{"admin"}, + PasswordHash: string(oldHash), + }, + }} + _, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{ + TenantID: "default", + Email: "admin@haixun.local", + Password: "New-Pass-2@", + }) + if err != nil { + t.Fatalf("EnsureAdminMember: %v", err) + } + if created { + t.Fatal("expected created=false") + } + member := repo.byEmail[memberKey("default", "admin@haixun.local")] + if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Old-Pass-1!")); err == nil { + t.Fatal("password should have been updated, but old hash still matches") + } + if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("New-Pass-2@")); err != nil { + t.Fatalf("new password hash does not match: %v", err) + } +} + +func TestEnsureAdminMemberKeepsPasswordWhenSame(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("Same-Pass-1!"), bcrypt.DefaultCost) + if err != nil { + t.Fatal(err) + } + repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{ + memberKey("default", "admin@haixun.local"): { + TenantID: "default", + UID: "uid-1", + Email: "admin@haixun.local", + Roles: []string{"admin"}, + PasswordHash: string(hash), + }, + }} + _, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{ + TenantID: "default", + Email: "admin@haixun.local", + Password: "Same-Pass-1!", + }) + if err != nil { + t.Fatalf("EnsureAdminMember: %v", err) + } + if created { + t.Fatal("expected created=false") + } + member := repo.byEmail[memberKey("default", "admin@haixun.local")] + if member.PasswordHash != string(hash) { + t.Fatal("password hash should not change when password is the same") + } } diff --git a/backend/internal/bootstrap/envfile.go b/backend/internal/bootstrap/envfile.go new file mode 100644 index 0000000..f4f73c6 --- /dev/null +++ b/backend/internal/bootstrap/envfile.go @@ -0,0 +1,67 @@ +package bootstrap + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// LoadInfraEnv reads local env files into process env (only keys not already set). +// Search order: HAIXUN_ENV_FILE, deploy/.env.dev (for native dev run). +func LoadInfraEnv() { + if path := strings.TrimSpace(os.Getenv("HAIXUN_ENV_FILE")); path != "" { + _ = loadEnvFile(path) + return + } + cwd, err := os.Getwd() + if err != nil { + return + } + candidates := []string{ + filepath.Join(cwd, "deploy", ".env.dev"), + filepath.Join(cwd, "..", "deploy", ".env.dev"), + filepath.Join(cwd, "..", "..", "deploy", ".env.dev"), + } + for _, path := range candidates { + if loadEnvFile(path) { + return + } + } +} + +func loadEnvFile(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + _ = os.Setenv(key, value) + } + return true +} diff --git a/backend/internal/bootstrap/envfile_test.go b/backend/internal/bootstrap/envfile_test.go new file mode 100644 index 0000000..0f6ab1a --- /dev/null +++ b/backend/internal/bootstrap/envfile_test.go @@ -0,0 +1,36 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadEnvFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + content := `# comment +HAIXUN_JWT_ACCESS_SECRET=from-file +THREADS_APP_ID="1566509781118049" +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("HAIXUN_JWT_REFRESH_SECRET", "preset") + unset := []string{"HAIXUN_JWT_ACCESS_SECRET", "THREADS_APP_ID"} + for _, key := range unset { + _ = os.Unsetenv(key) + } + if !loadEnvFile(path) { + t.Fatal("loadEnvFile returned false") + } + if got := os.Getenv("HAIXUN_JWT_ACCESS_SECRET"); got != "from-file" { + t.Fatalf("access secret = %q", got) + } + if got := os.Getenv("HAIXUN_JWT_REFRESH_SECRET"); got != "preset" { + t.Fatalf("refresh secret should stay preset, got %q", got) + } + if got := os.Getenv("THREADS_APP_ID"); got != "1566509781118049" { + t.Fatalf("threads app id = %q", got) + } +} diff --git a/backend/internal/bootstrap/init.go b/backend/internal/bootstrap/init.go index 5b00b39..baf77c2 100644 --- a/backend/internal/bootstrap/init.go +++ b/backend/internal/bootstrap/init.go @@ -8,19 +8,31 @@ import ( libcrypto "haixun-backend/internal/library/crypto" libmongo "haixun-backend/internal/library/mongo" brandrepo "haixun-backend/internal/model/brand/repository" + contentformularepo "haixun-backend/internal/model/content_formula/repository" cmatrixrepo "haixun-backend/internal/model/content_matrix/repository" + contentopsrepo "haixun-backend/internal/model/content_ops/repository" copydraftrepo "haixun-backend/internal/model/copy_draft/repository" copymissionrepo "haixun-backend/internal/model/copy_mission/repository" + crmcontactrepo "haixun-backend/internal/model/crm_contact/repository" jobrepo "haixun-backend/internal/model/job/repository" + jobusecase "haixun-backend/internal/model/job/usecase" kgrepo "haixun-backend/internal/model/knowledge_graph/repository" memberrepo "haixun-backend/internal/model/member/repository" + mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository" outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository" + ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository" permissionrepo "haixun-backend/internal/model/permission/repository" permissionuc "haixun-backend/internal/model/permission/usecase" personarepo "haixun-backend/internal/model/persona/repository" placementtopicrepo "haixun-backend/internal/model/placement_topic/repository" + publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository" + publishguardrepo "haixun-backend/internal/model/publish_guard/repository" + publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository" + publishqueuerepo "haixun-backend/internal/model/publish_queue/repository" + publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository" scanpostrepo "haixun-backend/internal/model/scan_post/repository" settingrepo "haixun-backend/internal/model/setting/repository" + stylepresetrepo "haixun-backend/internal/model/style_preset/repository" threadsaccountrepo "haixun-backend/internal/model/threads_account/repository" ) @@ -97,6 +109,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport {"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes}, {"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes}, {"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes}, + {"publish_analytics", publishanalyticsrepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_queue", publishqueuerepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_inventory", publishinventoryrepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_guard", publishguardrepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_queue_events", publishqueueeventrepo.NewMongoRepository(db).EnsureIndexes}, + {"style_presets", stylepresetrepo.NewMongoRepository(db).EnsureIndexes}, + {"own_post_formulas", ownpostformularepo.NewMongoRepository(db).EnsureIndexes}, + {"content_formulas", contentformularepo.NewMongoRepository(db).EnsureIndexes}, + {"content_ops", contentopsrepo.NewMongoRepository(db).EnsureIndexes}, + {"mention_inbox", mentioninboxrepo.NewMongoRepository(db).EnsureIndexes}, + {"crm_contacts", crmcontactrepo.NewMongoRepository(db).EnsureIndexes}, } for _, repo := range repos { if err := repo.fn(ctx); err != nil { @@ -105,6 +128,26 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport } report.IndexesEnsured = true + jobUseCase := jobusecase.NewUseCase(jobTemplateRepository, jobRunRepository, jobScheduleRepository, jobEventRepository, nil) + jobTemplates := []struct { + name string + fn func(context.Context) error + }{ + {"demo", jobUseCase.EnsureDemoTemplate}, + {"style_8d", jobUseCase.EnsureStyle8DTemplate}, + {"expand_graph", jobUseCase.EnsureExpandGraphTemplate}, + {"placement_scan", jobUseCase.EnsurePlacementScanTemplate}, + {"scan_viral", jobUseCase.EnsureScanViralTemplate}, + {"generate_outreach_draft", jobUseCase.EnsureGenerateOutreachDraftTemplate}, + {"refresh_threads_token", jobUseCase.EnsureRefreshThreadsTokenTemplate}, + {"publish_analytics", jobUseCase.EnsurePublishAnalyticsTemplate}, + } + for _, template := range jobTemplates { + if err := template.fn(ctx); err != nil { + return nil, fmt.Errorf("seed %s job template: %w", template.name, err) + } + } + permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository) if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil { return nil, fmt.Errorf("seed permissions catalog: %w", err) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 478ea7a..868cb19 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -52,15 +52,37 @@ type BraveConf struct { APIKey string `json:",optional"` } +type ThreadsOAuthConf struct { + AppID string `json:",optional"` + AppSecret string `json:",optional"` + RedirectURI string `json:",optional"` + SuccessRedirect string `json:",optional"` +} + +type ThreadsTokenRefreshConf struct { + Enabled bool `json:",default=true"` + IntervalSeconds int `json:",default=3600"` + LeadDays int `json:",default=7"` +} + +type ThreadsPublishQueueConf struct { + Enabled bool `json:",default=true"` + IntervalSeconds int `json:",default=60"` + BatchSize int `json:",default=20"` +} + type Config struct { rest.RestConf - Mongo MongoConf `json:",optional"` - Redis RedisConf `json:",optional"` - Auth AuthConf `json:",optional"` - Secrets SecretsConf `json:",optional"` - InternalWorker InternalWorkerConf `json:",optional"` - JobWorker JobWorkerConf `json:",optional"` - JobScheduler JobSchedulerConf `json:",optional"` - JobReaper JobReaperConf `json:",optional"` - Brave BraveConf `json:",optional"` + Mongo MongoConf `json:",optional"` + Redis RedisConf `json:",optional"` + Auth AuthConf `json:",optional"` + Secrets SecretsConf `json:",optional"` + InternalWorker InternalWorkerConf `json:",optional"` + JobWorker JobWorkerConf `json:",optional"` + JobScheduler JobSchedulerConf `json:",optional"` + JobReaper JobReaperConf `json:",optional"` + Brave BraveConf `json:",optional"` + ThreadsOAuth ThreadsOAuthConf `json:",optional"` + ThreadsTokenRefresh ThreadsTokenRefreshConf `json:",optional"` + ThreadsPublishQueue ThreadsPublishQueueConf `json:",optional"` } diff --git a/backend/internal/handler/scan_post/lookalike_handler.go b/backend/internal/handler/brand/update_outreach_draft_handler.go similarity index 66% rename from backend/internal/handler/scan_post/lookalike_handler.go rename to backend/internal/handler/brand/update_outreach_draft_handler.go index 5123c62..f78e0c9 100644 --- a/backend/internal/handler/scan_post/lookalike_handler.go +++ b/backend/internal/handler/brand/update_outreach_draft_handler.go @@ -1,9 +1,9 @@ -package scan_post +package brand import ( "net/http" - "haixun-backend/internal/logic/scan_post" + "haixun-backend/internal/logic/brand" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -11,9 +11,9 @@ import ( "github.com/zeromicro/go-zero/rest/httpx" ) -func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func UpdateOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.LookalikeReq + var req types.UpdateOutreachDraftHandlerReq if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return @@ -22,8 +22,9 @@ func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } - l := scan_post.NewLookalikeLogic(r.Context(), svcCtx) - data, err := l.Lookalike(&req) + + l := brand.NewUpdateOutreachDraftLogic(r.Context(), svcCtx) + data, err := l.UpdateOutreachDraft(&req) response.Write(r.Context(), w, data, err) } } diff --git a/backend/internal/handler/copy_mission/create_copy_mission_handler.go b/backend/internal/handler/copy_mission/create_copy_mission_handler.go index b97e098..870a129 100644 --- a/backend/internal/handler/copy_mission/create_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/create_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/delete_copy_mission_handler.go b/backend/internal/handler/copy_mission/delete_copy_mission_handler.go index cedeb8d..2340be4 100644 --- a/backend/internal/handler/copy_mission/delete_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/delete_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go b/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go index db5e4bb..f114ea8 100644 --- a/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go +++ b/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/get_copy_mission_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_handler.go index 6034abd..7cbb8d2 100644 --- a/backend/internal/handler/copy_mission/get_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/get_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go index 9737c28..493d849 100644 --- a/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go +++ b/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go index 5d0f751..74849bb 100644 --- a/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go @@ -1,14 +1,16 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go index ff4fe54..00db1ea 100644 --- a/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go +++ b/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go index 1f8705b..460684b 100644 --- a/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go +++ b/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/list_copy_missions_handler.go b/backend/internal/handler/copy_mission/list_copy_missions_handler.go index 3d82016..0ee198d 100644 --- a/backend/internal/handler/copy_mission/list_copy_missions_handler.go +++ b/backend/internal/handler/copy_mission/list_copy_missions_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go new file mode 100644 index 0000000..054fd6d --- /dev/null +++ b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package copy_mission + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/copy_mission" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ScheduleCopyMissionDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScheduleCopyMissionDraftsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := copy_mission.NewScheduleCopyMissionDraftsLogic(r.Context(), svcCtx) + data, err := l.ScheduleCopyMissionDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go index 8b550a5..1348728 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go index e76ce7f..fe5dda0 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go @@ -1,14 +1,16 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go index b626e5c..2cbcb84 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go @@ -1,14 +1,16 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go index 4309303..d9bbed4 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/update_copy_mission_handler.go b/backend/internal/handler/copy_mission/update_copy_mission_handler.go index 580a256..b766055 100644 --- a/backend/internal/handler/copy_mission/update_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/update_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go index 4beed7c..9819673 100644 --- a/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go +++ b/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/crm/delete_crm_contact_handler.go b/backend/internal/handler/crm/delete_crm_contact_handler.go deleted file mode 100644 index c46723d..0000000 --- a/backend/internal/handler/crm/delete_crm_contact_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package crm - -import ( - "net/http" - - "haixun-backend/internal/logic/crm" - "haixun-backend/internal/response" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" -) - -func DeleteCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req types.CrmContactIDPath - if err := httpx.Parse(r, &req); err != nil { - response.Write(r.Context(), w, nil, response.WrapRequestError(err)) - return - } - l := crm.NewDeleteCrmContactLogic(r.Context(), svcCtx) - err := l.DeleteCrmContact(&req) - response.Write(r.Context(), w, nil, err) - } -} diff --git a/backend/internal/handler/persona/create_content_plan_handler.go b/backend/internal/handler/persona/create_content_plan_handler.go new file mode 100644 index 0000000..33264ab --- /dev/null +++ b/backend/internal/handler/persona/create_content_plan_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateContentPlanHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewCreateContentPlanLogic(r.Context(), svcCtx) + data, err := l.CreateContentPlan(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/create_feedback_event_handler.go b/backend/internal/handler/persona/create_feedback_event_handler.go new file mode 100644 index 0000000..6085fc3 --- /dev/null +++ b/backend/internal/handler/persona/create_feedback_event_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateFeedbackEventHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateFeedbackEventHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewCreateFeedbackEventLogic(r.Context(), svcCtx) + data, err := l.CreateFeedbackEvent(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/create_formula_pool_handler.go b/backend/internal/handler/persona/create_formula_pool_handler.go new file mode 100644 index 0000000..209740e --- /dev/null +++ b/backend/internal/handler/persona/create_formula_pool_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateFormulaPoolHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateFormulaPoolHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewCreateFormulaPoolLogic(r.Context(), svcCtx) + data, err := l.CreateFormulaPool(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/create_knowledge_source_handler.go b/backend/internal/handler/persona/create_knowledge_source_handler.go new file mode 100644 index 0000000..5dcff2a --- /dev/null +++ b/backend/internal/handler/persona/create_knowledge_source_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateKnowledgeSourceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateKnowledgeSourceHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewCreateKnowledgeSourceLogic(r.Context(), svcCtx) + data, err := l.CreateKnowledgeSource(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/delete_style_preset_handler.go b/backend/internal/handler/persona/delete_style_preset_handler.go new file mode 100644 index 0000000..c7e0873 --- /dev/null +++ b/backend/internal/handler/persona/delete_style_preset_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func DeleteStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StylePresetPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewDeleteStylePresetLogic(r.Context(), svcCtx) + err := l.DeleteStylePreset(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/backend/internal/handler/persona/generate_from_content_formula_handler.go b/backend/internal/handler/persona/generate_from_content_formula_handler.go new file mode 100644 index 0000000..b874afc --- /dev/null +++ b/backend/internal/handler/persona/generate_from_content_formula_handler.go @@ -0,0 +1,29 @@ +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GenerateFromContentFormulaHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := persona.NewGenerateFromContentFormulaLogic(r.Context(), svcCtx) + data, err := l.GenerateFromContentFormula(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go b/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go new file mode 100644 index 0000000..3148c38 --- /dev/null +++ b/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go @@ -0,0 +1,29 @@ +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GeneratePersonaTopicMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GeneratePersonaTopicMatrixHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := persona.NewGeneratePersonaTopicMatrixLogic(r.Context(), svcCtx) + data, err := l.GeneratePersonaTopicMatrix(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_content_plans_handler.go b/backend/internal/handler/persona/list_content_plans_handler.go new file mode 100644 index 0000000..850b3c7 --- /dev/null +++ b/backend/internal/handler/persona/list_content_plans_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListContentPlansHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewListContentPlansLogic(r.Context(), svcCtx) + data, err := l.ListContentPlans(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_formula_pools_handler.go b/backend/internal/handler/persona/list_formula_pools_handler.go new file mode 100644 index 0000000..6759aa6 --- /dev/null +++ b/backend/internal/handler/persona/list_formula_pools_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListFormulaPoolsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewListFormulaPoolsLogic(r.Context(), svcCtx) + data, err := l.ListFormulaPools(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_knowledge_chunks_handler.go b/backend/internal/handler/persona/list_knowledge_chunks_handler.go new file mode 100644 index 0000000..f4d6649 --- /dev/null +++ b/backend/internal/handler/persona/list_knowledge_chunks_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListKnowledgeChunksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewListKnowledgeChunksLogic(r.Context(), svcCtx) + data, err := l.ListKnowledgeChunks(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_knowledge_sources_handler.go b/backend/internal/handler/persona/list_knowledge_sources_handler.go new file mode 100644 index 0000000..6dca00d --- /dev/null +++ b/backend/internal/handler/persona/list_knowledge_sources_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListKnowledgeSourcesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewListKnowledgeSourcesLogic(r.Context(), svcCtx) + data, err := l.ListKnowledgeSources(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_persona_content_inbox_handler.go b/backend/internal/handler/persona/list_persona_content_inbox_handler.go new file mode 100644 index 0000000..efd7e41 --- /dev/null +++ b/backend/internal/handler/persona/list_persona_content_inbox_handler.go @@ -0,0 +1,29 @@ +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListPersonaContentInboxHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := persona.NewListPersonaContentInboxLogic(r.Context(), svcCtx) + data, err := l.ListPersonaContentInbox(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_style_presets_handler.go b/backend/internal/handler/persona/list_style_presets_handler.go new file mode 100644 index 0000000..34cfa79 --- /dev/null +++ b/backend/internal/handler/persona/list_style_presets_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListStylePresetsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewListStylePresetsLogic(r.Context(), svcCtx) + data, err := l.ListStylePresets(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_topic_candidates_handler.go b/backend/internal/handler/persona/list_topic_candidates_handler.go new file mode 100644 index 0000000..f5c56ea --- /dev/null +++ b/backend/internal/handler/persona/list_topic_candidates_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListTopicCandidatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewListTopicCandidatesLogic(r.Context(), svcCtx) + data, err := l.ListTopicCandidates(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go b/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go new file mode 100644 index 0000000..fd51cde --- /dev/null +++ b/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go @@ -0,0 +1,30 @@ +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PrunePersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PrunePersonaCopyDraftsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewPrunePersonaCopyDraftsLogic(r.Context(), svcCtx) + data, err := l.PrunePersonaCopyDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go new file mode 100644 index 0000000..a799be6 --- /dev/null +++ b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go @@ -0,0 +1,29 @@ +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SchedulePersonaCopyDraftHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := persona.NewSchedulePersonaCopyDraftLogic(r.Context(), svcCtx) + data, err := l.SchedulePersonaCopyDraft(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/schedule_persona_drafts_handler.go b/backend/internal/handler/persona/schedule_persona_drafts_handler.go new file mode 100644 index 0000000..a8e9471 --- /dev/null +++ b/backend/internal/handler/persona/schedule_persona_drafts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func SchedulePersonaDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SchedulePersonaDraftsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewSchedulePersonaDraftsLogic(r.Context(), svcCtx) + data, err := l.SchedulePersonaDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go b/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go new file mode 100644 index 0000000..2d0db5a --- /dev/null +++ b/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go @@ -0,0 +1,30 @@ +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func StartPersonaFormulaDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaFormulaDraftJobHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewStartPersonaFormulaDraftJobLogic(r.Context(), svcCtx) + data, err := l.StartPersonaFormulaDraftJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go b/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go new file mode 100644 index 0000000..eac2b0d --- /dev/null +++ b/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func StartPersonaRewriteDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaRewriteDraftJobHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewStartPersonaRewriteDraftJobLogic(r.Context(), svcCtx) + data, err := l.StartPersonaRewriteDraftJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go b/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go new file mode 100644 index 0000000..ae9dc93 --- /dev/null +++ b/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go @@ -0,0 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func StartPersonaStyleAnalysisFromTextHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaStyleAnalysisFromTextHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewStartPersonaStyleAnalysisFromTextLogic(r.Context(), svcCtx) + data, err := l.StartPersonaStyleAnalysisFromText(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go b/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go new file mode 100644 index 0000000..108a6dc --- /dev/null +++ b/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go @@ -0,0 +1,30 @@ +package persona + +import ( + "net/http" + + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func StartPersonaTopicMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaTopicMatrixJobHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewStartPersonaTopicMatrixJobLogic(r.Context(), svcCtx) + data, err := l.StartPersonaTopicMatrixJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/update_content_plan_handler.go b/backend/internal/handler/persona/update_content_plan_handler.go new file mode 100644 index 0000000..ba1bb32 --- /dev/null +++ b/backend/internal/handler/persona/update_content_plan_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpdateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateContentPlanHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewUpdateContentPlanLogic(r.Context(), svcCtx) + data, err := l.UpdateContentPlan(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/upsert_style_preset_handler.go b/backend/internal/handler/persona/upsert_style_preset_handler.go new file mode 100644 index 0000000..cfc28d6 --- /dev/null +++ b/backend/internal/handler/persona/upsert_style_preset_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpsertStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpsertStylePresetHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewUpsertStylePresetLogic(r.Context(), svcCtx) + data, err := l.UpsertStylePreset(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go b/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go new file mode 100644 index 0000000..1a763bd --- /dev/null +++ b/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go @@ -0,0 +1,30 @@ +package placement_topic + +import ( + "net/http" + + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func StartPlacementTopicOutreachDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPlacementTopicOutreachDraftJobHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewStartPlacementTopicOutreachDraftJobLogic(r.Context(), svcCtx) + data, err := l.StartPlacementTopicOutreachDraftJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go b/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go new file mode 100644 index 0000000..fb4bcb0 --- /dev/null +++ b/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go @@ -0,0 +1,30 @@ +package placement_topic + +import ( + "net/http" + + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdatePlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdatePlacementTopicOutreachDraftHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewUpdatePlacementTopicOutreachDraftLogic(r.Context(), svcCtx) + data, err := l.UpdatePlacementTopicOutreachDraft(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/register_extra.go b/backend/internal/handler/register_extra.go new file mode 100644 index 0000000..e973666 --- /dev/null +++ b/backend/internal/handler/register_extra.go @@ -0,0 +1,37 @@ +package handler + +import ( + "net/http" + + "haixun-backend/internal/handler/static" + threads_account "haixun-backend/internal/handler/threads_account" + "haixun-backend/internal/svc" + + "github.com/zeromicro/go-zero/rest" +) + +// RegisterExtraHandlers mounts routes that are not generated by goctl. +func RegisterExtraHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodGet, + Path: "/downloads/haixun-threads-sync.zip", + Handler: static.ExtensionDownloadHandler(), + }, + }, + rest.WithPrefix("/api/v1"), + ) + + // Legacy Meta redirect URI (deploy/.env.dev 曾誤設為 /api/threads/oauth/callback)。 + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodGet, + Path: "/oauth/callback", + Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/threads"), + ) +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index fa9501e..efaebe0 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -10,14 +10,12 @@ import ( auth "haixun-backend/internal/handler/auth" brand "haixun-backend/internal/handler/brand" copy_mission "haixun-backend/internal/handler/copy_mission" - crm "haixun-backend/internal/handler/crm" job "haixun-backend/internal/handler/job" member "haixun-backend/internal/handler/member" normal "haixun-backend/internal/handler/normal" permission "haixun-backend/internal/handler/permission" persona "haixun-backend/internal/handler/persona" placement_topic "haixun-backend/internal/handler/placement_topic" - scan_post "haixun-backend/internal/handler/scan_post" setting "haixun-backend/internal/handler/setting" threads_account "haixun-backend/internal/handler/threads_account" "haixun-backend/internal/svc" @@ -164,6 +162,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/knowledge-graph/nodes", Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx), }, + { + Method: http.MethodPatch, + Path: "/:id/outreach-drafts/:draftId", + Handler: brand.UpdateOutreachDraftHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/outreach-drafts/generate", @@ -278,6 +281,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:personaId/copy-missions/:id/copy-drafts", Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:personaId/copy-missions/:id/copy-drafts/schedule", + Handler: copy_mission.ScheduleCopyMissionDraftsHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:personaId/copy-missions/:id/knowledge-graph", @@ -574,6 +582,31 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id", Handler: persona.DeletePersonaHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/content-formulas/:formulaId/generate", + Handler: persona.GenerateFromContentFormulaHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/content-inbox", + Handler: persona.ListPersonaContentInboxHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/content-plans", + Handler: persona.ListContentPlansHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/content-plans", + Handler: persona.CreateContentPlanHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/content-plans/:contentPlanId", + Handler: persona.UpdateContentPlanHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:id/copy-drafts", @@ -594,16 +627,106 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/copy-drafts/:draftId/publish", Handler: persona.PublishPersonaCopyDraftHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/copy-drafts/:draftId/schedule", + Handler: persona.SchedulePersonaCopyDraftHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/copy-drafts/generate", Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/copy-drafts/prune", + Handler: persona.PrunePersonaCopyDraftsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/copy-drafts/schedule", + Handler: persona.SchedulePersonaDraftsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/feedback-events", + Handler: persona.CreateFeedbackEventHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/formula-draft/jobs", + Handler: persona.StartPersonaFormulaDraftJobHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/formula-pools", + Handler: persona.ListFormulaPoolsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/formula-pools", + Handler: persona.CreateFormulaPoolHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/knowledge-chunks", + Handler: persona.ListKnowledgeChunksHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/knowledge-sources", + Handler: persona.CreateKnowledgeSourceHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/knowledge-sources", + Handler: persona.ListKnowledgeSourcesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/rewrite-draft/jobs", + Handler: persona.StartPersonaRewriteDraftJobHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/style-analysis", Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/style-analysis-from-text", + Handler: persona.StartPersonaStyleAnalysisFromTextHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/style-presets", + Handler: persona.ListStylePresetsHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/style-presets/:presetId", + Handler: persona.UpsertStylePresetHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/style-presets/:presetId", + Handler: persona.DeleteStylePresetHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/topic-candidates", + Handler: persona.ListTopicCandidatesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/topic-matrix/generate", + Handler: persona.GeneratePersonaTopicMatrixHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/topic-matrix/jobs", + Handler: persona.StartPersonaTopicMatrixJobHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/viral-scan-jobs", @@ -673,6 +796,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/knowledge-graph/nodes", Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/outreach-draft-jobs", + Handler: placement_topic.StartPlacementTopicOutreachDraftJobHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/outreach-drafts/:draftId", + Handler: placement_topic.UpdatePlacementTopicOutreachDraftHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/outreach-drafts/generate", @@ -752,54 +885,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/settings"), ) - server.AddRoutes( - rest.WithMiddlewares( - []rest.Middleware{serverCtx.AuthJWT}, - []rest.Route{ - { - Method: http.MethodGet, - Path: "/", - Handler: crm.ListCrmContactsHandler(serverCtx), - }, - { - Method: http.MethodPost, - Path: "/", - Handler: crm.CreateCrmContactHandler(serverCtx), - }, - { - Method: http.MethodGet, - Path: "/:id", - Handler: crm.GetCrmContactHandler(serverCtx), - }, - { - Method: http.MethodPatch, - Path: "/:id", - Handler: crm.UpdateCrmContactHandler(serverCtx), - }, - { - Method: http.MethodDelete, - Path: "/:id", - Handler: crm.DeleteCrmContactHandler(serverCtx), - }, - }..., - ), - rest.WithPrefix("/api/v1/crm/contacts"), - ) - - server.AddRoutes( - rest.WithMiddlewares( - []rest.Middleware{serverCtx.AuthJWT}, - []rest.Route{ - { - Method: http.MethodPost, - Path: "/lookalike", - Handler: scan_post.LookalikeHandler(serverCtx), - }, - }..., - ), - rest.WithPrefix("/api/v1/scan-post"), - ) - server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -824,6 +909,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id", Handler: threads_account.UpdateThreadsAccountHandler(serverCtx), }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: threads_account.DeleteThreadsAccountHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/activate", @@ -844,6 +934,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/ai-settings/providers/:provider/models", Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/api/disconnect", + Handler: threads_account.DisconnectThreadsApiHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/api/playground", + Handler: threads_account.RunThreadsApiPlaygroundHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/api/smoke-test", + Handler: threads_account.RunThreadsApiSmokeTestHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:id/connection", @@ -854,13 +959,209 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/connection", Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/content-formulas", + Handler: threads_account.ListContentFormulasHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/content-formulas/:formulaId", + Handler: threads_account.GetContentFormulaHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/content-formulas/:formulaId", + Handler: threads_account.PatchContentFormulaHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/content-formulas/:formulaId", + Handler: threads_account.DeleteContentFormulaHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/content-formulas/analyze", + Handler: threads_account.AnalyzeContentFormulaHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/content-formulas/import-own-post", + Handler: threads_account.ImportOwnPostToContentFormulaHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/content-formulas/search-posts", + Handler: threads_account.SearchContentFormulaPostsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/diagnostics", + Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/mention-inbox", + Handler: threads_account.ListMentionInboxHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/mention-inbox/:media_id/reply", + Handler: threads_account.PublishMentionReplyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/mention-inbox/sync", + Handler: threads_account.SyncMentionInboxHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/own-post-formula", + Handler: threads_account.GenerateOwnPostFormulaHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/own-post-formulas", + Handler: threads_account.ListOwnPostFormulasHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/own-post-reply-draft", + Handler: threads_account.GenerateOwnPostReplyDraftHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/post-performance", + Handler: threads_account.ListThreadsPostPerformanceHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-alerts", + Handler: threads_account.ListPublishAlertsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-calendar", + Handler: threads_account.ListPublishQueueRangeHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-guard-policy", + Handler: threads_account.GetPublishGuardPolicyHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/publish-guard-policy", + Handler: threads_account.UpsertPublishGuardPolicyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-guard/resume", + Handler: threads_account.ResumePublishGuardHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-health", + Handler: threads_account.ListThreadsPublishHealthHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-inventory-policy", + Handler: threads_account.GetPublishInventoryPolicyHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/publish-inventory-policy", + Handler: threads_account.UpsertPublishInventoryPolicyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-inventory/refill-jobs", + Handler: threads_account.StartPublishInventoryRefillJobHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-queue", + Handler: threads_account.CreatePublishQueueItemHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-queue", + Handler: threads_account.ListPublishQueueItemsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-queue/:qid", + Handler: threads_account.GetPublishQueueItemHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/publish-queue/:qid", + Handler: threads_account.PatchPublishQueueItemHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/publish-queue/:qid", + Handler: threads_account.DeletePublishQueueItemHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-queue/:qid/cancel", + Handler: threads_account.CancelPublishQueueItemHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-queue/:qid/events", + Handler: threads_account.ListPublishQueueEventsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-queue/:qid/retry", + Handler: threads_account.RetryPublishQueueItemHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-slot-insights", + Handler: threads_account.GetPublishSlotInsightsHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/session/import", Handler: threads_account.ImportThreadsAccountSessionHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/oauth/config", + Handler: threads_account.GetThreadsOauthConfigHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/oauth/logs", + Handler: threads_account.ListThreadsOauthLogsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/oauth/start", + Handler: threads_account.StartThreadsOauthHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/publish-dashboard-summary", + Handler: threads_account.GetPublishDashboardSummaryHandler(serverCtx), + }, }..., ), rest.WithPrefix("/api/v1/threads-accounts"), ) + + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodGet, + Path: "/oauth/callback", + Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1/threads-accounts"), + ) } diff --git a/backend/internal/handler/static/extension_download.go b/backend/internal/handler/static/extension_download.go new file mode 100644 index 0000000..ad1d2f5 --- /dev/null +++ b/backend/internal/handler/static/extension_download.go @@ -0,0 +1,42 @@ +package static + +import ( + "net/http" + "os" + "path/filepath" +) + +func ExtensionDownloadHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + path, ok := resolveExtensionZipPath() + if !ok { + http.Error(w, "extension package not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="haixun-threads-sync.zip"`) + http.ServeFile(w, r, path) + } +} + +func resolveExtensionZipPath() (string, bool) { + candidates := []string{ + filepath.Join("web", "public", "downloads", "haixun-threads-sync.zip"), + filepath.Join("backend", "web", "public", "downloads", "haixun-threads-sync.zip"), + filepath.Join("..", "web", "public", "downloads", "haixun-threads-sync.zip"), + filepath.Join("extension", "haixun-threads-sync.zip"), + } + if wd, err := os.Getwd(); err == nil { + candidates = append(candidates, + filepath.Join(wd, "web", "public", "downloads", "haixun-threads-sync.zip"), + filepath.Join(wd, "backend", "web", "public", "downloads", "haixun-threads-sync.zip"), + filepath.Join(wd, "..", "extension", "haixun-threads-sync.zip"), + ) + } + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > 0 { + return candidate, true + } + } + return "", false +} diff --git a/backend/internal/handler/threads_account/analyze_content_formula_handler.go b/backend/internal/handler/threads_account/analyze_content_formula_handler.go new file mode 100644 index 0000000..b9d9714 --- /dev/null +++ b/backend/internal/handler/threads_account/analyze_content_formula_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AnalyzeContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AnalyzeContentFormulaHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewAnalyzeContentFormulaLogic(r.Context(), svcCtx) + data, err := l.AnalyzeContentFormula(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go b/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go new file mode 100644 index 0000000..c521ec8 --- /dev/null +++ b/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CancelPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueItemPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewCancelPublishQueueItemLogic(r.Context(), svcCtx) + data, err := l.CancelPublishQueueItem(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/create_publish_queue_item_handler.go b/backend/internal/handler/threads_account/create_publish_queue_item_handler.go new file mode 100644 index 0000000..2f57380 --- /dev/null +++ b/backend/internal/handler/threads_account/create_publish_queue_item_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreatePublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreatePublishQueueHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewCreatePublishQueueItemLogic(r.Context(), svcCtx) + data, err := l.CreatePublishQueueItem(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/delete_content_formula_handler.go b/backend/internal/handler/threads_account/delete_content_formula_handler.go new file mode 100644 index 0000000..e3ec1ee --- /dev/null +++ b/backend/internal/handler/threads_account/delete_content_formula_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GetContentFormulaHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewDeleteContentFormulaLogic(r.Context(), svcCtx) + data, err := l.DeleteContentFormula(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go b/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go new file mode 100644 index 0000000..568c3a6 --- /dev/null +++ b/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func DeletePublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueItemPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewDeletePublishQueueItemLogic(r.Context(), svcCtx) + err := l.DeletePublishQueueItem(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/backend/internal/handler/threads_account/delete_threads_account_handler.go b/backend/internal/handler/threads_account/delete_threads_account_handler.go new file mode 100644 index 0000000..9029e7f --- /dev/null +++ b/backend/internal/handler/threads_account/delete_threads_account_handler.go @@ -0,0 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewDeleteThreadsAccountLogic(r.Context(), svcCtx) + data, err := l.DeleteThreadsAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/disconnect_threads_api_handler.go b/backend/internal/handler/threads_account/disconnect_threads_api_handler.go new file mode 100644 index 0000000..a1b8f53 --- /dev/null +++ b/backend/internal/handler/threads_account/disconnect_threads_api_handler.go @@ -0,0 +1,30 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DisconnectThreadsApiHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewDisconnectThreadsApiLogic(r.Context(), svcCtx) + data, err := l.DisconnectThreadsApi(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/generate_own_post_formula_handler.go b/backend/internal/handler/threads_account/generate_own_post_formula_handler.go new file mode 100644 index 0000000..f78d69d --- /dev/null +++ b/backend/internal/handler/threads_account/generate_own_post_formula_handler.go @@ -0,0 +1,28 @@ +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GenerateOwnPostFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GenerateOwnPostFormulaHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewGenerateOwnPostFormulaLogic(r.Context(), svcCtx) + data, err := l.GenerateOwnPostFormula(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go b/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go new file mode 100644 index 0000000..fc8341c --- /dev/null +++ b/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go @@ -0,0 +1,28 @@ +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GenerateOwnPostReplyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GenerateOwnPostReplyDraftHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewGenerateOwnPostReplyDraftLogic(r.Context(), svcCtx) + data, err := l.GenerateOwnPostReplyDraft(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_content_formula_handler.go b/backend/internal/handler/threads_account/get_content_formula_handler.go new file mode 100644 index 0000000..cc267a0 --- /dev/null +++ b/backend/internal/handler/threads_account/get_content_formula_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GetContentFormulaHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewGetContentFormulaLogic(r.Context(), svcCtx) + data, err := l.GetContentFormula(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go new file mode 100644 index 0000000..cc261ed --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go @@ -0,0 +1,20 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" +) + +func GetPublishDashboardSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := threads_account.NewGetPublishDashboardSummaryLogic(r.Context(), svcCtx) + data, err := l.GetPublishDashboardSummary() + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go new file mode 100644 index 0000000..0f2171f --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetPublishGuardPolicyLogic(r.Context(), svcCtx) + data, err := l.GetPublishGuardPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go new file mode 100644 index 0000000..6b0e387 --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetPublishInventoryPolicyLogic(r.Context(), svcCtx) + data, err := l.GetPublishInventoryPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_queue_item_handler.go b/backend/internal/handler/threads_account/get_publish_queue_item_handler.go new file mode 100644 index 0000000..c525c26 --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_queue_item_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueItemPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetPublishQueueItemLogic(r.Context(), svcCtx) + data, err := l.GetPublishQueueItem(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go new file mode 100644 index 0000000..d1d21ad --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPublishSlotInsightsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetPublishSlotInsightsLogic(r.Context(), svcCtx) + data, err := l.GetPublishSlotInsights(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go new file mode 100644 index 0000000..1053001 --- /dev/null +++ b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetThreadsDiagnosticsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetThreadsDiagnosticsLogic(r.Context(), svcCtx) + data, err := l.GetThreadsDiagnostics(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go b/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go new file mode 100644 index 0000000..74a2fde --- /dev/null +++ b/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go @@ -0,0 +1,16 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" +) + +func GetThreadsOauthConfigHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := threads_account.NewGetThreadsOAuthConfigLogic(svcCtx) + response.Write(r.Context(), w, l.GetThreadsOAuthConfig(), nil) + } +} diff --git a/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go b/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go new file mode 100644 index 0000000..7848037 --- /dev/null +++ b/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ImportOwnPostToContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ImportOwnPostFormulaHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewImportOwnPostToContentFormulaLogic(r.Context(), svcCtx) + data, err := l.ImportOwnPostToContentFormula(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_content_formulas_handler.go b/backend/internal/handler/threads_account/list_content_formulas_handler.go new file mode 100644 index 0000000..7b47455 --- /dev/null +++ b/backend/internal/handler/threads_account/list_content_formulas_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListContentFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListContentFormulasHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewListContentFormulasLogic(r.Context(), svcCtx) + data, err := l.ListContentFormulas(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/create_crm_contact_handler.go b/backend/internal/handler/threads_account/list_mention_inbox_handler.go similarity index 66% rename from backend/internal/handler/crm/create_crm_contact_handler.go rename to backend/internal/handler/threads_account/list_mention_inbox_handler.go index 52d1aed..51139bf 100644 --- a/backend/internal/handler/crm/create_crm_contact_handler.go +++ b/backend/internal/handler/threads_account/list_mention_inbox_handler.go @@ -1,19 +1,18 @@ -package crm +package threads_account import ( "net/http" - "haixun-backend/internal/logic/crm" + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) -func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func ListMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.CreateCrmContactReq + var req types.ListMentionInboxHandlerReq if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return @@ -22,8 +21,8 @@ func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } - l := crm.NewCreateCrmContactLogic(r.Context(), svcCtx) - data, err := l.CreateCrmContact(&req) + l := threads_account.NewListMentionInboxLogic(r.Context(), svcCtx) + data, err := l.ListMentionInbox(&req) response.Write(r.Context(), w, data, err) } } diff --git a/backend/internal/handler/threads_account/list_own_post_formulas_handler.go b/backend/internal/handler/threads_account/list_own_post_formulas_handler.go new file mode 100644 index 0000000..e40a02b --- /dev/null +++ b/backend/internal/handler/threads_account/list_own_post_formulas_handler.go @@ -0,0 +1,28 @@ +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListOwnPostFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListOwnPostFormulasHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewListOwnPostFormulasLogic(r.Context(), svcCtx) + data, err := l.ListOwnPostFormulas(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_alerts_handler.go b/backend/internal/handler/threads_account/list_publish_alerts_handler.go new file mode 100644 index 0000000..793552b --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_alerts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPublishAlertsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListPublishAlertsLogic(r.Context(), svcCtx) + data, err := l.ListPublishAlerts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_queue_events_handler.go b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go new file mode 100644 index 0000000..7aed7d9 --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPublishQueueEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueItemPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListPublishQueueEventsLogic(r.Context(), svcCtx) + data, err := l.ListPublishQueueEvents(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_queue_items_handler.go b/backend/internal/handler/threads_account/list_publish_queue_items_handler.go new file mode 100644 index 0000000..8044880 --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_queue_items_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPublishQueueItemsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListPublishQueueHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListPublishQueueItemsLogic(r.Context(), svcCtx) + data, err := l.ListPublishQueueItems(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_queue_range_handler.go b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go new file mode 100644 index 0000000..0c910b4 --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPublishQueueRangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueRangeHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListPublishQueueRangeLogic(r.Context(), svcCtx) + data, err := l.ListPublishQueueRange(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go b/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go new file mode 100644 index 0000000..652dc36 --- /dev/null +++ b/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go @@ -0,0 +1,17 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" +) + +func ListThreadsOauthLogsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := threads_account.NewListThreadsOAuthLogsLogic(r.Context(), svcCtx) + data, err := l.ListThreadsOAuthLogs() + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_threads_post_performance_handler.go b/backend/internal/handler/threads_account/list_threads_post_performance_handler.go new file mode 100644 index 0000000..9771db6 --- /dev/null +++ b/backend/internal/handler/threads_account/list_threads_post_performance_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListThreadsPostPerformanceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListThreadsPostPerformanceHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListThreadsPostPerformanceLogic(r.Context(), svcCtx) + data, err := l.ListThreadsPostPerformance(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_threads_publish_health_handler.go b/backend/internal/handler/threads_account/list_threads_publish_health_handler.go new file mode 100644 index 0000000..5b2c35b --- /dev/null +++ b/backend/internal/handler/threads_account/list_threads_publish_health_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListThreadsPublishHealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListThreadsPublishHealthHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListThreadsPublishHealthLogic(r.Context(), svcCtx) + data, err := l.ListThreadsPublishHealth(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/patch_content_formula_handler.go b/backend/internal/handler/threads_account/patch_content_formula_handler.go new file mode 100644 index 0000000..56a98d3 --- /dev/null +++ b/backend/internal/handler/threads_account/patch_content_formula_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PatchContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PatchContentFormulaHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewPatchContentFormulaLogic(r.Context(), svcCtx) + data, err := l.PatchContentFormula(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go b/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go new file mode 100644 index 0000000..13276a4 --- /dev/null +++ b/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func PatchPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PatchPublishQueueHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewPatchPublishQueueItemLogic(r.Context(), svcCtx) + data, err := l.PatchPublishQueueItem(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/publish_mention_reply_handler.go b/backend/internal/handler/threads_account/publish_mention_reply_handler.go new file mode 100644 index 0000000..0568b7c --- /dev/null +++ b/backend/internal/handler/threads_account/publish_mention_reply_handler.go @@ -0,0 +1,28 @@ +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func PublishMentionReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishMentionReplyHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewPublishMentionReplyLogic(r.Context(), svcCtx) + data, err := l.PublishMentionReply(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/resume_publish_guard_handler.go b/backend/internal/handler/threads_account/resume_publish_guard_handler.go new file mode 100644 index 0000000..67e2dd3 --- /dev/null +++ b/backend/internal/handler/threads_account/resume_publish_guard_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ResumePublishGuardHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewResumePublishGuardLogic(r.Context(), svcCtx) + data, err := l.ResumePublishGuard(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go new file mode 100644 index 0000000..c56893c --- /dev/null +++ b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func RetryPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueItemPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewRetryPublishQueueItemLogic(r.Context(), svcCtx) + data, err := l.RetryPublishQueueItem(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/get_crm_contact_handler.go b/backend/internal/handler/threads_account/run_threads_api_playground_handler.go similarity index 55% rename from backend/internal/handler/crm/get_crm_contact_handler.go rename to backend/internal/handler/threads_account/run_threads_api_playground_handler.go index d4df08c..b0be4e7 100644 --- a/backend/internal/handler/crm/get_crm_contact_handler.go +++ b/backend/internal/handler/threads_account/run_threads_api_playground_handler.go @@ -1,9 +1,9 @@ -package crm +package threads_account import ( "net/http" - "haixun-backend/internal/logic/crm" + "haixun-backend/internal/logic/threads_account" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -11,15 +11,15 @@ import ( "github.com/zeromicro/go-zero/rest/httpx" ) -func GetCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func RunThreadsApiPlaygroundHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.CrmContactIDPath + var req types.RunThreadsAPIPlaygroundHandlerReq if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } - l := crm.NewGetCrmContactLogic(r.Context(), svcCtx) - data, err := l.GetCrmContact(&req) + l := threads_account.NewRunThreadsAPIPlaygroundLogic(r.Context(), svcCtx) + data, err := l.RunThreadsAPIPlayground(&req) response.Write(r.Context(), w, data, err) } } diff --git a/backend/internal/handler/crm/update_crm_contact_handler.go b/backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go similarity index 56% rename from backend/internal/handler/crm/update_crm_contact_handler.go rename to backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go index 8d2746c..3893098 100644 --- a/backend/internal/handler/crm/update_crm_contact_handler.go +++ b/backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go @@ -1,9 +1,9 @@ -package crm +package threads_account import ( "net/http" - "haixun-backend/internal/logic/crm" + "haixun-backend/internal/logic/threads_account" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -11,15 +11,15 @@ import ( "github.com/zeromicro/go-zero/rest/httpx" ) -func UpdateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func RunThreadsApiSmokeTestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.UpdateCrmContactReq + var req types.ThreadsAccountPath if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } - l := crm.NewUpdateCrmContactLogic(r.Context(), svcCtx) - data, err := l.UpdateCrmContact(&req) + l := threads_account.NewRunThreadsAPISmokeTestLogic(r.Context(), svcCtx) + data, err := l.RunThreadsAPISmokeTest(&req) response.Write(r.Context(), w, data, err) } } diff --git a/backend/internal/handler/threads_account/search_content_formula_posts_handler.go b/backend/internal/handler/threads_account/search_content_formula_posts_handler.go new file mode 100644 index 0000000..75139b6 --- /dev/null +++ b/backend/internal/handler/threads_account/search_content_formula_posts_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SearchContentFormulaPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SearchContentFormulaPostsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewSearchContentFormulaPostsLogic(r.Context(), svcCtx) + data, err := l.SearchContentFormulaPosts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go new file mode 100644 index 0000000..639f209 --- /dev/null +++ b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func StartPublishInventoryRefillJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPublishInventoryRefillJobHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewStartPublishInventoryRefillJobLogic(r.Context(), svcCtx) + data, err := l.StartPublishInventoryRefillJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/list_crm_contacts_handler.go b/backend/internal/handler/threads_account/start_threads_oauth_handler.go similarity index 57% rename from backend/internal/handler/crm/list_crm_contacts_handler.go rename to backend/internal/handler/threads_account/start_threads_oauth_handler.go index 6d74542..9c2c39a 100644 --- a/backend/internal/handler/crm/list_crm_contacts_handler.go +++ b/backend/internal/handler/threads_account/start_threads_oauth_handler.go @@ -1,9 +1,9 @@ -package crm +package threads_account import ( "net/http" - "haixun-backend/internal/logic/crm" + "haixun-backend/internal/logic/threads_account" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -11,15 +11,16 @@ import ( "github.com/zeromicro/go-zero/rest/httpx" ) -func ListCrmContactsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func StartThreadsOauthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.ListCrmContactsReq + var req types.StartThreadsOAuthQuery if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } - l := crm.NewListCrmContactsLogic(r.Context(), svcCtx) - data, err := l.ListCrmContacts(&req) + + l := threads_account.NewStartThreadsOAuthLogic(r.Context(), svcCtx) + data, err := l.StartThreadsOAuth(&req) response.Write(r.Context(), w, data, err) } } diff --git a/backend/internal/handler/threads_account/sync_mention_inbox_handler.go b/backend/internal/handler/threads_account/sync_mention_inbox_handler.go new file mode 100644 index 0000000..2978069 --- /dev/null +++ b/backend/internal/handler/threads_account/sync_mention_inbox_handler.go @@ -0,0 +1,28 @@ +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func SyncMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SyncMentionInboxHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := threads_account.NewSyncMentionInboxLogic(r.Context(), svcCtx) + data, err := l.SyncMentionInbox(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/threads_oauth_callback_handler.go b/backend/internal/handler/threads_account/threads_oauth_callback_handler.go new file mode 100644 index 0000000..d9d7d6d --- /dev/null +++ b/backend/internal/handler/threads_account/threads_oauth_callback_handler.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ThreadsOauthCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsOAuthCallbackQuery + if err := httpx.Parse(r, &req); err != nil { + http.Redirect(w, r, threads_account.OAuthFailureRedirect(svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), http.StatusFound) + return + } + + l := threads_account.NewThreadsOAuthCallbackLogic(r.Context(), svcCtx) + redirectURL, err := l.ThreadsOAuthCallback(&req) + if err != nil { + http.Redirect(w, r, threads_account.OAuthFailureRedirect(svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, redirectURL, http.StatusFound) + } +} diff --git a/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go new file mode 100644 index 0000000..95eeb81 --- /dev/null +++ b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpsertPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpsertPublishGuardPolicyHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewUpsertPublishGuardPolicyLogic(r.Context(), svcCtx) + data, err := l.UpsertPublishGuardPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go new file mode 100644 index 0000000..9e55ad4 --- /dev/null +++ b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpsertPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpsertPublishInventoryPolicyHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewUpsertPublishInventoryPolicyLogic(r.Context(), svcCtx) + data, err := l.UpsertPublishInventoryPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/library/copymission/knowledge.go b/backend/internal/library/copymission/knowledge.go index 80d79a3..d5cdbf1 100644 --- a/backend/internal/library/copymission/knowledge.go +++ b/backend/internal/library/copymission/knowledge.go @@ -385,4 +385,4 @@ func BuildResearchMapFromGraph(prev missionentity.ResearchMap, nodes []libkg.Nod SelectedKnowledgeItems: selectedItems, ResearchItems: ResearchItemsFromSources(sources), } -} \ No newline at end of file +} diff --git a/backend/internal/library/copymission/knowledge_test.go b/backend/internal/library/copymission/knowledge_test.go index b2f6b45..a7205df 100644 --- a/backend/internal/library/copymission/knowledge_test.go +++ b/backend/internal/library/copymission/knowledge_test.go @@ -118,4 +118,4 @@ func TestApplyDefaultNodeSelectionPreserving(t *testing.T) { if !nodes[1].SelectedForScan { t.Fatal("expected default selection on new pain node") } -} \ No newline at end of file +} diff --git a/backend/internal/library/copymission/matrix_lock.go b/backend/internal/library/copymission/matrix_lock.go index 0410f26..cb79cb9 100644 --- a/backend/internal/library/copymission/matrix_lock.go +++ b/backend/internal/library/copymission/matrix_lock.go @@ -52,4 +52,4 @@ return 0 _ = redis.Eval(ctx, script, []string{key}, holder).Err() }() return fn() -} \ No newline at end of file +} diff --git a/backend/internal/library/copymission/reference_urls.go b/backend/internal/library/copymission/reference_urls.go index 1f32578..f81ab01 100644 --- a/backend/internal/library/copymission/reference_urls.go +++ b/backend/internal/library/copymission/reference_urls.go @@ -63,4 +63,4 @@ func normalizeURLKey(raw string) string { return "" } return strings.ToLower(strings.TrimRight(u, "/")) -} \ No newline at end of file +} diff --git a/backend/internal/library/copymission/reference_urls_test.go b/backend/internal/library/copymission/reference_urls_test.go index 0a89518..8ca19f3 100644 --- a/backend/internal/library/copymission/reference_urls_test.go +++ b/backend/internal/library/copymission/reference_urls_test.go @@ -31,4 +31,4 @@ func TestReferenceURLsFromSelection(t *testing.T) { if len(urls) != 1 || urls[0] != "https://example.com/a" { t.Fatalf("urls = %v", urls) } -} \ No newline at end of file +} diff --git a/backend/internal/library/copymission/research_items.go b/backend/internal/library/copymission/research_items.go index 6aea779..9e726a7 100644 --- a/backend/internal/library/copymission/research_items.go +++ b/backend/internal/library/copymission/research_items.go @@ -76,4 +76,4 @@ func MergeReferenceURLs(selected []string, researchItems []ResearchSource) []str out = append(out, raw) } return out -} \ No newline at end of file +} diff --git a/backend/internal/library/copymission/research_items_test.go b/backend/internal/library/copymission/research_items_test.go index 99a57c4..8714b37 100644 --- a/backend/internal/library/copymission/research_items_test.go +++ b/backend/internal/library/copymission/research_items_test.go @@ -25,4 +25,4 @@ func TestFormatResearchItemsForMatrix(t *testing.T) { if !strings.Contains(block, "備孕指南") || !strings.Contains(block, "葉酸補充重點") { t.Fatalf("block = %q", block) } -} \ No newline at end of file +} diff --git a/backend/internal/library/copymission/research_sources.go b/backend/internal/library/copymission/research_sources.go index f43c633..ce5a8f2 100644 --- a/backend/internal/library/copymission/research_sources.go +++ b/backend/internal/library/copymission/research_sources.go @@ -16,4 +16,4 @@ func ResearchSourcesFromSummaries(items []missiondomain.ResearchItemSummary) []R }) } return out -} \ No newline at end of file +} diff --git a/backend/internal/library/copyvoice/human.go b/backend/internal/library/copyvoice/human.go new file mode 100644 index 0000000..ac3352b --- /dev/null +++ b/backend/internal/library/copyvoice/human.go @@ -0,0 +1,50 @@ +package copyvoice + +import "strings" + +// SystemRules are shared instructions for human-like Threads copy generation. +func SystemRules() string { + return strings.TrimSpace(`寫作目標:像真人在 Threads / Instagram 發文,不是在套模板。若有人設語言指紋,必須優先照它的說話習慣寫。 + +敘事(比結構重要): +- 依主題選內容型態:心情陪伴、知識整理、清單工具、互動提問、輕幽默共鳴等,不要每篇都同一套。 +- 可以寫具體場景,也可以先承接情緒或整理資訊;不要為了故事感硬編不存在的生活細節。 +- 開場、推進方式、收尾都要自然變化;禁止固定三段式、固定問句收尾、固定金句收束。 + +仿寫原則: +- 只學參考文的情緒力度、資訊密度與節奏,不學填空骨架或固定 hook 句型。 +- 禁止照抄參考句型,只換幾個名詞;要用人設語言重新說一次。 +- 篇幅、行數、標點、換行優先貼近人設語言指紋;沒有指紋時再參考原文篇幅。 +- 每一句都要推進情緒、資訊或理解;重複強調、無關鋪陳、廉價雞湯一律刪掉。 + +禁止 AI 腔與複製感: +- 不要:過度總結、硬寫強 hook、每篇都同一種 CTA、教科書定義、品牌口吻、客服腔。 +- 不要:為了像人設而硬塞人設標籤或興趣名詞。 +- 不要全域禁止某個口頭禪;若人設樣本常用「其實」「有時候」「後來」等自然轉折,可以使用,但不能每篇硬塞。`) +} + +// PersonaCalibrationNote reminds the model how to use persona blocks. +func PersonaCalibrationNote() string { + return "注意:人設定位決定誰在說,語言指紋決定怎麼說;請遵守段落節奏、標點換行、知識轉譯與 CTA 習慣,不要套固定模板。" +} + +// NarrativeLens picks a storytelling angle from a stable seed (e.g. scan post id). +func NarrativeLens(seed string) string { + lenses := []string{ + "從一個具體場景開始講,再帶出感受", + "先坦白一件小事或窘況,再自然轉到觀點", + "像跟朋友聊天一樣開場,語氣可以鬆一點", + "從某個瞬間的畫面切入,再講後來怎麼想", + } + if strings.TrimSpace(seed) == "" { + return lenses[0] + } + sum := 0 + for _, ch := range seed { + sum = sum*31 + int(ch) + } + if sum < 0 { + sum = -sum + } + return lenses[sum%len(lenses)] +} diff --git a/backend/internal/library/copyvoice/human_test.go b/backend/internal/library/copyvoice/human_test.go new file mode 100644 index 0000000..ee9a620 --- /dev/null +++ b/backend/internal/library/copyvoice/human_test.go @@ -0,0 +1,21 @@ +package copyvoice + +import "testing" + +func TestNarrativeLensStableForSeed(t *testing.T) { + a := NarrativeLens("scan-post-1") + b := NarrativeLens("scan-post-1") + if a == "" || a != b { + t.Fatalf("NarrativeLens() = %q, %q", a, b) + } +} + +func TestNarrativeLensDiffersAcrossSeeds(t *testing.T) { + seen := map[string]struct{}{} + for _, seed := range []string{"a", "b", "c", "d", "e", "f"} { + seen[NarrativeLens(seed)] = struct{}{} + } + if len(seen) < 2 { + t.Fatalf("expected varied lenses, got %d", len(seen)) + } +} diff --git a/backend/internal/library/embedding/client.go b/backend/internal/library/embedding/client.go index 097d8df..2754e4e 100644 --- a/backend/internal/library/embedding/client.go +++ b/backend/internal/library/embedding/client.go @@ -1,3 +1,6 @@ +// Package embedding provides an optional OpenAI embeddings client. +// It is intentionally not wired into patrol/graph flows; relevance uses +// offline lexical intent scoring in knowledge/semantic_fit.go instead. package embedding import ( diff --git a/backend/internal/library/formula/analyze_pasted.go b/backend/internal/library/formula/analyze_pasted.go new file mode 100644 index 0000000..af422e7 --- /dev/null +++ b/backend/internal/library/formula/analyze_pasted.go @@ -0,0 +1,163 @@ +package formula + +import ( + "context" + "fmt" + "regexp" + "strings" + + libllmjson "haixun-backend/internal/library/llmjson" + libownpost "haixun-backend/internal/library/ownpost" + domai "haixun-backend/internal/model/ai/domain/usecase" +) + +var pastedFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") + +type AnalyzePastedInput struct { + PostText string + AuthorName string + PersonaBlock string + LikeCount int + ReplyCount int +} + +type PastedAnalysis struct { + Summary string `json:"summary"` + Wins []string `json:"wins"` + Improvements []string `json:"improvements"` + Formula string `json:"formula"` + PostTemplate string `json:"post_template"` + HookPattern string `json:"hook_pattern"` + Structure string `json:"structure"` + ReplicationTips []string `json:"replication_tips"` + Avoid []string `json:"avoid"` +} + +func BuildAnalyzePastedPrompt(in AnalyzePastedInput) (system string, user string) { + personaBlock := strings.TrimSpace(in.PersonaBlock) + if personaBlock == "" { + personaBlock = "(未指定人設,請依貼文本身語氣分析)" + } + postText := strings.TrimSpace(in.PostText) + if postText == "" { + postText = "(貼文內容未提供)" + } + author := strings.TrimSpace(in.AuthorName) + if author == "" { + author = "匿名" + } + + system = strings.TrimSpace(` +你是 Threads / Instagram 貼文結構分析師。任務是拆解「外部貼文」哪些地方可借鏡,再說明如何用指定人設的語言指紋重新說一次;不要產生會讓文案機械的填空模板。 + +規則: +- 用台灣繁體中文。 +- formula:整理可借鏡的敘事/資訊推進方式,但要提醒不可照搬句型。 +- post_template:不要寫填空骨架;改寫成「此人設可採用的鬆結構與注意事項」。 +- replication_tips:重點放在如何換成人設語言,包括段落節奏、標點換行、知識轉譯與 CTA。 +- 只輸出一個 JSON 物件,不要 markdown,欄位: + summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid +- formula / structure / hook_pattern 必須是字串,不要用陣列或巢狀物件 +- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`) + + var b strings.Builder + b.WriteString("【人設脈絡】\n") + b.WriteString(personaBlock) + b.WriteString("\n\n【貼文】\n作者:") + b.WriteString(author) + if in.LikeCount > 0 || in.ReplyCount > 0 { + b.WriteString(fmt.Sprintf(" · %d 讚 · %d 留言", in.LikeCount, in.ReplyCount)) + } + b.WriteString("\n") + b.WriteString(postText) + b.WriteString("\n\n請分析爆款結構並輸出 JSON。") + user = b.String() + return system, user +} + +func AnalyzePasted(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in AnalyzePastedInput) (*PastedAnalysis, error) { + system, user := BuildAnalyzePastedPrompt(in) + temp := 0.65 + req.System = system + req.Messages = []domai.Message{{Role: "user", Content: user}} + req.Temperature = &temp + out, err := ai.GenerateText(ctx, req) + if err != nil { + return nil, err + } + return ParsePastedAnalysis(out.Text) +} + +func ParsePastedAnalysis(raw string) (*PastedAnalysis, error) { + payload, err := extractPastedJSON(raw) + if err != nil { + return nil, err + } + var flex map[string]any + if err := libllmjson.Unmarshal(payload, &flex); err != nil { + return nil, fmt.Errorf("parse pasted formula json: %w", err) + } + review := pastedAnalysisFromMap(flex) + if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" { + return nil, fmt.Errorf("AI 未回傳有效公式內容") + } + return review, nil +} + +func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis { + if review == nil { + return nil + } + return &PastedAnalysis{ + Summary: review.Summary, + Wins: review.Wins, + Improvements: review.Improvements, + Formula: review.Formula, + PostTemplate: review.PostTemplate, + HookPattern: review.HookPattern, + Structure: review.Structure, + ReplicationTips: review.ReplicationTips, + Avoid: review.Avoid, + } +} + +func FromViralAnalysis(hook, structure, emotion, strategy string, takeaways []string) *PastedAnalysis { + summary := strings.TrimSpace(emotion) + if summary == "" { + summary = "爆款結構分析" + } + formula := strings.TrimSpace(strategy) + if structure != "" { + if formula != "" { + formula = structure + " → " + formula + } else { + formula = structure + } + } + template := "" + if hook != "" && structure != "" { + template = "[" + hook + "] → [" + structure + "] → [你的觀點] → [互動收尾]" + } + return &PastedAnalysis{ + Summary: summary, + Wins: takeaways, + Formula: formula, + PostTemplate: template, + HookPattern: hook, + Structure: structure, + ReplicationTips: takeaways, + } +} + +func extractPastedJSON(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + if m := pastedFenceRE.FindStringSubmatch(raw); len(m) == 2 { + raw = strings.TrimSpace(m[1]) + } + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("formula output missing json object") + } + return []byte(raw[start : end+1]), nil +} diff --git a/backend/internal/library/formula/generate_from_formula.go b/backend/internal/library/formula/generate_from_formula.go new file mode 100644 index 0000000..200db66 --- /dev/null +++ b/backend/internal/library/formula/generate_from_formula.go @@ -0,0 +1,148 @@ +package formula + +import ( + "context" + "fmt" + "regexp" + "strings" + + "haixun-backend/internal/library/copyvoice" + libllmjson "haixun-backend/internal/library/llmjson" + "haixun-backend/internal/library/threadspost" + domai "haixun-backend/internal/model/ai/domain/usecase" + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" +) + +var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") + +type GenerateInput struct { + Topic string + Brief string + PersonaBlock string + ResearchNotes string + Formula cfdom.FormulaSummary +} + +type GeneratedDraft struct { + Text string `json:"text"` + TopicTag string `json:"topic_tag"` + Hook string `json:"hook"` + Angle string `json:"angle"` + Rationale string `json:"rationale"` + StructureNotes string `json:"structure_notes"` +} + +func BuildGenerateSystemPrompt() string { + return strings.TrimSpace(`你是 Threads / Instagram 貼文代筆。寫出符合人設語言指紋、可直接發布的完整新貼文,不是解釋公式,也不是填空模板。 + +` + copyvoice.SystemRules() + ` + +規則: +- 用台灣繁體中文,符合人設語氣。 +- 正文素材以【主題】、【補充】、【周邊知識參考】為準;人設不得改變題材。 +- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。 +- text 必須是一篇完整自然的 Threads 貼文,可直接複製發布。 +- 不可以出現「公式、模板、Hook、情境、轉折、觀點、收尾、[括號]、步驟、結構」等後台分析字眼。 +- 不可以輸出填空骨架,不可以照抄參考原文。 +- 有參考原文時:只學資訊密度、情緒力度與可用結構;最後仍要換成人設會講出口的話。 +- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。 +- topic_tag 請產生 1 個適合 Threads 主題標籤的短詞,不含 #,例如「AI工具」「內容創作」「職場」。 +- 只回傳 JSON:text, topic_tag, hook, angle, rationale, structure_notes(繁體中文)。`) +} + +func BuildGenerateUserPrompt(in GenerateInput) string { + var b strings.Builder + b.WriteString("【主題】\n") + b.WriteString(strings.TrimSpace(in.Topic)) + if brief := strings.TrimSpace(in.Brief); brief != "" { + b.WriteString("\n補充:") + b.WriteString(brief) + } + b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n") + b.WriteString(strings.TrimSpace(in.PersonaBlock)) + b.WriteString("\n") + b.WriteString(copyvoice.PersonaCalibrationNote()) + b.WriteString("\n\n【這篇爆文的故事感(只感受情緒與敘事,禁止照搬句型或填空)】\n") + b.WriteString(strings.TrimSpace(in.Formula.Formula)) + if structure := strings.TrimSpace(in.Formula.Structure); structure != "" { + b.WriteString("\n\n敘事推進參考(勿照搬句型):") + b.WriteString(structure) + } + if len(in.Formula.Avoid) > 0 { + b.WriteString("\n\n【避免】\n") + b.WriteString(strings.Join(in.Formula.Avoid, "\n")) + } + if notes := strings.TrimSpace(in.ResearchNotes); notes != "" { + b.WriteString("\n\n【周邊知識參考】\n") + b.WriteString(notes) + } + if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" { + if note := threadspost.MimicLengthGuidance(src); note != "" { + b.WriteString("\n\n") + b.WriteString(note) + } + b.WriteString("\n\n【參考原文(僅學結構與篇幅,勿抄內容)】\n") + b.WriteString(src) + } + b.WriteString("\n\n本次敘事切入:") + b.WriteString(copyvoice.NarrativeLens(in.Topic)) + b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。text 欄位只能放貼文正文,不要放分析、標題、公式、模板或填空欄位。topic_tag 請只放標籤文字,不要加 #。") + return b.String() +} + +func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) { + temp := 0.82 + req.System = BuildGenerateSystemPrompt() + req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}} + req.Temperature = &temp + out, err := ai.GenerateText(ctx, req) + if err != nil { + return nil, err + } + return ParseGeneratedDraft(out.Text) +} + +func extractGeneratedDraftJSON(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + if m := generatedDraftFenceRE.FindStringSubmatch(raw); len(m) == 2 { + raw = strings.TrimSpace(m[1]) + } + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("generate output missing json object") + } + return []byte(raw[start : end+1]), nil +} + +func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) { + payload, err := extractGeneratedDraftJSON(raw) + if err != nil { + return nil, err + } + var draft GeneratedDraft + if err := libllmjson.Unmarshal(payload, &draft); err != nil { + return nil, fmt.Errorf("parse generated draft: %w", err) + } + draft.Text = threadspost.FormatDraftText(draft.Text) + draft.TopicTag = normalizeTopicTag(draft.TopicTag) + if draft.Text == "" { + return nil, fmt.Errorf("generated draft missing text") + } + badTokens := []string{"[", "]", "【", "】", "Hook", "公式", "模板", "情境→", "轉折→", "觀點→", "收尾", "步驟"} + for _, token := range badTokens { + if strings.Contains(draft.Text, token) { + return nil, fmt.Errorf("generated draft looks like a template, not a publishable post") + } + } + return &draft, nil +} + +func normalizeTopicTag(value string) string { + value = strings.TrimSpace(strings.TrimPrefix(value, "#")) + if len([]rune(value)) > 40 { + runes := []rune(value) + value = string(runes[:40]) + } + return value +} diff --git a/backend/internal/library/formula/generate_from_formula_test.go b/backend/internal/library/formula/generate_from_formula_test.go new file mode 100644 index 0000000..94f0745 --- /dev/null +++ b/backend/internal/library/formula/generate_from_formula_test.go @@ -0,0 +1,14 @@ +package formula + +import "testing" + +func TestParseGeneratedDraftWithCodeFence(t *testing.T) { + raw := "```json\n{\"text\":\"第一句\\n第二句\",\"topic_tag\":\"AI工具\",\"hook\":\"\",\"angle\":\"\",\"rationale\":\"\",\"structure_notes\":\"\"}\n```" + got, err := ParseGeneratedDraft(raw) + if err != nil { + t.Fatalf("ParseGeneratedDraft() err = %v", err) + } + if got.Text == "" { + t.Fatal("expected text") + } +} diff --git a/backend/internal/library/formula/jsonflex.go b/backend/internal/library/formula/jsonflex.go new file mode 100644 index 0000000..0da094c --- /dev/null +++ b/backend/internal/library/formula/jsonflex.go @@ -0,0 +1,95 @@ +package formula + +import ( + "encoding/json" + "fmt" + "strings" +) + +func coerceString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case json.Number: + return strings.TrimSpace(v.String()) + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return fmt.Sprintf("%v", v) + case bool: + if v { + return "true" + } + return "false" + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + if s := coerceString(item); s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, " → ") + case map[string]any: + if text, ok := v["text"].(string); ok { + return strings.TrimSpace(text) + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func coerceStringSlice(value any) []string { + switch v := value.(type) { + case nil: + return nil + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil + } + return []string{s} + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s := coerceString(item); s != "" { + out = append(out, s) + } + } + return out + case []string: + out := make([]string, 0, len(v)) + for _, item := range v { + if s := strings.TrimSpace(item); s != "" { + out = append(out, s) + } + } + return out + default: + if s := coerceString(v); s != "" { + return []string{s} + } + return nil + } +} + +func pastedAnalysisFromMap(raw map[string]any) *PastedAnalysis { + return &PastedAnalysis{ + Summary: coerceString(raw["summary"]), + Wins: coerceStringSlice(raw["wins"]), + Improvements: coerceStringSlice(raw["improvements"]), + Formula: coerceString(raw["formula"]), + PostTemplate: coerceString(raw["post_template"]), + HookPattern: coerceString(raw["hook_pattern"]), + Structure: coerceString(raw["structure"]), + ReplicationTips: coerceStringSlice(raw["replication_tips"]), + Avoid: coerceStringSlice(raw["avoid"]), + } +} diff --git a/backend/internal/library/formula/jsonflex_test.go b/backend/internal/library/formula/jsonflex_test.go new file mode 100644 index 0000000..f34169e --- /dev/null +++ b/backend/internal/library/formula/jsonflex_test.go @@ -0,0 +1,22 @@ +package formula + +import "testing" + +func TestCoerceStringFromArray(t *testing.T) { + got := coerceString([]any{"開場", "故事", "收尾"}) + want := "開場 → 故事 → 收尾" + if got != want { + t.Fatalf("coerceString() = %q, want %q", got, want) + } +} + +func TestParsePastedAnalysisFlexibleStructure(t *testing.T) { + raw := `{"summary":"測試","formula":"步驟","structure":["Hook","轉折","觀點"],"wins":["節奏好"]}` + got, err := ParsePastedAnalysis(raw) + if err != nil { + t.Fatalf("ParsePastedAnalysis() err = %v", err) + } + if got.Structure != "Hook → 轉折 → 觀點" { + t.Fatalf("Structure = %q", got.Structure) + } +} diff --git a/backend/internal/library/knowledge/graph.go b/backend/internal/library/knowledge/graph.go index bacb474..3a220e7 100644 --- a/backend/internal/library/knowledge/graph.go +++ b/backend/internal/library/knowledge/graph.go @@ -80,6 +80,32 @@ func TotalTagCandidates(nodes []Node) int { return count } +// PreserveNodeSelectionByLabel reapplies user scan selections after graph regen changes node IDs. +func PreserveNodeSelectionByLabel(nodes []Node, previous []Node) { + if len(nodes) == 0 || len(previous) == 0 { + return + } + selected := map[string]struct{}{} + for _, node := range previous { + if !node.SelectedForScan { + continue + } + label := strings.ToLower(strings.TrimSpace(node.Label)) + if label != "" { + selected[label] = struct{}{} + } + } + if len(selected) == 0 { + return + } + for i := range nodes { + label := strings.ToLower(strings.TrimSpace(nodes[i].Label)) + if _, ok := selected[label]; ok { + nodes[i].SelectedForScan = true + } + } +} + func NodeByID(nodes []Node, id string) (Node, bool) { id = strings.TrimSpace(id) for _, node := range nodes { diff --git a/backend/internal/library/knowledge/graph_rescore.go b/backend/internal/library/knowledge/graph_rescore.go new file mode 100644 index 0000000..ee00c6c --- /dev/null +++ b/backend/internal/library/knowledge/graph_rescore.go @@ -0,0 +1,110 @@ +package knowledge + +// RescoreGraphIntentFit recomputes node productFitScore using: +// 1. weighted intent similarity (product + research map) +// 2. graph proximity propagation from high-fit core nodes +// 3. tangential-topic penalty +func RescoreGraphIntentFit(graph *Graph, in PatrolTagInput) { + if graph == nil || len(graph.Nodes) == 0 { + return + } + profile := BuildIntentProfile(in) + intentScores := make([]int, len(graph.Nodes)) + for i, node := range graph.Nodes { + intentScores[i] = ScoreIntentSimilarity(NodeIntentText(node), profile) + } + propagated := propagateIntentAlongGraph(graph.Nodes, graph.Edges, intentScores) + + for i := range graph.Nodes { + node := &graph.Nodes[i] + semantic := intentScores[i] + if propagated[i] > semantic { + semantic = propagated[i] + } + llmScore := node.ProductFitScore + if llmScore <= 0 { + llmScore = defaultProductFit(node.NodeKind, node.Layer) + } + + layerBlend := 0.5 + float64(minInt(node.Layer, 3))*0.08 + blended := int(float64(semantic)*layerBlend + float64(llmScore)*(1.0-layerBlend)) + if IsTangentialToIntent(NodeIntentText(*node), profile) { + blended = minInt(blended, 28) + } + if blended < 0 { + blended = 0 + } + if blended > 100 { + blended = 100 + } + node.ProductFitScore = blended + } +} + +func propagateIntentAlongGraph(nodes []Node, edges []Edge, intentScores []int) []int { + if len(nodes) == 0 { + return nil + } + idIndex := map[string]int{} + for i, node := range nodes { + idIndex[node.ID] = i + } + adj := make([][]int, len(nodes)) + addEdge := func(from, to string) { + fi, okFrom := idIndex[from] + ti, okTo := idIndex[to] + if !okFrom || !okTo || fi == ti { + return + } + adj[fi] = append(adj[fi], ti) + adj[ti] = append(adj[ti], fi) + } + for _, edge := range edges { + addEdge(edge.From, edge.To) + } + + out := make([]int, len(nodes)) + copy(out, intentScores) + visited := make([]bool, len(nodes)) + type queueItem struct { + idx int + dist int + } + queue := make([]queueItem, 0, len(nodes)) + for i, score := range intentScores { + if score >= 55 || nodes[i].Layer == 0 { + queue = append(queue, queueItem{idx: i, dist: 0}) + visited[i] = true + } + } + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + if item.dist >= 3 { + continue + } + base := intentScores[item.idx] + if base <= 0 { + base = out[item.idx] + } + decayed := int(float64(base) * powDecay(0.74, item.dist+1)) + for _, next := range adj[item.idx] { + if decayed > out[next] { + out[next] = decayed + } + if !visited[next] { + visited[next] = true + queue = append(queue, queueItem{idx: next, dist: item.dist + 1}) + } + } + } + return out +} + +func powDecay(base float64, exp int) float64 { + out := 1.0 + for i := 0; i < exp; i++ { + out *= base + } + return out +} diff --git a/backend/internal/library/knowledge/graph_selection_test.go b/backend/internal/library/knowledge/graph_selection_test.go new file mode 100644 index 0000000..f80a884 --- /dev/null +++ b/backend/internal/library/knowledge/graph_selection_test.go @@ -0,0 +1,21 @@ +package knowledge + +import "testing" + +func TestPreserveNodeSelectionByLabel(t *testing.T) { + previous := []Node{ + {ID: "old-1", Label: "化療後 皮膚乾", SelectedForScan: true}, + {ID: "old-2", Label: "無關節點", SelectedForScan: false}, + } + nodes := []Node{ + {ID: "new-1", Label: "化療後 皮膚乾"}, + {ID: "new-2", Label: "新痛點"}, + } + PreserveNodeSelectionByLabel(nodes, previous) + if !nodes[0].SelectedForScan { + t.Fatal("expected first node selection to be preserved by label") + } + if nodes[1].SelectedForScan { + t.Fatal("expected unrelated node to stay unselected") + } +} diff --git a/backend/internal/library/knowledge/patrol_input.go b/backend/internal/library/knowledge/patrol_input.go index 5acafaf..5da4c30 100644 --- a/backend/internal/library/knowledge/patrol_input.go +++ b/backend/internal/library/knowledge/patrol_input.go @@ -13,6 +13,7 @@ type PatrolTagInput struct { ProductName string ProductFeatures string AudienceSummary string + TargetAudience string MatchTags []string Questions []string Pillars []string @@ -65,6 +66,7 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin ProductName: productName, ProductFeatures: features, AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary), + TargetAudience: strings.TrimSpace(brand.TargetAudience), MatchTags: matchTagsFromBrand(brand), Questions: append([]string{}, brand.ResearchMap.Questions...), Pillars: append([]string{}, brand.ResearchMap.Pillars...), @@ -72,6 +74,23 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin } } +// OverlayResearchMap copies topic-level research map fields onto patrol input. +func OverlayResearchMap(in PatrolTagInput, audienceSummary string, questions, pillars, patrolKeywords []string) PatrolTagInput { + if strings.TrimSpace(audienceSummary) != "" { + in.AudienceSummary = strings.TrimSpace(audienceSummary) + } + if len(questions) > 0 { + in.Questions = append([]string{}, questions...) + } + if len(pillars) > 0 { + in.Pillars = append([]string{}, pillars...) + } + if len(patrolKeywords) > 0 { + in.PatrolKeywords = append([]string{}, patrolKeywords...) + } + return in +} + func productLabelFromBrand(brand *branddomain.BrandSummary) string { if brand == nil { return "" @@ -134,9 +153,33 @@ func (in PatrolTagInput) HasProductContext() bool { len(in.MatchTags) > 0 } +var patrolCoreValueTokens = []string{ + "敏感", "無香", "抗敏", "低敏", "香精", "香料", "香味", "過敏", "皮膚", + "化療", "癌症", "乳癌", "荷爾蒙", "標靶", "病友", +} + +func primaryPatrolValueAnchor(in PatrolTagInput) string { + blob := strings.ToLower(strings.TrimSpace(in.ProductName + " " + in.ProductFeatures + " " + + strings.Join(in.MatchTags, " ") + " " + strings.Join(in.Pillars, " ") + " " + + in.AudienceSummary + " " + in.TargetAudience)) + priority := []string{"無香", "抗敏", "敏感", "化療", "癌症", "乳癌", "荷爾蒙", "過敏", "香味", "香精"} + for _, token := range priority { + if strings.Contains(blob, token) { + return token + } + } + for _, token := range patrolCoreValueTokens { + if strings.Contains(blob, token) { + return token + } + } + return "" +} + func productCategoryHint(productName, features string) string { combined := productName + " " + features for _, hint := range []string{ + "洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤", "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華", "防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔", } { diff --git a/backend/internal/library/knowledge/patrol_phrase.go b/backend/internal/library/knowledge/patrol_phrase.go index b4f7308..05c4927 100644 --- a/backend/internal/library/knowledge/patrol_phrase.go +++ b/backend/internal/library/knowledge/patrol_phrase.go @@ -162,6 +162,48 @@ func splitPatrolChunks(text string) []string { return []string{head, tail} } +// AnchorPatrolTagToProduct injects product category tokens when a tag lacks product context. +func AnchorPatrolTagToProduct(tag string, in PatrolTagInput) string { + tag = strings.TrimSpace(tag) + if tag == "" { + return "" + } + hint := productCategoryHint(in.ProductName, in.ProductFeatures) + if hint == "" { + for _, matchTag := range in.MatchTags { + if h := productCategoryHint(matchTag, ""); h != "" { + hint = h + break + } + } + } + if hint == "" { + return tag + } + lowerTag := strings.ToLower(tag) + if strings.Contains(lowerTag, strings.ToLower(hint)) { + return tag + } + for _, matchTag := range in.MatchTags { + matchTag = strings.TrimSpace(strings.ToLower(matchTag)) + if len([]rune(matchTag)) >= 2 && strings.Contains(lowerTag, matchTag) { + return tag + } + } + anchor := hint + if valueAnchor := primaryPatrolValueAnchor(in); valueAnchor != "" { + anchor = strings.TrimSpace(valueAnchor + " " + hint) + } + anchored := strings.TrimSpace(anchor + " " + tag) + if utf8.RuneCountInString(anchored) > maxPatrolTagRunes { + anchored = truncateRunes(anchor+" "+compressPatrolKeywords(tag), maxPatrolTagRunes) + } + if utf8.RuneCountInString(anchored) < minPatrolTagRunes { + return tag + } + return anchored +} + func ensurePatrolIntent(phrase, intent string) string { phrase = strings.TrimSpace(phrase) if phrase == "" { diff --git a/backend/internal/library/knowledge/patrol_phrase_test.go b/backend/internal/library/knowledge/patrol_phrase_test.go index 9f942d2..e7bf3a1 100644 --- a/backend/internal/library/knowledge/patrol_phrase_test.go +++ b/backend/internal/library/knowledge/patrol_phrase_test.go @@ -1,6 +1,9 @@ package knowledge -import "testing" +import ( + "strings" + "testing" +) func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) { got := PatrolTagFromQuestion("化療後皮膚敏感要換什麼沐浴乳") @@ -9,6 +12,35 @@ func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) { } } +func TestAnchorPatrolTagToProductInjectsCategory(t *testing.T) { + in := PatrolTagInput{ + ProductName: "天然洗衣精", + MatchTags: []string{"洗衣精", "衣物清潔"}, + } + got := AnchorPatrolTagToProduct("敏感肌 推薦", in) + if got == "敏感肌 推薦" { + t.Fatal("expected product category to be injected into generic tag") + } + if !strings.Contains(got, "洗衣") { + t.Fatalf("expected laundry anchor in %q", got) + } +} + +func TestAnchorPatrolTagToProductPrefersValueAnchor(t *testing.T) { + in := PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏、適合化療族群", + MatchTags: []string{"無香", "洗衣精"}, + } + got := AnchorPatrolTagToProduct("皮膚癢 推薦", in) + if !strings.Contains(got, "無香") { + t.Fatalf("expected value anchor in %q", got) + } + if !strings.Contains(got, "洗衣精") { + t.Fatalf("expected product form in %q", got) + } +} + func TestPatrolTagFromQuestionCompressesGenericLabel(t *testing.T) { got := PatrolTagFromQuestion("敏感肌保養") if got == "敏感肌保養" { diff --git a/backend/internal/library/knowledge/patrol_rank.go b/backend/internal/library/knowledge/patrol_rank.go index 9a8dc1e..7937570 100644 --- a/backend/internal/library/knowledge/patrol_rank.go +++ b/backend/internal/library/knowledge/patrol_rank.go @@ -5,7 +5,27 @@ import ( "strings" ) -const MaxTopPatrolTags = 8 +const MaxTopPatrolTags = 10 +const MaxScanPatrolKeywords = 10 + +// SelectedPatrolNodes returns nodes flagged for scan. +func SelectedPatrolNodes(nodes []Node) []Node { + out := make([]Node, 0, len(nodes)) + for _, node := range nodes { + if node.SelectedForScan { + out = append(out, node) + } + } + return out +} + +// NodesForPatrolKeywordDerivation prefers user-selected graph nodes when present. +func NodesForPatrolKeywordDerivation(nodes []Node) []Node { + if selected := SelectedPatrolNodes(nodes); len(selected) > 0 { + return selected + } + return nodes +} type PatrolTagCandidate struct { Tag string @@ -24,19 +44,20 @@ func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance) } for i, q := range in.Questions { - addPatrolCandidate(&out, PatrolTagFromQuestion(q), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance) + addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance) } for i, pillar := range in.Pillars { - addPatrolCandidate(&out, PatrolTagFromPillar(pillar), scorePillar(i)+6, "內容支柱", patrolIntentRelevance) + addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+6, "內容支柱", patrolIntentRelevance) } for i, matchTag := range in.MatchTags { - addPatrolCandidate(&out, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) + addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) } if in.ProductName != "" { - addPatrolCandidate(&out, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance) + addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance) } - for _, node := range nodes { - if !IsPainNode(node) && node.Layer > 1 { + nodesToUse := NodesForPatrolKeywordDerivation(nodes) + for _, node := range nodesToUse { + if len(SelectedPatrolNodes(nodes)) == 0 && !IsPainNode(node) && node.Layer > 1 { continue } nodeScore := scoreNode(node) @@ -216,7 +237,7 @@ func scoreNode(node Node) int { base += 8 } if node.ProductFitScore > 0 { - base += node.ProductFitScore / 6 + base += node.ProductFitScore / 5 } return base } diff --git a/backend/internal/library/knowledge/patrol_rank_test.go b/backend/internal/library/knowledge/patrol_rank_test.go index 11533be..adfa421 100644 --- a/backend/internal/library/knowledge/patrol_rank_test.go +++ b/backend/internal/library/knowledge/patrol_rank_test.go @@ -13,9 +13,14 @@ func TestSelectTopPatrolTagsLimitsAndDedupes(t *testing.T) { {Tag: "病友 沐浴乳", Score: 70, Reason: "周邊情境", Intent: patrolIntentRelevance}, {Tag: "抗敏 沐浴乳", Score: 65, Reason: "置入產品", Intent: patrolIntentRelevance}, {Tag: "香精 過敏", Score: 60, Reason: "周邊情境", Intent: patrolIntentRelevance}, + {Tag: "標靶 治療 沐浴", Score: 58, Reason: "周邊情境", Intent: patrolIntentRelevance}, + {Tag: "無香 沐浴乳 請問", Score: 56, Reason: "受眾提問", Intent: patrolIntentRelevance}, }, MaxTopPatrolTags) - if len(tags) != MaxTopPatrolTags { - t.Fatalf("expected %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags) + if len(tags) > MaxTopPatrolTags { + t.Fatalf("expected at most %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags) + } + if len(tags) < 8 { + t.Fatalf("expected at least 8 deduped tags, got %d: %v", len(tags), tags) } if tags[0] != "化療 沐浴乳" { t.Fatalf("expected highest score first, got %q", tags[0]) diff --git a/backend/internal/library/knowledge/patrol_resolve.go b/backend/internal/library/knowledge/patrol_resolve.go index 61b11a3..bdac43d 100644 --- a/backend/internal/library/knowledge/patrol_resolve.go +++ b/backend/internal/library/knowledge/patrol_resolve.go @@ -1,13 +1,16 @@ package knowledge -// ResolveScanPatrolKeywords picks keywords for placement-scan in UI/API order: -// explicit request > saved research map > auto-ranked from map + graph nodes. +// ResolveScanPatrolKeywords picks the most relevant search phrases for the Search API. +// Research map and graph nodes are inputs; UI display order is not preserved. func ResolveScanPatrolKeywords(explicit, saved []string, input PatrolTagInput, nodes []Node) []string { - if keywords := NormalizePatrolKeywordList(explicit); len(keywords) > 0 { - return keywords - } - if keywords := NormalizePatrolKeywordList(saved); len(keywords) > 0 { - return keywords - } - return NormalizePatrolKeywordList(CollectPatrolTagsFromGraph(input, nodes)) + nodes = NodesForPatrolKeywordDerivation(nodes) + return SelectBestSearchKeywords(explicit, saved, input, nodes, MaxScanPatrolKeywords) +} + +func capPatrolKeywordList(keywords []string, limit int) []string { + keywords = NormalizePatrolKeywordList(keywords) + if limit <= 0 || len(keywords) <= limit { + return keywords + } + return keywords[:limit] } diff --git a/backend/internal/library/knowledge/patrol_resolve_test.go b/backend/internal/library/knowledge/patrol_resolve_test.go index 349b458..05af32f 100644 --- a/backend/internal/library/knowledge/patrol_resolve_test.go +++ b/backend/internal/library/knowledge/patrol_resolve_test.go @@ -2,15 +2,25 @@ package knowledge import "testing" -func TestResolveScanPatrolKeywordsPrefersExplicit(t *testing.T) { +func TestResolveScanPatrolKeywordsIncludesExplicitHint(t *testing.T) { got := ResolveScanPatrolKeywords( []string{"手動 關鍵字"}, []string{"已儲存"}, PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}}, nil, ) - if len(got) != 1 || got[0] != "手動 關鍵字" { - t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit keyword", got) + if len(got) == 0 { + t.Fatal("expected ranked keywords") + } + found := false + for _, kw := range got { + if kw == "手動 關鍵字" { + found = true + break + } + } + if !found { + t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit hint included", got) } } @@ -22,3 +32,29 @@ func TestResolveScanPatrolKeywordsFromResearchMapWithoutGraph(t *testing.T) { t.Fatal("expected research map questions to produce patrol keywords without graph nodes") } } + +func TestResolveScanPatrolKeywordsPrefersSelectedNodes(t *testing.T) { + nodes := []Node{ + {ID: "pain", Label: "化療後 皮膚乾", SelectedForScan: true, ProductFitScore: 88, NodeKind: "pain", DerivedTags: DerivedTags{Relevance: []string{"化療後 皮膚乾 沐浴乳"}}}, + {ID: "skip", Label: "無關節點", SelectedForScan: false, ProductFitScore: 90, DerivedTags: DerivedTags{Relevance: []string{"不該出現"}}}, + } + got := ResolveScanPatrolKeywords(nil, nil, PatrolTagInput{}, nodes) + if len(got) == 0 { + t.Fatal("expected selected node to produce patrol keywords") + } + found := false + for _, kw := range got { + if kw == "化療後 皮膚乾 沐浴乳" || kw == "化療後 皮膚乾" { + found = true + break + } + } + if !found { + t.Fatalf("expected selected node keyword, got %#v", got) + } + for _, kw := range got { + if kw == "不該出現" { + t.Fatalf("unselected node leaked into keywords: %#v", got) + } + } +} diff --git a/backend/internal/library/knowledge/patrol_search.go b/backend/internal/library/knowledge/patrol_search.go new file mode 100644 index 0000000..aa08671 --- /dev/null +++ b/backend/internal/library/knowledge/patrol_search.go @@ -0,0 +1,82 @@ +package knowledge + +import ( + "sort" + "strings" +) + +// SelectBestSearchKeywords pools research map, graph, product, and optional hints, +// then returns the most intent-relevant phrases for Search API (not UI display order). +func SelectBestSearchKeywords(explicit, saved []string, in PatrolTagInput, nodes []Node, limit int) []string { + if limit <= 0 { + limit = MaxScanPatrolKeywords + } + profile := BuildIntentProfile(in) + candidates := BuildPatrolCandidates(in, nodes) + + for i, tag := range NormalizePatrolKeywordList(explicit) { + addPatrolCandidate(&candidates, tag, 156-i, "掃描指定", patrolIntentRelevance) + } + for i, tag := range NormalizePatrolKeywordList(saved) { + if containsNormalizedTag(explicit, tag) { + continue + } + addPatrolCandidate(&candidates, tag, 88-i, "研究地圖儲存", patrolIntentRelevance) + } + + type ranked struct { + tag string + score int + } + scored := make([]ranked, 0, len(candidates)) + seen := map[string]struct{}{} + for _, item := range selectTopPatrolCandidates(candidates, len(candidates)+32) { + tag := strings.TrimSpace(item.Tag) + if tag == "" { + continue + } + key := patrolTagDedupeKey(tag) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if IsTangentialToIntent(tag, profile) { + continue + } + intent := ScoreIntentSimilarity(tag, profile) + if intent < 8 && item.Score < 65 { + continue + } + final := (item.Score*2 + intent*4) / 6 + if final <= 0 { + continue + } + scored = append(scored, ranked{tag: tag, score: final}) + } + + sort.SliceStable(scored, func(i, j int) bool { + if scored[i].score == scored[j].score { + return scored[i].tag < scored[j].tag + } + return scored[i].score > scored[j].score + }) + + out := make([]string, 0, limit) + for _, item := range scored { + out = append(out, item.tag) + if len(out) >= limit { + break + } + } + return out +} + +func containsNormalizedTag(items []string, target string) bool { + targetKey := patrolTagDedupeKey(target) + for _, item := range items { + if patrolTagDedupeKey(item) == targetKey { + return true + } + } + return false +} diff --git a/backend/internal/library/knowledge/patrol_search_test.go b/backend/internal/library/knowledge/patrol_search_test.go new file mode 100644 index 0000000..c5738ae --- /dev/null +++ b/backend/internal/library/knowledge/patrol_search_test.go @@ -0,0 +1,58 @@ +package knowledge + +import ( + "strings" + "testing" +) + +func TestSelectBestSearchKeywordsRanksByIntentNotSavedOrder(t *testing.T) { + in := PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏、適合化療族群", + MatchTags: []string{"無香", "抗敏", "洗衣精"}, + Questions: []string{"化療後想找不會刺激的洗衣精"}, + Pillars: []string{"無香洗衣", "化療後衣物清潔"}, + } + got := SelectBestSearchKeywords( + nil, + []string{"洗衣機 推薦", "無香 洗衣精 請問"}, + in, + nil, + 4, + ) + if len(got) == 0 { + t.Fatal("expected ranked keywords") + } + for _, kw := range got { + if strings.Contains(kw, "洗衣機") { + t.Fatalf("expected weak saved keyword to lose ranking, got %#v", got) + } + } + found := false + for _, kw := range got { + if strings.Contains(kw, "無香") || strings.Contains(kw, "化療") { + found = true + break + } + } + if !found { + t.Fatalf("expected intent-aligned keyword in %#v", got) + } +} + +func TestSelectBestSearchKeywordsIncludesExplicitHint(t *testing.T) { + in := PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + MatchTags: []string{"無香", "洗衣精"}, + } + got := SelectBestSearchKeywords( + []string{"化療 無香 洗衣精"}, + []string{"洗衣機 推薦"}, + in, + nil, + 3, + ) + if len(got) == 0 || got[0] != "化療 無香 洗衣精" { + t.Fatalf("expected explicit hint to rank first, got %#v", got) + } +} diff --git a/backend/internal/library/knowledge/patrol_tags.go b/backend/internal/library/knowledge/patrol_tags.go index 08c2ea2..bee71c1 100644 --- a/backend/internal/library/knowledge/patrol_tags.go +++ b/backend/internal/library/knowledge/patrol_tags.go @@ -55,23 +55,23 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags { matches := isCoreNode(node) for i, q := range in.Questions { - if matches || nodeMatchesResearchText(node, q) { - addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance) - addPatrolCandidate(&candidates, patrolTagFromSource(q, patrolIntentRecency), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency) + if matches || nodeMatchesResearchText(node, q, in) { + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance) + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(q, patrolIntentRecency), in), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency) } } for i, pillar := range in.Pillars { - if matches || nodeMatchesResearchText(node, pillar) { - addPatrolCandidate(&candidates, PatrolTagFromPillar(pillar), scorePillar(i)+4, "內容支柱", patrolIntentRelevance) + if matches || nodeMatchesResearchText(node, pillar, in) { + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+4, "內容支柱", patrolIntentRelevance) } } for i, matchTag := range in.MatchTags { - if matches || nodeMatchesResearchText(node, matchTag) { - addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) + if matches || nodeMatchesResearchText(node, matchTag, in) { + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) } } - if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName)) { - addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance) + if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName, in)) { + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance) } if len(candidates) == 0 && IsPainNode(node) { @@ -79,16 +79,16 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags { if i >= 2 { break } - addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance) + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance) } } if len(candidates) == 0 && IsPainNode(node) { - addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance) + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance) for i, matchTag := range in.MatchTags { if i >= 1 { break } - addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) + addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) } } @@ -107,7 +107,7 @@ func isCoreNode(node Node) bool { return node.Layer == 0 || strings.EqualFold(strings.TrimSpace(node.Type), "core") } -func nodeMatchesResearchText(node Node, text string) bool { +func nodeMatchesResearchText(node Node, text string, in PatrolTagInput) bool { label := strings.TrimSpace(node.Label) text = strings.TrimSpace(text) if label == "" || text == "" { @@ -116,7 +116,12 @@ func nodeMatchesResearchText(node Node, text string) bool { if label == text || strings.Contains(text, label) || strings.Contains(label, text) { return true } - return sharesMeaningfulSubstring(label, text) + if sharesMeaningfulSubstring(label, text) { + return true + } + profile := BuildIntentProfile(in) + joined := strings.TrimSpace(NodeIntentText(node) + " " + text) + return ScoreIntentSimilarity(joined, profile) >= 40 } func deriveFallbackPatrolTags(node Node) DerivedTags { diff --git a/backend/internal/library/knowledge/research_map_fit.go b/backend/internal/library/knowledge/research_map_fit.go new file mode 100644 index 0000000..2ed550e --- /dev/null +++ b/backend/internal/library/knowledge/research_map_fit.go @@ -0,0 +1,59 @@ +package knowledge + +import ( + "sort" + "strings" +) + +// RankStringsByIntent sorts phrases by semantic proximity to product + research map. +func RankStringsByIntent(items []string, in PatrolTagInput) []string { + if len(items) <= 1 { + return append([]string{}, items...) + } + profile := BuildIntentProfile(in) + type scored struct { + text string + score int + } + scores := make([]scored, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + scores = append(scores, scored{ + text: item, + score: ScoreIntentSimilarity(item, profile), + }) + } + sort.SliceStable(scores, func(i, j int) bool { + if scores[i].score == scores[j].score { + return scores[i].text < scores[j].text + } + return scores[i].score > scores[j].score + }) + out := make([]string, 0, len(scores)) + for _, item := range scores { + out = append(out, item.text) + } + return out +} + +// FilterPatrolKeywordsByIntent drops low-intent patrol keywords from research map output. +func FilterPatrolKeywordsByIntent(items []string, in PatrolTagInput, minScore int) []string { + if minScore <= 0 { + return RankStringsByIntent(items, in) + } + profile := BuildIntentProfile(in) + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if ScoreIntentSimilarity(item, profile) >= minScore { + out = append(out, item) + } + } + return out +} diff --git a/backend/internal/library/knowledge/search_keyword_shape.go b/backend/internal/library/knowledge/search_keyword_shape.go new file mode 100644 index 0000000..2a617a4 --- /dev/null +++ b/backend/internal/library/knowledge/search_keyword_shape.go @@ -0,0 +1,122 @@ +package knowledge + +import ( + "strings" + "unicode/utf8" +) + +const ( + maxThreadsSearchRunes = 14 + maxWebRecencyCoreRunes = 12 + maxWebRelevanceTagRunes = 16 +) + +var searchIntentMarkers = []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助", "請益", "有推薦"} + +// ShortPatrolSearchCore compresses a patrol tag for Threads API / recency Search API. +// Keeps pain + product tokens; drops filler and redundant intent words (RECENT/TOP handles freshness). +func ShortPatrolSearchCore(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" { + return "" + } + tokens := selectSearchTokens(tag, 3) + if len(tokens) == 0 { + return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes) + } + core := strings.Join(tokens, " ") + return truncateRunes(core, maxWebRecencyCoreRunes) +} + +// ThreadsAPIKeyword is the plain keyword sent to official Threads keyword search. +func ThreadsAPIKeyword(tag string) string { + core := ShortPatrolSearchCore(tag) + if core != "" { + return core + } + return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes) +} + +// FullPatrolWebSearchTag keeps a slightly longer phrase for relevance Search API (exact-match quotes). +func FullPatrolWebSearchTag(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" { + return "" + } + if !hasSearchIntentMarker(tag) { + candidate := strings.TrimSpace(tag + " 推薦") + if utf8.RuneCountInString(candidate) <= maxWebRelevanceTagRunes { + tag = candidate + } + } + if utf8.RuneCountInString(tag) <= maxWebRelevanceTagRunes { + return tag + } + tokens := selectSearchTokens(tag, 4) + if len(tokens) == 0 { + return truncateRunes(tag, maxWebRelevanceTagRunes) + } + full := strings.Join(tokens, " ") + if !hasSearchIntentMarker(full) { + withIntent := strings.TrimSpace(full + " 推薦") + if utf8.RuneCountInString(withIntent) <= maxWebRelevanceTagRunes { + return withIntent + } + } + return truncateRunes(full, maxWebRelevanceTagRunes) +} + +func selectSearchTokens(tag string, maxTokens int) []string { + tag = stripSearchIntent(tag) + parts := []string{} + seen := map[string]struct{}{} + add := func(token string) { + token = strings.TrimSpace(token) + if isPatrolFiller(token) || len([]rune(token)) < 2 { + return + } + if _, ok := seen[token]; ok { + return + } + seen[token] = struct{}{} + parts = append(parts, token) + } + + for _, anchor := range patrolTopicAnchors { + if strings.Contains(tag, anchor) { + add(anchor) + } + } + for _, hint := range productFormHints { + if strings.Contains(tag, hint) { + add(hint) + } + } + for _, token := range intentTokenize(tag) { + add(token) + if len(parts) >= maxTokens { + break + } + } + if len(parts) > maxTokens { + parts = parts[:maxTokens] + } + return parts +} + +func stripSearchIntent(tag string) string { + tag = strings.TrimSpace(tag) + for _, marker := range searchIntentMarkers { + tag = strings.ReplaceAll(tag, marker, " ") + } + return strings.Join(strings.Fields(tag), " ") +} + +func hasSearchIntentMarker(tag string) bool { + for _, marker := range searchIntentMarkers { + if strings.Contains(tag, marker) { + return true + } + } + return false +} diff --git a/backend/internal/library/knowledge/search_keyword_shape_test.go b/backend/internal/library/knowledge/search_keyword_shape_test.go new file mode 100644 index 0000000..f3b4bde --- /dev/null +++ b/backend/internal/library/knowledge/search_keyword_shape_test.go @@ -0,0 +1,37 @@ +package knowledge + +import ( + "strings" + "testing" +) + +func TestShortPatrolSearchCoreDropsIntentNoise(t *testing.T) { + got := ShortPatrolSearchCore("化療 無香 洗衣精 推薦 請問") + if got == "" { + t.Fatal("expected non-empty core") + } + if len([]rune(got)) > maxThreadsSearchRunes { + t.Fatalf("core too long: %q", got) + } + if strings.Contains(got, "推薦") || strings.Contains(got, "請問") { + t.Fatalf("expected intent words stripped for API core, got %q", got) + } + if !strings.Contains(got, "無香") && !strings.Contains(got, "洗衣精") { + t.Fatalf("expected pain/product tokens kept, got %q", got) + } +} + +func TestFullPatrolWebSearchTagKeepsIntent(t *testing.T) { + got := FullPatrolWebSearchTag("化療 無香 洗衣精") + if !strings.Contains(got, "推薦") && !strings.Contains(got, "請問") && !strings.Contains(got, "怎麼辦") { + t.Fatalf("expected relevance tag to include intent marker, got %q", got) + } +} + +func TestThreadsAPIKeywordUsesShortCore(t *testing.T) { + long := "化療後皮膚敏感要換什麼無香洗衣精 推薦" + got := ThreadsAPIKeyword(long) + if got == "" || len([]rune(got)) > maxThreadsSearchRunes { + t.Fatalf("unexpected threads keyword %q", got) + } +} diff --git a/backend/internal/library/knowledge/semantic_fit.go b/backend/internal/library/knowledge/semantic_fit.go new file mode 100644 index 0000000..b430d0b --- /dev/null +++ b/backend/internal/library/knowledge/semantic_fit.go @@ -0,0 +1,289 @@ +package knowledge + +import ( + "strings" + "unicode" +) + +// IntentProfile is a weighted lexical intent model for product + research map. +// It generalizes relevance beyond hardcoded category rules (e.g. 洗衣精 vs 洗衣機). +type IntentProfile struct { + TokenWeights map[string]float64 + BroadTokens map[string]struct{} + Phrases []weightedPhrase +} + +type weightedPhrase struct { + Text string + Weight float64 +} + +const ( + intentWeightProduct = 3.0 + intentWeightFeature = 2.8 + intentWeightMatchTag = 2.5 + intentWeightQuestion = 2.2 + intentWeightPillar = 2.0 + intentWeightAudience = 1.6 + intentWeightPatrol = 1.2 + intentWeightSeed = 1.0 + + tangentialBroadMin = 38 + tangentialIntentMax = 30 +) + +var productFormHints = []string{ + "洗衣精", "洗衣粉", "洗衣劑", "洗衣液", + "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", +} + +// BuildIntentProfile materializes product + research-map text into a reusable relevance model. +func BuildIntentProfile(in PatrolTagInput) IntentProfile { + profile := IntentProfile{ + TokenWeights: map[string]float64{}, + BroadTokens: map[string]struct{}{}, + } + addPhrase := func(text string, weight float64) { + text = strings.TrimSpace(text) + if text == "" || weight <= 0 { + return + } + profile.Phrases = append(profile.Phrases, weightedPhrase{Text: text, Weight: weight}) + profile.addTokens(text, weight) + } + + addPhrase(in.ProductName, intentWeightProduct) + addPhrase(in.ProductFeatures, intentWeightFeature) + for _, tag := range in.MatchTags { + addPhrase(tag, intentWeightMatchTag) + } + for _, q := range in.Questions { + addPhrase(q, intentWeightQuestion) + } + for _, pillar := range in.Pillars { + addPhrase(pillar, intentWeightPillar) + } + addPhrase(in.AudienceSummary, intentWeightAudience) + addPhrase(in.TargetAudience, intentWeightAudience) + for _, kw := range in.PatrolKeywords { + addPhrase(kw, intentWeightPatrol) + } + + if hint := productCategoryHint(in.ProductName, in.ProductFeatures); hint != "" { + profile.markBroad(hint) + } + for _, hint := range productFormHints { + blob := strings.ToLower(in.ProductName + " " + in.ProductFeatures + " " + strings.Join(in.MatchTags, " ")) + if strings.Contains(blob, hint) { + profile.markBroad(hint) + } + } + return profile +} + +func (p *IntentProfile) addTokens(text string, weight float64) { + for _, token := range intentTokenize(text) { + if existing, ok := p.TokenWeights[token]; !ok || weight > existing { + p.TokenWeights[token] = weight + } + } +} + +func (p *IntentProfile) markBroad(token string) { + token = strings.TrimSpace(strings.ToLower(token)) + if token == "" { + return + } + p.BroadTokens[token] = struct{}{} + for _, part := range intentTokenize(token) { + p.BroadTokens[part] = struct{}{} + } +} + +func (p IntentProfile) totalWeight() float64 { + sum := 0.0 + for _, w := range p.TokenWeights { + sum += w + } + if sum <= 0 { + for _, phrase := range p.Phrases { + sum += phrase.Weight + } + } + return sum +} + +// NodeIntentText combines node fields used for semantic fit. +func NodeIntentText(node Node) string { + return strings.TrimSpace(strings.Join([]string{ + node.Label, + node.Relation, + node.PlacementValue, + strings.Join(node.PatrolRelevance, " "), + strings.Join(node.PatrolRecency, " "), + }, " ")) +} + +// ScoreIntentSimilarity returns 0-100 weighted token overlap against the intent profile. +func ScoreIntentSimilarity(text string, profile IntentProfile) int { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" || len(profile.TokenWeights) == 0 { + return 0 + } + matched := 0.0 + for token, weight := range profile.TokenWeights { + if strings.Contains(text, token) { + matched += weight + } + } + total := profile.totalWeight() + if total <= 0 { + return 0 + } + score := int((matched / total) * 100) + if score > 100 { + return 100 + } + return score +} + +// ScoreBroadSimilarity measures overlap with category-level tokens only. +func ScoreBroadSimilarity(text string, profile IntentProfile) int { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" || len(profile.BroadTokens) == 0 { + return 0 + } + hits := 0 + for token := range profile.BroadTokens { + if strings.Contains(text, token) { + hits++ + continue + } + runes := []rune(token) + if len(runes) >= 2 && strings.Contains(text, string(runes[:2])) { + hits++ + } + } + if hits == 0 { + return 0 + } + denom := len(profile.BroadTokens) + if denom > 6 { + denom = 6 + } + score := (hits * 100) / denom + if score > 100 { + return 100 + } + return score +} + +// IsTangentialToIntent detects same broad category but weak product-intent alignment. +// Example: 洗衣機 vs 抗敏無香洗衣精 — shares 洗衣, lacks 無香/敏感/化療 intent. +func IsTangentialToIntent(text string, profile IntentProfile) bool { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" || len(profile.TokenWeights) == 0 { + return false + } + intentScore := ScoreIntentSimilarity(text, profile) + if intentScore >= tangentialIntentMax+10 { + return false + } + if hasCategoryStemDrift(text, profile) { + return true + } + broadScore := ScoreBroadSimilarity(text, profile) + if broadScore >= tangentialBroadMin { + return intentScore <= tangentialIntentMax + } + return false +} + +func hasCategoryStemDrift(text string, profile IntentProfile) bool { + for broad := range profile.BroadTokens { + runes := []rune(broad) + if len(runes) < 2 { + continue + } + stem := string(runes[:2]) + if !strings.Contains(text, stem) { + continue + } + if strings.Contains(text, broad) { + continue + } + for _, suffix := range []string{"機", "槽", "烘", "劑", "粉", "精", "乳", "露", "液", "皂"} { + alt := stem + suffix + if alt == broad || !strings.Contains(text, alt) { + continue + } + if !profile.matchesIntentToken(alt) { + return true + } + } + } + return false +} + +func (p IntentProfile) matchesIntentToken(token string) bool { + if _, ok := p.TokenWeights[token]; ok { + return true + } + for intentToken := range p.TokenWeights { + if strings.Contains(token, intentToken) || strings.Contains(intentToken, token) { + return true + } + } + return false +} + +func intentTokenize(text string) []string { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" { + return nil + } + repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ", "?", " ", "?", " ", "!", " ", "!", " ") + text = repl.Replace(text) + seen := map[string]struct{}{} + var out []string + add := func(token string) { + token = strings.Trim(token, `"'「」『』::`) + if len([]rune(token)) < 2 { + return + } + if _, ok := seen[token]; ok { + return + } + seen[token] = struct{}{} + out = append(out, token) + } + for _, part := range strings.Fields(text) { + add(part) + } + if len(out) == 0 { + for _, chunk := range intentSplitRunes(text) { + add(chunk) + } + } + return out +} + +func intentSplitRunes(s string) []string { + var out []string + var buf []rune + flush := func() { + if len(buf) >= 2 { + out = append(out, string(buf)) + } + buf = buf[:0] + } + for _, r := range s { + if unicode.Is(unicode.Han, r) { + buf = append(buf, r) + continue + } + flush() + } + flush() + return out +} diff --git a/backend/internal/library/knowledge/semantic_fit_test.go b/backend/internal/library/knowledge/semantic_fit_test.go new file mode 100644 index 0000000..449f6bf --- /dev/null +++ b/backend/internal/library/knowledge/semantic_fit_test.go @@ -0,0 +1,53 @@ +package knowledge + +import "testing" + +func TestIsTangentialToIntentWashingMachineVsHypoallergenicDetergent(t *testing.T) { + profile := BuildIntentProfile(PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏,適合敏感肌與化療族群", + MatchTags: []string{"無香", "抗敏", "洗衣精"}, + Pillars: []string{"化療後衣物清潔", "無香洗衣"}, + Questions: []string{"化療後想找不會刺激的洗衣精"}, + }) + text := "請問洗脫烘洗衣機有推薦嗎?" + if !IsTangentialToIntent(text, profile) { + t.Fatal("expected washing machine post to be tangential to hypoallergenic detergent intent") + } +} + +func TestIsTangentialToIntentAcceptsCorePain(t *testing.T) { + profile := BuildIntentProfile(PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏", + MatchTags: []string{"無香", "洗衣精"}, + }) + text := "化療後對香味很敏感,有無香洗衣精推薦嗎?" + if IsTangentialToIntent(text, profile) { + t.Fatal("expected core pain post to align with intent") + } +} + +func TestRescoreGraphIntentFitPenalizesTangentialNode(t *testing.T) { + graph := Graph{ + Seed: "無香 洗衣精", + Nodes: []Node{ + {ID: "core", Label: "化療後對香味敏感", NodeKind: "pain", Layer: 0, ProductFitScore: 92, Relation: "需要無香洗衣精"}, + {ID: "weak", Label: "洗衣機選購", NodeKind: "knowledge", Layer: 2, ProductFitScore: 72, Relation: "家電預算與容量"}, + }, + Edges: []Edge{{From: "core", To: "weak", Relation: "延伸"}}, + } + in := PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏", + MatchTags: []string{"無香", "洗衣精"}, + Questions: []string{"化療後想找無香洗衣精"}, + } + RescoreGraphIntentFit(&graph, in) + if graph.Nodes[0].ProductFitScore < 60 { + t.Fatalf("expected core node to stay high, got %d", graph.Nodes[0].ProductFitScore) + } + if graph.Nodes[1].ProductFitScore > 35 { + t.Fatalf("expected tangential node to be penalized, got %d", graph.Nodes[1].ProductFitScore) + } +} diff --git a/backend/internal/library/matrix/generate.go b/backend/internal/library/matrix/generate.go index 81ee400..3bbd571 100644 --- a/backend/internal/library/matrix/generate.go +++ b/backend/internal/library/matrix/generate.go @@ -13,14 +13,22 @@ import ( ) type Row struct { - SortOrder int `json:"sort_order"` - SearchTag string `json:"search_tag"` - Angle string `json:"angle"` - Hook string `json:"hook"` - Text string `json:"text"` - ReferenceNotes string `json:"reference_notes"` - SourcePermalinks []string `json:"source_permalinks"` - Rationale string `json:"rationale"` + SortOrder int `json:"sort_order"` + SearchTag string `json:"search_tag"` + Angle string `json:"angle"` + Hook string `json:"hook"` + Text string `json:"text"` + ReferenceNotes string `json:"reference_notes"` + SourcePermalinks []string `json:"source_permalinks"` + Rationale string `json:"rationale"` + AiScore int `json:"ai_score"` + FormulaScore int `json:"formula_score"` + BrandFitScore int `json:"brand_fit_score"` + RiskScore int `json:"risk_score"` + SimilarityScore int `json:"similarity_score"` + EngagementPotential int `json:"engagement_potential"` + FreshnessScore int `json:"freshness_score"` + ReviewSuggestion string `json:"review_suggestion"` } type GenerateResult struct { @@ -106,7 +114,7 @@ func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.Gene if count <= 0 { count = 5 } - temp := 0.45 + temp := 0.78 tokens := 8192 + count*1024 if tokens > 16384 { tokens = 16384 @@ -122,8 +130,8 @@ func MatrixRetryUserPrompt(count int) string { } return fmt.Sprintf( "上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+ - "每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。\n"+ - "reference_notes 與 rationale 各不超過 40 字;text 建議 80~220 字。", + "每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale、ai_score、formula_score、brand_fit_score、risk_score、similarity_score、engagement_potential、freshness_score、review_suggestion。\n"+ + "reference_notes 與 rationale 各不超過 40 字;text 要有具體場景與故事感、每篇敘事節奏不同;text 一句一行、完全不用任何標點(emoji 可保留)。", count, ) } @@ -206,7 +214,7 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) { return GenerateResult{}, fmt.Errorf("matrix rows missing") } for i := range out.Rows { - out.Rows[i].Text = trimText(out.Rows[i].Text) + out.Rows[i].Text = threadspost.FormatDraftText(out.Rows[i].Text) if out.Rows[i].Text == "" { return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1) } @@ -217,15 +225,6 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) { return out, nil } -func trimText(text string) string { - text = strings.TrimSpace(text) - runes := []rune(text) - if len(runes) > threadspost.MaxPublishRunes { - return string(runes[:threadspost.MaxPublishRunes]) - } - return text -} - func extractJSONObject(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 { diff --git a/backend/internal/library/matrix/generate_test.go b/backend/internal/library/matrix/generate_test.go index 157b1eb..828ba4d 100644 --- a/backend/internal/library/matrix/generate_test.go +++ b/backend/internal/library/matrix/generate_test.go @@ -71,4 +71,4 @@ func TestGenerateCopyOutputRetriesOnParseFailure(t *testing.T) { if gen.calls != 2 { t.Fatalf("calls = %d, want retry", gen.calls) } -} \ No newline at end of file +} diff --git a/backend/internal/library/matrix/reference.go b/backend/internal/library/matrix/reference.go index 6c57d63..dc9a3e3 100644 --- a/backend/internal/library/matrix/reference.go +++ b/backend/internal/library/matrix/reference.go @@ -41,4 +41,4 @@ func FormatCopyResearchMapBlockWithReferences( func BuildReferenceMarkdown(digests []libweb.Digest) string { return libweb.FormatMarkdownBlock(digests, libweb.DefaultMaxMarkdown) -} \ No newline at end of file +} diff --git a/backend/internal/library/outreach/draft_context.go b/backend/internal/library/outreach/draft_context.go new file mode 100644 index 0000000..eccea44 --- /dev/null +++ b/backend/internal/library/outreach/draft_context.go @@ -0,0 +1,107 @@ +package outreach + +import ( + "fmt" + "strings" + + libkg "haixun-backend/internal/library/knowledge" + libplacement "haixun-backend/internal/library/placement" + brandentity "haixun-backend/internal/model/brand/domain/entity" + branddomain "haixun-backend/internal/model/brand/domain/usecase" + scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" +) + +func FindGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + return nil + } + for i := range nodes { + if nodes[i].ID == nodeID { + return &nodes[i] + } + } + return nil +} + +func BuildPlacementReason(post *scanpostusecase.ScanPostSummary) string { + if post == nil { + return "" + } + parts := []string{} + if post.Priority == "gold" { + parts = append(parts, "雙軌命中(相關+近期)") + } else if post.Priority == "recent" { + parts = append(parts, "近期軌命中") + } + if post.SolvedByProduct { + parts = append(parts, "產品可解此需求") + } + if post.ProductFitScore > 0 { + parts = append(parts, fmt.Sprintf("產品匹配 %d", post.ProductFitScore)) + } + return strings.Join(parts, ";") +} + +func MergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.ScanPostSummary) string { + parts := []string{} + if strings.TrimSpace(base) != "" { + parts = append(parts, strings.TrimSpace(base)) + } + if node != nil { + if strings.TrimSpace(node.PlacementValue) != "" { + parts = append(parts, "置入價值 "+strings.TrimSpace(node.PlacementValue)) + } + if node.ProductFitScore > 0 && (post == nil || post.ProductFitScore == 0) { + parts = append(parts, fmt.Sprintf("節點產品匹配 %d", node.ProductFitScore)) + } + } + return strings.Join(parts, ";") +} + +func ProductBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag, postText string) string { + if brand == nil { + return "" + } + if product := PickProductForOutreach(brand, productID, searchTag, postText); product != nil { + merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label) + merged = libplacement.ApplyPlacementURL(merged, product.PlacementURL) + if pb := libplacement.ProductBriefFromContext(merged); pb != "" { + return pb + } + } + if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" { + return pb + } + return strings.TrimSpace(brand.ProductBrief) +} + +func PickProductForOutreach(brand *branddomain.BrandSummary, preferredProductID, searchTag, postText string) *branddomain.ProductSummary { + if brand == nil || len(brand.Products) == 0 { + return nil + } + entities := make([]brandentity.Product, len(brand.Products)) + for i := range brand.Products { + entities[i] = brandentity.Product{ + ID: brand.Products[i].ID, + Label: brand.Products[i].Label, + ProductContext: brand.Products[i].ProductContext, + MatchTags: append([]string(nil), brand.Products[i].MatchTags...), + } + } + if picked := libplacement.RecommendProductForPost(entities, searchTag, postText, preferredProductID); picked != nil { + for i := range brand.Products { + if brand.Products[i].ID == picked.ID { + return &brand.Products[i] + } + } + } + if id := strings.TrimSpace(preferredProductID); id != "" { + for i := range brand.Products { + if brand.Products[i].ID == id { + return &brand.Products[i] + } + } + } + return &brand.Products[0] +} diff --git a/backend/internal/library/outreach/draft_format.go b/backend/internal/library/outreach/draft_format.go new file mode 100644 index 0000000..d797df1 --- /dev/null +++ b/backend/internal/library/outreach/draft_format.go @@ -0,0 +1,76 @@ +package outreach + +import ( + "strings" +) + +func NormalizeDraftText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + text = strings.TrimSpace(text) + text = humanizeDraftLineBreaks(text) + return trimDraftText(text) +} + +func humanizeDraftLineBreaks(text string) string { + if text == "" || strings.Contains(text, "\n") { + return text + } + if len([]rune(text)) < 40 { + return text + } + + sentences := splitSentences(text) + if len(sentences) <= 1 { + return text + } + + paragraphs := make([]string, 0, (len(sentences)+1)/2) + for i := 0; i < len(sentences); { + end := i + 2 + if end > len(sentences) { + end = len(sentences) + } + paragraphs = append(paragraphs, strings.Join(sentences[i:end], "")) + i = end + } + return strings.Join(paragraphs, "\n\n") +} + +func splitSentences(text string) []string { + runes := []rune(text) + if len(runes) == 0 { + return nil + } + out := make([]string, 0, 4) + var current strings.Builder + for i := 0; i < len(runes); i++ { + ch := runes[i] + current.WriteRune(ch) + if !isSentenceEnd(ch) { + continue + } + // Keep ellipsis / repeated punctuation attached to the same sentence. + for i+1 < len(runes) && isSentenceEnd(runes[i+1]) { + i++ + current.WriteRune(runes[i]) + } + if sentence := strings.TrimSpace(current.String()); sentence != "" { + out = append(out, sentence) + } + current.Reset() + } + if tail := strings.TrimSpace(current.String()); tail != "" { + out = append(out, tail) + } + return out +} + +func isSentenceEnd(ch rune) bool { + switch ch { + case '。', '!', '?', '!', '?', '…': + return true + default: + return false + } +} diff --git a/backend/internal/library/outreach/draft_format_test.go b/backend/internal/library/outreach/draft_format_test.go new file mode 100644 index 0000000..5a431de --- /dev/null +++ b/backend/internal/library/outreach/draft_format_test.go @@ -0,0 +1,36 @@ +package outreach + +import "testing" + +func TestNormalizeDraftTextPreservesNewlines(t *testing.T) { + raw := "第一段。\n\n第二段。" + got := NormalizeDraftText(raw) + if got != raw { + t.Fatalf("got %q, want %q", got, raw) + } +} + +func TestHumanizeDraftLineBreaks(t *testing.T) { + raw := "我懂你的感受。之前我們家也遇過類似狀況。後來換了無香的才比較舒服。希望你有找到適合的。" + got := NormalizeDraftText(raw) + if !containsNewline(got) { + t.Fatalf("expected paragraph breaks, got %q", got) + } +} + +func TestNormalizeDraftTextShortSingleParagraph(t *testing.T) { + raw := "加油,會好的。" + got := NormalizeDraftText(raw) + if got != raw { + t.Fatalf("got %q, want %q", got, raw) + } +} + +func containsNewline(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + return true + } + } + return false +} diff --git a/backend/internal/library/outreach/generate.go b/backend/internal/library/outreach/generate.go index a47277a..1c5e2d0 100644 --- a/backend/internal/library/outreach/generate.go +++ b/backend/internal/library/outreach/generate.go @@ -24,14 +24,16 @@ type GenerateResult struct { } type GenerateInput struct { - Persona string - TopicLabel string - AudienceBrief string - ProductBrief string - PlacementReason string - TargetText string - AuthorName string - Count int + Persona string + TopicLabel string + AudienceBrief string + ProductBrief string + PlacementReason string + TargetText string + AuthorName string + Count int + Regenerate bool + PreviousDraftsHint string } var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$") @@ -65,6 +67,13 @@ func BuildUserPrompt(in GenerateInput) (string, error) { if author == "" { author = "匿名" } + regenerateLine := "" + if in.Regenerate || strings.TrimSpace(in.PreviousDraftsHint) != "" { + regenerateLine = "\n【重新生成】請產出與舊版明顯不同的新草稿:換開頭、換切角、換句式與情境細節,不要複製舊文案。" + if hint := strings.TrimSpace(in.PreviousDraftsHint); hint != "" { + regenerateLine += "\n" + hint + } + } return libprompt.OutreachPlacementUser(map[string]string{ "persona_block": personaBlock, "topic_label": topic, @@ -73,6 +82,7 @@ func BuildUserPrompt(in GenerateInput) (string, error) { "placement_reason_line": reasonLine, "author_name": author, "target_text": strings.TrimSpace(in.TargetText), + "regenerate_line": regenerateLine, "count": fmt.Sprintf("%d", count), }) } @@ -90,7 +100,7 @@ func ParseGenerateOutput(raw string) (GenerateResult, error) { return GenerateResult{}, fmt.Errorf("outreach drafts missing") } for i := range out.Drafts { - out.Drafts[i].Text = trimDraftText(out.Drafts[i].Text) + out.Drafts[i].Text = NormalizeDraftText(out.Drafts[i].Text) if out.Drafts[i].Text == "" { return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1) } diff --git a/backend/internal/library/outreach/generate_draft.go b/backend/internal/library/outreach/generate_draft.go new file mode 100644 index 0000000..fea7e67 --- /dev/null +++ b/backend/internal/library/outreach/generate_draft.go @@ -0,0 +1,188 @@ +package outreach + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libprompt "haixun-backend/internal/library/prompt" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + branddomain "haixun-backend/internal/model/brand/domain/usecase" + kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase" + outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + scanpostentity "haixun-backend/internal/model/scan_post/domain/entity" + scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateDraftRequest struct { + TenantID string + OwnerUID string + BrandID string + TopicID string + ScanPostID string + Count int + VoicePersonaID string + ProductID string + Regenerate bool +} + +type GenerateDraftDeps struct { + Brand branddomain.UseCase + Persona personadomain.UseCase + ScanPost scanpostusecase.UseCase + KnowledgeGraph kgdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + AI aiusecase.UseCase + OutreachDraft outreachusecase.UseCase +} + +func GenerateDraft(ctx context.Context, deps GenerateDraftDeps, req GenerateDraftRequest) (*outreachusecase.DraftSummary, error) { + scanPostID := strings.TrimSpace(req.ScanPostID) + count := req.Count + if count <= 0 { + count = 2 + } + if count > 4 { + count = 4 + } + if scanPostID == "" { + return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required") + } + + brand, err := deps.Brand.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID) + if err != nil { + return nil, err + } + voiceID := strings.TrimSpace(req.VoicePersonaID) + if voiceID == "" { + return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required") + } + vp, err := deps.Persona.Get(ctx, req.TenantID, req.OwnerUID, voiceID) + if err != nil { + return nil, err + } + voicePersona := FormatVoicePersonaBlock(vp) + if voicePersona == "" { + return nil, app.For(code.Persona).InputMissingRequired("請先在人設頁填寫人設描述或完成 8D 風格分析") + } + preferredProductID := strings.TrimSpace(req.ProductID) + if preferredProductID == "" { + preferredProductID = strings.TrimSpace(brand.ProductID) + } + post, err := deps.ScanPost.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID, scanPostID) + if err != nil { + return nil, err + } + pickedProduct := PickProductForOutreach(brand, preferredProductID, post.SearchTag, post.Text) + productID := preferredProductID + if pickedProduct != nil { + productID = pickedProduct.ID + } + + topicLabel := strings.TrimSpace(post.SearchTag) + placementReason := BuildPlacementReason(post) + if graph, graphErr := deps.KnowledgeGraph.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID); graphErr == nil && graph != nil { + if node := FindGraphNode(graph.Nodes, post.GraphNodeID); node != nil { + if strings.TrimSpace(node.Label) != "" { + topicLabel = strings.TrimSpace(node.Label) + } + placementReason = MergePlacementReason(placementReason, node, post) + } + } + + regenerate := req.Regenerate + previousHint := "" + if latest, latestErr := deps.OutreachDraft.GetLatestByScanPost( + ctx, req.TenantID, req.OwnerUID, req.BrandID, strings.TrimSpace(req.TopicID), scanPostID, + ); latestErr == nil && latest != nil && len(latest.Drafts) > 0 { + regenerate = true + previousHint = FormatPreviousDraftsHint(latest) + } + + userPrompt, err := BuildUserPrompt(GenerateInput{ + Persona: voicePersona, + TopicLabel: topicLabel, + AudienceBrief: brand.TargetAudience, + ProductBrief: ProductBriefForBrand(brand, productID, post.SearchTag, post.Text), + PlacementReason: placementReason, + TargetText: post.Text, + AuthorName: post.Author, + Count: count, + Regenerate: regenerate, + PreviousDraftsHint: previousHint, + }) + if err != nil { + return nil, app.For(code.AI).SysInternal("outreach user prompt load failed") + } + + systemPrompt, err := libprompt.OutreachPlacementSystem() + if err != nil { + return nil, app.For(code.AI).SysInternal("outreach system prompt load failed") + } + systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n【最高優先】以下人設與語氣是硬約束,每則留言都必須像這個角色親自回覆,不得變成通用網友口吻:\n" + strings.TrimSpace(voicePersona) + + credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, req.TenantID, req.OwnerUID) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + temp := 0.88 + result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + System: systemPrompt, + Messages: []domai.Message{ + {Role: "user", Content: userPrompt}, + }, + Temperature: &temp, + }) + if err != nil { + return nil, err + } + + parsed, err := ParseGenerateOutput(result.Text) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("獲客留言 LLM 回傳無法解析:" + err.Error()) + } + + draftItems := make([]outreachusecase.DraftItem, 0, len(parsed.Drafts)) + for _, item := range parsed.Drafts { + draftItems = append(draftItems, outreachusecase.DraftItem{ + Text: item.Text, + Angle: item.Angle, + Rationale: item.Rationale, + }) + } + saved, err := deps.OutreachDraft.Create(ctx, outreachusecase.CreateRequest{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + BrandID: req.BrandID, + TopicID: strings.TrimSpace(req.TopicID), + ScanPostID: scanPostID, + Relevance: parsed.Relevance, + Reason: parsed.Reason, + Drafts: draftItems, + }) + if err != nil { + return nil, err + } + _, _ = deps.ScanPost.UpdateOutreach(ctx, scanpostusecase.UpdateOutreachRequest{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + BrandID: req.BrandID, + PostID: scanPostID, + Status: scanpostentity.OutreachStatusDrafted, + }) + return saved, nil +} diff --git a/backend/internal/library/outreach/persona_voice.go b/backend/internal/library/outreach/persona_voice.go new file mode 100644 index 0000000..292571d --- /dev/null +++ b/backend/internal/library/outreach/persona_voice.go @@ -0,0 +1,33 @@ +package outreach + +import ( + "strings" + + "haixun-backend/internal/library/style8d" + personadomain "haixun-backend/internal/model/persona/domain/usecase" +) + +// FormatVoicePersonaBlock builds the LLM persona block from a full persona profile. +func FormatVoicePersonaBlock(vp *personadomain.PersonaSummary) string { + if vp == nil { + return "" + } + block := style8d.ResolvePersonaBlock(vp.Persona, vp.StyleProfile, vp.Brief) + if block == "" { + return "" + } + extras := make([]string, 0, 3) + if name := strings.TrimSpace(vp.DisplayName); name != "" { + extras = append(extras, "角色名稱:"+name) + } + if goals := strings.TrimSpace(vp.Goals); goals != "" { + extras = append(extras, "回覆目標:"+goals) + } + if benchmark := strings.TrimSpace(vp.StyleBenchmark); benchmark != "" { + extras = append(extras, "語氣參考:"+benchmark) + } + if len(extras) == 0 { + return block + } + return strings.TrimSpace(block + "\n\n" + strings.Join(extras, "\n")) +} diff --git a/backend/internal/library/outreach/persona_voice_test.go b/backend/internal/library/outreach/persona_voice_test.go new file mode 100644 index 0000000..cc7f763 --- /dev/null +++ b/backend/internal/library/outreach/persona_voice_test.go @@ -0,0 +1,34 @@ +package outreach + +import ( + "strings" + "testing" + + personadomain "haixun-backend/internal/model/persona/domain/usecase" +) + +func TestFormatVoicePersonaBlockUsesPersonaAndGoals(t *testing.T) { + block := FormatVoicePersonaBlock(&personadomain.PersonaSummary{ + DisplayName: "小安", + Persona: "溫柔媽媽,說話慢一點、會先共情", + Goals: "讓對方覺得被理解,再輕鬆分享經驗", + }) + if !strings.Contains(block, "溫柔媽媽") { + t.Fatalf("expected persona text, got %q", block) + } + if !strings.Contains(block, "小安") { + t.Fatalf("expected display name, got %q", block) + } + if !strings.Contains(block, "讓對方覺得被理解") { + t.Fatalf("expected goals, got %q", block) + } +} + +func TestFormatVoicePersonaBlockEmpty(t *testing.T) { + if FormatVoicePersonaBlock(nil) != "" { + t.Fatal("expected empty block for nil persona") + } + if FormatVoicePersonaBlock(&personadomain.PersonaSummary{}) != "" { + t.Fatal("expected empty block for blank persona") + } +} diff --git a/backend/internal/library/outreach/previous_drafts.go b/backend/internal/library/outreach/previous_drafts.go new file mode 100644 index 0000000..f9f577d --- /dev/null +++ b/backend/internal/library/outreach/previous_drafts.go @@ -0,0 +1,32 @@ +package outreach + +import ( + "fmt" + "strings" + + outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" +) + +func FormatPreviousDraftsHint(latest *outreachusecase.DraftSummary) string { + if latest == nil || len(latest.Drafts) == 0 { + return "" + } + lines := []string{"上一版草稿(請避開相同寫法):"} + for i, item := range latest.Drafts { + angle := strings.TrimSpace(item.Angle) + if angle == "" { + angle = fmt.Sprintf("版本%d", i+1) + } + lines = append(lines, fmt.Sprintf("- %s:%s", angle, truncateRunes(item.Text, 140))) + } + return strings.Join(lines, "\n") +} + +func truncateRunes(text string, max int) string { + text = strings.TrimSpace(text) + runes := []rune(text) + if len(runes) <= max { + return text + } + return string(runes[:max]) + "…" +} diff --git a/backend/internal/library/outreach/previous_drafts_test.go b/backend/internal/library/outreach/previous_drafts_test.go new file mode 100644 index 0000000..c5bb007 --- /dev/null +++ b/backend/internal/library/outreach/previous_drafts_test.go @@ -0,0 +1,19 @@ +package outreach + +import ( + "strings" + "testing" + + outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" +) + +func TestFormatPreviousDraftsHint(t *testing.T) { + hint := FormatPreviousDraftsHint(&outreachusecase.DraftSummary{ + Drafts: []outreachusecase.DraftItem{ + {Angle: "共情", Text: "我懂你的感受"}, + }, + }) + if !strings.Contains(hint, "上一版草稿") || !strings.Contains(hint, "共情") { + t.Fatalf("hint = %q", hint) + } +} diff --git a/backend/internal/library/ownpost/post_formula.go b/backend/internal/library/ownpost/post_formula.go new file mode 100644 index 0000000..b74ad64 --- /dev/null +++ b/backend/internal/library/ownpost/post_formula.go @@ -0,0 +1,162 @@ +package ownpost + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + domai "haixun-backend/internal/model/ai/domain/usecase" +) + +var formulaFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") + +type PostFormulaInput struct { + AccountName string + PersonaBlock string + PostText string + TopicTag string + MediaType string + LikeCount int + ReplyCount int + Views int + RepostCount int + QuoteCount int + Shares int + Timestamp string +} + +type PostFormulaReview struct { + Summary string `json:"summary"` + Wins []string `json:"wins"` + Improvements []string `json:"improvements"` + Formula string `json:"formula"` + PostTemplate string `json:"post_template"` + HookPattern string `json:"hook_pattern"` + Structure string `json:"structure"` + ReplicationTips []string `json:"replication_tips"` + Avoid []string `json:"avoid"` +} + +func BuildPostFormulaPrompt(in PostFormulaInput) (system string, user string) { + personaBlock := strings.TrimSpace(in.PersonaBlock) + if personaBlock == "" { + personaBlock = "(未指定人設,請依貼文本身語氣分析)" + } + postText := strings.TrimSpace(in.PostText) + if postText == "" { + postText = "(貼文內容未提供)" + } + + system = strings.TrimSpace(` +你是 Threads 內容策略教練。任務是針對「已發布的單篇貼文」做誠實覆盤:好的要提炼成可複製公式,壞的要指出問題並給具體改善做法。不要重寫整篇貼文。 + +規則: +- 用台灣繁體中文。 +- 必須同時寫優點與缺點;依成效數據判斷,成效普通或偏差時更要直言問題。 +- 分析要具體、可執行,禁止空泛稱讚或空泛批評。 +- formula:把「做對的部分」整理成步驟化公式(例:Hook→情境→轉折→觀點→收尾/互動)。 +- post_template:依 formula 產出可複製的發文骨架,用 [括號] 標示可替換欄位,讓使用者下次填空就能產文。 +- improvements:每點格式固定為「問題:…|改善:…」,改善必須是可立刻執行的動作(改哪一句、加什麼、刪什麼、換什麼角度)。 +- 只輸出一個 JSON 物件,不要 markdown,欄位: + summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid +- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`) + + var b strings.Builder + b.WriteString("【1. 人設】\n") + b.WriteString(personaBlock) + b.WriteString("\n\n帳號:") + b.WriteString(strings.TrimSpace(in.AccountName)) + b.WriteString("\n\n【2. 貼文內容】\n") + b.WriteString(postText) + if tag := strings.TrimSpace(in.TopicTag); tag != "" { + b.WriteString("\n話題標籤:#") + b.WriteString(tag) + } + if mt := strings.TrimSpace(in.MediaType); mt != "" { + b.WriteString("\n媒體類型:") + b.WriteString(mt) + } + if ts := strings.TrimSpace(in.Timestamp); ts != "" { + b.WriteString("\n發布時間:") + b.WriteString(ts) + } + b.WriteString("\n\n【3. 成效數據】\n") + b.WriteString(fmt.Sprintf("讚 %d|回覆 %d|瀏覽 %d|轉發 %d|引用 %d|分享 %d", + in.LikeCount, in.ReplyCount, in.Views, in.RepostCount, in.QuoteCount, in.Shares)) + b.WriteString("\n\n請誠實覆盤:做對了什麼、哪裡不好、怎麼改,並把做對的部分整理成下次可複製的公式與發文模板 JSON。") + user = b.String() + return system, user +} + +func GeneratePostFormula(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in PostFormulaInput) (*PostFormulaReview, error) { + system, user := BuildPostFormulaPrompt(in) + temp := 0.65 + req.System = system + req.Messages = []domai.Message{{Role: "user", Content: user}} + req.Temperature = &temp + out, err := ai.GenerateText(ctx, req) + if err != nil { + return nil, err + } + review, err := ParsePostFormulaOutput(out.Text) + if err != nil { + return nil, err + } + return review, nil +} + +func ParsePostFormulaOutput(raw string) (*PostFormulaReview, error) { + payload, err := extractFormulaJSONObject(raw) + if err != nil { + return nil, err + } + var review PostFormulaReview + if err := json.Unmarshal(payload, &review); err != nil { + return nil, fmt.Errorf("parse post formula json: %w", err) + } + review.Summary = strings.TrimSpace(review.Summary) + review.Formula = strings.TrimSpace(review.Formula) + review.PostTemplate = strings.TrimSpace(review.PostTemplate) + review.HookPattern = strings.TrimSpace(review.HookPattern) + review.Structure = strings.TrimSpace(review.Structure) + review.Wins = trimStringList(review.Wins) + review.Improvements = trimStringList(review.Improvements) + review.ReplicationTips = trimStringList(review.ReplicationTips) + review.Avoid = trimStringList(review.Avoid) + if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" { + return nil, fmt.Errorf("AI 未回傳有效覆盤內容") + } + return &review, nil +} + +func trimStringList(items []string) []string { + if len(items) == 0 { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item != "" { + out = append(out, item) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func extractFormulaJSONObject(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + if m := formulaFenceRE.FindStringSubmatch(raw); len(m) == 2 { + raw = strings.TrimSpace(m[1]) + } + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("formula output missing json object") + } + return []byte(raw[start : end+1]), nil +} diff --git a/backend/internal/library/ownpost/post_formula_test.go b/backend/internal/library/ownpost/post_formula_test.go new file mode 100644 index 0000000..5575e60 --- /dev/null +++ b/backend/internal/library/ownpost/post_formula_test.go @@ -0,0 +1,34 @@ +package ownpost + +import "testing" + +func TestParsePostFormulaOutputIncludesImprovementsAndTemplate(t *testing.T) { + raw := `{ + "summary": "整體互動偏低,但開場有吸引力。", + "wins": ["開場直接點痛點"], + "improvements": ["問題:收尾沒有問句|改善:最後加一句開放式提問引導留言"], + "formula": "Hook→情境→觀點→互動問句", + "post_template": "[痛點一句]\n[親身情境]\n[你的觀點]\n[問句收尾]", + "hook_pattern": "先丟痛點再現身說法", + "structure": "4 段,每段 1-2 句", + "replication_tips": ["下次同主題先寫 Hook 再補故事"], + "avoid": ["不要一次講太多資訊"] +}` + review, err := ParsePostFormulaOutput(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(review.Improvements) != 1 { + t.Fatalf("improvements: got %d", len(review.Improvements)) + } + if review.PostTemplate == "" { + t.Fatal("expected post_template") + } +} + +func TestTrimStringListDropsEmpty(t *testing.T) { + got := trimStringList([]string{" a ", "", "b"}) + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("unexpected: %#v", got) + } +} diff --git a/backend/internal/library/ownpost/reply_draft.go b/backend/internal/library/ownpost/reply_draft.go new file mode 100644 index 0000000..8ccf529 --- /dev/null +++ b/backend/internal/library/ownpost/reply_draft.go @@ -0,0 +1,226 @@ +package ownpost + +import ( + "context" + "fmt" + "regexp" + "strings" + "unicode/utf8" + + domai "haixun-backend/internal/model/ai/domain/usecase" +) + +type ReplyDraftInput struct { + AccountName string + PersonaBlock string + PostText string + ReplyText string + ReplyToID string + ThreadPostText string + ParentReplyText string +} + +var ( + wrapQuoteRE = regexp.MustCompile(`^["'「『](.*)["'」』]$`) + multiBlankRE = regexp.MustCompile(`\n{3,}`) + sentenceEndRE = regexp.MustCompile(`([。!?…!?])\s*`) + aiPhraseRE = regexp.MustCompile(`(?i)(很高興|感謝您的|希望這對您|作為一個|總而言之|綜上所述|不妨考慮|值得注意)`) +) + +func isMentionReplyDraft(in ReplyDraftInput) bool { + return strings.TrimSpace(in.ThreadPostText) != "" || strings.TrimSpace(in.ParentReplyText) != "" +} + +func BuildReplyDraftPrompt(in ReplyDraftInput) (system string, user string) { + if isMentionReplyDraft(in) { + return buildMentionReplyDraftPrompt(in) + } + accountName := strings.TrimSpace(in.AccountName) + if accountName == "" { + accountName = "我" + } + postText := strings.TrimSpace(in.PostText) + if postText == "" { + postText = "(貼文內容未提供)" + } + replyText := strings.TrimSpace(in.ReplyText) + replyToID := strings.TrimSpace(in.ReplyToID) + personaBlock := strings.TrimSpace(in.PersonaBlock) + if personaBlock == "" { + personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)" + } + + system = strings.TrimSpace(` +你是 Threads 貼文作者本人,要在自己貼文底下回留言或接續串文。 +請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。 + +硬規則: +1. 人設語氣是最高優先:用字、節奏、距離感、價值觀必須符合【1. 人設】,不得變成通用網友或官方口吻。 +2. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。 +3. 先讀【2. 我的貼文】掌握語境;若有【3. 要回覆的留言】,要直接回應對方說的內容,不要自說自話。 +4. 禁止 AI 腔:不要「很高興/感謝您的/希望這對您有幫助/作為一個…/總而言之/綜上所述」這類句型。 +5. 禁止客服業配腔:不要硬推、不要條列式教學、不要一次講太多重點。 +6. 排版要像真人留言:2~4 個短段落,每段 1~2 句;段落之間必須換行(用實際換行,不要整段擠在一起)。 +7. 可口語、可省略主詞、可用 …、可 0~1 個 emoji;總長度約 40~180 字,最多不超過 280 字。 +8. 你是貼文作者本人在回,不要假裝是粉絲或路人。`) + + var b strings.Builder + b.WriteString("【1. 人設】\n") + b.WriteString(personaBlock) + b.WriteString("\n\n帳號名稱:") + b.WriteString(accountName) + b.WriteString("\n\n【2. 我的貼文】\n") + b.WriteString(postText) + b.WriteString("\n\n【3. 要回覆的留言】\n") + if replyToID != "" && replyText != "" { + b.WriteString(replyText) + b.WriteString("\n\n請依人設語氣,回覆這則留言。") + } else { + b.WriteString("(無特定留言,這是接續自己貼文的補充回覆/串文)") + b.WriteString("\n\n請依人設語氣,寫一則自然接續這篇貼文的留言。") + } + user = b.String() + return system, user +} + +func buildMentionReplyDraftPrompt(in ReplyDraftInput) (system string, user string) { + accountName := strings.TrimSpace(in.AccountName) + if accountName == "" { + accountName = "我" + } + threadText := strings.TrimSpace(in.ThreadPostText) + if threadText == "" { + threadText = strings.TrimSpace(in.PostText) + } + if threadText == "" { + threadText = "(主串貼文內容未提供)" + } + parentText := strings.TrimSpace(in.ParentReplyText) + mentionText := strings.TrimSpace(in.ReplyText) + if mentionText == "" { + mentionText = "(對方 @ 你的內容未提供)" + } + personaBlock := strings.TrimSpace(in.PersonaBlock) + if personaBlock == "" { + personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)" + } + + system = strings.TrimSpace(` +你是 Threads 帳號本人,有人在你參與的串文裡 @ 你,你要回覆對方。 +請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。 + +硬規則: +1. 人設語氣是最高優先:用字、節奏、距離感必須符合【1. 人設】。 +2. 先讀【2. 主串貼文】理解整串在講什麼;若有【3. 上層留言】,要知道留言脈絡。 +3. 你的回覆必須直接回應【4. 對方 @ 你的內容】,不要忽略對方 tag 你的那句話。 +4. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。 +5. 禁止 AI 腔與客服腔;2~4 個短段落,段落間換行;約 40~180 字,最多 280 字。 +6. 你是被 @ 的當事人在回,語氣自然、有來有往。`) + + var b strings.Builder + b.WriteString("【1. 人設】\n") + b.WriteString(personaBlock) + b.WriteString("\n\n帳號名稱:") + b.WriteString(accountName) + b.WriteString("\n\n【2. 主串貼文】\n") + b.WriteString(threadText) + if parentText != "" { + b.WriteString("\n\n【3. 上層留言】\n") + b.WriteString(parentText) + } + b.WriteString("\n\n【4. 對方 @ 你的內容】\n") + b.WriteString(mentionText) + b.WriteString("\n\n請依人設語氣,回覆對方 @ 你的這則內容。") + return system, b.String() +} + +func GenerateReplyDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in ReplyDraftInput) (string, error) { + system, user := BuildReplyDraftPrompt(in) + temp := 0.9 + req.System = system + req.Messages = []domai.Message{{Role: "user", Content: user}} + req.Temperature = &temp + out, err := ai.GenerateText(ctx, req) + if err != nil { + return "", err + } + text := NormalizeHumanReply(out.Text) + if text == "" { + return "", fmt.Errorf("AI 未回傳回覆草稿") + } + return text, nil +} + +// NormalizeHumanReply trims AI artifacts and nudges layout toward short mobile-style paragraphs. +func NormalizeHumanReply(raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return "" + } + if m := wrapQuoteRE.FindStringSubmatch(text); len(m) == 2 { + text = strings.TrimSpace(m[1]) + } + text = strings.ReplaceAll(text, `\n`, "\n") + text = multiBlankRE.ReplaceAllString(text, "\n\n") + text = strings.TrimSpace(text) + + if !strings.Contains(text, "\n") { + if sentenceCount(text) >= 2 || utf8.RuneCountInString(text) > 72 { + text = breakLongReply(text) + } + } + if aiPhraseRE.MatchString(text) && !strings.Contains(text, "\n") { + text = breakLongReply(text) + } + return strings.TrimSpace(text) +} + +func sentenceCount(text string) int { + n := 0 + for _, r := range text { + switch r { + case '。', '!', '?', '…', '!', '?': + n++ + } + } + return n +} + +func breakLongReply(text string) string { + parts := sentenceEndRE.Split(text, -1) + seps := sentenceEndRE.FindAllString(text, -1) + if len(parts) <= 1 { + return text + } + var chunks []string + var current strings.Builder + for i, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + sep := "" + if i < len(seps) { + sep = strings.TrimSpace(seps[i]) + } + segment := part + sep + if current.Len() == 0 { + current.WriteString(segment) + continue + } + if utf8.RuneCountInString(current.String())+utf8.RuneCountInString(segment) > 42 { + chunks = append(chunks, strings.TrimSpace(current.String())) + current.Reset() + current.WriteString(segment) + continue + } + current.WriteString(segment) + } + if current.Len() > 0 { + chunks = append(chunks, strings.TrimSpace(current.String())) + } + if len(chunks) <= 1 { + return text + } + return strings.Join(chunks, "\n") +} diff --git a/backend/internal/library/ownpost/reply_draft_test.go b/backend/internal/library/ownpost/reply_draft_test.go new file mode 100644 index 0000000..975536d --- /dev/null +++ b/backend/internal/library/ownpost/reply_draft_test.go @@ -0,0 +1,66 @@ +package ownpost + +import ( + "strings" + "testing" +) + +func TestBuildReplyDraftPromptIncludesPersonaPostAndReply(t *testing.T) { + _, user := BuildReplyDraftPrompt(ReplyDraftInput{ + AccountName: "小安", + PersonaBlock: "溫柔媽媽,說話慢、會先共情", + PostText: "今天帶小孩好累", + ReplyText: "我也是欸", + ReplyToID: "123", + }) + for _, want := range []string{"【1. 人設】", "溫柔媽媽", "【2. 我的貼文】", "今天帶小孩好累", "【3. 要回覆的留言】", "我也是欸"} { + if !strings.Contains(user, want) { + t.Fatalf("missing %q in user prompt:\n%s", want, user) + } + } +} + +func TestBuildReplyDraftPromptThreadContinuation(t *testing.T) { + _, user := BuildReplyDraftPrompt(ReplyDraftInput{ + PersonaBlock: "生活觀察者", + PostText: "最近在想轉職", + }) + if !strings.Contains(user, "接續自己貼文") { + t.Fatalf("expected thread continuation hint, got:\n%s", user) + } + if strings.Contains(user, "【3. 要回覆的留言】\n(無特定留言") { + // ok + } else if !strings.Contains(user, "無特定留言") { + t.Fatalf("expected empty reply section marker, got:\n%s", user) + } +} + +func TestBuildMentionReplyDraftPromptIncludesThreadAndMention(t *testing.T) { + _, user := BuildReplyDraftPrompt(ReplyDraftInput{ + AccountName: "小安", + PersonaBlock: "直率科技人", + ThreadPostText: "今天來分享育兒心得", + ParentReplyText: "我覺得放鬆最重要", + ReplyText: "@小安 你覺得呢?", + ReplyToID: "999", + }) + for _, want := range []string{ + "【2. 主串貼文】", "今天來分享育兒心得", + "【3. 上層留言】", "我覺得放鬆最重要", + "【4. 對方 @ 你的內容】", "@小安 你覺得呢?", + } { + if !strings.Contains(user, want) { + t.Fatalf("missing %q in mention user prompt:\n%s", want, user) + } + } +} + +func TestNormalizeHumanReplyStripsQuotesAndBreaksLines(t *testing.T) { + got := NormalizeHumanReply(`"第一段真的超累到不想講話。第二段後來洗個澡就還好一點。第三段你加油,我們一起撐過這週。"`) + if strings.HasPrefix(got, `"`) { + t.Fatalf("expected quotes stripped, got %q", got) + } + if !strings.Contains(got, "\n") { + t.Fatalf("expected line breaks, got %q", got) + } +} diff --git a/backend/internal/library/permmatch/match_test.go b/backend/internal/library/permmatch/match_test.go index 64dbcbd..3bfbf18 100644 --- a/backend/internal/library/permmatch/match_test.go +++ b/backend/internal/library/permmatch/match_test.go @@ -25,8 +25,9 @@ func TestPathAllowed(t *testing.T) { func TestRequestAllowed(t *testing.T) { perms := map[string]string{ - "/api/v1/members/me": "GET|PATCH", - "/api/v1/jobs/*": "GET|POST", + "/api/v1/members/me": "GET|PATCH", + "/api/v1/jobs/*": "GET|POST", + "/api/v1/threads-accounts/*": "GET|POST|PUT|PATCH|DELETE", } if !RequestAllowed(perms, "GET", "/api/v1/members/me") { t.Fatal("expected member me") @@ -37,6 +38,12 @@ func TestRequestAllowed(t *testing.T) { if !RequestAllowed(perms, "POST", "/api/v1/jobs/x/cancel") { t.Fatal("expected job cancel") } + if !RequestAllowed(perms, "DELETE", "/api/v1/threads-accounts/acc-1") { + t.Fatal("expected threads account delete") + } + if !RequestAllowed(perms, "POST", "/api/v1/threads-accounts/acc-1/activate") { + t.Fatal("expected threads account activate") + } } func TestPathAllowedListRoot(t *testing.T) { diff --git a/backend/internal/library/personacopy/formula_draft.go b/backend/internal/library/personacopy/formula_draft.go new file mode 100644 index 0000000..e83795e --- /dev/null +++ b/backend/internal/library/personacopy/formula_draft.go @@ -0,0 +1,161 @@ +package personacopy + +import ( + "context" + "fmt" + "strings" + + libformula "haixun-backend/internal/library/formula" + "haixun-backend/internal/library/placement" + "haixun-backend/internal/library/style8d" + "haixun-backend/internal/library/websearch" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type FormulaDraftInput struct { + TenantID string + OwnerUID string + PersonaID string + AccountID string + FormulaID string + Topic string + Brief string + UseWebSearch bool + DraftCount int +} + +type FormulaDraftDeps struct { + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI domai.UseCase +} + +func RunFormulaDraft(ctx context.Context, deps FormulaDraftDeps, in FormulaDraftInput, progress ProgressFn) (int, error) { + if progress == nil { + progress = func(string, int) {} + } + topic := strings.TrimSpace(in.Topic) + if topic == "" { + return 0, fmt.Errorf("topic is required") + } + count := in.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + progress("讀取人設與寫法公式…", 10) + persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID) + if err != nil { + return 0, err + } + if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { + return 0, fmt.Errorf("請先完成人設 8D 對標分析") + } + formula, err := deps.ContentFormula.Get(ctx, in.TenantID, in.OwnerUID, in.AccountID, in.FormulaID) + if err != nil { + return 0, err + } + credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID) + if err != nil { + return 0, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return 0, err + } + aiReq := domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + + researchNotes := "" + if in.UseWebSearch { + progress("搜尋網路參考資料…", 20) + researchNotes = searchFormulaNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief) + } + + created := 0 + for i := 0; i < count; i++ { + progress(fmt.Sprintf("呼叫 AI 產生草稿 %d/%d(%s / %s)…", i+1, count, credential.Provider, credential.Model), 35+(i*40/count)) + generated, genErr := libformula.GenerateDraft(ctx, deps.AI, aiReq, libformula.GenerateInput{ + Topic: topic, + Brief: in.Brief, + PersonaBlock: personaBlock, + ResearchNotes: researchNotes, + Formula: *formula, + }) + if genErr != nil { + return created, genErr + } + if _, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + PersonaID: in.PersonaID, + FormulaID: formula.ID, + DraftType: copydraftentity.DraftTypeFormula, + Text: generated.Text, + TopicTag: generated.TopicTag, + Angle: generated.Angle, + Hook: generated.Hook, + Rationale: generated.Rationale, + ReferenceNotes: generated.StructureNotes, + Sources: []string{formula.Label}, + }); saveErr != nil { + return created, saveErr + } + created++ + } + progress(fmt.Sprintf("已產生 %d 篇草稿", created), 100) + return created, nil +} + +func searchFormulaNotes(ctx context.Context, deps FormulaDraftDeps, tenantID, uid, topic, brief string) string { + research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid) + if err != nil || !placement.WebSearchAvailable(research) { + return "" + } + memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research) + if err != nil { + return "" + } + webClient := websearch.New(memberCtx.WebSearchConfig()) + if !webClient.Enabled() { + return "" + } + resp, err := webClient.Search(ctx, websearch.SearchOptions{ + Query: topic + " " + strings.TrimSpace(brief), + Limit: 5, + Mode: websearch.ModeKnowledgeExpand, + }) + if err != nil || len(resp.Results) == 0 { + return "" + } + var b strings.Builder + for _, snip := range resp.Results { + if snip.Title != "" { + b.WriteString("- ") + b.WriteString(snip.Title) + b.WriteString("\n") + } + if snip.Snippet != "" { + b.WriteString(snip.Snippet) + b.WriteString("\n") + } + } + return strings.TrimSpace(b.String()) +} diff --git a/backend/internal/library/personacopy/rewrite_draft.go b/backend/internal/library/personacopy/rewrite_draft.go new file mode 100644 index 0000000..56bf638 --- /dev/null +++ b/backend/internal/library/personacopy/rewrite_draft.go @@ -0,0 +1,139 @@ +package personacopy + +import ( + "context" + "fmt" + "strings" + "time" + + libformula "haixun-backend/internal/library/formula" + "haixun-backend/internal/library/style8d" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type RewriteDraftInput struct { + TenantID string + OwnerUID string + PersonaID string + AccountID string + ReferenceText string + Topic string + Brief string + SaveLabel string + UseWebSearch bool + DraftCount int +} + +type RewriteDraftDeps struct { + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI domai.UseCase +} + +func RunRewriteDraft(ctx context.Context, deps RewriteDraftDeps, in RewriteDraftInput, progress ProgressFn) (int, error) { + if progress == nil { + progress = func(string, int) {} + } + referenceText := strings.TrimSpace(in.ReferenceText) + topic := strings.TrimSpace(in.Topic) + if referenceText == "" { + return 0, fmt.Errorf("reference_text is required") + } + if topic == "" { + return 0, fmt.Errorf("topic is required") + } + + progress("讀取人設…", 5) + persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID) + if err != nil { + return 0, err + } + if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { + return 0, fmt.Errorf("請先完成人設 8D 對標分析") + } + credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID) + if err != nil { + return 0, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return 0, err + } + aiReq := domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + + progress(fmt.Sprintf("分析參考貼文寫法(%s / %s)…", credential.Provider, credential.Model), 15) + analysis, err := libformula.AnalyzePasted(ctx, deps.AI, aiReq, libformula.AnalyzePastedInput{ + PostText: referenceText, + PersonaBlock: personaBlock, + }) + if err != nil { + return 0, fmt.Errorf("分析參考貼文失敗:%w", err) + } + if analysis == nil { + return 0, fmt.Errorf("分析未產出有效寫法公式") + } + + label := strings.TrimSpace(in.SaveLabel) + if label == "" { + label = fmt.Sprintf("貼文改寫 %s", time.Now().UTC().Format("2006-01-02")) + } + progress("存入寫法公式…", 30) + saved, err := deps.ContentFormula.Create(ctx, contentformuladomain.CreateRequest{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + AccountID: in.AccountID, + Label: label, + SourceType: cfentity.SourcePaste, + SourceRef: "paste", + SourcePostText: referenceText, + Summary: analysis.Summary, + Wins: analysis.Wins, + Improvements: analysis.Improvements, + Formula: analysis.Formula, + PostTemplate: analysis.PostTemplate, + HookPattern: analysis.HookPattern, + Structure: analysis.Structure, + ReplicationTips: analysis.ReplicationTips, + Avoid: analysis.Avoid, + }) + if err != nil { + return 0, err + } + + return RunFormulaDraft(ctx, FormulaDraftDeps{ + Persona: deps.Persona, + ContentFormula: deps.ContentFormula, + CopyDraft: deps.CopyDraft, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, FormulaDraftInput{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + PersonaID: in.PersonaID, + AccountID: in.AccountID, + FormulaID: saved.ID, + Topic: topic, + Brief: in.Brief, + UseWebSearch: in.UseWebSearch, + DraftCount: in.DraftCount, + }, func(summary string, percentage int) { + adjusted := 35 + (percentage * 65 / 100) + progress(summary, adjusted) + }) +} diff --git a/backend/internal/library/personacopy/topic_matrix.go b/backend/internal/library/personacopy/topic_matrix.go new file mode 100644 index 0000000..9fc2453 --- /dev/null +++ b/backend/internal/library/personacopy/topic_matrix.go @@ -0,0 +1,321 @@ +package personacopy + +import ( + "context" + "fmt" + "strings" + + "haixun-backend/internal/library/copyvoice" + libmatrix "haixun-backend/internal/library/matrix" + "haixun-backend/internal/library/placement" + "haixun-backend/internal/library/style8d" + "haixun-backend/internal/library/websearch" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase" + copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type TopicMatrixInput struct { + TenantID string + OwnerUID string + PersonaID string + ContentPlanID string + Topic string + Brief string + UseWebSearch bool + DraftCount int +} + +type TopicMatrixDeps struct { + Persona personadomain.UseCase + CopyDraft copydraftdomain.UseCase + ContentOps contentopsdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI domai.UseCase +} + +type ProgressFn func(summary string, percentage int) + +func RunTopicMatrix(ctx context.Context, deps TopicMatrixDeps, in TopicMatrixInput, progress ProgressFn) ([]copydraftdomain.CopyDraftSummary, error) { + if progress == nil { + progress = func(string, int) {} + } + topic := strings.TrimSpace(in.Topic) + if topic == "" { + return nil, fmt.Errorf("topic is required") + } + count := in.DraftCount + if count <= 0 { + count = 3 + } + if count < 1 { + count = 1 + } + if count > 5 { + count = 5 + } + + progress("讀取人設語氣…", 10) + persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID) + if err != nil { + return nil, err + } + credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + researchNotes := "" + if in.UseWebSearch { + progress("搜尋網路參考資料…", 25) + researchNotes = searchTopicNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief) + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + feedbackNotes := recentFeedbackNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID) + knowledgeNotes := recentKnowledgeNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID, topic) + formulaNotes := formulaPoolNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID) + + progress(fmt.Sprintf("呼叫 AI 產生 %d 篇草稿(%s / %s)…", count, credential.Provider, credential.Model), 45) + parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + System: TopicMatrixSystemPrompt(), + Messages: []domai.Message{{Role: "user", Content: TopicMatrixUserPrompt(topic, in.Brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes, count)}}, + }, count) + if err != nil { + return nil, err + } + + progress("寫入草稿…", 85) + created := make([]copydraftdomain.CopyDraftSummary, 0, len(parsed.Rows)) + for _, row := range parsed.Rows { + saved, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + PersonaID: in.PersonaID, + ContentPlanID: in.ContentPlanID, + DraftType: copydraftentity.DraftTypeMatrix, + SortOrder: row.SortOrder, + Text: row.Text, + TopicTag: row.SearchTag, + Angle: row.Angle, + Hook: row.Hook, + Rationale: row.Rationale, + ReferenceNotes: row.ReferenceNotes, + Sources: row.SourcePermalinks, + AiScore: row.AiScore, + FormulaScore: row.FormulaScore, + BrandFitScore: row.BrandFitScore, + RiskScore: row.RiskScore, + SimilarityScore: row.SimilarityScore, + EngagementPotential: row.EngagementPotential, + FreshnessScore: row.FreshnessScore, + ReviewSuggestion: row.ReviewSuggestion, + }) + if saveErr != nil { + return created, saveErr + } + created = append(created, *saved) + } + progress(fmt.Sprintf("已產生 %d 篇草稿", len(created)), 100) + return created, nil +} + +func recentFeedbackNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string { + if deps.ContentOps == nil { + return "" + } + items, err := deps.ContentOps.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, 8) + if err != nil || len(items) == 0 { + return "" + } + var b strings.Builder + for _, item := range items { + decision := strings.TrimSpace(item.Decision) + note := strings.TrimSpace(item.Note) + if decision == "" && note == "" { + continue + } + b.WriteString("- ") + if decision != "" { + b.WriteString(decision) + } + if note != "" { + b.WriteString(":") + b.WriteString(note) + } + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func recentKnowledgeNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID, topic string) string { + if deps.ContentOps == nil { + return "" + } + items, err := deps.ContentOps.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, 12) + if err != nil { + return "" + } + var b strings.Builder + for _, item := range items { + content := strings.TrimSpace(item.Content) + if content == "" { + continue + } + b.WriteString("- ") + b.WriteString(shorten(content, 220)) + if item.RiskLevel != "" { + b.WriteString("(風險:") + b.WriteString(item.RiskLevel) + b.WriteString(")") + } + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func formulaPoolNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string { + if deps.ContentOps == nil { + return "" + } + items, err := deps.ContentOps.ListFormulaPools(ctx, tenantID, ownerUID, personaID, 12) + if err != nil { + return "" + } + var b strings.Builder + for _, item := range items { + b.WriteString("- ") + b.WriteString(item.Type) + b.WriteString(" / ") + b.WriteString(item.Name) + if item.Pattern != "" { + b.WriteString(":") + b.WriteString(item.Pattern) + } + if len(item.Avoid) > 0 { + b.WriteString(";避免:") + b.WriteString(strings.Join(item.Avoid, "、")) + } + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func shorten(raw string, max int) string { + r := []rune(strings.TrimSpace(raw)) + if len(r) <= max { + return string(r) + } + return string(r[:max]) + "..." +} + +func searchTopicNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, uid, topic, brief string) string { + research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid) + if err != nil || !placement.WebSearchAvailable(research) { + return "" + } + memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research) + if err != nil { + return "" + } + webClient := websearch.New(memberCtx.WebSearchConfig()) + if !webClient.Enabled() { + return "" + } + resp, err := webClient.Search(ctx, websearch.SearchOptions{ + Query: strings.TrimSpace(topic + " " + brief), + Limit: 5, + Mode: websearch.ModeKnowledgeExpand, + }) + if err != nil || len(resp.Results) == 0 { + return "" + } + var b strings.Builder + for _, item := range resp.Results { + if item.Title != "" { + b.WriteString("- ") + b.WriteString(item.Title) + b.WriteString("\n") + } + if item.Snippet != "" { + b.WriteString(item.Snippet) + b.WriteString("\n") + } + if item.URL != "" { + b.WriteString(item.URL) + b.WriteString("\n") + } + } + return strings.TrimSpace(b.String()) +} + +func TopicMatrixSystemPrompt() string { + return strings.TrimSpace(`你是 Threads / Instagram 貼文編輯。請依人設語言指紋產出自然、可直接發布的繁體中文貼文矩陣。 + +` + copyvoice.SystemRules() + ` + +規則: +- 不要每篇都用同一套 hook / 三段式 / 結尾問句。 +- 不要企業八股、不要像教科書、不要一直喊口號。 +- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。 +- 不要把人設介紹裡的物件/標籤硬寫進正文情境。例:人設寫 Y2K、咖啡、穿搭,只代表語感與審美,不代表每篇都要出現老歌、咖啡、穿搭、MV。 +- 正文內容必須由【話題】與【補充】決定;人設不得蓋過主題。 +- 每篇要有不同角度;長度與格式依人設習慣和主題型態決定,不要強制短文。 +- 每次至少產出 3 種版本思路:穩定帳號風格、更有共鳴、更容易互動;若 count 超過 3,再補更短或更反差版本。 +- text 只能放貼文正文,不要放標題、分析、公式、模板或說明。 +- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。 +- 只回傳 JSON 物件:{"rows":[...]}。 +- 每筆 row 必須包含 sort_order, search_tag, angle, hook, text, reference_notes, source_permalinks, rationale, ai_score, formula_score, brand_fit_score, risk_score, similarity_score, engagement_potential, freshness_score, review_suggestion。 +- 分數欄位都是 0-100 整數:ai_score / formula_score / risk_score / similarity_score 越低越好;brand_fit_score / engagement_potential / freshness_score 越高越好。 +- review_suggestion 只能是「可通過」「需人工確認」「建議重寫」之一。 +- rationale 必須用繁中短列點包含:版本定位、AI感分數(0-100,越高越AI)、公式感分數(0-100)、人設符合度(0-100)、風險分數(0-100)、互動潛力(0-100)、審核建議(可通過/需人工確認/建議重寫)與原因。`) +} + +func TopicMatrixUserPrompt(topic, brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes string, count int) string { + var b strings.Builder + b.WriteString("請產生 ") + b.WriteString(fmt.Sprintf("%d", count)) + b.WriteString(" 篇 Threads 草稿。\n\n【話題】\n") + b.WriteString(strings.TrimSpace(topic)) + if brief = strings.TrimSpace(brief); brief != "" { + b.WriteString("\n\n【補充】\n") + b.WriteString(brief) + } + if personaBlock = strings.TrimSpace(personaBlock); personaBlock != "" { + b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n") + b.WriteString(personaBlock) + b.WriteString("\n\n") + b.WriteString(copyvoice.PersonaCalibrationNote()) + } + if researchNotes = strings.TrimSpace(researchNotes); researchNotes != "" { + b.WriteString("\n\n【網路資料參考】\n") + b.WriteString(researchNotes) + } + if knowledgeNotes = strings.TrimSpace(knowledgeNotes); knowledgeNotes != "" { + b.WriteString("\n\n【Knowledge Memory:帳號知識來源,優先採用但不要硬塞】\n") + b.WriteString(knowledgeNotes) + } + if formulaNotes = strings.TrimSpace(formulaNotes); formulaNotes != "" { + b.WriteString("\n\n【Formula Pool:可選公式池,請依 Content Plan 選擇,不要每篇都套同一套】\n") + b.WriteString(formulaNotes) + } + if feedbackNotes = strings.TrimSpace(feedbackNotes); feedbackNotes != "" { + b.WriteString("\n\n【最近人工審核回饋(Feedback Memory,必須吸收)】\n") + b.WriteString(feedbackNotes) + } + b.WriteString("\n\n【品質閘門】\n請先自我檢查 AI 感、公式感、事實風險、人設一致性、與過去常見句型重複。高風險內容不得建議直接通過。") + b.WriteString("\n\n請讓每篇的 angle 明確不同,search_tag 是短主題標籤不含 #。source_permalinks 可放參考 URL,沒有就空陣列。") + return b.String() +} diff --git a/backend/internal/library/placement/capabilities.go b/backend/internal/library/placement/capabilities.go index 24fd855..424bdf8 100644 --- a/backend/internal/library/placement/capabilities.go +++ b/backend/internal/library/placement/capabilities.go @@ -46,8 +46,5 @@ func publishCapabilityHint(member MemberContext) string { if strings.TrimSpace(member.ActiveAccountID) == "" { return "請先建立並選定經營帳號" } - if member.DevMode && !member.AllowsThreadsAPI { - return "開發模式請在連線設定啟用 API 發文" - } return "請先完成 Threads API 連線後再發布貼文" } diff --git a/backend/internal/library/placement/context.go b/backend/internal/library/placement/context.go index 86e0ce6..3b128ae 100644 --- a/backend/internal/library/placement/context.go +++ b/backend/internal/library/placement/context.go @@ -101,6 +101,27 @@ func BuildMemberContext( } } +// MemberContextForAPIOnly forces Threads / web-search discover and disables Playwright routing. +// Used for all flows except explicit test-patrol jobs. +func MemberContextForAPIOnly(base MemberContext) MemberContext { + base.DevMode = false + base.SearchSourceMode = WithoutCrawler(base.SearchSourceMode) + base.AllowsCrawler = false + base.AllowsThreadsAPI = ModeAllowsThreadsAPI(base.SearchSourceMode) + base.AllowsBrave = ModeAllowsBrave(base.SearchSourceMode) + return base +} + +// MemberContextForCrawlerOnly forces Playwright-only discover for test patrol jobs. +func MemberContextForCrawlerOnly(base MemberContext) MemberContext { + base.DevMode = true + base.SearchSourceMode = SearchSourceCrawler + base.AllowsCrawler = true + base.AllowsBrave = false + base.AllowsThreadsAPI = false + return base +} + func (c MemberContext) PayloadFields() map[string]any { return map[string]any{ "tenant_id": c.TenantID, diff --git a/backend/internal/library/placement/context_api_only_test.go b/backend/internal/library/placement/context_api_only_test.go new file mode 100644 index 0000000..ad37a6b --- /dev/null +++ b/backend/internal/library/placement/context_api_only_test.go @@ -0,0 +1,33 @@ +package placement + +import "testing" + +func TestMemberContextForAPIOnlyDisablesCrawler(t *testing.T) { + base := BuildMemberContext( + "t", "u", "acc", + ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)}, + true, true, ResearchSettings{}, false, 10, + ) + apiOnly := MemberContextForAPIOnly(base) + if apiOnly.AllowsCrawler { + t.Fatal("API-only context must not allow crawler") + } + if apiOnly.DevMode { + t.Fatal("API-only context must not be dev mode") + } + if !apiOnly.AllowsThreadsAPI { + t.Fatal("API-only context should keep Threads API") + } +} + +func TestMemberContextForCrawlerOnlyStillWorks(t *testing.T) { + base := MemberContextForAPIOnly(BuildMemberContext( + "t", "u", "acc", + ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)}, + true, true, ResearchSettings{}, false, 10, + )) + crawler := MemberContextForCrawlerOnly(base) + if !crawler.AllowsCrawler || crawler.SearchSourceMode != SearchSourceCrawler { + t.Fatal("test patrol should force crawler-only routing") + } +} diff --git a/backend/internal/library/placement/crawler_exec.go b/backend/internal/library/placement/crawler_exec.go index c74a630..6a03905 100644 --- a/backend/internal/library/placement/crawler_exec.go +++ b/backend/internal/library/placement/crawler_exec.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -49,6 +51,9 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim if limit <= 0 { limit = 12 } + if endpoint := strings.TrimSpace(os.Getenv("HAIXUN_KEYWORD_SEARCH_URL")); endpoint != "" { + return runHTTPCrawlerSearch(ctx, endpoint, storageState, keyword, limit) + } repoRoot, cliPath, err := resolveKeywordSearchCLI() if err != nil { @@ -86,8 +91,41 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim return nil, fmt.Errorf("crawler search output parse failed: %w", err) } - posts := make([]DiscoverPost, 0, len(out.Posts)) - for _, item := range out.Posts { + return execCrawlerPostsToDiscover(out.Posts), nil +} + +func runHTTPCrawlerSearch(ctx context.Context, endpoint, storageState, keyword string, limit int) ([]DiscoverPost, error) { + payload, err := json.Marshal(execCrawlerInput{StorageState: storageState, Query: keyword, Limit: limit}) + if err != nil { + return nil, err + } + runCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + url := strings.TrimRight(endpoint, "/") + "/search" + req, err := http.NewRequestWithContext(runCtx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("crawler search service failed: %w", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("crawler search service returned HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body))) + } + var out execCrawlerOutput + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("crawler search service output parse failed: %w", err) + } + return execCrawlerPostsToDiscover(out.Posts), nil +} + +func execCrawlerPostsToDiscover(items []execCrawlerPost) []DiscoverPost { + posts := make([]DiscoverPost, 0, len(items)) + for _, item := range items { text := strings.TrimSpace(item.Text) if text == "" { continue @@ -107,7 +145,7 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim Source: DiscoverCrawler, }) } - return posts, nil + return posts } func resolveKeywordSearchCLI() (repoRoot, cliPath string, err error) { diff --git a/backend/internal/library/placement/discover.go b/backend/internal/library/placement/discover.go index d317506..845d2cc 100644 --- a/backend/internal/library/placement/discover.go +++ b/backend/internal/library/placement/discover.go @@ -49,13 +49,13 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove if ShouldTryCrawlerFirst(m) { posts, err := runCrawlerDiscover(ctx, req) if err == nil && len(posts) > 0 { - return posts, DiscoverCrawler, nil + return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil } if m.SearchSourceMode == SearchSourceCrawler { if err != nil { return nil, DiscoverCrawler, err } - return posts, DiscoverCrawler, nil + return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil } } @@ -73,7 +73,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove if m.CrawlerFallbackAllowed() { cPosts, cErr := runCrawlerDiscover(ctx, req) if cErr == nil && len(cPosts) > 0 { - return cPosts, DiscoverCrawler, nil + return maybeEnrichDiscoverPosts(ctx, m, cPosts), DiscoverCrawler, nil } } if !m.AllowsBrave && !m.CrawlerFallbackAllowed() { @@ -89,7 +89,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove if m.CrawlerFallbackAllowed() { posts, err := runCrawlerDiscover(ctx, req) if err == nil { - return posts, DiscoverCrawler, nil + return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil } } return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key(跟隨此登入帳號)", m.WebSearchProviderLabel()) @@ -102,8 +102,15 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove if err != nil { return nil, DiscoverCrawler, err } - return posts, DiscoverCrawler, nil + return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil } return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode) } + +func maybeEnrichDiscoverPosts(ctx context.Context, m MemberContext, posts []DiscoverPost) []DiscoverPost { + if !m.ApiConnected { + return posts + } + return EnrichDiscoverPostsMediaIDs(ctx, m.ThreadsAPIAccessToken, posts) +} diff --git a/backend/internal/library/placement/discover_for_query_test.go b/backend/internal/library/placement/discover_for_query_test.go new file mode 100644 index 0000000..1f6b30b --- /dev/null +++ b/backend/internal/library/placement/discover_for_query_test.go @@ -0,0 +1,69 @@ +package placement + +import ( + "context" + "testing" + + libkg "haixun-backend/internal/library/knowledge" + "haixun-backend/internal/library/websearch" +) + +type stubWebSearch struct { + enabled bool + posts []DiscoverPost +} + +func (s stubWebSearch) Search(ctx context.Context, opts websearch.SearchOptions) (websearch.SearchResponse, error) { + _ = ctx + _ = opts + results := make([]websearch.SearchResult, 0, len(s.posts)) + for _, post := range s.posts { + results = append(results, websearch.SearchResult{ + Title: post.Text, + URL: post.Permalink, + Snippet: "", + }) + } + return websearch.SearchResponse{Status: "success", Results: results, Provider: websearch.ProviderBrave}, nil +} + +func (s stubWebSearch) Enabled() bool { return s.enabled } +func (s stubWebSearch) Provider() websearch.Provider { return websearch.ProviderBrave } + +func TestDiscoverForQueryMergesWebSearchWhenBraveEnabled(t *testing.T) { + input := DualTrackInput{ + Member: MemberContext{ + AllowsBrave: true, + AllowsThreadsAPI: false, + SearchSourceMode: SearchSourceBrave, + WebSearchProvider: string(websearch.ProviderBrave), + BraveAPIKey: "test-key", + }, + WebSearch: stubWebSearch{ + enabled: true, + posts: []DiscoverPost{{ + Text: "最近洗衣精有推薦嗎?皮膚會癢", + Permalink: "https://www.threads.net/@user1/post/abc123", + }}, + }, + PatrolContext: PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "天然洗衣精", + MatchTags: []string{"洗衣精"}, + }, nil), + } + tq := TagQuery{ + Tag: "洗衣精 推薦", + Query: `site:threads.net 洗衣精 推薦`, + Dimension: QueryRelevance, + } + posts, channel, err := discoverForQuery(context.Background(), input, tq, 5) + if err != nil { + t.Fatalf("discoverForQuery error: %v", err) + } + if len(posts) == 0 { + t.Fatal("expected web search posts to be merged") + } + if channel != DiscoverBrave { + t.Fatalf("expected brave channel, got %s", channel) + } +} diff --git a/backend/internal/library/placement/dual_track.go b/backend/internal/library/placement/dual_track.go index 4f37509..bedbaae 100644 --- a/backend/internal/library/placement/dual_track.go +++ b/backend/internal/library/placement/dual_track.go @@ -24,6 +24,7 @@ type ScanCandidate struct { Text string SearchTag string QueryDimension QueryDimension + RecencyDays int GraphNodeID string ProductFitScore int Source DiscoverChannel @@ -50,6 +51,7 @@ type DualTrackInput struct { Nodes []libkg.Node PatrolKeywords []string Exclusions []string + PatrolContext PostScanContext Member MemberContext WebSearch websearch.Client Crawler CrawlerSearchFn @@ -104,17 +106,6 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue RecencyDays: IdealMaxPostAgeDays, }) } - q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays) - if q30 != "" && q30 != q7 { - out = append(out, TagQuery{ - Tag: tag, - Query: q30, - Dimension: QueryRecency, - GraphNodeID: node.ID, - ProductFitScore: fit, - RecencyDays: MaxPostAgeDays, - }) - } } } return out @@ -122,7 +113,12 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue // RunDualTrackDiscover executes relevance + recency queries and merges by permalink. func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) { - queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords, input.Member.WebSearchProviderEnum()) + queries := ResolveTagQueries( + input.Nodes, + input.PatrolKeywords, + input.Member.WebSearchProviderEnum(), + PatrolTagInputFromScanContext(input.PatrolContext), + ) if len(queries) == 0 { if len(input.PatrolKeywords) > 0 { return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句") @@ -145,15 +141,31 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress runQuery := func(tq TagQuery, limit int) error { posts, channel, err := discoverForQuery(ctx, input, tq, limit) if err != nil { - return err + if onProgress != nil { + onProgress(fmt.Sprintf("略過「%s」:%s", tq.Tag, err.Error()), -1) + } + return nil + } + if len(posts) == 0 { + return nil } for _, post := range posts { if MatchesExclusion(post.Text, input.Exclusions) { continue } - if !PassesPlacementFilter(post.Text) { + if LooksLikeCasualChat(post.Text) { continue } + postedAt := strings.TrimSpace(post.PostedAt) + if !PassesDiscoverFilters(post.Text, tq.Tag, postedAt, tq.Dimension, tq.ProductFitScore, tq.RecencyDays, input.PatrolContext) { + continue + } + bodyFit := ScorePostBodyProductFit(post.Text, input.PatrolContext) + semanticScore := LocalIntentScore(post.Text, input.PatrolContext) + effectiveFit := bodyFit + if tq.ProductFitScore > 0 && bodyFit > 0 { + effectiveFit = (tq.ProductFitScore + bodyFit*2) / 3 + } key := post.Permalink if key == "" { continue @@ -170,6 +182,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress extID = parsed.ExternalID } } + semP := &semanticScore merged[key] = &ScanCandidate{ Permalink: post.Permalink, ExternalID: extID, @@ -179,17 +192,19 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress Text: post.Text, SearchTag: tq.Tag, QueryDimension: tq.Dimension, + RecencyDays: recencyDaysForCandidate(tq), GraphNodeID: tq.GraphNodeID, - ProductFitScore: tq.ProductFitScore, + ProductFitScore: effectiveFit, Source: channel, HasRelevance: tq.Dimension == QueryRelevance, HasRecency: tq.Dimension == QueryRecency, Priority: priority, LikeCount: post.LikeCount, ReplyCount: post.ReplyCount, - PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil), - SolvedByProduct: tq.ProductFitScore >= 55, - PostedAt: strings.TrimSpace(post.PostedAt), + SemanticScore: semanticScore, + PlacementScore: computePlacementScore(post.Text, effectiveFit, tq.Dimension == QueryRecency, nil, semP, nil), + SolvedByProduct: PostSolvedByProduct(post.Text, effectiveFit, input.PatrolContext), + PostedAt: postedAt, } order = append(order, key) continue @@ -199,14 +214,27 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress } if tq.Dimension == QueryRecency { existing.HasRecency = true + existing.RecencyDays = mergeRecencyDays(existing.RecencyDays, tq.RecencyDays) } - if tq.ProductFitScore > existing.ProductFitScore { - existing.ProductFitScore = tq.ProductFitScore - existing.SolvedByProduct = tq.ProductFitScore >= 55 + if effectiveFit > existing.ProductFitScore || semanticScore > existing.SemanticScore { + if effectiveFit > existing.ProductFitScore { + existing.ProductFitScore = effectiveFit + } + if semanticScore > existing.SemanticScore { + existing.SemanticScore = semanticScore + } + existing.SolvedByProduct = PostSolvedByProduct(existing.Text, existing.ProductFitScore, input.PatrolContext) + semP := existing.SemanticScore + semPtr := &semP + if semP <= 0 { + semPtr = nil + } + existing.PlacementScore = computePlacementScore(existing.Text, existing.ProductFitScore, existing.HasRecency, nil, semPtr, nil) } if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" { existing.PostedAt = strings.TrimSpace(post.PostedAt) } + existing.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID) } return nil } @@ -217,15 +245,12 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress pct := 10 + ((i + 1) * 75 / max(total, 1)) onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct) } - limit := relevanceLimitPerTag - if tq.Dimension == QueryRecency { - limit = recencyLimitPerTag - } + limit := perTagDiscoverLimit(total, tq.Dimension) if err := runQuery(tq, limit); err != nil { return nil, err } if input.OnCheckpoint != nil { - snapshot := snapshotMergedCandidates(merged, order, false) + snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext) if err := input.OnCheckpoint(snapshot); err != nil { return nil, err } @@ -237,7 +262,48 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress } } - out := snapshotMergedCandidates(merged, order, true) + cascadeExtra := 0 + for _, st := range buildTagCascadeStates(queries) { + for countCandidatesForTag(merged, st.Tag) < MinRecencyCandidatesPerTag { + if cascadeExtra >= MaxRecencyCascadeQueries { + break + } + days := nextRecencyCascadeWindow(st.RanWindows) + if days == 0 { + break + } + tq, ok := buildRecencyCascadeQuery(st, days, input.Member.WebSearchProviderEnum()) + if !ok { + st.RanWindows[days] = true + continue + } + if onProgress != nil { + onProgress(fmt.Sprintf("近 %d 天結果不足,改搜近 %d 天:%s", previousRecencyWindow(days), days, st.Tag), -1) + } + limit := perTagDiscoverLimit(total, QueryRecency) + if err := runQuery(tq, limit); err != nil { + return nil, err + } + st.RanWindows[days] = true + cascadeExtra++ + if input.OnCheckpoint != nil { + snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext) + if err := input.OnCheckpoint(snapshot); err != nil { + return nil, err + } + } + if input.Member.AllowsCrawler && input.Member.BrowserConnected { + if err := politeDiscoverPause(ctx); err != nil { + return nil, err + } + } + } + if cascadeExtra >= MaxRecencyCascadeQueries { + break + } + } + + out := snapshotMergedCandidates(merged, order, true, input.PatrolContext) if onProgress != nil { onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90) } @@ -245,32 +311,71 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress } func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, limit int) ([]DiscoverPost, DiscoverChannel, error) { + apiKeyword := tq.Tag + if shaped := libkg.ThreadsAPIKeyword(tq.Tag); shaped != "" { + apiKeyword = shaped + } req := DiscoverRequest{ Query: tq.Query, - Keyword: tq.Tag, + Keyword: apiKeyword, Recency: tq.Dimension == QueryRecency, Limit: limit, Member: input.Member, Crawler: input.Crawler, } - posts, channel, err := Discover(ctx, req) - if err == nil && len(posts) > 0 { - return posts, channel, nil + merged := map[string]DiscoverPost{} + channel := DiscoverChannel("") + posts, primaryChannel, err := Discover(ctx, req) + for _, post := range posts { + key := strings.TrimSpace(post.Permalink) + if key == "" { + continue + } + if existing, ok := merged[key]; ok { + post.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID) + } + merged[key] = post } - if input.WebSearch == nil || !input.WebSearch.Enabled() { + if primaryChannel != "" { + channel = primaryChannel + } + + webEnabled := input.WebSearch != nil && input.WebSearch.Enabled() + if webEnabled && input.Member.AllowsBrave { + webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit) + if werr != nil && len(merged) == 0 && err != nil { + return nil, "", err + } + for _, post := range webPosts { + key := strings.TrimSpace(post.Permalink) + if key == "" { + continue + } + merged[key] = post + } + if len(merged) > 0 && channel == "" { + channel = input.Member.WebSearchDiscoverChannel() + } + } else if len(merged) == 0 { if err != nil { return nil, "", err } - return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel()) - } - webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit) - if werr != nil { - if err != nil { - return nil, "", err + if !webEnabled { + return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel()) } - return nil, "", werr } - return webPosts, input.Member.WebSearchDiscoverChannel(), nil + + out := make([]DiscoverPost, 0, len(merged)) + for _, post := range merged { + out = append(out, post) + } + if len(out) > limit { + out = out[:limit] + } + if channel == "" && len(out) > 0 { + channel = DiscoverThreadsAPI + } + return out, channel, nil } func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) { @@ -307,20 +412,50 @@ func discoverViaWebSearch(ctx context.Context, client websearch.Client, member M return out, nil } -func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool) []ScanCandidate { +func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool, ctx PostScanContext) []ScanCandidate { out := make([]ScanCandidate, 0, len(order)) for _, key := range order { item := merged[key] - finalizeScanCandidate(item) - if applyFinalFilter && item.ProductFitScore < 30 && item.Priority != "gold" { + finalizeScanCandidate(item, ctx) + if applyFinalFilter && !passesFinalStrictFilter(item) { continue } out = append(out, *item) } + if applyFinalFilter && len(out) == 0 && len(order) > 0 { + for _, key := range order { + item := merged[key] + finalizeScanCandidate(item, ctx) + if !PassesRelaxedFinalFilters(item.Text, item.ProductFitScore, ctx) { + continue + } + out = append(out, *item) + } + } + if applyFinalFilter && len(out) == 0 && len(order) > 0 { + for _, key := range order { + item := merged[key] + finalizeScanCandidate(item, ctx) + if !PassesIngestFallbackFilters(item.Text, item.ProductFitScore, ctx) { + continue + } + out = append(out, *item) + } + } return out } -func finalizeScanCandidate(item *ScanCandidate) { +func passesFinalStrictFilter(item *ScanCandidate) bool { + if item == nil { + return false + } + if item.ProductFitScore < minPostProductFitPatrol && item.Priority != "gold" { + return false + } + return item.SolvedByProduct +} + +func finalizeScanCandidate(item *ScanCandidate, ctx PostScanContext) { if item == nil { return } @@ -345,7 +480,7 @@ func finalizeScanCandidate(item *ScanCandidate) { qualP = &v } item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP) - item.SolvedByProduct = item.ProductFitScore >= 55 + item.SolvedByProduct = PostSolvedByProduct(item.Text, item.ProductFitScore, ctx) } func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int { @@ -413,6 +548,55 @@ func max(a, b int) int { return b } +func recencyDaysForCandidate(tq TagQuery) int { + if tq.Dimension != QueryRecency { + return 0 + } + if tq.RecencyDays > 0 { + return tq.RecencyDays + } + return IdealMaxPostAgeDays +} + +func mergeRecencyDays(existing, incoming int) int { + if incoming <= 0 { + return existing + } + if existing <= 0 { + return incoming + } + if incoming < existing { + return incoming + } + return existing +} + +func previousRecencyWindow(days int) int { + prev := IdealMaxPostAgeDays + for _, window := range RecencyCascadeDays() { + if window == days { + return prev + } + prev = window + } + return IdealMaxPostAgeDays +} + +func perTagDiscoverLimit(totalQueries int, dimension QueryDimension) int { + limit := relevanceLimitPerTag + if dimension == QueryRecency { + limit = recencyLimitPerTag + } + switch { + case totalQueries > 18: + return max(7, limit-3) + case totalQueries > 14: + return max(8, limit-2) + default: + return limit + } +} + func politeDiscoverPause(ctx context.Context) error { wait := 2*time.Second + jitterDuration(2*time.Second) timer := time.NewTimer(wait) diff --git a/backend/internal/library/placement/external_id.go b/backend/internal/library/placement/external_id.go new file mode 100644 index 0000000..1f52c8b --- /dev/null +++ b/backend/internal/library/placement/external_id.go @@ -0,0 +1,50 @@ +package placement + +import ( + "context" + "strings" + + libthreads "haixun-backend/internal/library/threadsapi" +) + +// PreferReplyExternalID keeps a numeric Threads media id when merging scan rows. +func PreferReplyExternalID(existing, incoming string) string { + a := strings.TrimSpace(existing) + b := strings.TrimSpace(incoming) + if libthreads.IsNumericMediaID(a) { + return a + } + if libthreads.IsNumericMediaID(b) { + return b + } + if b != "" { + return b + } + return a +} + +// EnrichDiscoverPostsMediaIDs resolves shortcode-only crawler rows via Threads API when possible. +func EnrichDiscoverPostsMediaIDs(ctx context.Context, accessToken string, posts []DiscoverPost) []DiscoverPost { + token := strings.TrimSpace(accessToken) + if token == "" || len(posts) == 0 { + return posts + } + client := libthreads.NewClient(token) + out := make([]DiscoverPost, len(posts)) + copy(out, posts) + for i := range out { + if libthreads.IsNumericMediaID(out[i].ExternalID) { + continue + } + id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: out[i].ExternalID, + Permalink: out[i].Permalink, + HintText: out[i].Text, + }) + if err != nil || !libthreads.IsNumericMediaID(id) { + continue + } + out[i].ExternalID = id + } + return out +} diff --git a/backend/internal/library/placement/external_id_test.go b/backend/internal/library/placement/external_id_test.go new file mode 100644 index 0000000..03db367 --- /dev/null +++ b/backend/internal/library/placement/external_id_test.go @@ -0,0 +1,14 @@ +package placement + +import "testing" + +func TestPreferReplyExternalIDKeepsNumeric(t *testing.T) { + got := PreferReplyExternalID("18123456789012345", "AbCdEf123") + if got != "18123456789012345" { + t.Fatalf("got %q", got) + } + got = PreferReplyExternalID("AbCdEf123", "18123456789012345") + if got != "18123456789012345" { + t.Fatalf("got %q", got) + } +} diff --git a/backend/internal/library/placement/filter.go b/backend/internal/library/placement/filter.go index 4d68aad..4fbdc31 100644 --- a/backend/internal/library/placement/filter.go +++ b/backend/internal/library/placement/filter.go @@ -5,6 +5,7 @@ import "regexp" var ( placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`) placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`) + painProblemRe = regexp.MustCompile(`痛|癢|乾|紅|腫|過敏|困擾|煩惱|難搞|不好用|沒效|無效|洗不掉|洗不乾淨|洗不乾|臭|異味|異味重|怕|擔心|問題|不舒服|刺激|脫皮|粗糙|暗沉|掉屑|掉毛|污漬|油漬|發炎`) casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`) ) @@ -35,3 +36,11 @@ func PassesPlacementFilter(text string) bool { } return HasPlacementIntent(text) } + +// HasPainPointDemand requires the post to read like someone asking for help with a solvable problem. +func HasPainPointDemand(text string) bool { + if !PassesPlacementFilter(text) { + return false + } + return painProblemRe.MatchString(text) || LooksLikeRecommendationPost(text) +} diff --git a/backend/internal/library/placement/media_resolve.go b/backend/internal/library/placement/media_resolve.go new file mode 100644 index 0000000..bc4b3c3 --- /dev/null +++ b/backend/internal/library/placement/media_resolve.go @@ -0,0 +1,57 @@ +package placement + +import ( + "context" + "strings" + + libthreads "haixun-backend/internal/library/threadsapi" +) + +type ReplyMediaResolveInput struct { + AccessToken string + StorageState string + ExternalID string + Permalink string + HintText string +} + +// ResolveReplyMediaWithFallback resolves a Threads reply target id via API and permalink scraping. +func ResolveReplyMediaWithFallback(ctx context.Context, in ReplyMediaResolveInput) (string, error) { + return resolveReplyMedia(ctx, in, false) +} + +// ResolveReplyMediaForPublish prefers fast permalink scraping, then API lookup. +func ResolveReplyMediaForPublish(ctx context.Context, in ReplyMediaResolveInput) (string, error) { + return resolveReplyMedia(ctx, in, true) +} + +func resolveReplyMedia(ctx context.Context, in ReplyMediaResolveInput, publishFastPath bool) (string, error) { + if libthreads.IsNumericMediaID(in.ExternalID) { + return strings.TrimSpace(in.ExternalID), nil + } + + permalink := strings.TrimSpace(in.Permalink) + if publishFastPath && permalink != "" { + if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) { + return scraped, nil + } + } + + client := libthreads.NewClient(in.AccessToken) + id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: in.ExternalID, + Permalink: in.Permalink, + HintText: in.HintText, + }) + if err == nil && libthreads.IsNumericMediaID(id) { + return id, nil + } + + if !publishFastPath && permalink != "" { + if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) { + return scraped, nil + } + } + + return "", err +} diff --git a/backend/internal/library/placement/parse_threads.go b/backend/internal/library/placement/parse_threads.go index ee1ae31..740d867 100644 --- a/backend/internal/library/placement/parse_threads.go +++ b/backend/internal/library/placement/parse_threads.go @@ -23,8 +23,14 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos if len(match) < 3 { return ParsedThreadsPost{}, false } - text := strings.TrimSpace(strings.Join([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}, " — ")) - if len([]rune(text)) < 8 { + text := strings.TrimSpace(strings.Join(filterNonEmpty([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}), " — ")) + if text == "" { + text = strings.TrimSpace(title) + } + if text == "" { + text = strings.TrimSpace(snippet) + } + if len([]rune(text)) < 6 { return ParsedThreadsPost{}, false } return ParsedThreadsPost{ @@ -35,6 +41,16 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos }, true } +func filterNonEmpty(items []string) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item) != "" { + out = append(out, item) + } + } + return out +} + func normalizeThreadsPermalink(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/backend/internal/library/placement/patrol_queries.go b/backend/internal/library/placement/patrol_queries.go index b1c1dd7..cc2d851 100644 --- a/backend/internal/library/placement/patrol_queries.go +++ b/backend/internal/library/placement/patrol_queries.go @@ -1,24 +1,31 @@ package placement import ( + "sort" "strings" libkg "haixun-backend/internal/library/knowledge" "haixun-backend/internal/library/websearch" ) -const defaultPatrolProductFit = 78 +const ( + defaultPatrolProductFitNoGraph = 42 + maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2 +) -// CollectPatrolTagQueries builds dual-track crawl jobs from user-edited patrol keywords only. -func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider) []TagQuery { +// CollectPatrolTagQueries builds lean dual-track jobs: relevance + 7d recency per keyword. +func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery { keywords = libkg.NormalizePatrolKeywordList(keywords) if len(keywords) == 0 { return nil } + if len(keywords) > libkg.MaxScanPatrolKeywords { + keywords = keywords[:libkg.MaxScanPatrolKeywords] + } - out := make([]TagQuery, 0, len(keywords)*3) + out := make([]TagQuery, 0, len(keywords)*2) for _, tag := range keywords { - fit := productFitForPatrolTag(tag, nodes) + fit := productFitForPatrolTag(tag, nodes, patrolInput) if q := BuildRelevanceQuery(provider, tag); q != "" { out = append(out, TagQuery{ Tag: tag, @@ -36,25 +43,15 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider web RecencyDays: IdealMaxPostAgeDays, }) } - if q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays); q30 != "" { - if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q30 != q7 { - out = append(out, TagQuery{ - Tag: tag, - Query: q30, - Dimension: QueryRecency, - ProductFitScore: fit, - RecencyDays: MaxPostAgeDays, - }) - } - } } return out } -func productFitForPatrolTag(tag string, nodes []libkg.Node) int { +func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int { tagKey := patrolTagMatchKey(tag) best := 0 - bestNodeID := "" + profile := libkg.BuildIntentProfile(patrolInput) + intentFit := libkg.ScoreIntentSimilarity(tag, profile) for _, node := range nodes { score := node.ProductFitScore if score <= 0 { @@ -72,14 +69,25 @@ func productFitForPatrolTag(tag string, nodes []libkg.Node) int { } if score > best { best = score - bestNodeID = node.ID } } if best > 0 { - return best + return maxPatrolFit(best, intentFit) } - _ = bestNodeID - return defaultPatrolProductFit + if intentFit > 0 { + return intentFit + } + return defaultPatrolProductFitNoGraph +} + +func maxPatrolFit(values ...int) int { + best := 0 + for _, v := range values { + if v > best { + best = v + } + } + return best } func patrolTagMatchKey(tag string) string { @@ -93,10 +101,66 @@ func patrolTagMatchKey(tag string) string { return strings.TrimSpace(tag) } -// ResolveTagQueries prefers explicit patrol keywords over graph node selection. -func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider) []TagQuery { - if len(patrolKeywords) > 0 { - return CollectPatrolTagQueries(patrolKeywords, nodes, provider) +// ResolveTagQueries builds Search API jobs from the best-ranked patrol keywords. +func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery { + nodes = libkg.NodesForPatrolKeywordDerivation(nodes) + keywords := patrolKeywords + if len(keywords) == 0 { + keywords = libkg.SelectBestSearchKeywords(nil, nil, patrolInput, nodes, libkg.MaxScanPatrolKeywords) } - return CollectTagQueries(nodes, provider) + queries := CollectPatrolTagQueries(keywords, nodes, provider, patrolInput) + if len(queries) == 0 { + queries = CollectTagQueries(nodes, provider) + } + return trimPatrolQueries(queries, maxPatrolQueriesPerScan, patrolInput) +} + +func trimPatrolQueries(queries []TagQuery, max int, patrolInput libkg.PatrolTagInput) []TagQuery { + profile := libkg.BuildIntentProfile(patrolInput) + if max <= 0 || len(queries) <= max { + return queries + } + type ranked struct { + query TagQuery + key string + } + rankedQueries := make([]ranked, 0, len(queries)) + seen := map[string]struct{}{} + for _, item := range queries { + key := strings.TrimSpace(item.Query) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + rankedQueries = append(rankedQueries, ranked{query: item, key: key}) + } + sort.SliceStable(rankedQueries, func(i, j int) bool { + left := rankedQueries[i].query.ProductFitScore + right := rankedQueries[j].query.ProductFitScore + leftIntent := libkg.ScoreIntentSimilarity(rankedQueries[i].query.Tag, profile) + rightIntent := libkg.ScoreIntentSimilarity(rankedQueries[j].query.Tag, profile) + leftRank := left*2 + leftIntent*3 + rightRank := right*2 + rightIntent*3 + if leftRank == rightRank { + if rankedQueries[i].query.Dimension == rankedQueries[j].query.Dimension { + return rankedQueries[i].key < rankedQueries[j].key + } + if rankedQueries[i].query.Dimension == QueryRelevance { + return true + } + return false + } + return leftRank > rightRank + }) + out := make([]TagQuery, 0, max) + for _, item := range rankedQueries { + out = append(out, item.query) + if len(out) >= max { + break + } + } + return out } diff --git a/backend/internal/library/placement/patrol_queries_test.go b/backend/internal/library/placement/patrol_queries_test.go index 5551df3..cc8dc10 100644 --- a/backend/internal/library/placement/patrol_queries_test.go +++ b/backend/internal/library/placement/patrol_queries_test.go @@ -8,15 +8,15 @@ import ( ) func TestCollectPatrolTagQueriesManualOnly(t *testing.T) { - queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave) + queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave, libkg.PatrolTagInput{}) if len(queries) < 2 { t.Fatalf("expected relevance + recency queries, got %d", len(queries)) } if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance { t.Fatalf("unexpected first query: %+v", queries[0]) } - if queries[0].ProductFitScore != defaultPatrolProductFit { - t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFit, queries[0].ProductFitScore) + if queries[0].ProductFitScore != defaultPatrolProductFitNoGraph { + t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFitNoGraph, queries[0].ProductFitScore) } } @@ -31,7 +31,7 @@ func TestCollectPatrolTagQueriesUsesGraphFit(t *testing.T) { }, }, } - queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave) + queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave, libkg.PatrolTagInput{}) if len(queries) == 0 || queries[0].ProductFitScore != 92 { t.Fatalf("expected graph fit 92, got %+v", queries) } @@ -41,7 +41,7 @@ func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) { nodes := []libkg.Node{ {ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}}, } - queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave) + queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave, libkg.PatrolTagInput{}) if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" { t.Fatalf("expected patrol keyword query, got %+v", queries) } diff --git a/backend/internal/library/placement/post_relevance.go b/backend/internal/library/placement/post_relevance.go new file mode 100644 index 0000000..3b717c8 --- /dev/null +++ b/backend/internal/library/placement/post_relevance.go @@ -0,0 +1,534 @@ +package placement + +import ( + "strings" + "time" + "unicode" + + libkg "haixun-backend/internal/library/knowledge" +) + +const ( + minPostBodyProductFit = 50 + minPostBodyProductFitDiscover = 28 + minPostProductFitPatrol = 58 + minPostProductFitDiscover = 34 + minPostProductFitRelevant = 62 + minPostProductFitRelaxedFinal = 38 + minPostProductFitIngestFinal = 28 + minPostPillarAnchor = 1 + patrolMaxPostAgeDays = IdealMaxPostAgeDays + relevanceMaxPostAgeDays = 14 +) + +// PostScanContext carries research map + product signals for post-level filtering. +type PostScanContext struct { + MatchTags []string + ProductName string + ProductFeatures string + AudienceSummary string + TargetAudience string + Questions []string + Pillars []string + Exclusions []string +} + +func PostScanContextFromPatrolInput(in libkg.PatrolTagInput, exclusions []string) PostScanContext { + return PostScanContext{ + MatchTags: append([]string{}, in.MatchTags...), + ProductName: strings.TrimSpace(in.ProductName), + ProductFeatures: strings.TrimSpace(in.ProductFeatures), + AudienceSummary: strings.TrimSpace(in.AudienceSummary), + TargetAudience: strings.TrimSpace(in.TargetAudience), + Questions: append([]string{}, in.Questions...), + Pillars: append([]string{}, in.Pillars...), + Exclusions: ExpandExclusionTokens(exclusions), + } +} + +func ScorePostProductFit(text, searchTag string, ctx PostScanContext) int { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" { + return 0 + } + tagScore := ScoreProductForTag(searchTag, ctx.MatchTags) + if tagScore == 0 && ctx.ProductName != "" { + tagScore = ScoreProductForTag(searchTag, []string{ctx.ProductName}) + } + postScore := ScoreProductForTag(text, ctx.MatchTags) + if postScore == 0 && ctx.ProductName != "" { + postScore = scoreTextAgainstPhrase(text, ctx.ProductName) + } + if ctx.ProductFeatures != "" { + postScore = maxInt(postScore, scoreTextAgainstPhrase(text, ctx.ProductFeatures)) + } + for _, pillar := range ctx.Pillars { + postScore = maxInt(postScore, scoreTextAgainstPhrase(text, pillar)/2) + } + combined := postScore + if tagScore > 0 && postScore > 0 { + combined = (tagScore*2 + postScore*3) / 5 + } else if postScore > 0 { + combined = postScore + } else if tagScore > 0 { + combined = tagScore / 2 + } + if HasCategoryConflict(text, ctx) { + return minInt(combined, 25) + } + if HasTangentialTopicMismatch(text, ctx) { + return minInt(combined, 20) + } + intentScore := LocalIntentScore(text, ctx) + if intentScore > combined { + combined = (combined*2 + intentScore*3) / 5 + } + return combined +} + +// PassesDiscoverFilters is the crawl-stage gate: collect candidates from Search/Threads API. +// Stricter ranking happens at finalize. +func PassesDiscoverFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit, recencyDays int, ctx PostScanContext) bool { + text = strings.TrimSpace(text) + if text == "" { + return false + } + if MatchesExclusion(text, ctx.Exclusions) { + return false + } + if HasCategoryConflict(text, ctx) { + return false + } + if HasTangentialTopicMismatch(text, ctx) { + return false + } + if !HasPainPointDemand(text) && !HasPlacementIntent(text) { + return false + } + if !PassesPostAge(postedAt, dimension, recencyDays) { + return false + } + bodyFit := ScorePostBodyProductFit(text, ctx) + effectiveFit := bodyFit + if tagFit > 0 && bodyFit > 0 { + effectiveFit = (tagFit + bodyFit*2) / 3 + } else if tagFit > 0 && HasPlacementIntent(text) { + effectiveFit = tagFit / 2 + } + if bodyFit < minPostBodyProductFitDiscover { + if !(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) { + return false + } + } + return effectiveFit >= minPostProductFitDiscover || + (HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) +} + +func PassesPostScanFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit int, ctx PostScanContext) bool { + text = strings.TrimSpace(text) + if text == "" { + return false + } + if MatchesExclusion(text, ctx.Exclusions) { + return false + } + if HasCategoryConflict(text, ctx) { + return false + } + if HasTangentialTopicMismatch(text, ctx) { + return false + } + if !HasPainPointDemand(text) { + return false + } + if !PassesPostAge(postedAt, dimension, 0) { + return false + } + bodyFit := ScorePostBodyProductFit(text, ctx) + if bodyFit < minPostBodyProductFit { + return false + } + if !ProductSolvesPostPain(text, ctx) { + return false + } + effectiveFit := bodyFit + if tagFit > 0 && bodyFit > 0 { + effectiveFit = (tagFit + bodyFit*2) / 3 + } + minFit := minPostProductFitPatrol + if dimension == QueryRelevance { + minFit = minPostProductFitRelevant + } + return effectiveFit >= minFit +} + +// ScorePostBodyProductFit scores only the post body against the product offer (ignores search tag). +func ScorePostBodyProductFit(text string, ctx PostScanContext) int { + return ScorePostProductFit(text, "", ctx) +} + +// ProductSolvesPostPain checks pain in the post aligns with what the product can solve. +func ProductSolvesPostPain(text string, ctx PostScanContext) bool { + if HasCategoryConflict(text, ctx) { + return false + } + if HasTangentialTopicMismatch(text, ctx) { + return false + } + if matchesTopicAnchor(text, ctx) { + return true + } + bodyFit := ScorePostBodyProductFit(text, ctx) + if bodyFit < minPostBodyProductFit { + return false + } + for _, q := range ctx.Questions { + if tokenHits(text, q) >= 2 && bodyFit >= minPostBodyProductFit { + return true + } + } + if ctx.ProductName != "" && scoreTextAgainstPhrase(text, ctx.ProductName) >= 55 { + return true + } + for _, tag := range ctx.MatchTags { + if scoreTextAgainstPhrase(text, tag) >= 55 { + return true + } + } + return LocalIntentScore(text, ctx) >= minPostBodyProductFit +} + +// PostSolvedByProduct is the final flag for UI: demand + fit + no mismatch. +func PostSolvedByProduct(text string, effectiveFit int, ctx PostScanContext) bool { + return effectiveFit >= minPostProductFitPatrol && + HasPainPointDemand(text) && + ProductSolvesPostPain(text, ctx) && + !HasCategoryConflict(text, ctx) && + !HasTangentialTopicMismatch(text, ctx) +} + +// PassesRelaxedFinalFilters keeps demand + basic fit when strict pass yields zero results. +func PassesRelaxedFinalFilters(text string, effectiveFit int, ctx PostScanContext) bool { + text = strings.TrimSpace(text) + if text == "" { + return false + } + if MatchesExclusion(text, ctx.Exclusions) { + return false + } + if HasCategoryConflict(text, ctx) { + return false + } + if HasTangentialTopicMismatch(text, ctx) { + return false + } + if !HasPainPointDemand(text) && !HasPlacementIntent(text) { + return false + } + return effectiveFit >= minPostProductFitRelaxedFinal +} + +// PassesIngestFallbackFilters keeps basic searchable posts when strict/relaxed gates yield zero. +func PassesIngestFallbackFilters(text string, effectiveFit int, ctx PostScanContext) bool { + text = strings.TrimSpace(text) + if text == "" { + return false + } + if MatchesExclusion(text, ctx.Exclusions) { + return false + } + if HasCategoryConflict(text, ctx) { + return false + } + if !HasPlacementIntent(text) && !HasPainPointDemand(text) { + return false + } + return effectiveFit >= minPostProductFitIngestFinal +} + +func matchesTopicAnchor(text string, ctx PostScanContext) bool { + text = strings.ToLower(text) + hits := 0 + for _, pillar := range ctx.Pillars { + if tokenHits(text, pillar) > 0 { + hits++ + } + } + for _, q := range ctx.Questions { + if tokenHits(text, q) >= 2 { + hits++ + } + } + if hits >= minPostPillarAnchor { + return true + } + for _, tag := range ctx.MatchTags { + if tokenHits(text, tag) > 0 { + return true + } + } + if ctx.ProductName != "" && tokenHits(text, ctx.ProductName) > 0 { + return true + } + for _, phrase := range []string{ctx.AudienceSummary, ctx.TargetAudience} { + if tokenHits(text, phrase) >= 2 { + return true + } + } + return false +} + +func PassesPostAge(postedAt string, dimension QueryDimension, recencyDays int) bool { + postedAt = strings.TrimSpace(postedAt) + if postedAt == "" { + // Web Search snippets often lack dates; do not drop at ingest. + return true + } + maxDays := patrolMaxPostAgeDays + switch dimension { + case QueryRelevance: + maxDays = relevanceMaxPostAgeDays + case QueryRecency: + if recencyDays > 0 { + maxDays = recencyDays + } + } + return IsPostWithinMaxAge(postedAt, maxDays, time.Now()) +} + +func IsPostWithinMaxAge(postedAt string, maxDays int, now time.Time) bool { + if maxDays <= 0 { + return true + } + ts, ok := ParsePostedAt(postedAt) + if !ok { + return true + } + if now.IsZero() { + now = time.Now() + } + cutoff := now.AddDate(0, 0, -maxDays) + return !ts.Before(cutoff) +} + +func ParsePostedAt(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + layouts := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z07:00", + "2006-01-02 15:04:05", + "2006-01-02", + "2006/01/02", + } + for _, layout := range layouts { + if ts, err := time.Parse(layout, raw); err == nil { + return ts, true + } + } + return time.Time{}, false +} + +func ExpandExclusionTokens(exclusions []string) []string { + seen := map[string]struct{}{} + out := []string{} + add := func(token string) { + token = strings.TrimSpace(strings.ToLower(token)) + if len([]rune(token)) < 2 { + return + } + if _, ok := seen[token]; ok { + return + } + seen[token] = struct{}{} + out = append(out, token) + } + for _, rule := range exclusions { + rule = strings.TrimSpace(rule) + if rule == "" { + continue + } + add(rule) + for _, token := range tokenizePhrase(rule) { + add(token) + } + for _, synonym := range exclusionSynonyms(rule) { + add(synonym) + } + } + return out +} + +func exclusionSynonyms(rule string) []string { + rule = strings.ToLower(rule) + out := []string{} + for _, group := range exclusionCategoryGroups { + if strings.Contains(rule, group.key) { + out = append(out, group.tokens...) + } + } + return out +} + +var exclusionCategoryGroups = []struct { + key string + tokens []string +}{ + {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房清潔", "油污"}}, + {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服清洗", "污漬"}}, + {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡"}}, + {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "頭皮", "髮品"}}, + {key: "寵物", tokens: []string{"寵物", "貓砂", "狗糧", "貓咪", "狗狗"}}, + {key: "廚房", tokens: []string{"廚房", "抽油煙機", "鍋具"}}, +} + +var productCategoryGroups = []struct { + key string + tokens []string +}{ + {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服", "污漬", "漂白"}}, + {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房", "油污"}}, + {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡", "沖澡"}}, + {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "洗髮乳", "頭皮", "髮絲"}}, + {key: "清潔", tokens: []string{"清潔", "打掃", "去污"}}, +} + +func productCategories(ctx PostScanContext) []string { + blob := strings.ToLower(ctx.ProductName + " " + ctx.ProductFeatures + " " + strings.Join(ctx.MatchTags, " ")) + found := []string{} + for _, group := range productCategoryGroups { + for _, token := range group.tokens { + if strings.Contains(blob, token) { + found = append(found, group.key) + break + } + } + } + return found +} + +func postCategories(text string) []string { + text = strings.ToLower(text) + found := []string{} + for _, group := range productCategoryGroups { + for _, token := range group.tokens { + if strings.Contains(text, token) { + found = append(found, group.key) + break + } + } + } + return found +} + +var genericProductCategories = map[string]struct{}{ + "清潔": {}, +} + +func HasCategoryConflict(text string, ctx PostScanContext) bool { + productCats := productCategories(ctx) + if len(productCats) == 0 { + return false + } + postCats := postCategories(text) + if len(postCats) == 0 { + return false + } + productSet := map[string]struct{}{} + for _, c := range productCats { + productSet[c] = struct{}{} + } + for _, c := range postCats { + if _, ok := genericProductCategories[c]; ok { + continue + } + if _, ok := productSet[c]; !ok { + return true + } + } + return false +} + +func scoreTextAgainstPhrase(text, phrase string) int { + text = strings.ToLower(text) + phrase = strings.TrimSpace(strings.ToLower(phrase)) + if text == "" || phrase == "" { + return 0 + } + if strings.Contains(text, phrase) { + return 85 + } + best := 0 + for _, token := range tokenizePhrase(phrase) { + if len([]rune(token)) < 2 { + continue + } + if strings.Contains(text, token) { + best = maxInt(best, 55) + } + } + return best +} + +func tokenHits(text, phrase string) int { + text = strings.ToLower(text) + hits := 0 + for _, token := range tokenizePhrase(phrase) { + if len([]rune(token)) < 2 { + continue + } + if strings.Contains(text, token) { + hits++ + } + } + return hits +} + +func tokenizePhrase(phrase string) []string { + phrase = strings.ToLower(strings.TrimSpace(phrase)) + if phrase == "" { + return nil + } + repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ") + phrase = repl.Replace(phrase) + var tokens []string + for _, part := range strings.Fields(phrase) { + part = strings.Trim(part, `"'「」『』::`) + if part != "" { + tokens = append(tokens, part) + } + } + if len(tokens) == 0 { + return splitRunes(phrase) + } + return tokens +} + +func splitRunes(s string) []string { + var out []string + var buf []rune + flush := func() { + if len(buf) >= 2 { + out = append(out, string(buf)) + } + buf = buf[:0] + } + for _, r := range s { + if unicode.Is(unicode.Han, r) { + buf = append(buf, r) + continue + } + flush() + } + flush() + return out +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/library/placement/post_relevance_test.go b/backend/internal/library/placement/post_relevance_test.go new file mode 100644 index 0000000..7609dc9 --- /dev/null +++ b/backend/internal/library/placement/post_relevance_test.go @@ -0,0 +1,148 @@ +package placement + +import ( + "testing" + "time" + + libkg "haixun-backend/internal/library/knowledge" +) + +func TestHasCategoryConflictLaundryVsDish(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "天然洗衣精", + MatchTags: []string{"洗衣精", "衣物清潔"}, + }, nil) + text := "請問大家洗碗機清潔劑有推薦嗎?碗盤油污洗不掉" + if !HasCategoryConflict(text, ctx) { + t.Fatal("expected dishwasher post to conflict with laundry product") + } + if PassesPostScanFilters(text, "洗衣精 推薦", "", QueryRecency, 80, ctx) { + t.Fatal("expected conflicting post to be filtered out") + } +} + +func TestPassesPostScanFiltersAcceptsMatchingLaundry(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "天然洗衣精", + MatchTags: []string{"洗衣精", "衣物"}, + Pillars: []string{"衣物清潔", "敏感皮膚洗衣"}, + }, []string{"洗碗精等其他品類"}) + text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉" + if !PassesPostScanFilters(text, "洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 70, ctx) { + t.Fatal("expected matching laundry post to pass") + } +} + +func TestPassesPostAgeAllowsMissingDateOnRelevance(t *testing.T) { + if !PassesPostAge("", QueryRelevance, 0) { + t.Fatal("expected missing posted_at to pass age gate at ingest") + } +} + +func TestPassesDiscoverFiltersAllowsWebSnippetWithoutDate(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "天然洗衣精", + MatchTags: []string{"洗衣精", "衣物"}, + }, nil) + text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉" + if !PassesDiscoverFilters(text, "洗衣精 推薦", "", QueryRelevance, 70, 0, ctx) { + t.Fatal("expected web snippet without date to pass discover filters") + } +} + +func TestPassesPostAgeRejectsOldRelevancePost(t *testing.T) { + old := time.Now().AddDate(0, 0, -20).Format("2006-01-02") + if PassesPostAge(old, QueryRelevance, 0) { + t.Fatal("expected old relevance post to fail age gate") + } +} + +func TestHasPainPointDemandRejectsCasualChat(t *testing.T) { + if HasPainPointDemand("今天天氣真好,晒照給大家看") { + t.Fatal("expected casual chat without demand to fail") + } +} + +func TestHasPainPointDemandAcceptsHelpSeeking(t *testing.T) { + text := "最近皮膚一直癢,沐浴乳有推薦嗎?" + if !HasPainPointDemand(text) { + t.Fatal("expected help-seeking post with pain signal to pass") + } +} + +func TestPostSolvedByProductRejectsCategoryMismatch(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "天然洗衣精", + MatchTags: []string{"洗衣精"}, + }, nil) + text := "洗碗精有推薦嗎?碗盤油污洗不掉" + if PostSolvedByProduct(text, 80, ctx) { + t.Fatal("expected dish post not to be solved by laundry product") + } +} + +func TestHasTangentialTopicMismatchRejectsWashingMachine(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏、適合敏感肌與化療族群", + MatchTags: []string{"無香", "抗敏", "洗衣精"}, + Pillars: []string{"化療後衣物清潔", "無香洗衣"}, + }, nil) + text := "請問大家有推薦的洗衣機嗎?想買洗脫烘" + if !HasTangentialTopicMismatch(text, ctx) { + t.Fatal("expected washing machine post to be tangential mismatch for hypoallergenic detergent") + } + if PassesPostScanFilters(text, "無香 洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 80, ctx) { + t.Fatal("expected washing machine post to be filtered out") + } +} + +func TestHasTangentialTopicMismatchAcceptsDetergentPain(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏", + MatchTags: []string{"無香", "洗衣精"}, + }, nil) + text := "化療後對香味很敏感,有無香洗衣精推薦嗎?" + if HasTangentialTopicMismatch(text, ctx) { + t.Fatal("expected detergent pain post to pass tangential check") + } +} + +func TestHasTangentialTopicMismatchAcceptsMachineWithDetergentNeed(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "抗敏無香洗衣精", + ProductFeatures: "完全無香、抗敏", + MatchTags: []string{"無香", "洗衣精"}, + }, nil) + text := "買了洗衣機,但想找不會讓皮膚癢的無香洗衣精" + if HasTangentialTopicMismatch(text, ctx) { + t.Fatal("expected combined machine + detergent pain post to pass") + } +} + +func TestPostSolvedByProductAcceptsAlignedDemand(t *testing.T) { + ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{ + ProductName: "天然洗衣精", + MatchTags: []string{"洗衣精", "衣物"}, + Pillars: []string{"衣物清潔"}, + }, nil) + text := "衣服污漬洗不掉,洗衣精有推薦嗎?" + if !PostSolvedByProduct(text, 70, ctx) { + t.Fatal("expected aligned laundry demand to be solved by product") + } +} + +func TestExpandExclusionTokensIncludesDishSynonyms(t *testing.T) { + tokens := ExpandExclusionTokens([]string{"洗碗精等其他品類"}) + found := false + for _, token := range tokens { + if token == "洗碗機" || token == "洗碗精" { + found = true + break + } + } + if !found { + t.Fatalf("expected dish synonyms, got %v", tokens) + } +} diff --git a/backend/internal/library/placement/product_catalog.go b/backend/internal/library/placement/product_catalog.go index 29ed36f..32ef56b 100644 --- a/backend/internal/library/placement/product_catalog.go +++ b/backend/internal/library/placement/product_catalog.go @@ -60,7 +60,8 @@ func ResolveBrandProductContext(brand brandentity.Brand, productID, searchTag st if len(brand.Products) > 0 { picked := RecommendProduct(brand.Products, searchTag, productID) if picked != nil { - return BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label) + merged := BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label) + return ApplyPlacementURL(merged, picked.PlacementURL) } } if formatted := ProductBriefFromContext(brand.ProductContext); formatted != "" { diff --git a/backend/internal/library/placement/product_context.go b/backend/internal/library/placement/product_context.go index b7aa429..a8c6b2f 100644 --- a/backend/internal/library/placement/product_context.go +++ b/backend/internal/library/placement/product_context.go @@ -95,6 +95,20 @@ func FormatProductContextForPrompt(raw string) string { return strings.Join(lines, "\n") } +func ApplyPlacementURL(productContext, placementURL string) string { + placementURL = strings.TrimSpace(placementURL) + if placementURL == "" { + return productContext + } + fields := ParseProductContext(productContext) + if fields.CtaType == CtaLink && strings.TrimSpace(fields.CtaUrl) != "" { + return productContext + } + fields.CtaType = CtaLink + fields.CtaUrl = placementURL + return SerializeProductContext(fields) +} + func ProductBriefFromContext(raw string) string { formatted := FormatProductContextForPrompt(raw) if formatted != "" { diff --git a/backend/internal/library/placement/product_context_placement_url_test.go b/backend/internal/library/placement/product_context_placement_url_test.go new file mode 100644 index 0000000..37b91ea --- /dev/null +++ b/backend/internal/library/placement/product_context_placement_url_test.go @@ -0,0 +1,25 @@ +package placement + +import ( + "strings" + "testing" +) + +func TestApplyPlacementURL(t *testing.T) { + raw := `{"brand":"ecostore","product":"沐浴露","features":"無香抗敏"}` + got := ApplyPlacementURL(raw, "https://shop.example.com/body-wash") + formatted := ProductBriefFromContext(got) + if !strings.Contains(formatted, "留言 CTA 連結") || !strings.Contains(formatted, "https://shop.example.com/body-wash") { + t.Fatalf("formatted brief = %q", formatted) + } + + existing := SerializeProductContext(ProductContextFields{ + Brand: "ecostore", + Product: "沐浴露", + CtaType: CtaLink, + CtaUrl: "https://existing.example.com", + }) + if changed := ApplyPlacementURL(existing, "https://new.example.com"); changed != existing { + t.Fatalf("should not override existing cta url") + } +} diff --git a/backend/internal/library/placement/product_match.go b/backend/internal/library/placement/product_match.go index 81d4203..75afa19 100644 --- a/backend/internal/library/placement/product_match.go +++ b/backend/internal/library/placement/product_match.go @@ -2,6 +2,8 @@ package placement import ( "strings" + + brandentity "haixun-backend/internal/model/brand/domain/entity" ) func ScoreProductForTag(searchTag string, matchTags []string) int { @@ -40,6 +42,71 @@ func ScoreProductForTag(searchTag string, matchTags []string) int { return best } +// ScoreProductForPost ranks a SKU against the search tag and post body. +func ScoreProductForPost(productLabel string, matchTags []string, searchTag, postText string) int { + tagScore := ScoreProductForTag(searchTag, matchTags) + if tagScore == 0 && strings.TrimSpace(productLabel) != "" { + tagScore = ScoreProductForTag(searchTag, []string{productLabel}) + } + postScore := ScoreProductForTag(postText, matchTags) + if postScore == 0 && strings.TrimSpace(productLabel) != "" { + postScore = ScoreProductForTag(postText, []string{productLabel}) + } + for _, token := range categoryTokens(productLabel, matchTags) { + if strings.Contains(strings.ToLower(postText), token) { + postScore = maxInt(postScore, 78) + } + if strings.Contains(strings.ToLower(searchTag), token) { + tagScore = maxInt(tagScore, 82) + } + } + switch { + case tagScore > 0 && postScore > 0: + return (tagScore*2 + postScore*3) / 5 + case postScore > 0: + return postScore + default: + return tagScore + } +} + +// RecommendProductForPost picks the best SKU for a single post. +func RecommendProductForPost(products []brandentity.Product, searchTag, postText, defaultProductID string) *brandentity.Product { + if len(products) == 0 { + return nil + } + bestScore := 0 + var best *brandentity.Product + for i := range products { + score := ScoreProductForPost(products[i].Label, products[i].MatchTags, searchTag, postText) + if score > bestScore { + bestScore = score + best = &products[i] + } + } + if best != nil && bestScore >= 55 { + return best + } + return RecommendProduct(products, searchTag, defaultProductID) +} + +func categoryTokens(productLabel string, matchTags []string) []string { + blob := strings.ToLower(strings.TrimSpace(productLabel + " " + strings.Join(matchTags, " "))) + tokens := []string{ + "洗衣精", "洗衣粉", "洗衣劑", "洗衣液", + "沐浴乳", "沐浴露", + "洗髮精", "洗髮乳", + "洗碗精", "洗碗劑", + } + out := make([]string, 0, 4) + for _, token := range tokens { + if strings.Contains(blob, token) { + out = append(out, token) + } + } + return out +} + func maxInt(a, b int) int { if a > b { return a diff --git a/backend/internal/library/placement/product_match_test.go b/backend/internal/library/placement/product_match_test.go new file mode 100644 index 0000000..c0b9998 --- /dev/null +++ b/backend/internal/library/placement/product_match_test.go @@ -0,0 +1,39 @@ +package placement + +import ( + "testing" + + brandentity "haixun-backend/internal/model/brand/domain/entity" +) + +func TestRecommendProductForPostPicksLaundryOverDefaultBodyWash(t *testing.T) { + products := []brandentity.Product{ + {ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}}, + {ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}}, + } + post := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉" + got := RecommendProductForPost(products, "洗衣精 推薦", post, "body") + if got == nil || got.ID != "laundry" { + id := "" + if got != nil { + id = got.ID + } + t.Fatalf("expected laundry product, got %q", id) + } +} + +func TestRecommendProductForPostPicksBodyWashForShowerPost(t *testing.T) { + products := []brandentity.Product{ + {ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}}, + {ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}}, + } + post := "化療後皮膚很乾,沐浴乳有推薦嗎?想要無香味" + got := RecommendProductForPost(products, "沐浴乳 推薦", post, "laundry") + if got == nil || got.ID != "body" { + id := "" + if got != nil { + id = got.ID + } + t.Fatalf("expected body wash product, got %q", id) + } +} diff --git a/backend/internal/library/placement/query_build.go b/backend/internal/library/placement/query_build.go index cd0e5bc..b776973 100644 --- a/backend/internal/library/placement/query_build.go +++ b/backend/internal/library/placement/query_build.go @@ -5,6 +5,7 @@ import ( "strings" "time" + libkg "haixun-backend/internal/library/knowledge" "haixun-backend/internal/library/websearch" ) @@ -25,26 +26,37 @@ type TagQuery struct { } func BuildRelevanceQuery(provider websearch.Provider, tag string) string { - tag = strings.TrimSpace(tag) - if tag == "" { + if websearch.ParseProvider(string(provider)) == websearch.ProviderExa { + full := strings.TrimSpace(libkg.FullPatrolWebSearchTag(tag)) + if full == "" { + return "" + } + return fmt.Sprintf("Threads 貼文 繁體中文 %s", full) + } + core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag)) + if core == "" { + core = strings.TrimSpace(tag) + } + if core == "" { return "" } - if websearch.ParseProvider(string(provider)) == websearch.ProviderExa { - return fmt.Sprintf("Threads 貼文 繁體中文 %s", tag) - } - return `site:threads.net "` + tag + `"` + // Unquoted site search matches more real Threads posts than exact long phrases. + return `site:threads.net ` + core + ` 推薦` } func BuildRecencyQuery(provider websearch.Provider, tag string, maxAgeDays int) string { - tag = strings.TrimSpace(tag) - if tag == "" { + core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag)) + if core == "" { + core = strings.TrimSpace(tag) + } + if core == "" { return "" } if websearch.ParseProvider(string(provider)) == websearch.ProviderExa { - return fmt.Sprintf("Threads 近期貼文 繁體中文 %s", tag) + return fmt.Sprintf("Threads 近期貼文 繁體中文 %s 請問", core) } after := FormatAfterDate(maxAgeDays, timeNow()) - return `site:threads.net "` + tag + `" 請問 after:` + after + return `site:threads.net "` + core + `" 請問 after:` + after } func PublishedAfterForRecency(provider websearch.Provider, maxAgeDays int) string { diff --git a/backend/internal/library/placement/query_build_test.go b/backend/internal/library/placement/query_build_test.go new file mode 100644 index 0000000..3ff0a7f --- /dev/null +++ b/backend/internal/library/placement/query_build_test.go @@ -0,0 +1,32 @@ +package placement + +import ( + "strings" + "testing" + + "haixun-backend/internal/library/websearch" +) + +func TestBuildRelevanceQueryUsesBroadSiteSearch(t *testing.T) { + q := BuildRelevanceQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦") + if !strings.HasPrefix(q, "site:threads.net ") { + t.Fatalf("unexpected query %q", q) + } + if strings.Contains(q, `"`) { + t.Fatalf("expected unquoted broad query, got %q", q) + } + if !strings.Contains(q, "推薦") { + t.Fatalf("expected intent marker in query, got %q", q) + } +} + +func TestBuildRecencyQueryUsesShortCore(t *testing.T) { + q := BuildRecencyQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦 請問", 7) + if !strings.Contains(q, "請問 after:") { + t.Fatalf("expected recency date filter, got %q", q) + } + quoted := strings.Trim(q, `site:threads.net `) + if strings.Contains(quoted, "推薦") { + t.Fatalf("expected short quoted core without 推薦, got %q", q) + } +} diff --git a/backend/internal/library/placement/recency.go b/backend/internal/library/placement/recency.go index 0694bfa..fe273e9 100644 --- a/backend/internal/library/placement/recency.go +++ b/backend/internal/library/placement/recency.go @@ -6,8 +6,18 @@ const ( MaxPostAgeDays = 30 IdealMaxPostAgeDays = 7 WebSearchMaxAgeDays = 14 + + // MinRecencyCandidatesPerTag triggers wider recency windows when a keyword finds fewer posts. + MinRecencyCandidatesPerTag = 1 + // MaxRecencyCascadeQueries caps extra Search API calls per patrol run. + MaxRecencyCascadeQueries = 10 ) +// RecencyCascadeDays is the patrol recency ladder: nearest window first, then shift older. +func RecencyCascadeDays() []int { + return []int{IdealMaxPostAgeDays, WebSearchMaxAgeDays, MaxPostAgeDays} +} + func FormatAfterDate(maxAgeDays int, now time.Time) string { if now.IsZero() { now = time.Now() diff --git a/backend/internal/library/placement/recency_cascade.go b/backend/internal/library/placement/recency_cascade.go new file mode 100644 index 0000000..746283c --- /dev/null +++ b/backend/internal/library/placement/recency_cascade.go @@ -0,0 +1,89 @@ +package placement + +import ( + "haixun-backend/internal/library/websearch" +) + +type tagCascadeState struct { + Tag string + GraphNodeID string + ProductFitScore int + RanWindows map[int]bool +} + +func buildTagCascadeStates(queries []TagQuery) []*tagCascadeState { + states := map[string]*tagCascadeState{} + for _, q := range queries { + key := patrolTagMatchKey(q.Tag) + if key == "" { + continue + } + st, ok := states[key] + if !ok { + st = &tagCascadeState{ + Tag: q.Tag, + RanWindows: map[int]bool{}, + } + states[key] = st + } + if q.GraphNodeID != "" { + st.GraphNodeID = q.GraphNodeID + } + if q.ProductFitScore > st.ProductFitScore { + st.ProductFitScore = q.ProductFitScore + } + if q.Dimension == QueryRecency && q.RecencyDays > 0 { + st.RanWindows[q.RecencyDays] = true + } + } + out := make([]*tagCascadeState, 0, len(states)) + for _, st := range states { + out = append(out, st) + } + return out +} + +func countCandidatesForTag(merged map[string]*ScanCandidate, tag string) int { + key := patrolTagMatchKey(tag) + if key == "" { + return 0 + } + count := 0 + for _, item := range merged { + if item == nil { + continue + } + if patrolTagMatchKey(item.SearchTag) == key { + count++ + } + } + return count +} + +func nextRecencyCascadeWindow(ran map[int]bool) int { + for _, days := range RecencyCascadeDays() { + if ran[days] { + continue + } + return days + } + return 0 +} + +func buildRecencyCascadeQuery(st *tagCascadeState, days int, provider websearch.Provider) (TagQuery, bool) { + if st == nil || days <= 0 { + return TagQuery{}, false + } + query := BuildRecencyQuery(provider, st.Tag, days) + if query == "" { + return TagQuery{}, false + } + return TagQuery{ + Tag: st.Tag, + Query: query, + Dimension: QueryRecency, + GraphNodeID: st.GraphNodeID, + ProductFitScore: st.ProductFitScore, + RecencyDays: days, + }, true +} diff --git a/backend/internal/library/placement/recency_cascade_test.go b/backend/internal/library/placement/recency_cascade_test.go new file mode 100644 index 0000000..7c9082b --- /dev/null +++ b/backend/internal/library/placement/recency_cascade_test.go @@ -0,0 +1,40 @@ +package placement + +import ( + "testing" + + "haixun-backend/internal/library/websearch" +) + +func TestNextRecencyCascadeWindowSkipsRanWindows(t *testing.T) { + ran := map[int]bool{7: true} + if got := nextRecencyCascadeWindow(ran); got != 14 { + t.Fatalf("expected 14, got %d", got) + } + ran[14] = true + if got := nextRecencyCascadeWindow(ran); got != 30 { + t.Fatalf("expected 30, got %d", got) + } + ran[30] = true + if got := nextRecencyCascadeWindow(ran); got != 0 { + t.Fatalf("expected 0, got %d", got) + } +} + +func TestPassesPostAgeUsesRecencyWindow(t *testing.T) { + old := timeNow().AddDate(0, 0, -10).Format("2006-01-02") + if PassesPostAge(old, QueryRecency, 7) { + t.Fatal("expected 10-day-old post to fail 7-day recency window") + } + if !PassesPostAge(old, QueryRecency, 14) { + t.Fatal("expected 10-day-old post to pass 14-day recency window") + } +} + +func TestBuildRecencyCascadeQuery(t *testing.T) { + st := &tagCascadeState{Tag: "洗衣精 推薦", ProductFitScore: 55} + tq, ok := buildRecencyCascadeQuery(st, 14, websearch.ProviderBrave) + if !ok || tq.RecencyDays != 14 || tq.Dimension != QueryRecency { + t.Fatalf("unexpected cascade query: %+v ok=%v", tq, ok) + } +} diff --git a/backend/internal/library/placement/research_map.go b/backend/internal/library/placement/research_map.go index fbc258b..b584ab8 100644 --- a/backend/internal/library/placement/research_map.go +++ b/backend/internal/library/placement/research_map.go @@ -103,7 +103,7 @@ func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) st 3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機 4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**) 5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起 -6. patrolKeywords 至少 6 組、最多 8 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`) +6. patrolKeywords 至少 8 組、最多 10 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`) return b.String() } @@ -149,7 +149,7 @@ func BuildResearchMapSystemPrompt() string { - 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等) - **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal -## patrolKeywords(6~8 組,每組 6~14 字) +## patrolKeywords(8~10 組,每組 6~14 字) - 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句** - 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」 - 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」 diff --git a/backend/internal/library/placement/resolve_media_exec.go b/backend/internal/library/placement/resolve_media_exec.go new file mode 100644 index 0000000..d486237 --- /dev/null +++ b/backend/internal/library/placement/resolve_media_exec.go @@ -0,0 +1,143 @@ +package placement + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type resolveMediaInput struct { + Permalink string `json:"permalink"` + StorageState string `json:"storage_state,omitempty"` +} + +type resolveMediaOutput struct { + MediaID string `json:"mediaId"` +} + +// RunResolveMediaFromPermalink opens a Threads post page via Playwright and extracts numeric media id. +func RunResolveMediaFromPermalink(ctx context.Context, permalink, storageState string) (string, error) { + permalink = strings.TrimSpace(permalink) + if permalink == "" { + return "", fmt.Errorf("permalink is required") + } + if endpoint := strings.TrimSpace(os.Getenv("HAIXUN_RESOLVE_MEDIA_URL")); endpoint != "" { + return runHTTPResolveMedia(ctx, endpoint, permalink, storageState) + } + + repoRoot, cliPath, err := resolveMediaCLI() + if err != nil { + return "", err + } + + payload, err := json.Marshal(resolveMediaInput{ + Permalink: permalink, + StorageState: strings.TrimSpace(storageState), + }) + if err != nil { + return "", err + } + + runCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + cmd := exec.CommandContext(runCtx, "npx", "tsx", cliPath) + cmd.Dir = repoRoot + cmd.Stdin = bytes.NewReader(payload) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("permalink media resolve failed: %s", msg) + } + + var out resolveMediaOutput + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + return "", fmt.Errorf("permalink media resolve output parse failed: %w", err) + } + mediaID := strings.TrimSpace(out.MediaID) + if mediaID == "" { + return "", fmt.Errorf("permalink media resolve returned empty media id") + } + return mediaID, nil +} + +func runHTTPResolveMedia(ctx context.Context, endpoint, permalink, storageState string) (string, error) { + payload, err := json.Marshal(resolveMediaInput{Permalink: permalink, StorageState: strings.TrimSpace(storageState)}) + if err != nil { + return "", err + } + runCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + url := strings.TrimRight(endpoint, "/") + "/resolve" + req, err := http.NewRequestWithContext(runCtx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("permalink media resolve service failed: %w", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", fmt.Errorf("permalink media resolve service returned HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body))) + } + var out resolveMediaOutput + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("permalink media resolve service output parse failed: %w", err) + } + mediaID := strings.TrimSpace(out.MediaID) + if mediaID == "" { + return "", fmt.Errorf("permalink media resolve service returned empty media id") + } + return mediaID, nil +} + +func resolveMediaCLI() (repoRoot, cliPath string, err error) { + if root := strings.TrimSpace(os.Getenv("HAIXUN_REPO_ROOT")); root != "" { + cli := filepath.Join(root, "haixun-backend", "worker", "threads-resolve-media-cli.ts") + if fileExists(cli) { + return root, cli, nil + } + cli = filepath.Join(root, "worker", "threads-resolve-media-cli.ts") + if fileExists(cli) { + return root, cli, nil + } + } + + cwd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("resolve media cli: %w", err) + } + dir := cwd + for i := 0; i < 6; i++ { + cli := filepath.Join(dir, "haixun-backend", "worker", "threads-resolve-media-cli.ts") + if fileExists(cli) { + return dir, cli, nil + } + cli = filepath.Join(dir, "worker", "threads-resolve-media-cli.ts") + if fileExists(cli) { + return dir, cli, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", "", fmt.Errorf("找不到 threads-resolve-media-cli.ts,請設定 HAIXUN_REPO_ROOT") +} diff --git a/backend/internal/library/placement/resolve_reply_target.go b/backend/internal/library/placement/resolve_reply_target.go new file mode 100644 index 0000000..b6d50e8 --- /dev/null +++ b/backend/internal/library/placement/resolve_reply_target.go @@ -0,0 +1,18 @@ +package placement + +import "strings" + +func ResolveReplyTargetID(externalID, permalink string) string { + if id := strings.TrimSpace(externalID); id != "" { + return id + } + permalink = normalizeThreadsPermalink(permalink) + if permalink == "" { + return "" + } + match := threadsPostURLRe.FindStringSubmatch(permalink) + if len(match) < 3 { + return "" + } + return strings.TrimSpace(match[2]) +} diff --git a/backend/internal/library/placement/resolve_reply_target_test.go b/backend/internal/library/placement/resolve_reply_target_test.go new file mode 100644 index 0000000..267a0c2 --- /dev/null +++ b/backend/internal/library/placement/resolve_reply_target_test.go @@ -0,0 +1,17 @@ +package placement + +import "testing" + +func TestResolveReplyTargetIDFromPermalink(t *testing.T) { + got := ResolveReplyTargetID("", "https://www.threads.net/@alice/post/AbCdEf123") + if got != "AbCdEf123" { + t.Fatalf("got %q", got) + } +} + +func TestResolveReplyTargetIDPrefersExternalID(t *testing.T) { + got := ResolveReplyTargetID("media-123", "https://www.threads.net/@alice/post/AbCdEf123") + if got != "media-123" { + t.Fatalf("got %q", got) + } +} diff --git a/backend/internal/library/placement/source_mode.go b/backend/internal/library/placement/source_mode.go index 038108d..2f5f2db 100644 --- a/backend/internal/library/placement/source_mode.go +++ b/backend/internal/library/placement/source_mode.go @@ -56,7 +56,7 @@ func ModeAllowsCrawler(mode SearchSourceMode) bool { // MemberNeedsWebSearchKey reports whether placement scan should require a web search API key. func MemberNeedsWebSearchKey(ctx MemberContext) bool { - if !ctx.AllowsBrave || ctx.DevMode { + if !ctx.AllowsBrave { return false } switch ctx.SearchSourceMode { diff --git a/backend/internal/library/placement/source_mode_test.go b/backend/internal/library/placement/source_mode_test.go index 1afd1d7..3b5b0ed 100644 --- a/backend/internal/library/placement/source_mode_test.go +++ b/backend/internal/library/placement/source_mode_test.go @@ -20,9 +20,9 @@ func TestMemberNeedsBraveKey(t *testing.T) { if !MemberNeedsBraveKey(braveOnly) { t.Fatal("brave-only should require brave key") } - devCrawler := MemberContext{AllowsBrave: true, DevMode: true, SearchSourceMode: SearchSourceCrawler} - if MemberNeedsBraveKey(devCrawler) { - t.Fatal("dev crawler should not require brave key") + testPatrol := MemberContextForCrawlerOnly(MemberContext{AllowsBrave: true, SearchSourceMode: SearchSourceBrave}) + if MemberNeedsBraveKey(testPatrol) { + t.Fatal("test patrol crawler should not require brave key") } } diff --git a/backend/internal/library/placement/threads_api.go b/backend/internal/library/placement/threads_api.go index 76e21ae..aff4eb6 100644 --- a/backend/internal/library/placement/threads_api.go +++ b/backend/internal/library/placement/threads_api.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + libkg "haixun-backend/internal/library/knowledge" libthreads "haixun-backend/internal/library/threadsapi" ) @@ -24,14 +25,19 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis limit = 12 } - // Strip site: prefix for API keyword search. - query := strings.TrimSpace(req.Query) - query = strings.TrimPrefix(query, "site:threads.net ") - query = strings.Trim(query, `"`) - if idx := strings.Index(query, " after:"); idx > 0 { - query = strings.TrimSpace(query[:idx]) + query := strings.TrimSpace(libkg.ThreadsAPIKeyword(req.Keyword)) + if query == "" { + query = strings.TrimSpace(req.Query) + query = strings.TrimPrefix(query, "site:threads.net ") + query = strings.Trim(query, `"`) + if idx := strings.Index(query, " after:"); idx > 0 { + query = strings.TrimSpace(query[:idx]) + } + query = libkg.ShortPatrolSearchCore(strings.Trim(query, `"`)) + } + if query == "" { + return nil, fmt.Errorf("threads api keyword is empty") } - query = strings.Trim(query, `"`) items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{ Query: query, @@ -48,14 +54,18 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis if text == "" { continue } + externalID := strings.TrimSpace(item.ID) + if !libthreads.IsNumericMediaID(externalID) { + continue + } permalink := strings.TrimSpace(item.Permalink) - if permalink == "" && item.Username != "" && item.ID != "" { - permalink = "https://www.threads.net/@" + item.Username + "/post/" + item.ID + if permalink == "" && item.Username != "" { + permalink = "https://www.threads.net/@" + item.Username + "/post/" + externalID } out = append(out, DiscoverPost{ Text: text, Permalink: permalink, - ExternalID: strings.TrimSpace(item.ID), + ExternalID: externalID, Author: strings.TrimSpace(item.Username), PostedAt: strings.TrimSpace(item.Timestamp), LikeCount: item.LikeCount, diff --git a/backend/internal/library/placement/topic_fit.go b/backend/internal/library/placement/topic_fit.go new file mode 100644 index 0000000..a068582 --- /dev/null +++ b/backend/internal/library/placement/topic_fit.go @@ -0,0 +1,42 @@ +package placement + +import ( + "strings" + + libkg "haixun-backend/internal/library/knowledge" +) + +// HasTangentialTopicMismatch uses the shared intent model: broad category overlap +// without product/research-map intent alignment (generalizes beyond hardcoded lists). +func HasTangentialTopicMismatch(text string, ctx PostScanContext) bool { + text = strings.TrimSpace(text) + if text == "" { + return false + } + if matchesTopicAnchor(text, ctx) { + return false + } + profile := libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx)) + return libkg.IsTangentialToIntent(text, profile) +} + +func PatrolTagInputFromScanContext(ctx PostScanContext) libkg.PatrolTagInput { + return intentProfileInputFromPostScan(ctx) +} + +func intentProfileInputFromPostScan(ctx PostScanContext) libkg.PatrolTagInput { + return libkg.PatrolTagInput{ + ProductName: ctx.ProductName, + ProductFeatures: ctx.ProductFeatures, + AudienceSummary: ctx.AudienceSummary, + TargetAudience: ctx.TargetAudience, + MatchTags: append([]string{}, ctx.MatchTags...), + Questions: append([]string{}, ctx.Questions...), + Pillars: append([]string{}, ctx.Pillars...), + } +} + +// LocalIntentScore is the offline semantic scorer (no embedding API). +func LocalIntentScore(text string, ctx PostScanContext) int { + return libkg.ScoreIntentSimilarity(text, libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx))) +} diff --git a/backend/internal/library/prompt/compose_test.go b/backend/internal/library/prompt/compose_test.go index 87b07bb..3fd1266 100644 --- a/backend/internal/library/prompt/compose_test.go +++ b/backend/internal/library/prompt/compose_test.go @@ -23,7 +23,7 @@ func TestStyle8DSystemIncludesSchema(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(got, "Threads 創作者風格研究員") || !strings.Contains(got, `"d1Tone"`) { + if !strings.Contains(got, "創作者「語言指紋」研究員") || !strings.Contains(got, `"d1Tone"`) { t.Fatalf("missing expected fragments: %q", got) } } diff --git a/backend/internal/library/prompt/files/matrix_copy.system.md b/backend/internal/library/prompt/files/matrix_copy.system.md index 09a7dde..b4c1c70 100644 --- a/backend/internal/library/prompt/files/matrix_copy.system.md +++ b/backend/internal/library/prompt/files/matrix_copy.system.md @@ -1,11 +1,25 @@ -你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。 +你是 Threads / Instagram 內容策略師,為人設帳號產出可發佈草稿。產文時必須優先使用人設的「語言指紋與寫作規則」,把新主題、參考貼文或外部資料翻譯成這個人設會講出口的話,不沿用任何預設爆款模板。 + +寫作目標: +- 先根據人設語言指紋判斷主題適合哪一種內容型態:心情陪伴、知識整理、清單工具、伴侶溝通、互動提問或輕幽默共鳴,再選對鬆結構。 +- 每篇要有一個清楚情境或情緒入口,讓讀者覺得「這就是我」,不要寫成摘要報告或廣告文。 +- 必須沿用人設的段落節奏、標點、換行、稱呼、口頭禪與收尾習慣;不要硬剪短成破碎金句。 +- 只學參考樣本的節奏、取材方式、收尾習慣與互動方式;不可照抄句子。 +- 避免 AI 腔:過度總結、過度正能量、每段都像教條、連續排比到不自然、硬塞「大家一起」式口號。 規則: - 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。 - reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。 - 每篇必須角度不同,避免重複 hook。 -- 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。 +- 人設定位決定「誰在說」;語言指紋決定「怎麼說」。兩者都要遵守,不可只拿主題資料直接改寫。 +- 人設只用來控制語氣、詞彙密度、句型節奏、人稱、標點換行與禁忌;不可把人設介紹裡的物件或標籤硬塞進正文情境。 +- 若人設含 Y2K、咖啡、穿搭、音樂等元素,除非任務主題/Brief/勾選知識明確要求,禁止把這些元素寫成貼文內容或場景。 +- 正文素材以任務主題、Brief、研究地圖與勾選延伸知識為準;人設不得改變題材。 - 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。 - 繁體中文,口語自然,適合 Threads。 - 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。 -- 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。 \ No newline at end of file +- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。 +- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji;不要整段擠成一塊,也不要每句都硬換行。 +- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。 +- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。 +- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。 diff --git a/backend/internal/library/prompt/files/matrix_copy.user.md b/backend/internal/library/prompt/files/matrix_copy.user.md index 8bc220e..fe35934 100644 --- a/backend/internal/library/prompt/files/matrix_copy.user.md +++ b/backend/internal/library/prompt/files/matrix_copy.user.md @@ -3,7 +3,7 @@ 任務主題:{{topic_label}} Brief:{{topic_brief}} -人設 8D(最高優先,語氣與禁忌以此為準): +人設定位、語言指紋與禁忌(最高優先): {{persona_block}} 研究地圖與勾選延伸知識: @@ -14,14 +14,20 @@ Brief:{{topic_brief}} 參考網頁 Markdown 已併入上方研究地圖區塊(若有)。 -爆款樣本(可選,只學結構;若沒有樣本,請改用研究地圖與人設原創): +爆款樣本(可選,只學故事感;若沒有樣本,請改用研究地圖與人設原創): {{viral_samples_block}} 請先遵守: -- **最高優先:人設 8D** — 語氣、人稱、句型、節奏、禁忌必須完全符合人設;讀起來要像這個帳號在說話。 +- **最高優先:語言指紋**:先讀 persona_block 裡的「語言指紋與寫作規則」,包含開頭、句型、段落長度、標點換行、知識轉譯、CTA 與禁忌。產出的文字必須像這個人設會講出口的話。 +- **不要套舊規格**:不要套用短句爆款、固定三段式、固定強 hook、固定 CTA;格式由人設語言指紋與主題型態決定。 +- **語氣參考**:讀起來要像這個帳號在說話,但每篇敘事節奏與開場都要自然變化。 +- **人設不是內容素材**:只學語氣、詞彙密度、句型節奏與審美,不要把人設裡的名詞、興趣、裝飾符號硬塞進正文。例:人設寫 Y2K/咖啡/穿搭,只代表語感,不代表每篇都要寫老歌、咖啡、穿搭、MV。 - **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。 - 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。 -- 爆款樣本只學結構節奏,不抄原文,不可為仿爆款偏離任務主題。 +- 爆款樣本只學故事感、資訊密度、情緒力度與互動方式,不抄原文,不可為仿爆款偏離任務主題。 +- 若樣本呈現「長 caption + 輪播圖卡重點」的內容型態,Threads 草稿也要保留完整情緒承接與可收藏資訊,不要只輸出金句摘要。 +- 若任務是改寫或參考別人的貼文,請用人設語言重新說一次,不是同義詞替換;保留觀點,不保留對方句型。 +- 若任務需要找資料或引用外部知識,請先消化成此人設的說話順序:先安撫或帶入情境,再整理重點,最後用符合人設的方式收尾。 - 若沒有爆款樣本,source_permalinks 回傳空陣列,reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。 -回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。 \ No newline at end of file +回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。 diff --git a/backend/internal/library/prompt/files/matrix_placement.system.md b/backend/internal/library/prompt/files/matrix_placement.system.md index 18a1cb2..66a5f73 100644 --- a/backend/internal/library/prompt/files/matrix_placement.system.md +++ b/backend/internal/library/prompt/files/matrix_placement.system.md @@ -6,6 +6,7 @@ - 從素材萃取角度與鉤子,寫成自己的觀點或經驗 - 不要捏造療效、收益或無法驗證的數據 - 可適度帶入 product_brief,但不硬推銷 +- text 排版:一句一行,句與句之間用換行(\n)分隔;完全不要使用任何標點符號(emoji 可保留) 只回傳 JSON,不要 markdown。格式: {"rows":[{"sort_order":1,"search_tag":"標籤","angle":"切角","hook":"開頭鉤子","text":"正文","reference_notes":"參考哪篇素材","source_permalinks":["url"],"rationale":"為何這樣寫"}]} \ No newline at end of file diff --git a/backend/internal/library/prompt/files/outreach_placement.system.md b/backend/internal/library/prompt/files/outreach_placement.system.md index 3a39508..4939eef 100644 --- a/backend/internal/library/prompt/files/outreach_placement.system.md +++ b/backend/internal/library/prompt/files/outreach_placement.system.md @@ -13,9 +13,10 @@ 4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。 5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。 6. 誠實:不承諾療效、不捏造誇大數據。 -7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀。 +7. 人設與語氣是最高優先硬約束:每則留言都必須像指定角色親自回覆,用字、節奏、距離感、價值觀與 8D 風格策略都要一致,不得變成通用網友口吻。 8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。 9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。 +10. 排版要像真人用手機留言:每則 text 用 2~4 個短段落,段落之間一定要換行(JSON 字串內用 `\n`,可用 `\n\n` 分隔段落),不要整段擠成一大塊。 每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。 diff --git a/backend/internal/library/prompt/files/outreach_placement.user.md b/backend/internal/library/prompt/files/outreach_placement.user.md index a1dc397..31ccc0c 100644 --- a/backend/internal/library/prompt/files/outreach_placement.user.md +++ b/backend/internal/library/prompt/files/outreach_placement.user.md @@ -8,5 +8,6 @@ 目標貼文作者:@{{author_name}} 目標貼文: {{target_text}} +{{regenerate_line}} 請產生 {{count}} 則留言草稿,每則都要有溫度、帶一個具體的真實情境或經驗。另外請評估這篇是否值得留言置入(relevance 0-1)與 reason(繁體中文一句話)。 \ No newline at end of file diff --git a/backend/internal/library/prompt/files/style8d.schema.md b/backend/internal/library/prompt/files/style8d.schema.md index a3d8960..a38ab13 100644 --- a/backend/internal/library/prompt/files/style8d.schema.md +++ b/backend/internal/library/prompt/files/style8d.schema.md @@ -1,3 +1,3 @@ 只回傳以下 JSON 結構,不要 markdown: -{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","examples":"","avoid":""}} \ No newline at end of file +{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}} diff --git a/backend/internal/library/prompt/files/style8d.system.md b/backend/internal/library/prompt/files/style8d.system.md index 5862add..8ff14f9 100644 --- a/backend/internal/library/prompt/files/style8d.system.md +++ b/backend/internal/library/prompt/files/style8d.system.md @@ -1,5 +1,21 @@ -你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。 -逐一輸出八個維度:D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。 -每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。 -證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。 -最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。 \ No newline at end of file +你是創作者「語言指紋」研究員。只能根據提供的貼文或使用者手動貼上的文字樣本歸納,不可捏造未出現的背景。 + +任務不是寫一般人設介紹,而是萃取「這個人設會怎麼把一件事說出口」。輸出要能直接拿去產文、改寫貼文、把網路知識轉成此人設語氣。 + +逐一輸出八個維度:D1 語氣人格、D2 內容結構、D3 互動方式、D4 主題分布、D5 段落節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。 + +每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。 + +personaDraft 必須產出可執行的寫作規則,不是抽象形容詞: +- identity:這個帳號像誰在說話,和讀者的距離。 +- tone:口吻、情緒溫度、人稱與信任感。 +- audience:主要對誰說,讀者正在什麼情境。 +- hooks:常用開頭方式與不適合的開頭方式。 +- languageFingerprint:常用句型、轉折詞、口頭禪、語助詞;不要列成硬塞清單,要說明如何自然使用。 +- rhythm:段落長度、空行習慣、長短句比例、心情文與知識文差異。 +- punctuation: 標點、括號、列點、emoji、特殊符號的使用習慣。 +- contentPatterns:不同內容型態的鬆結構,例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴;不要變成固定模板。 +- knowledgeTranslation:如何把外部資料、醫療知識、產品知識轉成此人設會講的話;哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。 +- ctaStyle:收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。 +- examples:抽象仿寫範例,可展示語氣與換行,但不可逐字複製原文。 +- avoid:不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。 diff --git a/backend/internal/library/publishschedule/slots.go b/backend/internal/library/publishschedule/slots.go new file mode 100644 index 0000000..611582b --- /dev/null +++ b/backend/internal/library/publishschedule/slots.go @@ -0,0 +1,88 @@ +package publishschedule + +import ( + "sort" + "strings" + "time" +) + +type Slot struct { + Weekday int + Time string +} + +func BuildSchedule(startAt int64, timezone string, slots []Slot, count int) []int64 { + if count <= 0 { + return nil + } + loc := time.UTC + if strings.TrimSpace(timezone) != "" { + if loaded, err := time.LoadLocation(strings.TrimSpace(timezone)); err == nil { + loc = loaded + } + } + start := time.Now().In(loc) + if startAt > 0 { + start = time.Unix(0, startAt).In(loc) + } + normalized := normalizeSlots(slots) + if len(normalized) == 0 { + out := make([]int64, 0, count) + for i := 0; i < count; i++ { + out = append(out, start.Add(time.Duration(i)*2*time.Hour).UTC().UnixNano()) + } + return out + } + out := make([]int64, 0, count) + day := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc) + for len(out) < count { + for _, slot := range normalized { + if int(day.Weekday()) != slot.Weekday { + continue + } + hour, minute, ok := parseHHMM(slot.Time) + if !ok { + continue + } + candidate := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc) + if candidate.Before(start) { + continue + } + out = append(out, candidate.UTC().UnixNano()) + if len(out) >= count { + break + } + } + day = day.AddDate(0, 0, 1) + } + return out +} + +func normalizeSlots(slots []Slot) []Slot { + out := make([]Slot, 0, len(slots)) + for _, slot := range slots { + if strings.TrimSpace(slot.Time) == "" { + continue + } + weekday := slot.Weekday + if weekday < 0 || weekday > 6 { + weekday = ((weekday % 7) + 7) % 7 + } + out = append(out, Slot{Weekday: weekday, Time: strings.TrimSpace(slot.Time)}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Weekday == out[j].Weekday { + return out[i].Time < out[j].Time + } + return out[i].Weekday < out[j].Weekday + }) + return out +} + +func parseHHMM(value string) (int, int, bool) { + parsed, err := time.Parse("15:04", strings.TrimSpace(value)) + if err != nil { + return 0, 0, false + } + return parsed.Hour(), parsed.Minute(), true +} diff --git a/backend/internal/library/publishschedule/slots_test.go b/backend/internal/library/publishschedule/slots_test.go new file mode 100644 index 0000000..5cc8d29 --- /dev/null +++ b/backend/internal/library/publishschedule/slots_test.go @@ -0,0 +1,40 @@ +package publishschedule + +import ( + "testing" + "time" +) + +func TestBuildScheduleUsesTimezoneSlotsAndReturnsUTCNano(t *testing.T) { + loc, err := time.LoadLocation("Asia/Taipei") + if err != nil { + t.Fatal(err) + } + start := time.Date(2026, 6, 29, 9, 0, 0, 0, loc) // Monday + got := BuildSchedule(start.UTC().UnixNano(), "Asia/Taipei", []Slot{ + {Weekday: 1, Time: "09:30"}, + {Weekday: 3, Time: "12:30"}, + }, 2) + if len(got) != 2 { + t.Fatalf("expected 2 timestamps, got %d", len(got)) + } + first := time.Unix(0, got[0]).In(loc) + if first.Weekday() != time.Monday || first.Hour() != 9 || first.Minute() != 30 { + t.Fatalf("unexpected first slot: %s", first) + } + second := time.Unix(0, got[1]).In(loc) + if second.Weekday() != time.Wednesday || second.Hour() != 12 || second.Minute() != 30 { + t.Fatalf("unexpected second slot: %s", second) + } +} + +func TestBuildScheduleFallsBackToIntervalsWhenNoSlots(t *testing.T) { + start := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC) + got := BuildSchedule(start.UnixNano(), "UTC", nil, 2) + if len(got) != 2 { + t.Fatalf("expected 2 timestamps, got %d", len(got)) + } + if got[1]-got[0] != int64(2*time.Hour) { + t.Fatalf("expected 2h interval, got %s", time.Duration(got[1]-got[0])) + } +} diff --git a/backend/internal/library/style8d/analyze.go b/backend/internal/library/style8d/analyze.go index 1b13d51..9d75a6e 100644 --- a/backend/internal/library/style8d/analyze.go +++ b/backend/internal/library/style8d/analyze.go @@ -1,6 +1,7 @@ package style8d import ( + "bytes" "encoding/json" "fmt" "math" @@ -22,12 +23,18 @@ type Dimension struct { } type PersonaDraft struct { - Identity string `json:"identity"` - Tone string `json:"tone"` - Audience string `json:"audience"` - Hooks string `json:"hooks"` - Examples string `json:"examples"` - Avoid string `json:"avoid"` + Identity string `json:"identity"` + Tone string `json:"tone"` + Audience string `json:"audience"` + Hooks string `json:"hooks"` + LanguageFingerprint string `json:"languageFingerprint"` + Rhythm string `json:"rhythm"` + Punctuation string `json:"punctuation"` + ContentPatterns string `json:"contentPatterns"` + KnowledgeTranslation string `json:"knowledgeTranslation"` + CTAStyle string `json:"ctaStyle"` + Examples string `json:"examples"` + Avoid string `json:"avoid"` } type LLMOutput struct { @@ -53,6 +60,8 @@ type Engagement struct { type StoredProfile struct { Username string `json:"username"` + SourceType string `json:"sourceType,omitempty"` + SourceLabel string `json:"sourceLabel,omitempty"` AnalyzedAt string `json:"analyzedAt"` PostCount int `json:"postCount"` Engagement Engagement `json:"engagement"` @@ -61,6 +70,125 @@ type StoredProfile struct { PersonaDraft string `json:"personaDraft"` } +const ( + SourceTypeBenchmark = "benchmark" + SourceTypeManual = "manual" + maxReferenceTexts = 12 + maxReferenceRunes = 8000 + minReferenceRunes = 10 +) + +func ManualStyleBenchmark(label string) string { + label = strings.TrimSpace(label) + if label == "" { + label = "手動貼文" + } + return "manual:" + label +} + +func NormalizeReferenceTexts(referenceTexts []string, rawText string) ([]string, error) { + texts := make([]string, 0, len(referenceTexts)) + for _, item := range referenceTexts { + item = strings.TrimSpace(item) + if item != "" { + texts = append(texts, item) + } + } + if len(texts) == 0 { + rawText = strings.TrimSpace(rawText) + if rawText != "" { + for _, chunk := range splitReferenceRawText(rawText) { + chunk = strings.TrimSpace(chunk) + if chunk != "" { + texts = append(texts, chunk) + } + } + } + } + if len(texts) == 0 { + return nil, fmt.Errorf("至少需要 1 段參考文字") + } + if len(texts) > maxReferenceTexts { + return nil, fmt.Errorf("參考文字最多 %d 段", maxReferenceTexts) + } + totalRunes := 0 + out := make([]string, 0, len(texts)) + for _, item := range texts { + runes := []rune(item) + if len(runes) < minReferenceRunes { + return nil, fmt.Errorf("每段參考文字至少 %d 字", minReferenceRunes) + } + totalRunes += len(runes) + if totalRunes > maxReferenceRunes { + return nil, fmt.Errorf("參考文字總長度不可超過 %d 字", maxReferenceRunes) + } + out = append(out, item) + } + return out, nil +} + +func TextsToPosts(texts []string) []Post { + posts := make([]Post, 0, len(texts)) + for _, text := range texts { + posts = append(posts, Post{Text: text}) + } + return posts +} + +func splitReferenceRawText(raw string) []string { + lines := strings.Split(raw, "\n") + chunks := make([]string, 0, 4) + var b strings.Builder + flush := func() { + if b.Len() == 0 { + return + } + chunks = append(chunks, b.String()) + b.Reset() + } + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "---" || trimmed == "———" || trimmed == "___" { + flush() + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(line) + } + flush() + if len(chunks) == 0 { + return []string{strings.TrimSpace(raw)} + } + return chunks +} + +func BuildUserPromptFromTexts(sourceLabel string, brief string, posts []Post) string { + label := strings.TrimSpace(sourceLabel) + if label == "" { + label = "手動貼文" + } + var b strings.Builder + fmt.Fprintf(&b, "文字樣本來源:%s\n", label) + if brief := strings.TrimSpace(brief); brief != "" { + fmt.Fprintf(&b, "人設定位補充:%s\n", brief) + } + b.WriteString("參考文字樣本(無互動數據,請只根據文字風格歸納):\n\n") + limit := len(posts) + if limit > maxReferenceTexts { + limit = maxReferenceTexts + } + for i := 0; i < limit; i++ { + text := posts[i].Text + if len(text) > 500 { + text = text[:500] + } + fmt.Fprintf(&b, "[%d]\n%s\n\n", i+1, text) + } + return b.String() +} + func BuildUserPrompt(username string, posts []Post) string { var b strings.Builder fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本:\n\n", strings.TrimPrefix(username, "@")) @@ -144,6 +272,12 @@ func SerializePersonaDraft(draft PersonaDraft) string { "【語氣】\n" + strings.TrimSpace(draft.Tone), "【對誰說】\n" + strings.TrimSpace(draft.Audience), "【開場習慣】\n" + strings.TrimSpace(draft.Hooks), + "【語言指紋】\n" + strings.TrimSpace(draft.LanguageFingerprint), + "【段落節奏】\n" + strings.TrimSpace(draft.Rhythm), + "【標點與換行】\n" + strings.TrimSpace(draft.Punctuation), + "【內容型態】\n" + strings.TrimSpace(draft.ContentPatterns), + "【知識轉譯】\n" + strings.TrimSpace(draft.KnowledgeTranslation), + "【互動與 CTA】\n" + strings.TrimSpace(draft.CTAStyle), "【代表句範例】\n" + strings.TrimSpace(draft.Examples), "【避免】\n" + strings.TrimSpace(draft.Avoid), } @@ -211,8 +345,26 @@ func buildSamplePosts(posts []Post) []Post { } func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile { + profile := baseStoredProfile(posts, out) + profile.Username = strings.TrimPrefix(strings.TrimSpace(username), "@") + profile.SourceType = SourceTypeBenchmark + return profile +} + +func BuildStoredProfileFromTexts(sourceLabel string, posts []Post, out LLMOutput) StoredProfile { + label := strings.TrimSpace(sourceLabel) + if label == "" { + label = "手動貼文" + } + profile := baseStoredProfile(posts, out) + profile.SourceType = SourceTypeManual + profile.SourceLabel = label + profile.Engagement.Verdict = "not_applicable" + return profile +} + +func baseStoredProfile(posts []Post, out LLMOutput) StoredProfile { return StoredProfile{ - Username: strings.TrimPrefix(strings.TrimSpace(username), "@"), AnalyzedAt: time.Now().UTC().Format(time.RFC3339), PostCount: len(posts), Engagement: EvaluateEngagement(posts, 10), @@ -289,7 +441,23 @@ func extractJSONObject(raw string) ([]byte, error) { if start < 0 || end <= start { return nil, fmt.Errorf("LLM response does not contain JSON object") } - return []byte(text[start : end+1]), nil + return repairTruncatedJSON([]byte(text[start : end+1])), nil +} + +// repairTruncatedJSON adds missing closing braces/brackets when the LLM output +// was cut off mid-JSON. Returns nil if no repair was needed or possible. +func repairTruncatedJSON(raw []byte) []byte { + opens := bytes.Count(raw, []byte("{")) + closes := bytes.Count(raw, []byte("}")) + if closes < opens { + raw = append(raw, bytes.Repeat([]byte("}"), opens-closes)...) + } + bracketOpens := bytes.Count(raw, []byte("[")) + bracketCloses := bytes.Count(raw, []byte("]")) + if bracketCloses < bracketOpens { + raw = append(raw, bytes.Repeat([]byte("]"), bracketOpens-bracketCloses)...) + } + return raw } func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension { @@ -332,12 +500,18 @@ func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft { continue } return PersonaDraft{ - Identity: firstString(obj, "identity", "role"), - Tone: firstString(obj, "tone", "voice"), - Audience: firstString(obj, "audience", "targetAudience"), - Hooks: firstString(obj, "hooks", "openings"), - Examples: firstString(obj, "examples", "sample"), - Avoid: firstString(obj, "avoid", "risks"), + Identity: firstString(obj, "identity", "role"), + Tone: firstString(obj, "tone", "voice"), + Audience: firstString(obj, "audience", "targetAudience"), + Hooks: firstString(obj, "hooks", "openings"), + LanguageFingerprint: firstString(obj, "languageFingerprint", "language_fingerprint", "signaturePhrases", "signature_phrases"), + Rhythm: firstString(obj, "rhythm", "paragraphRhythm", "paragraph_rhythm"), + Punctuation: firstString(obj, "punctuation", "visualSyntax", "visual_syntax"), + ContentPatterns: firstString(obj, "contentPatterns", "content_patterns", "structures"), + KnowledgeTranslation: firstString(obj, "knowledgeTranslation", "knowledge_translation", "researchTranslation", "research_translation"), + CTAStyle: firstString(obj, "ctaStyle", "cta_style", "conversionStyle", "conversion_style"), + Examples: firstString(obj, "examples", "sample"), + Avoid: firstString(obj, "avoid", "risks"), } } return PersonaDraft{} diff --git a/backend/internal/library/style8d/analyze_test.go b/backend/internal/library/style8d/analyze_test.go index 6d17eb3..d8ee8b0 100644 --- a/backend/internal/library/style8d/analyze_test.go +++ b/backend/internal/library/style8d/analyze_test.go @@ -1,9 +1,12 @@ package style8d -import "testing" +import ( + "strings" + "testing" +) func TestParseLLMOutput(t *testing.T) { - raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","examples":"有時候真的會累","avoid":"不要硬銷"}}` + raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}` out, err := ParseLLMOutput(raw) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -23,4 +26,7 @@ func TestParseLLMOutput(t *testing.T) { if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" { t.Fatalf("samplePosts=%+v", profile.SamplePosts) } + if !strings.Contains(profile.PersonaDraft, "【語言指紋】") { + t.Fatalf("personaDraft missing language fingerprint: %q", profile.PersonaDraft) + } } diff --git a/backend/internal/library/style8d/prompt.go b/backend/internal/library/style8d/prompt.go index bbd6e33..687848c 100644 --- a/backend/internal/library/style8d/prompt.go +++ b/backend/internal/library/style8d/prompt.go @@ -44,14 +44,29 @@ func HasReady8D(personaText, styleProfileJSON string) bool { return BuildStyle8DPromptBlock(styleProfileJSON) != "" } +var copyVoiceDimensionOrder = []string{"d1Tone", "d8Risk"} + // BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts. func BuildStyle8DPromptBlock(styleProfileJSON string) string { + return buildStylePromptBlock(styleProfileJSON, dimensionOrder, "【8D 風格策略】\n產文必須遵守:\n") +} + +// BuildLightPersonaVoiceBlock keeps only tone + risk guardrails for copy generation. +func BuildLightPersonaVoiceBlock(styleProfileJSON string) string { + return buildStylePromptBlock( + styleProfileJSON, + copyVoiceDimensionOrder, + "【語氣與禁忌參考】\n以下僅供口語風格與紅線參考,勿照搬句型、段落模板或固定 hook:\n", + ) +} + +func buildStylePromptBlock(styleProfileJSON string, keys []string, header string) string { profile, ok := ParseStoredProfile(styleProfileJSON) if !ok { return "" } - lines := make([]string, 0, len(dimensionOrder)) - for _, key := range dimensionOrder { + lines := make([]string, 0, len(keys)) + for _, key := range keys { summary := strings.TrimSpace(profile.Analysis[key].Summary) if summary == "" { continue @@ -66,30 +81,31 @@ func BuildStyle8DPromptBlock(styleProfileJSON string) string { return "" } var b strings.Builder - b.WriteString("【8D 風格策略】\n產文必須遵守:\n") + b.WriteString(header) b.WriteString(strings.Join(lines, "\n")) return b.String() } -// ResolvePersonaBlock picks the best persona voice block for copy generation. -// Priority: explicit persona field → 8D personaDraft → brief fallback; always append 8D strategy when present. +// ResolvePersonaBlock combines explicit persona positioning with analyzed writing fingerprints. +// The persona field answers "who this is"; personaDraft/style profile answers "how this person says things". func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string { var parts []string - voice := strings.TrimSpace(personaText) - if voice == "" { - if profile, ok := ParseStoredProfile(styleProfileJSON); ok { - voice = strings.TrimSpace(profile.PersonaDraft) - } - } - if voice == "" { - voice = strings.TrimSpace(brief) - } - if voice != "" { - parts = append(parts, "【人設語氣】\n"+voice) + profile, hasProfile := ParseStoredProfile(styleProfileJSON) + persona := strings.TrimSpace(personaText) + if persona != "" { + parts = append(parts, "【人設定位】\n"+persona) + } else if fallback := strings.TrimSpace(brief); fallback != "" { + parts = append(parts, "【人設定位】\n"+fallback) } - if block := BuildStyle8DPromptBlock(styleProfileJSON); block != "" { + if hasProfile { + if draft := strings.TrimSpace(profile.PersonaDraft); draft != "" { + parts = append(parts, "【語言指紋與寫作規則】\n"+draft) + } + } + + if block := BuildLightPersonaVoiceBlock(styleProfileJSON); block != "" { parts = append(parts, block) } return strings.TrimSpace(strings.Join(parts, "\n\n")) diff --git a/backend/internal/library/style8d/prompt_test.go b/backend/internal/library/style8d/prompt_test.go index 9fdbb02..bbd42c8 100644 --- a/backend/internal/library/style8d/prompt_test.go +++ b/backend/internal/library/style8d/prompt_test.go @@ -11,7 +11,17 @@ func TestResolvePersonaBlockUsesPersonaDraftWhenPersonaEmpty(t *testing.T) { if block == "" { t.Fatal("expected non-empty block") } - for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近"} { + for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近", "語言指紋與寫作規則"} { + if !strings.Contains(block, part) { + t.Fatalf("block missing %q: %q", part, block) + } + } +} + +func TestResolvePersonaBlockCombinesPersonaAndStyleFingerprint(t *testing.T) { + raw := `{"analysis":{"d1Tone":{"summary":"口語親近","evidence":[]}},"personaDraft":"【語言指紋】\n常用慢慢、其實當轉折"}` + block := ResolvePersonaBlock("備孕陪伴帳號", raw, "") + for _, part := range []string{"備孕陪伴帳號", "常用慢慢", "語言指紋與寫作規則"} { if !strings.Contains(block, part) { t.Fatalf("block missing %q: %q", part, block) } diff --git a/backend/internal/library/style8d/runner.go b/backend/internal/library/style8d/runner.go new file mode 100644 index 0000000..4ed44c3 --- /dev/null +++ b/backend/internal/library/style8d/runner.go @@ -0,0 +1,41 @@ +package style8d + +import "strings" + +// AnalyzeMode selects how reference samples are interpreted. +type AnalyzeMode string + +const ( + AnalyzeModeBenchmark AnalyzeMode = "benchmark" + AnalyzeModeManual AnalyzeMode = "manual" +) + +// AnalyzeInput groups prompt + storage options for one 8D run. +type AnalyzeInput struct { + Mode AnalyzeMode + Username string + SourceLabel string + Brief string + Posts []Post +} + +func (in AnalyzeInput) BuildUserPrompt() string { + if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" { + return BuildUserPromptFromTexts(in.SourceLabel, in.Brief, in.Posts) + } + return BuildUserPrompt(in.Username, in.Posts) +} + +func (in AnalyzeInput) BuildStoredProfile(out LLMOutput) StoredProfile { + if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" { + return BuildStoredProfileFromTexts(in.SourceLabel, in.Posts, out) + } + return BuildStoredProfile(in.Username, in.Posts, out) +} + +func (in AnalyzeInput) StyleBenchmark() string { + if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" { + return ManualStyleBenchmark(in.SourceLabel) + } + return strings.TrimPrefix(strings.TrimSpace(in.Username), "@") +} diff --git a/backend/internal/library/style8d/text_input_test.go b/backend/internal/library/style8d/text_input_test.go new file mode 100644 index 0000000..d351898 --- /dev/null +++ b/backend/internal/library/style8d/text_input_test.go @@ -0,0 +1,73 @@ +package style8d + +import "testing" + +func TestNormalizeReferenceTextsFromArray(t *testing.T) { + texts, err := NormalizeReferenceTexts([]string{"第一段參考文字內容超過二十個字元", "第二段參考文字內容也超過二十個字元"}, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(texts) != 2 { + t.Fatalf("texts=%v", texts) + } +} + +func TestNormalizeReferenceTextsFromRawText(t *testing.T) { + raw := "第一段參考文字內容超過二十個字元\n還有第二行\n\n---\n\n第二段參考文字內容也超過二十個字元" + texts, err := NormalizeReferenceTexts(nil, raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(texts) != 2 { + t.Fatalf("texts=%v", texts) + } +} + +func TestBuildUserPromptFromTexts(t *testing.T) { + got := BuildUserPromptFromTexts("我的舊文", "產品教學帳", TextsToPosts([]string{"這是一段超過二十個字的參考文字樣本內容"})) + if got == "" { + t.Fatal("empty prompt") + } + if !containsAll(got, "我的舊文", "產品教學帳", "[1]") { + t.Fatalf("prompt=%q", got) + } +} + +func TestBuildStoredProfileFromTexts(t *testing.T) { + out, err := ParseLLMOutput(`{"d1Tone":{"summary":"口語","evidence":[]},"d2Structure":{"summary":"短句","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","examples":"有時候真的會累","avoid":"硬銷"}}`) + if err != nil { + t.Fatalf("parse: %v", err) + } + profile := BuildStoredProfileFromTexts("手動貼文", TextsToPosts([]string{"sample text long enough"}), out) + if profile.SourceType != SourceTypeManual { + t.Fatalf("sourceType=%q", profile.SourceType) + } + if profile.Engagement.Verdict != "not_applicable" { + t.Fatalf("verdict=%q", profile.Engagement.Verdict) + } + if ManualStyleBenchmark("手動貼文") != "manual:手動貼文" { + t.Fatal("manual benchmark mismatch") + } +} + +func containsAll(s string, parts ...string) bool { + for _, part := range parts { + if !contains(s, part) { + return false + } + } + return true +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/backend/internal/library/threadsapi/extended.go b/backend/internal/library/threadsapi/extended.go new file mode 100644 index 0000000..7f60492 --- /dev/null +++ b/backend/internal/library/threadsapi/extended.go @@ -0,0 +1,522 @@ +package threadsapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + // userThreadsListFields lists fields supported on GET /{user-id}/threads. + userThreadsListFields = "id,text,permalink,timestamp,media_type,media_url,thumbnail_url,topic_tag,shortcode" +) + +type MediaItem struct { + ID string `json:"id"` + Shortcode string `json:"shortcode"` + Text string `json:"text"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + TopicTag string `json:"topic_tag"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` +} + +type ProfileLookupResult struct { + Username string `json:"username"` + Name string `json:"name"` + ProfilePictureURL string `json:"profile_picture_url,omitempty"` + Biography string `json:"biography,omitempty"` + FollowerCount int `json:"follower_count,omitempty"` + LikesCount int `json:"likes_count,omitempty"` + QuotesCount int `json:"quotes_count,omitempty"` + RepostsCount int `json:"reposts_count,omitempty"` + ViewsCount int `json:"views_count,omitempty"` + IsVerified bool `json:"is_verified,omitempty"` +} + +type MentionItem struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + IsReply bool `json:"is_reply"` + IsQuotePost bool `json:"is_quote_post"` + HasReplies bool `json:"has_replies"` + RootPostID string `json:"root_post_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` +} + +type LocationItem struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type InsightValue struct { + Name string `json:"name"` + Values []struct { + Value int `json:"value"` + } `json:"values"` +} + +// UserThreads lists the authenticated user's own posts (threads_basic). +func (c *Client) UserThreads(ctx context.Context, userID string, limit int) ([]MediaItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + userID = strings.TrimSpace(userID) + if userID == "" { + return nil, fmt.Errorf("threads user id is required") + } + if limit <= 0 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("fields", userThreadsListFields) + params.Set("limit", fmt.Sprintf("%d", limit)) + endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads?" + params.Encode() + return c.getMediaList(ctx, endpoint) +} + +// DeleteMedia deletes a Threads post (threads_delete). +func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error { + if !c.Enabled() { + return fmt.Errorf("threads api access token is required") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return fmt.Errorf("threads media id is required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + body, status, err := doRequest(req) + if err != nil { + return err + } + if status != http.StatusOK { + return parseAPIError(body, status) + } + return nil +} + +// ProfilePosts lists recent public posts for a username (threads_profile_discovery). +func (c *Client) ProfilePosts(ctx context.Context, username string, limit int, after string) ([]MediaItem, string, error) { + if !c.Enabled() { + return nil, "", fmt.Errorf("threads api access token is required") + } + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, "", fmt.Errorf("username is required") + } + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("username", username) + params.Set("fields", "id,shortcode,permalink,text,timestamp") + params.Set("limit", fmt.Sprintf("%d", limit)) + if after = strings.TrimSpace(after); after != "" { + params.Set("after", after) + } + endpoint := graphBaseURL + "/profile_posts?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, "", err + } + body, status, err := doRequest(req) + if err != nil { + return nil, "", err + } + if status != http.StatusOK { + return nil, "", parseAPIError(body, status) + } + var payload struct { + Data []MediaItem `json:"data"` + Paging struct { + Cursors struct { + After string `json:"after"` + } `json:"cursors"` + } `json:"paging"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, "", fmt.Errorf("parse profile_posts: %w", err) + } + return payload.Data, strings.TrimSpace(payload.Paging.Cursors.After), nil +} + +// FindMediaIDByAuthorShortcode scans profile_posts pages until shortcode is found. +func (c *Client) FindMediaIDByAuthorShortcode(ctx context.Context, username, shortcode string) (string, error) { + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + shortcode = strings.TrimSpace(shortcode) + if username == "" || shortcode == "" { + return "", fmt.Errorf("username and shortcode are required") + } + wantSuffix := "/post/" + shortcode + after := "" + for page := 0; page < 3; page++ { + items, next, err := c.ProfilePosts(ctx, username, 50, after) + if err != nil { + return "", err + } + for _, item := range items { + id := strings.TrimSpace(item.ID) + if !IsNumericMediaID(id) { + continue + } + if strings.EqualFold(strings.TrimSpace(item.Shortcode), shortcode) { + return id, nil + } + link := strings.TrimSpace(item.Permalink) + if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) { + return id, nil + } + } + if next == "" || len(items) == 0 { + break + } + after = next + } + return "", fmt.Errorf("profile_posts did not include @%s post %s", username, shortcode) +} + +// ProfileLookup fetches a public profile by username (threads_profile_discovery). +func (c *Client) ProfileLookup(ctx context.Context, username string) (*ProfileLookupResult, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, fmt.Errorf("username is required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("username", username) + params.Set("fields", "username,name,profile_picture_url,biography,follower_count,likes_count,quotes_count,reposts_count,views_count,is_verified") + endpoint := graphBaseURL + "/profile_lookup?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + body, status, err := doRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var out ProfileLookupResult + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("parse profile_lookup: %w", err) + } + return &out, nil +} + +type mentionItemRaw struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + IsReply bool `json:"is_reply"` + IsQuotePost bool `json:"is_quote_post"` + HasReplies bool `json:"has_replies"` + RootPost json.RawMessage `json:"root_post"` + ParentID string `json:"parent_id"` +} + +func normalizeMentionItem(raw mentionItemRaw) MentionItem { + return MentionItem{ + ID: strings.TrimSpace(raw.ID), + Text: strings.TrimSpace(raw.Text), + Username: strings.TrimSpace(raw.Username), + Permalink: strings.TrimSpace(raw.Permalink), + Timestamp: strings.TrimSpace(raw.Timestamp), + MediaType: strings.TrimSpace(raw.MediaType), + IsReply: raw.IsReply, + IsQuotePost: raw.IsQuotePost, + HasReplies: raw.HasReplies, + RootPostID: parseMediaRefID(raw.RootPost), + ParentID: strings.TrimSpace(raw.ParentID), + } +} + +// UserMentions lists the first page of posts mentioning the user (threads_manage_mentions). +func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([]MentionItem, error) { + items, _, err := c.UserMentionsPage(ctx, userID, limit, "") + return items, err +} + +// UserMentionsPage lists one cursor page of mentions. nextAfter is empty when there is no next page. +func (c *Client) UserMentionsPage(ctx context.Context, userID string, limit int, after string) ([]MentionItem, string, error) { + if !c.Enabled() { + return nil, "", fmt.Errorf("threads api access token is required") + } + userID = strings.TrimSpace(userID) + if userID == "" { + return nil, "", fmt.Errorf("threads user id is required") + } + if limit <= 0 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id") + params.Set("limit", fmt.Sprintf("%d", limit)) + if after = strings.TrimSpace(after); after != "" { + params.Set("after", after) + } + endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/mentions?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, "", err + } + body, status, err := doRequest(req) + if err != nil { + return nil, "", err + } + if status != http.StatusOK { + return nil, "", parseAPIError(body, status) + } + var payload struct { + Data []mentionItemRaw `json:"data"` + Paging struct { + Cursors struct { + After string `json:"after"` + } `json:"cursors"` + } `json:"paging"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, "", fmt.Errorf("parse mentions: %w", err) + } + out := make([]MentionItem, 0, len(payload.Data)) + for _, raw := range payload.Data { + out = append(out, normalizeMentionItem(raw)) + } + return out, strings.TrimSpace(payload.Paging.Cursors.After), nil +} + +// LocationSearch searches public locations (threads_location_tagging). +func (c *Client) LocationSearch(ctx context.Context, query string, limit int) ([]LocationItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + query = strings.TrimSpace(query) + if query == "" { + return nil, fmt.Errorf("location query is required") + } + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("q", query) + params.Set("fields", "id,name") + params.Set("limit", fmt.Sprintf("%d", limit)) + endpoint := graphBaseURL + "/location_search?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + body, status, err := doRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var payload struct { + Data []LocationItem `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse location_search: %w", err) + } + return payload.Data, nil +} + +// ShareToInstagram cross-posts a Threads media to linked Instagram (threads_share_to_instagram). +func (c *Client) ShareToInstagram(ctx context.Context, mediaID string) error { + if !c.Enabled() { + return fmt.Errorf("threads api access token is required") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return fmt.Errorf("threads media id is required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/crosspost?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return err + } + body, status, err := doRequest(req) + if err != nil { + return err + } + if status != http.StatusOK { + return parseAPIError(body, status) + } + return nil +} + +// HideReply hides a reply on the user's thread (threads_manage_replies). +func (c *Client) HideReply(ctx context.Context, replyID string, hide bool) error { + if !c.Enabled() { + return fmt.Errorf("threads api access token is required") + } + replyID = strings.TrimSpace(replyID) + if replyID == "" { + return fmt.Errorf("reply id is required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("hide", fmt.Sprintf("%t", hide)) + endpoint := graphBaseURL + "/" + url.PathEscape(replyID) + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return err + } + body, status, err := doRequest(req) + if err != nil { + return err + } + if status != http.StatusOK { + return parseAPIError(body, status) + } + return nil +} + +// MediaInsights fetches per-post lifetime metrics (threads_manage_insights). +func (c *Client) MediaInsights(ctx context.Context, mediaID string, metrics []string) (map[string]int, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, fmt.Errorf("threads media id is required") + } + if len(metrics) == 0 { + metrics = []string{"views", "likes", "replies", "reposts", "quotes", "shares"} + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("metric", strings.Join(metrics, ",")) + endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/insights?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + body, status, err := doRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var payload struct { + Data []InsightValue `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse media insights: %w", err) + } + out := make(map[string]int, len(payload.Data)) + for _, item := range payload.Data { + name := strings.TrimSpace(strings.ToLower(item.Name)) + if name == "" || len(item.Values) == 0 { + continue + } + out[name] = item.Values[0].Value + } + return out, nil +} + +// UserInsights fetches profile-level insights (threads_manage_insights). +func (c *Client) UserInsights(ctx context.Context, userID string, metrics []string) ([]InsightValue, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + userID = strings.TrimSpace(userID) + if userID == "" { + return nil, fmt.Errorf("threads user id is required") + } + if len(metrics) == 0 { + metrics = []string{"views", "likes", "replies", "reposts", "quotes", "followers_count"} + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("metric", strings.Join(metrics, ",")) + endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads_insights?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + body, status, err := doRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var payload struct { + Data []InsightValue `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse threads_insights: %w", err) + } + return payload.Data, nil +} + +func (c *Client) getMediaList(ctx context.Context, endpoint string) ([]MediaItem, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + res, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, parseAPIError(body, res.StatusCode) + } + var payload struct { + Data []MediaItem `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse threads media list: %w", err) + } + return payload.Data, nil +} diff --git a/backend/internal/library/threadsapi/fields_test.go b/backend/internal/library/threadsapi/fields_test.go new file mode 100644 index 0000000..ae3314f --- /dev/null +++ b/backend/internal/library/threadsapi/fields_test.go @@ -0,0 +1,27 @@ +package threadsapi + +import ( + "strings" + "testing" +) + +func TestUserThreadsListFieldsExcludeEngagement(t *testing.T) { + if fieldInList(userThreadsListFields, "like_count") || fieldInList(userThreadsListFields, "reply_count") { + t.Fatalf("userThreadsListFields must not request engagement fields: %q", userThreadsListFields) + } +} + +func TestMediaConversationFieldsExcludeEngagement(t *testing.T) { + if fieldInList(mediaConversationFields, "like_count") { + t.Fatalf("mediaConversationFields must not request like_count: %q", mediaConversationFields) + } +} + +func fieldInList(fields, name string) bool { + for _, part := range strings.Split(fields, ",") { + if strings.TrimSpace(part) == name { + return true + } + } + return false +} diff --git a/backend/internal/library/threadsapi/media.go b/backend/internal/library/threadsapi/media.go index eb62234..b0e203d 100644 --- a/backend/internal/library/threadsapi/media.go +++ b/backend/internal/library/threadsapi/media.go @@ -6,53 +6,99 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" ) +type MediaDetail struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + IsReply bool `json:"is_reply"` +} + +func (c *Client) GetMediaDetail(ctx context.Context, mediaID string) (*MediaDetail, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, fmt.Errorf("threads media id is required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply") + endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + body, status, err := doRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var out MediaDetail + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("parse media detail: %w", err) + } + out.ID = strings.TrimSpace(out.ID) + if out.ID == "" { + return nil, fmt.Errorf("threads media detail missing id") + } + return &out, nil +} + type MediaStats struct { LikeCount int `json:"like_count"` ReplyCount int `json:"reply_count"` RepostCount int `json:"repost_count"` QuoteCount int `json:"quote_count"` + Views int `json:"views,omitempty"` + Shares int `json:"shares,omitempty"` } +// GetMediaStats loads engagement counts via GET /{media-id}/insights. +// Meta does not expose like_count on the media object itself (requesting those fields returns API errors). func GetMediaStats(ctx context.Context, accessToken, mediaID string) (*MediaStats, error) { - u := fmt.Sprintf("%s/%s", graphBaseURL, mediaID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + return NewClient(accessToken).GetMediaStats(ctx, mediaID) +} + +func (c *Client) GetMediaStats(ctx context.Context, mediaID string) (*MediaStats, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, fmt.Errorf("threads media id is required") + } + metrics, err := c.MediaInsights(ctx, mediaID, []string{"likes", "replies", "reposts", "quotes", "views", "shares"}) if err != nil { + if IsPermissionError(err) { + return nil, fmt.Errorf("%w(貼文互動數需 threads_manage_insights,請確認 OAuth 已授權)", err) + } return nil, err } - q := url.Values{} - q.Set("fields", "like_count,reply_count,repost_count,quote_count") - q.Set("access_token", accessToken) - req.URL.RawQuery = q.Encode() + return mapInsightMetrics(metrics), nil +} - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("threads api media stats error: %s", resp.Status) - } - - var raw struct { - ID string `json:"id"` - LikeCount int `json:"like_count"` - ReplyCount int `json:"reply_count"` - RepostCount int `json:"repost_count"` - QuoteCount int `json:"quote_count"` - } - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return nil, err +func mapInsightMetrics(metrics map[string]int) *MediaStats { + if metrics == nil { + metrics = map[string]int{} } return &MediaStats{ - LikeCount: raw.LikeCount, - ReplyCount: raw.ReplyCount, - RepostCount: raw.RepostCount, - QuoteCount: raw.QuoteCount, - }, nil + LikeCount: metrics["likes"], + ReplyCount: metrics["replies"], + RepostCount: metrics["reposts"], + QuoteCount: metrics["quotes"], + Views: metrics["views"], + Shares: metrics["shares"], + } } type TokenRefreshResult struct { diff --git a/backend/internal/library/threadsapi/media_test.go b/backend/internal/library/threadsapi/media_test.go new file mode 100644 index 0000000..5558c61 --- /dev/null +++ b/backend/internal/library/threadsapi/media_test.go @@ -0,0 +1,27 @@ +package threadsapi + +import "testing" + +func TestMapInsightMetrics(t *testing.T) { + stats := mapInsightMetrics(map[string]int{ + "likes": 10, + "replies": 3, + "reposts": 2, + "quotes": 1, + "views": 99, + "shares": 4, + }) + if stats.LikeCount != 10 || stats.ReplyCount != 3 || stats.RepostCount != 2 || stats.QuoteCount != 1 { + t.Fatalf("unexpected counts: %+v", stats) + } + if stats.Views != 99 || stats.Shares != 4 { + t.Fatalf("unexpected views/shares: %+v", stats) + } +} + +func TestMapInsightMetricsEmpty(t *testing.T) { + stats := mapInsightMetrics(nil) + if stats.LikeCount != 0 || stats.ReplyCount != 0 { + t.Fatalf("expected zero stats, got %+v", stats) + } +} diff --git a/backend/internal/library/threadsapi/oauth.go b/backend/internal/library/threadsapi/oauth.go new file mode 100644 index 0000000..f3b38e2 --- /dev/null +++ b/backend/internal/library/threadsapi/oauth.go @@ -0,0 +1,257 @@ +package threadsapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + threadsAuthorizeURL = "https://threads.net/oauth/authorize" + threadsShortTokenURL = "https://graph.threads.net/oauth/access_token" + threadsLongLivedTokenURL = "https://graph.threads.net/access_token" +) + +// flexString unmarshals JSON string or number (Meta often returns user_id as a number). +type flexString string + +func (s *flexString) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + *s = "" + return nil + } + if data[0] == '"' { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + *s = flexString(str) + return nil + } + var num json.Number + if err := json.Unmarshal(data, &num); err != nil { + return err + } + *s = flexString(num.String()) + return nil +} + +func (s flexString) String() string { + return strings.TrimSpace(string(s)) +} + +type OAuthConfig struct { + AppID string + AppSecret string + RedirectURI string + SuccessRedirect string +} + +func (c OAuthConfig) Enabled() bool { + return strings.TrimSpace(c.AppID) != "" && + strings.TrimSpace(c.AppSecret) != "" && + strings.TrimSpace(c.RedirectURI) != "" +} + +type TokenExchangeResult struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + UserID flexString `json:"user_id"` +} + +type UserProfile struct { + ID flexString `json:"id"` + Username string `json:"username"` + Name string `json:"name"` +} + +func BuildAuthorizeURL(cfg OAuthConfig, state string) (string, error) { + if !cfg.Enabled() { + return "", fmt.Errorf("threads oauth is not configured") + } + state = strings.TrimSpace(state) + if state == "" { + return "", fmt.Errorf("oauth state is required") + } + params := url.Values{} + params.Set("client_id", strings.TrimSpace(cfg.AppID)) + params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI)) + params.Set("scope", OAuthScopeString()) + params.Set("response_type", "code") + params.Set("state", state) + return threadsAuthorizeURL + "?" + params.Encode(), nil +} + +func ExchangeCodeForToken(ctx context.Context, cfg OAuthConfig, code string) (*TokenExchangeResult, error) { + if !cfg.Enabled() { + return nil, fmt.Errorf("threads oauth is not configured") + } + code = strings.TrimSpace(code) + if code == "" { + return nil, fmt.Errorf("authorization code is required") + } + // Meta may append #_ to the redirect code; strip suffix if present. + if idx := strings.Index(code, "#_"); idx >= 0 { + code = code[:idx] + } + + params := url.Values{} + params.Set("client_id", strings.TrimSpace(cfg.AppID)) + params.Set("client_secret", strings.TrimSpace(cfg.AppSecret)) + params.Set("grant_type", "authorization_code") + params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI)) + params.Set("code", code) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsShortTokenURL+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + + body, status, err := oauthDoRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var result TokenExchangeResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse threads token response: %w", err) + } + if strings.TrimSpace(result.AccessToken) == "" { + return nil, fmt.Errorf("threads token exchange returned empty access_token") + } + return &result, nil +} + +func ExchangeLongLivedToken(ctx context.Context, cfg OAuthConfig, shortLivedToken string) (*TokenExchangeResult, error) { + if !cfg.Enabled() { + return nil, fmt.Errorf("threads oauth is not configured") + } + shortLivedToken = strings.TrimSpace(shortLivedToken) + if shortLivedToken == "" { + return nil, fmt.Errorf("short-lived access token is required") + } + + params := url.Values{} + params.Set("grant_type", "th_exchange_token") + params.Set("client_secret", strings.TrimSpace(cfg.AppSecret)) + params.Set("access_token", shortLivedToken) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsLongLivedTokenURL+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + body, status, err := oauthDoRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var result TokenExchangeResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse long-lived token response: %w", err) + } + if strings.TrimSpace(result.AccessToken) == "" { + return nil, fmt.Errorf("long-lived token exchange returned empty access_token") + } + return &result, nil +} + +func GetUserProfile(ctx context.Context, accessToken, userID string) (*UserProfile, error) { + accessToken = strings.TrimSpace(accessToken) + userID = strings.TrimSpace(userID) + if accessToken == "" { + return nil, fmt.Errorf("access token is required") + } + if userID == "" { + return GetMeProfile(ctx, accessToken) + } + + params := url.Values{} + params.Set("access_token", accessToken) + params.Set("fields", "id,username,name") + + endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "?" + params.Encode() + return fetchUserProfile(ctx, endpoint) +} + +// GetMeProfile loads the authenticated Threads user via /me (preferred after OAuth). +func GetMeProfile(ctx context.Context, accessToken string) (*UserProfile, error) { + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return nil, fmt.Errorf("access token is required") + } + params := url.Values{} + params.Set("access_token", accessToken) + params.Set("fields", "id,username,name") + endpoint := graphBaseURL + "/me?" + params.Encode() + return fetchUserProfile(ctx, endpoint) +} + +// ThreadsUserIDFromToken picks user_id returned by the OAuth token exchange. +func ThreadsUserIDFromToken(short, long *TokenExchangeResult) string { + if short != nil { + if id := short.UserID.String(); id != "" { + return id + } + } + if long != nil { + return long.UserID.String() + } + return "" +} + +// IsPermissionError reports Meta permission / app-review / tester-list errors. +func IsPermissionError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "threads_basic") || + strings.Contains(msg, "app review") || + strings.Contains(msg, "testers") +} + +func fetchUserProfile(ctx context.Context, endpoint string) (*UserProfile, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + body, status, err := oauthDoRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var profile UserProfile + if err := json.Unmarshal(body, &profile); err != nil { + return nil, fmt.Errorf("parse threads user profile: %w", err) + } + if profile.ID.String() == "" { + return nil, fmt.Errorf("threads user profile missing id") + } + return &profile, nil +} + +func oauthDoRequest(req *http.Request) ([]byte, int, error) { + client := &http.Client{Timeout: 25 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return nil, res.StatusCode, err + } + return body, res.StatusCode, nil +} diff --git a/backend/internal/library/threadsapi/oauth_permission_test.go b/backend/internal/library/threadsapi/oauth_permission_test.go new file mode 100644 index 0000000..4b3f7b4 --- /dev/null +++ b/backend/internal/library/threadsapi/oauth_permission_test.go @@ -0,0 +1,28 @@ +package threadsapi + +import ( + "errors" + "testing" +) + +func TestIsPermissionError(t *testing.T) { + err := errors.New(`threads api 400: This action requires the threads_basic permission`) + if !IsPermissionError(err) { + t.Fatal("expected permission error") + } + if IsPermissionError(errors.New("network timeout")) { + t.Fatal("unexpected permission match") + } +} + +func TestThreadsUserIDFromToken(t *testing.T) { + short := &TokenExchangeResult{UserID: flexString("111")} + long := &TokenExchangeResult{UserID: flexString("222")} + if got := ThreadsUserIDFromToken(short, long); got != "111" { + t.Fatalf("got %q", got) + } + short.UserID = "" + if got := ThreadsUserIDFromToken(short, long); got != "222" { + t.Fatalf("got %q", got) + } +} diff --git a/backend/internal/library/threadsapi/oauth_test.go b/backend/internal/library/threadsapi/oauth_test.go new file mode 100644 index 0000000..163b864 --- /dev/null +++ b/backend/internal/library/threadsapi/oauth_test.go @@ -0,0 +1,28 @@ +package threadsapi + +import ( + "encoding/json" + "testing" +) + +func TestTokenExchangeResultUnmarshalNumericUserID(t *testing.T) { + raw := []byte(`{"access_token":"tok","token_type":"bearer","expires_in":3600,"user_id":123456789}`) + var result TokenExchangeResult + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result.UserID.String() != "123456789" { + t.Fatalf("user_id = %q, want 123456789", result.UserID.String()) + } +} + +func TestUserProfileUnmarshalNumericID(t *testing.T) { + raw := []byte(`{"id":987654321,"username":"demo","name":"Demo"}`) + var profile UserProfile + if err := json.Unmarshal(raw, &profile); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if profile.ID.String() != "987654321" { + t.Fatalf("id = %q, want 987654321", profile.ID.String()) + } +} diff --git a/backend/internal/library/threadsapi/publish.go b/backend/internal/library/threadsapi/publish.go index d793dc3..1cbac06 100644 --- a/backend/internal/library/threadsapi/publish.go +++ b/backend/internal/library/threadsapi/publish.go @@ -14,14 +14,15 @@ import ( ) type PublishResult struct { - MediaID string - Permalink string + MediaID string `json:"media_id"` + Permalink string `json:"permalink"` } type PublishTextInput struct { ThreadsUserID string AccessToken string Text string + TopicTag string } type PublishReplyInput struct { @@ -46,7 +47,7 @@ func PublishText(ctx context.Context, in PublishTextInput) (*PublishResult, erro return nil, err } - containerID, err := createTextContainer(ctx, userID, token, text) + containerID, err := createTextContainer(ctx, userID, token, text, in.TopicTag) if err != nil { return nil, err } @@ -95,11 +96,14 @@ func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, er return &PublishResult{MediaID: mediaID, Permalink: permalink}, nil } -func createTextContainer(ctx context.Context, userID, token, text string) (string, error) { +func createTextContainer(ctx context.Context, userID, token, text, topicTag string) (string, error) { params := url.Values{} params.Set("access_token", token) params.Set("media_type", "TEXT") params.Set("text", text) + if tag := normalizeTopicTag(topicTag); tag != "" { + params.Set("topic_tag", tag) + } endpoint := graphBaseURL + "/" + userID + "/threads?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) if err != nil { @@ -124,6 +128,17 @@ func createTextContainer(ctx context.Context, userID, token, text string) (strin return payload.ID, nil } +func normalizeTopicTag(value string) string { + tag := strings.TrimSpace(value) + tag = strings.TrimLeft(tag, "#") + tag = strings.TrimSpace(tag) + if len([]rune(tag)) > 40 { + runes := []rune(tag) + tag = string(runes[:40]) + } + return tag +} + func createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) { params := url.Values{} params.Set("access_token", token) @@ -184,12 +199,12 @@ func publishContainer(ctx context.Context, userID, token, containerID string) (s } func waitForContainerReady(ctx context.Context, containerID, token string) error { - for attempt := 0; attempt < 20; attempt++ { + for attempt := 0; attempt < 12; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return ctx.Err() - case <-time.After(3 * time.Second): + case <-time.After(2 * time.Second): } } params := url.Values{} diff --git a/backend/internal/library/threadsapi/replies.go b/backend/internal/library/threadsapi/replies.go index 8864feb..d058294 100644 --- a/backend/internal/library/threadsapi/replies.go +++ b/backend/internal/library/threadsapi/replies.go @@ -10,17 +10,61 @@ import ( "strings" ) +const mediaConversationFields = "id,text,username,permalink,timestamp,is_reply,is_reply_owned_by_me,has_replies,replied_to,root_post,parent_id,media_type,media_url,thumbnail_url,gif_url,children{id,media_type,media_url,thumbnail_url}" + +type ReplyMediaChild struct { + ID string `json:"id"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` +} + type ReplyItem struct { - ID string `json:"id"` - Text string `json:"text"` - Username string `json:"username"` - Permalink string `json:"permalink"` - Timestamp string `json:"timestamp"` - LikeCount int `json:"like_count"` - ParentID string `json:"parent_id"` + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + LikeCount int `json:"like_count"` + ParentID string `json:"parent_id"` + RepliedToID string `json:"replied_to_id"` + RootPostID string `json:"root_post_id"` + IsReply bool `json:"is_reply"` + IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` + HasReplies bool `json:"has_replies"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + GifURL string `json:"gif_url"` + MediaChildren []ReplyMediaChild `json:"media_children,omitempty"` +} + +type replyItemRaw struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + LikeCount int `json:"like_count"` + ParentID string `json:"parent_id"` + RepliedTo json.RawMessage `json:"replied_to"` + RootPost json.RawMessage `json:"root_post"` + IsReply bool `json:"is_reply"` + IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` + HasReplies bool `json:"has_replies"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + GifURL string `json:"gif_url"` + Children json.RawMessage `json:"children"` } func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) { + return c.MediaConversation(ctx, mediaID, limit) +} + +// MediaConversation returns flattened top-level and nested replies for a thread root. +func (c *Client) MediaConversation(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) { if !c.Enabled() { return nil, fmt.Errorf("threads api access token is required") } @@ -31,39 +75,146 @@ func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([ if limit <= 0 { limit = 10 } - if limit > 50 { - limit = 50 + if limit > 100 { + limit = 100 } - params := url.Values{} - params.Set("access_token", c.accessToken) - params.Set("fields", "id,text,username,permalink,timestamp,like_count,parent_id") - params.Set("limit", fmt.Sprintf("%d", limit)) + out := make([]ReplyItem, 0, limit) + nextURL := "" + for len(out) < limit { + pageLimit := limit - len(out) + if pageLimit > 50 { + pageLimit = 50 + } + endpoint := nextURL + if endpoint == "" { + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("fields", mediaConversationFields) + params.Set("limit", fmt.Sprintf("%d", pageLimit)) + params.Set("reverse", "false") + endpoint = graphBaseURL + "/" + url.PathEscape(mediaID) + "/conversation?" + params.Encode() + } - endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/replies?" + params.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - res, err := c.http.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + res, err := c.http.Do(req) + if err != nil { + return nil, err + } - body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) - if err != nil { - return nil, err - } - if res.StatusCode != http.StatusOK { - return nil, parseAPIError(body, res.StatusCode) - } + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + res.Body.Close() + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, parseAPIError(body, res.StatusCode) + } - var payload struct { - Data []ReplyItem `json:"data"` + var payload struct { + Data []replyItemRaw `json:"data"` + Paging struct { + Next string `json:"next"` + } `json:"paging"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse threads conversation: %w", err) + } + for _, raw := range payload.Data { + out = append(out, normalizeReplyItem(raw)) + if len(out) >= limit { + break + } + } + nextURL = strings.TrimSpace(payload.Paging.Next) + if nextURL == "" || len(payload.Data) == 0 { + break + } } - if err := json.Unmarshal(body, &payload); err != nil { - return nil, fmt.Errorf("parse threads replies: %w", err) - } - return payload.Data, nil + return out, nil +} + +func normalizeReplyItem(raw replyItemRaw) ReplyItem { + repliedToID := parseMediaRefID(raw.RepliedTo) + if repliedToID == "" { + repliedToID = strings.TrimSpace(raw.ParentID) + } + return ReplyItem{ + ID: strings.TrimSpace(raw.ID), + Text: strings.TrimSpace(raw.Text), + Username: strings.TrimSpace(raw.Username), + Permalink: strings.TrimSpace(raw.Permalink), + Timestamp: strings.TrimSpace(raw.Timestamp), + LikeCount: raw.LikeCount, + ParentID: strings.TrimSpace(raw.ParentID), + RepliedToID: repliedToID, + RootPostID: parseMediaRefID(raw.RootPost), + IsReply: raw.IsReply, + IsReplyOwnedByMe: raw.IsReplyOwnedByMe, + HasReplies: raw.HasReplies, + MediaType: strings.TrimSpace(raw.MediaType), + MediaURL: strings.TrimSpace(raw.MediaURL), + ThumbnailURL: strings.TrimSpace(raw.ThumbnailURL), + GifURL: strings.TrimSpace(raw.GifURL), + MediaChildren: parseMediaChildren(raw.Children), + } +} + +func parseMediaChildren(raw json.RawMessage) []ReplyMediaChild { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var asList []ReplyMediaChild + if err := json.Unmarshal(raw, &asList); err == nil { + return normalizeMediaChildren(asList) + } + + var wrapped struct { + Data []ReplyMediaChild `json:"data"` + } + if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Data) > 0 { + return normalizeMediaChildren(wrapped.Data) + } + return nil +} + +func normalizeMediaChildren(items []ReplyMediaChild) []ReplyMediaChild { + out := make([]ReplyMediaChild, 0, len(items)) + for _, item := range items { + child := ReplyMediaChild{ + ID: strings.TrimSpace(item.ID), + MediaType: strings.TrimSpace(item.MediaType), + MediaURL: strings.TrimSpace(item.MediaURL), + ThumbnailURL: strings.TrimSpace(item.ThumbnailURL), + } + if child.ID == "" && child.MediaURL == "" && child.ThumbnailURL == "" { + continue + } + out = append(out, child) + } + if len(out) == 0 { + return nil + } + return out +} + +func parseMediaRefID(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return strings.TrimSpace(asString) + } + var asObject struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &asObject); err == nil { + return strings.TrimSpace(asObject.ID) + } + return "" } diff --git a/backend/internal/library/threadsapi/replies_test.go b/backend/internal/library/threadsapi/replies_test.go new file mode 100644 index 0000000..d123180 --- /dev/null +++ b/backend/internal/library/threadsapi/replies_test.go @@ -0,0 +1,69 @@ +package threadsapi + +import ( + "encoding/json" + "testing" +) + +func TestParseMediaRefIDObjectAndString(t *testing.T) { + obj := json.RawMessage(`{"id":"123"}`) + if got := parseMediaRefID(obj); got != "123" { + t.Fatalf("object ref: got %q", got) + } + str := json.RawMessage(`"456"`) + if got := parseMediaRefID(str); got != "456" { + t.Fatalf("string ref: got %q", got) + } +} + +func TestNormalizeReplyItemPrefersRepliedTo(t *testing.T) { + item := normalizeReplyItem(replyItemRaw{ + ID: "child", + ParentID: "legacy-parent", + RepliedTo: json.RawMessage(`{"id":"real-parent"}`), + Text: "hello", + }) + if item.RepliedToID != "real-parent" { + t.Fatalf("expected replied_to id, got %q", item.RepliedToID) + } +} + +func TestNormalizeReplyItemKeepsMediaURLs(t *testing.T) { + item := normalizeReplyItem(replyItemRaw{ + ID: "img-reply", + MediaType: "IMAGE", + MediaURL: "https://example.com/photo.jpg", + ThumbnailURL: "https://example.com/thumb.jpg", + GifURL: "https://example.com/cat.gif", + }) + if item.MediaURL != "https://example.com/photo.jpg" { + t.Fatalf("media_url: got %q", item.MediaURL) + } + if item.GifURL != "https://example.com/cat.gif" { + t.Fatalf("gif_url: got %q", item.GifURL) + } +} + +func TestNormalizeReplyItemParsesCarouselChildren(t *testing.T) { + item := normalizeReplyItem(replyItemRaw{ + ID: "carousel-reply", + MediaType: "CAROUSEL_ALBUM", + Children: json.RawMessage(`{"data":[{"id":"c1","media_type":"IMAGE","media_url":"https://example.com/1.jpg"},{"id":"c2","media_type":"VIDEO","media_url":"https://example.com/2.mp4","thumbnail_url":"https://example.com/2.jpg"}]}`), + }) + if len(item.MediaChildren) != 2 { + t.Fatalf("expected 2 media children, got %d", len(item.MediaChildren)) + } + if item.MediaChildren[0].MediaURL != "https://example.com/1.jpg" { + t.Fatalf("first child media_url: got %q", item.MediaChildren[0].MediaURL) + } + if item.MediaChildren[1].ThumbnailURL != "https://example.com/2.jpg" { + t.Fatalf("second child thumbnail_url: got %q", item.MediaChildren[1].ThumbnailURL) + } +} + +func TestParseMediaChildrenAcceptsArray(t *testing.T) { + children := parseMediaChildren(json.RawMessage(`[{"id":"x","media_type":"IMAGE","media_url":"https://example.com/x.jpg"}]`)) + if len(children) != 1 || children[0].ID != "x" { + t.Fatalf("unexpected children: %#v", children) + } +} diff --git a/backend/internal/library/threadsapi/resolve_media.go b/backend/internal/library/threadsapi/resolve_media.go new file mode 100644 index 0000000..ab8e38a --- /dev/null +++ b/backend/internal/library/threadsapi/resolve_media.go @@ -0,0 +1,298 @@ +package threadsapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" +) + +var threadsPermalinkRe = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([^/]+)/post/([^/?#]+)`) + +// IsNumericMediaID reports whether the value looks like a Threads Graph media id. +func IsNumericMediaID(raw string) bool { + raw = strings.TrimSpace(raw) + if len(raw) < 8 { + return false + } + for _, r := range raw { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func parsePermalinkParts(permalink string) (username, shortcode string) { + permalink = strings.TrimSpace(permalink) + if permalink == "" { + return "", "" + } + permalink = strings.Split(permalink, "?")[0] + permalink = strings.Split(permalink, "#")[0] + match := threadsPermalinkRe.FindStringSubmatch(permalink) + if len(match) < 3 { + return "", "" + } + return strings.TrimSpace(match[1]), strings.TrimSpace(match[2]) +} + +type ResolveReplyMediaInput struct { + ExternalID string + Permalink string + HintText string +} + +// ResolveReplyMediaID returns a Graph API media id suitable for reply_to_id. +func (c *Client) ResolveReplyMediaID(ctx context.Context, externalID, permalink string) (string, error) { + return c.ResolveReplyMedia(ctx, ResolveReplyMediaInput{ + ExternalID: externalID, + Permalink: permalink, + }) +} + +// ResolveReplyMedia resolves shortcode/permalink targets into numeric media ids for reply_to_id. +func (c *Client) ResolveReplyMedia(ctx context.Context, in ResolveReplyMediaInput) (string, error) { + if !c.Enabled() { + return "", fmt.Errorf("threads api access token is required") + } + candidate := strings.TrimSpace(in.ExternalID) + if IsNumericMediaID(candidate) { + return candidate, nil + } + username, shortcode := parsePermalinkParts(in.Permalink) + if !IsNumericMediaID(candidate) && candidate != "" { + shortcode = candidate + } + if IsNumericMediaID(shortcode) { + return shortcode, nil + } + if shortcode == "" { + return "", fmt.Errorf("缺少有效的 Threads 貼文 ID") + } + if username == "" { + return "", fmt.Errorf("permalink 缺少作者資訊,無法解析貼文 ID") + } + + for _, query := range keywordQueriesFromHint(in.HintText) { + if id, err := c.lookupMediaIDByKeywordText(ctx, username, shortcode, query); err == nil && id != "" { + return id, nil + } + } + if id, err := c.lookupMediaIDViaProfilePosts(ctx, username, shortcode); err == nil && id != "" { + return id, nil + } + if id, err := c.lookupMediaIDByAuthorAndShortcode(ctx, username, shortcode); err == nil && id != "" { + return id, nil + } + return "", fmt.Errorf( + "無法將貼文 shortcode 轉成 Threads media id(%s/@%s/post/%s)。請確認 OAuth 具備 threads_profile_discovery 與 threads_keyword_search,或重新以 API 海巡抓取貼文", + shortcode, username, shortcode, + ) +} + +func keywordQueriesFromHint(hint string) []string { + hint = strings.TrimSpace(hint) + if hint == "" { + return nil + } + hint = strings.Join(strings.Fields(hint), " ") + seen := map[string]struct{}{} + out := make([]string, 0, 3) + add := func(query string) { + query = strings.TrimSpace(query) + if query == "" || utf8.RuneCountInString(query) < 4 { + return + } + if _, ok := seen[query]; ok { + return + } + seen[query] = struct{}{} + out = append(out, query) + } + add(trimRunes(hint, 80)) + if idx := strings.IndexAny(hint, "。!?!?.\n"); idx > 0 { + add(trimRunes(hint[:idx], 60)) + } + add(trimRunes(hint, 32)) + return out +} + +func trimRunes(raw string, max int) string { + raw = strings.TrimSpace(raw) + if max <= 0 || utf8.RuneCountInString(raw) <= max { + return raw + } + runes := []rune(raw) + return strings.TrimSpace(string(runes[:max])) +} + +func (c *Client) lookupMediaIDViaProfilePosts(ctx context.Context, username, shortcode string) (string, error) { + return c.FindMediaIDByAuthorShortcode(ctx, username, shortcode) +} + +func (c *Client) lookupMediaIDByKeywordText(ctx context.Context, username, shortcode, query string) (string, error) { + query = strings.TrimSpace(query) + if query == "" { + return "", fmt.Errorf("keyword query is required") + } + for _, searchType := range []string{"TOP", "RECENT"} { + id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) + if err == nil && id != "" { + return id, nil + } + } + return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode) +} + +func (c *Client) lookupMediaIDByAuthorAndShortcode(ctx context.Context, username, shortcode string) (string, error) { + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + shortcode = strings.TrimSpace(shortcode) + if username == "" || shortcode == "" { + return "", fmt.Errorf("username and shortcode are required") + } + type lookupStrategy struct { + searchType string + withAuthor bool + } + strategies := []lookupStrategy{ + {searchType: "TOP", withAuthor: true}, + {searchType: "RECENT", withAuthor: true}, + {searchType: "TOP", withAuthor: false}, + {searchType: "RECENT", withAuthor: false}, + } + var lastErr error + for _, strategy := range strategies { + id, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, strategy.searchType, strategy.withAuthor, shortcode) + if err == nil && id != "" { + return id, nil + } + if err != nil { + lastErr = err + } + } + if lastErr != nil { + return "", lastErr + } + return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode) +} + +func (c *Client) keywordSearchMediaByShortcode( + ctx context.Context, + username, query, searchType string, + withAuthor bool, + wantShortcode string, +) (string, error) { + query = strings.TrimSpace(query) + wantShortcode = strings.TrimSpace(wantShortcode) + if query == "" || wantShortcode == "" { + return "", fmt.Errorf("query and shortcode are required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("q", query) + params.Set("search_type", searchType) + if withAuthor { + params.Set("author_username", username) + } + params.Set("fields", "id,permalink,shortcode") + params.Set("limit", "25") + endpoint := graphBaseURL + "/keyword_search?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + res, err := c.http.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return "", err + } + if res.StatusCode != http.StatusOK { + return "", parseAPIError(body, res.StatusCode) + } + var payload struct { + Data []struct { + ID string `json:"id"` + Permalink string `json:"permalink"` + Shortcode string `json:"shortcode"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", err + } + wantSuffix := "/post/" + wantShortcode + for _, item := range payload.Data { + id := strings.TrimSpace(item.ID) + if !IsNumericMediaID(id) { + continue + } + if withAuthor { + link := strings.TrimSpace(item.Permalink) + if link != "" && !strings.Contains(strings.ToLower(link), "/@"+strings.ToLower(username)+"/") { + continue + } + } + if strings.EqualFold(strings.TrimSpace(item.Shortcode), wantShortcode) { + return id, nil + } + link := strings.TrimSpace(item.Permalink) + if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) { + return id, nil + } + } + return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, wantShortcode) +} + +// PrimeReplyTargetForPublish registers a public post via Threads API search before reply_to_id publish. +// Meta only allows replying to public posts that were recently returned by keyword_search. +func (c *Client) PrimeReplyTargetForPublish(ctx context.Context, in ResolveReplyMediaInput, mediaID string) error { + if !c.Enabled() { + return fmt.Errorf("threads api access token is required") + } + username, shortcode := parsePermalinkParts(in.Permalink) + if candidate := strings.TrimSpace(in.ExternalID); !IsNumericMediaID(candidate) && candidate != "" { + shortcode = candidate + } + mediaID = strings.TrimSpace(mediaID) + if username == "" || shortcode == "" || mediaID == "" { + return fmt.Errorf("缺少作者、shortcode 或 media id,無法預先搜尋貼文") + } + + // Fast path: shortcode / author queries with author_username filter (fewer API round-trips). + for _, query := range []string{shortcode, username, "@" + username} { + for _, searchType := range []string{"TOP", "RECENT"} { + id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) + if err == nil && strings.TrimSpace(id) == mediaID { + return nil + } + } + } + for _, query := range keywordQueriesFromHint(in.HintText) { + for _, searchType := range []string{"TOP", "RECENT"} { + id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) + if err == nil && strings.TrimSpace(id) == mediaID { + return nil + } + } + } + if id, err := c.FindMediaIDByAuthorShortcode(ctx, username, shortcode); err == nil && strings.TrimSpace(id) == mediaID { + for _, searchType := range []string{"TOP", "RECENT"} { + if _, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, searchType, true, shortcode); err == nil { + return nil + } + } + } + return fmt.Errorf( + "Threads API 搜尋不到 @%s 的這篇貼文(%s)。回覆他人貼文前必須先用 keyword_search 找到它,請確認 threads_keyword_search 權限已核准,或改用「開始海巡(API)」重新抓取", + username, shortcode, + ) +} diff --git a/backend/internal/library/threadsapi/resolve_media_hint_test.go b/backend/internal/library/threadsapi/resolve_media_hint_test.go new file mode 100644 index 0000000..4673002 --- /dev/null +++ b/backend/internal/library/threadsapi/resolve_media_hint_test.go @@ -0,0 +1,13 @@ +package threadsapi + +import "testing" + +func TestKeywordQueriesFromHint(t *testing.T) { + queries := keywordQueriesFromHint("最近換季皮膚好乾,大家有推薦的保濕產品嗎?") + if len(queries) == 0 { + t.Fatal("expected keyword queries") + } + if queries[0] == "" { + t.Fatal("first query empty") + } +} diff --git a/backend/internal/library/threadsapi/resolve_media_test.go b/backend/internal/library/threadsapi/resolve_media_test.go new file mode 100644 index 0000000..4a16e3d --- /dev/null +++ b/backend/internal/library/threadsapi/resolve_media_test.go @@ -0,0 +1,19 @@ +package threadsapi + +import "testing" + +func TestIsNumericMediaID(t *testing.T) { + if !IsNumericMediaID("18123456789012345") { + t.Fatal("expected numeric media id") + } + if IsNumericMediaID("AbCdEf123") { + t.Fatal("shortcode should not be numeric media id") + } +} + +func TestParsePermalinkParts(t *testing.T) { + user, code := parsePermalinkParts("https://www.threads.net/@alice/post/AbCdEf123") + if user != "alice" || code != "AbCdEf123" { + t.Fatalf("got %q %q", user, code) + } +} diff --git a/backend/internal/library/threadsapi/scopes.go b/backend/internal/library/threadsapi/scopes.go new file mode 100644 index 0000000..5e76c48 --- /dev/null +++ b/backend/internal/library/threadsapi/scopes.go @@ -0,0 +1,23 @@ +package threadsapi + +import "strings" + +// DefaultOAuthScopes lists all Threads API permissions used by this product. +// threads_basic is required for all API usage. +var DefaultOAuthScopes = []string{ + "threads_basic", + "threads_content_publish", + "threads_delete", + "threads_keyword_search", + "threads_location_tagging", + "threads_manage_insights", + "threads_manage_mentions", + "threads_manage_replies", + "threads_profile_discovery", + "threads_read_replies", + "threads_share_to_instagram", +} + +func OAuthScopeString() string { + return strings.Join(DefaultOAuthScopes, ",") +} diff --git a/backend/internal/library/threadspost/format.go b/backend/internal/library/threadspost/format.go new file mode 100644 index 0000000..84debcd --- /dev/null +++ b/backend/internal/library/threadspost/format.go @@ -0,0 +1,35 @@ +package threadspost + +import ( + "strings" +) + +// FormatDraftText normalizes generated post bodies without destroying the author's voice. +func FormatDraftText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + text = strings.TrimSpace(text) + if text == "" { + return "" + } + + lines := strings.Split(text, "\n") + out := make([]string, 0, len(lines)) + blankCount := 0 + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + blankCount++ + if blankCount <= 1 && len(out) > 0 { + out = append(out, "") + } + continue + } + blankCount = 0 + out = append(out, line) + } + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + return ClampPublish(strings.Join(out, "\n")) +} diff --git a/backend/internal/library/threadspost/format_test.go b/backend/internal/library/threadspost/format_test.go new file mode 100644 index 0000000..6485dd9 --- /dev/null +++ b/backend/internal/library/threadspost/format_test.go @@ -0,0 +1,48 @@ +package threadspost + +import "testing" + +func TestFormatDraftTextPreservesPunctuationAndLineBreaks(t *testing.T) { + raw := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?" + want := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextKeepsSingleParagraph(t *testing.T) { + raw := "第一句很好。第二句也不錯!第三句收尾?" + want := "第一句很好。第二句也不錯!第三句收尾?" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextCollapsesExcessBlankLines(t *testing.T) { + raw := "第一段\n\n\n第二段" + want := "第一段\n\n第二段" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextPreservesEmoji(t *testing.T) { + raw := "廚房像打完仗一樣 😂\n但看到成果就值得了" + want := "廚房像打完仗一樣 😂\n但看到成果就值得了" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextPreservesHashtag(t *testing.T) { + raw := "今天心情不錯\n#早餐日記" + want := "今天心情不錯\n#早餐日記" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} diff --git a/backend/internal/library/threadspost/mimic.go b/backend/internal/library/threadspost/mimic.go new file mode 100644 index 0000000..b78bd9d --- /dev/null +++ b/backend/internal/library/threadspost/mimic.go @@ -0,0 +1,65 @@ +package threadspost + +import ( + "fmt" + "strings" +) + +// LineCount returns non-empty lines after normalizing newlines. +func LineCount(text string) int { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + n := 0 + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) != "" { + n++ + } + } + return n +} + +// MimicLengthRange returns a suggested min/max rune count when imitating referenceText. +func MimicLengthRange(referenceText string) (min, max int) { + ref := RuneLen(referenceText) + if ref <= 0 { + return ViralSweetMin, ViralSweetMax + } + min = int(float64(ref) * 0.75) + max = int(float64(ref) * 1.25) + if min < 20 { + min = 20 + } + if max < min+10 { + max = min + 10 + } + if max > MaxPublishRunes { + max = MaxPublishRunes + } + if min > max { + min = max + } + return min, max +} + +// MimicLengthGuidance builds prompt text for rewrite/mimic flows. +func MimicLengthGuidance(referenceText string) string { + referenceText = strings.TrimSpace(referenceText) + if referenceText == "" { + return "" + } + refRunes := RuneLen(referenceText) + refLines := LineCount(referenceText) + min, max := MimicLengthRange(referenceText) + lineMin, lineMax := refLines, refLines + if refLines > 1 { + lineMin = refLines - 1 + if lineMin < 1 { + lineMin = 1 + } + lineMax = refLines + 1 + } + return fmt.Sprintf( + "【篇幅(仿寫)】參考原文約 %d 字、%d 行。產出正文請控制在 %d~%d 字、約 %d~%d 行,不要明顯比參考長很多或短很多。精簡優先:刪掉重複鋪陳、無關細節、雞湯總結與多餘轉折;每一句都要推進故事或情緒,不然就砍掉。", + refRunes, refLines, min, max, lineMin, lineMax, + ) +} diff --git a/backend/internal/library/threadspost/mimic_test.go b/backend/internal/library/threadspost/mimic_test.go new file mode 100644 index 0000000..08fcffc --- /dev/null +++ b/backend/internal/library/threadspost/mimic_test.go @@ -0,0 +1,26 @@ +package threadspost + +import ( + "strings" + "testing" +) + +func TestMimicLengthRange(t *testing.T) { + min, max := MimicLengthRange("今天早餐吃了蛋餅\n加辣真的爽") + if min <= 0 || max < min { + t.Fatalf("MimicLengthRange() = %d, %d", min, max) + } +} + +func TestMimicLengthGuidanceIncludesReferenceStats(t *testing.T) { + ref := "第一句\n第二句\n第三句" + got := MimicLengthGuidance(ref) + if got == "" { + t.Fatal("MimicLengthGuidance() empty") + } + for _, part := range []string{"3 行", "精簡優先"} { + if !strings.Contains(got, part) { + t.Fatalf("MimicLengthGuidance() missing %q: %q", part, got) + } + } +} diff --git a/backend/internal/library/viral/analyze_viral.go b/backend/internal/library/viral/analyze_viral.go index a7ef92e..45f1cdf 100644 --- a/backend/internal/library/viral/analyze_viral.go +++ b/backend/internal/library/viral/analyze_viral.go @@ -7,6 +7,14 @@ import ( ) type ViralAnalysis struct { + StoryHook string `json:"storyHook"` + NarrativeBeat string `json:"narrativeBeat"` + EmotionalCore string `json:"emotionalCore"` + SceneSeed string `json:"sceneSeed"` + AuthenticityCues []string `json:"authenticityCues"` + WhatToAvoid string `json:"whatToAvoid"` + + // Legacy fields kept for backward-compatible parsing. HookPattern string `json:"hookPattern"` StructurePattern string `json:"structurePattern"` EmotionalTrigger string `json:"emotionalTrigger"` @@ -26,9 +34,15 @@ type AnalyzeViralInput struct { } func BuildAnalyzeViralSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 爆款結構分析師。拆解貼文為什麼會紅、怎麼仿寫結構,不要建議抄襲原文。 + return strings.TrimSpace(`你是 Threads 敘事分析師。拆解這篇貼文為什麼讓人想看完、怎麼寫出同樣的故事感(不是抄句型)。 -只回傳 JSON:hookPattern, structurePattern, emotionalTrigger, replicationStrategy, keyTakeaways(字串陣列 3~5 項)。繁體中文。`) +只回傳 JSON(繁體中文): +- storyHook:讓人停下滑動的開場力量(描述手法,不要給可照抄的句子) +- narrativeBeat:故事怎麼推進(經歷/轉折/感受,不是段落模板) +- emotionalCore:核心情緒是什麼 +- sceneSeed:一個值得借鑑的「具體場景種子」(時間/地點/動作,供啟發勿照搬) +- authenticityCues:3~5 個讓它像真人的小細節(口語、坦白、不完美、具體細節等) +- whatToAvoid:若照搬哪些寫法會立刻變成 AI 複製文`) } func BuildAnalyzeViralUserPrompt(in AnalyzeViralInput) string { @@ -65,7 +79,10 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) { if err := json.Unmarshal(payload, &out); err != nil { return ViralAnalysis{}, fmt.Errorf("parse viral analysis: %w", err) } - if strings.TrimSpace(out.HookPattern) == "" && strings.TrimSpace(out.StructurePattern) == "" { + if strings.TrimSpace(out.StoryHook) == "" && + strings.TrimSpace(out.NarrativeBeat) == "" && + strings.TrimSpace(out.HookPattern) == "" && + strings.TrimSpace(out.StructurePattern) == "" { return ViralAnalysis{}, fmt.Errorf("viral analysis missing content") } return out, nil @@ -73,29 +90,47 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) { func FormatAnalysisForReplicate(analysis ViralAnalysis) string { var b strings.Builder - if hook := strings.TrimSpace(analysis.HookPattern); hook != "" { - b.WriteString("Hook 模式:") + if hook := firstNonEmpty(analysis.StoryHook, analysis.HookPattern); hook != "" { + b.WriteString("故事開場力量:") b.WriteString(hook) b.WriteString("\n") } - if structure := strings.TrimSpace(analysis.StructurePattern); structure != "" { - b.WriteString("結構節奏:") - b.WriteString(structure) + if beat := firstNonEmpty(analysis.NarrativeBeat, analysis.StructurePattern); beat != "" { + b.WriteString("敘事推進:") + b.WriteString(beat) b.WriteString("\n") } - if emotion := strings.TrimSpace(analysis.EmotionalTrigger); emotion != "" { - b.WriteString("情緒觸發:") + if emotion := firstNonEmpty(analysis.EmotionalCore, analysis.EmotionalTrigger); emotion != "" { + b.WriteString("核心情緒:") b.WriteString(emotion) b.WriteString("\n") } - if strategy := strings.TrimSpace(analysis.ReplicationStrategy); strategy != "" { - b.WriteString("仿寫策略:") - b.WriteString(strategy) + if scene := strings.TrimSpace(analysis.SceneSeed); scene != "" { + b.WriteString("場景種子(啟發用,勿照搬):") + b.WriteString(scene) b.WriteString("\n") } - if len(analysis.KeyTakeaways) > 0 { - b.WriteString("重點:") - b.WriteString(strings.Join(analysis.KeyTakeaways, ";")) + cues := analysis.AuthenticityCues + if len(cues) == 0 { + cues = analysis.KeyTakeaways + } + if len(cues) > 0 { + b.WriteString("真人質感線索:") + b.WriteString(strings.Join(cues, ";")) + b.WriteString("\n") + } + if avoid := firstNonEmpty(analysis.WhatToAvoid, analysis.ReplicationStrategy); avoid != "" { + b.WriteString("避免變成複製文:") + b.WriteString(avoid) } return strings.TrimSpace(b.String()) } + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/backend/internal/library/viral/analyze_viral_test.go b/backend/internal/library/viral/analyze_viral_test.go new file mode 100644 index 0000000..8a5af82 --- /dev/null +++ b/backend/internal/library/viral/analyze_viral_test.go @@ -0,0 +1,43 @@ +package viral + +import "testing" + +func TestFormatAnalysisForReplicateUsesNarrativeFields(t *testing.T) { + got := FormatAnalysisForReplicate(ViralAnalysis{ + StoryHook: "先丟一個具體窘況", + NarrativeBeat: "從失敗經驗拐到頓悟", + EmotionalCore: "鬆了一口氣", + SceneSeed: "週日早晨的廚房", + AuthenticityCues: []string{"坦白搞砸", "口語碎碎念"}, + WhatToAvoid: "不要照抄三段式", + }) + for _, part := range []string{"故事開場力量", "敘事推進", "核心情緒", "場景種子", "真人質感線索", "避免變成複製文", "週日早晨的廚房"} { + if got == "" || !contains(got, part) { + t.Fatalf("FormatAnalysisForReplicate() missing %q: %q", part, got) + } + } +} + +func TestFormatAnalysisForReplicateFallsBackToLegacyFields(t *testing.T) { + got := FormatAnalysisForReplicate(ViralAnalysis{ + HookPattern: "痛點開場", + StructurePattern: "三段式", + KeyTakeaways: []string{"具體細節"}, + }) + if !contains(got, "故事開場力量") || !contains(got, "痛點開場") { + t.Fatalf("legacy fallback missing: %q", got) + } +} + +func contains(s, part string) bool { + return len(s) >= len(part) && (s == part || len(part) == 0 || indexOf(s, part) >= 0) +} + +func indexOf(s, part string) int { + for i := 0; i+len(part) <= len(s); i++ { + if s[i:i+len(part)] == part { + return i + } + } + return -1 +} diff --git a/backend/internal/library/viral/free_trends.go b/backend/internal/library/viral/free_trends.go new file mode 100644 index 0000000..4771ea0 --- /dev/null +++ b/backend/internal/library/viral/free_trends.go @@ -0,0 +1,121 @@ +package viral + +import ( + "context" + "encoding/xml" + "html" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +var rssTagRE = regexp.MustCompile(`<[^>]+>`) + +type freeRSS struct { + Channel struct { + Items []freeRSSItem `xml:"item"` + } `xml:"channel"` +} + +type freeRSSItem struct { + Title string `xml:"title"` + Description string `xml:"description"` + Link string `xml:"link"` +} + +func CollectFreeTopicTrends(ctx context.Context, keyword, personaBrief string) []MissionInspireTrendSnippet { + queries := freeTrendFeedQueries(keyword, personaBrief) + client := &http.Client{Timeout: 7 * time.Second} + out := make([]MissionInspireTrendSnippet, 0, 12) + seen := map[string]struct{}{} + + add := func(query, title, snippet, link string) { + title = cleanRSSField(title) + snippet = cleanRSSField(snippet) + link = strings.TrimSpace(link) + if title == "" && snippet == "" { + return + } + key := strings.ToLower(title + "|" + snippet) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, MissionInspireTrendSnippet{Query: query, Title: title, Snippet: snippet, URL: link}) + } + + for _, feed := range queries { + if len(out) >= 12 { + break + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.url, nil) + if err != nil { + continue + } + req.Header.Set("User-Agent", "HaixunTopicBot/1.0") + resp, err := client.Do(req) + if err != nil || resp == nil { + continue + } + func() { + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return + } + var parsed freeRSS + if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return + } + for _, item := range parsed.Channel.Items { + if len(out) >= 12 { + break + } + add(feed.label, item.Title, item.Description, item.Link) + } + }() + } + return out +} + +type freeTrendFeed struct { + label string + url string +} + +func freeTrendFeedQueries(keyword, personaBrief string) []freeTrendFeed { + feeds := []freeTrendFeed{ + {label: "Google Trends 台灣每日趨勢", url: "https://trends.google.com/trends/trendingsearches/daily/rss?geo=TW"}, + {label: "Google Trends 台灣即時趨勢", url: "https://trends.google.com/trends/trendingsearches/realtime/rss?geo=TW&category=all"}, + } + terms := []string{strings.TrimSpace(keyword)} + brief := strings.TrimSpace(personaBrief) + if brief != "" { + if len([]rune(brief)) > 18 { + brief = string([]rune(brief)[:18]) + } + terms = append(terms, brief) + } + for _, term := range terms { + if term == "" { + continue + } + q := url.QueryEscape(term + " 共鳴 OR 痛點 OR 趨勢") + feeds = append(feeds, freeTrendFeed{ + label: "Google News 搜尋:" + term, + url: "https://news.google.com/rss/search?q=" + q + "&hl=zh-TW&gl=TW&ceid=TW:zh-Hant", + }) + } + return feeds +} + +func cleanRSSField(raw string) string { + raw = html.UnescapeString(raw) + raw = rssTagRE.ReplaceAllString(raw, " ") + raw = strings.Join(strings.Fields(raw), " ") + if len([]rune(raw)) > 220 { + return string([]rune(raw)[:220]) + "…" + } + return raw +} diff --git a/backend/internal/library/viral/mission_inspire.go b/backend/internal/library/viral/mission_inspire.go index 32f4718..17d3884 100644 --- a/backend/internal/library/viral/mission_inspire.go +++ b/backend/internal/library/viral/mission_inspire.go @@ -21,6 +21,8 @@ type MissionInspireInput struct { PersonaPillars []string RecentMissionLabels []string RecentSeedQueries []string + AvoidTopics []string + ContentDirection string UserKeyword string TrendSnippets []MissionInspireTrendSnippet WebSearchProvider string @@ -45,27 +47,50 @@ type MissionInspireThreadsInput struct { } type MissionInspireOutput struct { - Label string - SeedQuery string - Brief string - TrendReason string - TrendKeywords []string + Label string + SeedQuery string + Brief string + TrendReason string + TrendKeywords []string + Angles []string + Mission string + TargetAudience string + OpeningType string + BodyType string + Emotion string + CtaType string + RiskLevel string + Avoid []string } func BuildMissionInspireSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期 Threads 熱門貼文、網路搜尋訊號與創作者人設,產出一組**全新**拷貝任務草稿。 + return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar。根據近期 Threads / 社群討論訊號、網路搜尋素材與創作者人設,先產出「可執行的內容計畫」,不要直接寫貼文正文。 規則: -1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事或熱門貼文 +1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;優先使用社群熱門關鍵字、受眾求助、心得討論、留言常見問題,不要把新聞事件本身當主題 2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源) 3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊 4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點 -5. brief:這次想找什麼,2~4 句,說明要海巡哪類爆款、語氣或格式偏好 -6. trendReason:1~2 句,為何選這個趨勢(可引用訊號來源大意,不要貼 URL) -7. trendKeywords:3~6 個相關熱詞(字串陣列) -8. 避開【近期已做過的任務】相同題材 -9. 繁體中文;只回傳 JSON: -{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`) +5. brief:這次內容計畫摘要,2~4 句,說明話題、受眾痛點、為何適合這個帳號 +6. trendReason:1~2 句,為何選這個趨勢(說明它和受眾討論、搜尋意圖或痛點的關係,不要貼 URL) +7. trendKeywords:3~6 個相關熱詞(字串陣列),必須像 Threads 會搜尋/討論的關鍵字,不要像新聞標題 +8. angles:3~5 個可勾選切角(字串陣列),每個切角都要說明「這篇要怎麼切」,不要直接寫成完整貼文 +9. mission:內容任務,只能是「日常陪伴」「專業知識」「情緒共鳴」「工具清單」「熱門觀點」「互動提問」之一,除非使用者方向要求更精準 +10. targetAudience:這篇真正要對誰說,越具體越好 +11. openingType:開頭策略,例如「問題開場」「場景開場」「反直覺開場」「經驗開場」「清單開場」 +12. bodyType:中段結構,例如「先共鳴再整理」「問題拆解」「步驟清單」「案例對照」「觀點辯證」 +13. emotion:情緒槓桿,例如「焦慮被接住」「想被理解」「怕踩雷」「想準備好」「想被鼓勵」 +14. ctaType:互動/收藏/私訊等 CTA 型態,不要硬業配 +15. riskLevel:low / medium / high;醫療、財務、法律、政策、名人爭議等至少 medium +16. avoid:3~6 個禁忌或不要寫偏的方向(字串陣列) +17. 避開【近期已做過的任務】相同題材 +18. 起號優先:題目要能連續延伸 5~10 篇,不要只是一則熱搜心得 +19. 除非使用者關鍵字、人設或趨勢訊號明確要求,不要產出早餐、早安、日常飲食、純生活流水帳這類泛題 +20. 人設介紹只用來判斷語氣與受眾,不是題材庫。不可因人設寫 Y2K、咖啡、穿搭、音樂,就把靈感主題漂移成咖啡、穿搭或復古日常 +21. 若使用者有指定關鍵字,label、seedQuery、brief 都必須保留該方向,不可漂移成無關日常題 +22. 繁體中文;只回傳 JSON: +23. 避免新聞導向:不要產出「某事件最新進展」「政策新聞懶人包」「名人/品牌新聞」這類題目;除非它已經轉化成受眾正在問的生活問題或經驗討論 +{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[],"angles":[],"mission":"","targetAudience":"","openingType":"","bodyType":"","emotion":"","ctaType":"","riskLevel":"low","avoid":[]}`) } func BuildMissionInspireUserPrompt(in MissionInspireInput) string { @@ -76,9 +101,9 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { b.WriteString("\n") } if p := strings.TrimSpace(in.PersonaBlock); p != "" { - b.WriteString("【人設摘要】\n") + b.WriteString("【人設摘要(只用來校準語氣與受眾,不是題材庫)】\n") b.WriteString(p) - b.WriteString("\n") + b.WriteString("\n注意:不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\n") } if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" { b.WriteString("【對標帳號】@") @@ -105,8 +130,20 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { b.WriteString(keyword) b.WriteString("\n") } - if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 { + if direction := strings.TrimSpace(in.ContentDirection); direction != "" { + b.WriteString("【使用者選擇內容方向】") + b.WriteString(direction) + b.WriteString("\n請讓 label、brief、angles 都符合這個方向;如果方向是 auto,請依熱度與人設自動選。\n") + } + if len(in.AvoidTopics) > 0 || len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 { b.WriteString("【近期已做過的任務(請避開)】\n") + for _, topic := range in.AvoidTopics { + if topic = strings.TrimSpace(topic); topic != "" { + b.WriteString("- ") + b.WriteString(topic) + b.WriteString("\n") + } + } for _, label := range in.RecentMissionLabels { if label = strings.TrimSpace(label); label != "" { b.WriteString("- ") @@ -147,15 +184,15 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { b.WriteString("\n") } } - b.WriteString("請產出拷貝任務靈感 JSON。") + b.WriteString("請產出 Topic Radar 與 Content Plan JSON。") return b.String() } func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string { queries := []string{ - "Google Trends 台灣 今日 熱門搜尋 關鍵字", - "Threads 台灣 熱門 話題 最近 一週", - "近期 網路 熱搜 關鍵字 台灣", + "Threads 台灣 熱門 關鍵字 討論 最近 一週", + "Threads 台灣 請問 推薦 心得 最近", + "社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads", } context := strings.TrimSpace(personaBrief + " " + styleBenchmark) if context != "" { @@ -163,7 +200,10 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string { if len([]rune(trimmed)) > 24 { trimmed = string([]rune(trimmed)[:24]) } - queries = append(queries, trimmed+" 熱門 話題 最近") + queries = append(queries, + trimmed+" Threads 熱門 關鍵字 討論", + trimmed+" 請問 心得 推薦 Threads", + ) } return queries } @@ -186,9 +226,11 @@ func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string { keyword := cleanInspireQuery(in.Keyword) if keyword != "" { add(keyword) - add(keyword + " 心情") - add(keyword + " 經驗") + add(keyword + " 熱門") + add(keyword + " 心得") add(keyword + " 請問") + add(keyword + " 推薦") + add(keyword + " 怎麼辦") } for _, pillar := range in.Pillars { if len(out) >= 6 { @@ -324,10 +366,17 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) { return MissionInspireOutput{}, err } out := MissionInspireOutput{ - Label: firstJSONString(root, "label", "title", "mission_label"), - SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"), - Brief: firstJSONString(root, "brief", "description", "goal"), - TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"), + Label: firstJSONString(root, "label", "title", "mission_label"), + SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"), + Brief: firstJSONString(root, "brief", "description", "goal"), + TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"), + Mission: firstJSONString(root, "mission", "contentMission", "content_mission"), + TargetAudience: firstJSONString(root, "targetAudience", "target_audience", "audience"), + OpeningType: firstJSONString(root, "openingType", "opening_type", "hookType", "hook_type"), + BodyType: firstJSONString(root, "bodyType", "body_type", "structure", "bodyStructure"), + Emotion: firstJSONString(root, "emotion", "emotionalLever", "emotional_lever"), + CtaType: firstJSONString(root, "ctaType", "cta_type", "cta"), + RiskLevel: firstJSONString(root, "riskLevel", "risk_level", "risk"), } if out.Label == "" || out.SeedQuery == "" || out.Brief == "" { return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief") @@ -340,5 +389,21 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) { out.TrendKeywords = parseFlexibleStringList(rawKW) } } + if rawAngles, ok := root["angles"]; ok { + out.Angles = parseFlexibleStringList(rawAngles) + } + if len(out.Angles) == 0 { + if rawAngles, ok := root["contentAngles"]; ok { + out.Angles = parseFlexibleStringList(rawAngles) + } + } + if rawAvoid, ok := root["avoid"]; ok { + out.Avoid = parseFlexibleStringList(rawAvoid) + } + if len(out.Avoid) == 0 { + if rawAvoid, ok := root["avoidTopics"]; ok { + out.Avoid = parseFlexibleStringList(rawAvoid) + } + } return out, nil } diff --git a/backend/internal/library/viral/mission_inspire_collect.go b/backend/internal/library/viral/mission_inspire_collect.go index c22fdc1..febd496 100644 --- a/backend/internal/library/viral/mission_inspire_collect.go +++ b/backend/internal/library/viral/mission_inspire_collect.go @@ -49,6 +49,9 @@ func CollectMissionInspireTrends( if title == "" && snippet == "" { continue } + if looksLikeNewsResult(title, snippet, item.URL) { + continue + } key := strings.ToLower(title + "|" + snippet) if _, ok := seen[key]; ok { continue @@ -64,3 +67,24 @@ func CollectMissionInspireTrends( } return out } + +func looksLikeNewsResult(title, snippet, url string) bool { + text := strings.ToLower(title + " " + snippet + " " + url) + newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"} + socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"} + newsScore := 0 + for _, signal := range newsSignals { + if strings.Contains(text, strings.ToLower(signal)) { + newsScore++ + } + } + if newsScore == 0 { + return false + } + for _, signal := range socialSignals { + if strings.Contains(text, strings.ToLower(signal)) { + return false + } + } + return true +} diff --git a/backend/internal/library/viral/replicate.go b/backend/internal/library/viral/replicate.go index 6be7051..ef01504 100644 --- a/backend/internal/library/viral/replicate.go +++ b/backend/internal/library/viral/replicate.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "haixun-backend/internal/library/copyvoice" "haixun-backend/internal/library/threadspost" ) @@ -19,6 +20,7 @@ type ReplicateInput struct { OriginalText string AuthorName string StructureAnalysis string + NarrativeSeed string } type ReplicateResult struct { @@ -30,13 +32,15 @@ type ReplicateResult struct { } func BuildSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 爆款複製策略師。根據參考爆文,為使用者撰寫「同結構、同節奏、但完全原創」的貼文。 + return strings.TrimSpace(`你是 Threads / Instagram 創作者。讀完參考爆文後,用指定人設的語言指紋寫一篇全新貼文。 -規則: -- 複製的是爆款公式(hook 手法、情緒節奏),不是抄襲原文 -- 文筆必須像創作者本人,套用其 8D 風格策略 +` + copyvoice.SystemRules() + ` + +輸出: - text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤) -- 爆款互動最佳 80~220 字:前 1~2 行強 hook,口語精簡;超過 300 字互動通常下降 +- 參考原文只學情緒力度、資訊密度與節奏;最後必須換成人設會講出口的話 +- 篇幅、行數、標點與換行優先遵守人設語言指紋;沒有指紋時才貼近參考原文 +- text 排版要像真人手機發文:可使用自然標點、空行、列點與少量 emoji;不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣 - 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`) } @@ -51,29 +55,39 @@ func BuildUserPrompt(input ReplicateInput) string { b.WriteString("\n") } if persona := strings.TrimSpace(input.Persona); persona != "" { - b.WriteString("\n人設與語氣:\n") + b.WriteString("\n人設定位、語言指紋與禁忌(最高優先):\n") b.WriteString(persona) b.WriteString("\n") + b.WriteString(copyvoice.PersonaCalibrationNote()) + b.WriteString("\n") } if style := strings.TrimSpace(input.StyleProfile); style != "" { b.WriteString("\n8D 風格策略:\n") b.WriteString(style) b.WriteString("\n") } + if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" { + b.WriteString("\n敘事分析(只學故事感,不要套句型或段落公式):\n") + b.WriteString(analysis) + b.WriteString("\n") + } author := strings.TrimSpace(input.AuthorName) if author == "" { author = "匿名" } - if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" { - b.WriteString("\n爆款結構分析(仿寫時套用):\n") - b.WriteString(analysis) + original := strings.TrimSpace(input.OriginalText) + if note := threadspost.MimicLengthGuidance(original); note != "" { + b.WriteString("\n") + b.WriteString(note) b.WriteString("\n") } b.WriteString("\n原文參考(@") b.WriteString(author) - b.WriteString(",只學結構不抄內容):\n") - b.WriteString(strings.TrimSpace(input.OriginalText)) - b.WriteString("\n\n請產出一篇可發布的複製版貼文 JSON。") + b.WriteString(",只感受情緒、敘事與篇幅,禁止抄句型):\n") + b.WriteString(original) + b.WriteString("\n\n本次敘事切入:") + b.WriteString(copyvoice.NarrativeLens(input.NarrativeSeed)) + b.WriteString("\n\n請產出一篇可發布的全新貼文 JSON。hook 欄位只放內部分析,不要把模板句型寫進 text。") return b.String() } @@ -86,7 +100,7 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) { if err := json.Unmarshal(payload, &out); err != nil { return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err) } - out.Text = trimText(out.Text) + out.Text = threadspost.FormatDraftText(out.Text) if out.Text == "" { return ReplicateResult{}, fmt.Errorf("replica text missing") } @@ -97,15 +111,6 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) { return out, nil } -func trimText(text string) string { - text = strings.TrimSpace(text) - runes := []rune(text) - if len(runes) > threadspost.MaxPublishRunes { - return string(runes[:threadspost.MaxPublishRunes]) - } - return text -} - func extractJSONObject(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 { diff --git a/backend/internal/library/webpage/digest.go b/backend/internal/library/webpage/digest.go index 4f61628..a36f6ce 100644 --- a/backend/internal/library/webpage/digest.go +++ b/backend/internal/library/webpage/digest.go @@ -13,11 +13,11 @@ import ( ) const ( - DefaultMaxPages = 6 - DefaultMaxMarkdown = 6000 - DefaultPerPageChars = 2500 - DefaultFetchTimeout = 18 * time.Second - DefaultMaxBodyBytes = 2 << 20 + DefaultMaxPages = 6 + DefaultMaxMarkdown = 6000 + DefaultPerPageChars = 2500 + DefaultFetchTimeout = 18 * time.Second + DefaultMaxBodyBytes = 2 << 20 ) type FetchOptions struct { @@ -179,4 +179,4 @@ func min(a, b int) int { return a } return b -} \ No newline at end of file +} diff --git a/backend/internal/library/webpage/digest_test.go b/backend/internal/library/webpage/digest_test.go index b0cc541..e66b279 100644 --- a/backend/internal/library/webpage/digest_test.go +++ b/backend/internal/library/webpage/digest_test.go @@ -40,4 +40,4 @@ func TestFetchDigests_ExtractsMarkdown(t *testing.T) { if !strings.Contains(digests[0].Markdown, "備孕指南") || !strings.Contains(digests[0].Markdown, "葉酸") { t.Fatalf("markdown = %q", digests[0].Markdown) } -} \ No newline at end of file +} diff --git a/backend/internal/library/webpage/format.go b/backend/internal/library/webpage/format.go index 4362d0a..2f7126c 100644 --- a/backend/internal/library/webpage/format.go +++ b/backend/internal/library/webpage/format.go @@ -49,4 +49,4 @@ func formatDigestSection(item Digest) string { return "" } return "來源:" + item.URL + "\n(抓取失敗:" + item.Error + ")" -} \ No newline at end of file +} diff --git a/backend/internal/logic/brand/create_brand_product_logic.go b/backend/internal/logic/brand/create_brand_product_logic.go index 85e631e..76c5184 100644 --- a/backend/internal/logic/brand/create_brand_product_logic.go +++ b/backend/internal/logic/brand/create_brand_product_logic.go @@ -38,6 +38,7 @@ func (l *CreateBrandProductLogic) CreateBrandProduct(req *types.CreateBrandProdu BrandID: req.ID, Label: req.Label, ProductContext: req.ProductContext, + PlacementURL: req.PlacementURL, MatchTags: req.MatchTags, }) if err != nil { diff --git a/backend/internal/logic/brand/generate_outreach_drafts_logic.go b/backend/internal/logic/brand/generate_outreach_drafts_logic.go index 40dd871..ba3de8b 100644 --- a/backend/internal/logic/brand/generate_outreach_drafts_logic.go +++ b/backend/internal/logic/brand/generate_outreach_drafts_logic.go @@ -5,21 +5,12 @@ package brand import ( "context" - "fmt" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" liboutreach "haixun-backend/internal/library/outreach" - libplacement "haixun-backend/internal/library/placement" - libprompt "haixun-backend/internal/library/prompt" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - branddomain "haixun-backend/internal/model/brand/domain/usecase" outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" - scanpostentity "haixun-backend/internal/model/scan_post/domain/entity" - scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -46,225 +37,40 @@ func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.Generate return nil, err } scanPostID := strings.TrimSpace(req.ScanPostID) - count := 2 - if req.Count > 0 { - count = req.Count - } if scanPostID == "" { return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required") } - if count > 4 { - count = 4 + if strings.TrimSpace(req.VoicePersonaID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required") } - brand, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID) - if err != nil { - return nil, err - } - voicePersona := "" - voiceID := strings.TrimSpace(req.VoicePersonaID) - if voiceID != "" { - vp, vpErr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, voiceID) - if vpErr != nil { - return nil, vpErr - } - voicePersona = vp.Persona - } - productID := strings.TrimSpace(req.ProductID) - if productID == "" { - productID = strings.TrimSpace(brand.ProductID) - } - post, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, req.ID, scanPostID) - if err != nil { - return nil, err - } - - topicLabel := strings.TrimSpace(post.SearchTag) - placementReason := buildPlacementReason(post) - if graph, graphErr := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID); graphErr == nil && graph != nil { - if node := findGraphNode(graph.Nodes, post.GraphNodeID); node != nil { - if strings.TrimSpace(node.Label) != "" { - topicLabel = strings.TrimSpace(node.Label) - } - placementReason = mergePlacementReason(placementReason, node, post) - } - } - - userPrompt, err := liboutreach.BuildUserPrompt(liboutreach.GenerateInput{ - Persona: voicePersona, - TopicLabel: topicLabel, - AudienceBrief: brand.TargetAudience, - ProductBrief: productBriefForBrand(brand, productID, post.SearchTag), - PlacementReason: placementReason, - TargetText: post.Text, - AuthorName: post.Author, - Count: count, - }) - if err != nil { - return nil, app.For(code.AI).SysInternal("outreach user prompt load failed") - } - - systemPrompt, err := libprompt.OutreachPlacementSystem() - if err != nil { - return nil, app.For(code.AI).SysInternal("outreach system prompt load failed") - } - if strings.TrimSpace(voicePersona) != "" { - systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n人設與語氣:\n" + strings.TrimSpace(voicePersona) - } - - credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) - if err != nil { - return nil, err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return nil, err - } - - result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, + saved, err := liboutreach.GenerateDraft(l.ctx, outreachDepsFrom(l.svcCtx), liboutreach.GenerateDraftRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + TopicID: strings.TrimSpace(req.TopicID), + ScanPostID: scanPostID, + Count: req.Count, + VoicePersonaID: req.VoicePersonaID, + ProductID: req.ProductID, + Regenerate: req.Regenerate, }) if err != nil { return nil, err } - - parsed, err := liboutreach.ParseGenerateOutput(result.Text) - if err != nil { - return nil, app.For(code.AI).SvcThirdParty("獲客留言 LLM 回傳無法解析:" + err.Error()) - } - - draftItems := make([]outreachusecase.DraftItem, 0, len(parsed.Drafts)) - for _, item := range parsed.Drafts { - draftItems = append(draftItems, outreachusecase.DraftItem{ - Text: item.Text, - Angle: item.Angle, - Rationale: item.Rationale, - }) - } - saved, err := l.svcCtx.OutreachDraft.Create(l.ctx, outreachusecase.CreateRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.ID, - TopicID: strings.TrimSpace(req.TopicID), - ScanPostID: scanPostID, - Relevance: parsed.Relevance, - Reason: parsed.Reason, - Drafts: draftItems, - }) - if err != nil { - return nil, err - } - _, _ = l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.ID, - PostID: scanPostID, - Status: scanpostentity.OutreachStatusDrafted, - }) - return toOutreachDraftData(saved), nil } -func findGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node { - nodeID = strings.TrimSpace(nodeID) - if nodeID == "" { - return nil +func outreachDepsFrom(svcCtx *svc.ServiceContext) liboutreach.GenerateDraftDeps { + return liboutreach.GenerateDraftDeps{ + Brand: svcCtx.Brand, + Persona: svcCtx.Persona, + ScanPost: svcCtx.ScanPost, + KnowledgeGraph: svcCtx.KnowledgeGraph, + ThreadsAccount: svcCtx.ThreadsAccount, + AI: svcCtx.AI, + OutreachDraft: svcCtx.OutreachDraft, } - for i := range nodes { - if nodes[i].ID == nodeID { - return &nodes[i] - } - } - return nil -} - -func buildPlacementReason(post *scanpostusecase.ScanPostSummary) string { - if post == nil { - return "" - } - parts := []string{} - if post.Priority == "gold" { - parts = append(parts, "雙軌命中(相關+近期)") - } else if post.Priority == "recent" { - parts = append(parts, "近期軌命中") - } - if post.SolvedByProduct { - parts = append(parts, "產品可解此需求") - } - if post.ProductFitScore > 0 { - parts = append(parts, fmt.Sprintf("產品匹配 %d", post.ProductFitScore)) - } - return strings.Join(parts, ";") -} - -func mergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.ScanPostSummary) string { - parts := []string{} - if strings.TrimSpace(base) != "" { - parts = append(parts, strings.TrimSpace(base)) - } - if node != nil { - if strings.TrimSpace(node.PlacementValue) != "" { - parts = append(parts, "置入價值 "+strings.TrimSpace(node.PlacementValue)) - } - if node.ProductFitScore > 0 && (post == nil || post.ProductFitScore == 0) { - parts = append(parts, fmt.Sprintf("節點產品匹配 %d", node.ProductFitScore)) - } - } - return strings.Join(parts, ";") -} - -func productBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag string) string { - if brand == nil { - return "" - } - if product := pickProductForOutreach(brand, productID, searchTag); product != nil { - merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label) - if pb := libplacement.ProductBriefFromContext(merged); pb != "" { - return pb - } - } - if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" { - return pb - } - return strings.TrimSpace(brand.ProductBrief) -} - -func pickProductForOutreach(brand *branddomain.BrandSummary, productID, searchTag string) *branddomain.ProductSummary { - if brand == nil || len(brand.Products) == 0 { - return nil - } - if id := strings.TrimSpace(productID); id != "" { - for i := range brand.Products { - if brand.Products[i].ID == id { - return &brand.Products[i] - } - } - } - tag := strings.TrimSpace(searchTag) - if tag == "" { - return &brand.Products[0] - } - bestScore := -1 - var best *branddomain.ProductSummary - for i := range brand.Products { - score := libplacement.ScoreProductForTag(tag, brand.Products[i].MatchTags) - if score > bestScore { - bestScore = score - best = &brand.Products[i] - } - } - if best != nil && bestScore > 0 { - return best - } - return &brand.Products[0] } func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData { diff --git a/backend/internal/logic/brand/mapper.go b/backend/internal/logic/brand/mapper.go index 5e6bf1e..bd18f9c 100644 --- a/backend/internal/logic/brand/mapper.go +++ b/backend/internal/logic/brand/mapper.go @@ -34,6 +34,7 @@ func toBrandProductData(item domusecase.ProductSummary) types.BrandProductData { ID: item.ID, Label: item.Label, ProductContext: item.ProductContext, + PlacementURL: item.PlacementURL, MatchTags: append([]string(nil), item.MatchTags...), CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, diff --git a/backend/internal/logic/brand/patch_scan_post_outreach_logic.go b/backend/internal/logic/brand/patch_scan_post_outreach_logic.go index 34cb05d..cc9e067 100644 --- a/backend/internal/logic/brand/patch_scan_post_outreach_logic.go +++ b/backend/internal/logic/brand/patch_scan_post_outreach_logic.go @@ -73,6 +73,7 @@ func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData { GraphNodeID: post.GraphNodeID, SearchTag: post.SearchTag, QueryDimension: post.QueryDimension, + RecencyDays: post.RecencyDays, ExternalID: post.ExternalID, Permalink: post.Permalink, Author: post.Author, diff --git a/backend/internal/logic/brand/publish_outreach_draft_logic.go b/backend/internal/logic/brand/publish_outreach_draft_logic.go index f28428f..2a2d965 100644 --- a/backend/internal/logic/brand/publish_outreach_draft_logic.go +++ b/backend/internal/logic/brand/publish_outreach_draft_logic.go @@ -9,7 +9,10 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + libplacement "haixun-backend/internal/library/placement" libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/library/threadspost" scanpostentity "haixun-backend/internal/model/scan_post/domain/entity" scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" @@ -38,7 +41,7 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre return nil, err } scanPostID := strings.TrimSpace(req.ScanPostID) - text := strings.TrimSpace(req.Text) + text := liboutreach.NormalizeDraftText(req.Text) confirm := req.Confirm if scanPostID == "" { return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required") @@ -49,6 +52,9 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre if text == "" { return nil, app.For(code.Brand).InputMissingRequired("text is required") } + if err := threadspost.ValidateReply(text); err != nil { + return nil, app.For(code.Brand).InputInvalidFormat(err.Error()) + } if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil { return nil, err @@ -57,26 +63,49 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre if err != nil { return nil, err } - externalID := strings.TrimSpace(post.ExternalID) - if externalID == "" { - return nil, app.For(code.Brand).InputMissingRequired("此貼文缺少 external_id,無法透過 API 回覆") + rawTargetID := libplacement.ResolveReplyTargetID(post.ExternalID, post.Permalink) + if rawTargetID == "" { + return nil, app.For(code.Brand).InputMissingRequired("此貼文缺少 Threads 貼文 ID(external_id 或有效 permalink),無法透過 API 回覆") } - cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsPublishCredential(l.ctx, tenantID, uid) + cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsReplyCredential(l.ctx, tenantID, uid) if err != nil { return nil, err } + storageState := "" + if session, sessErr := l.svcCtx.ThreadsAccount.GetBrowserSession(l.ctx, tenantID, uid, cred.AccountID); sessErr == nil && session != nil { + storageState = strings.TrimSpace(session.StorageState) + } + resolveInput := libplacement.ReplyMediaResolveInput{ + AccessToken: cred.AccessToken, + StorageState: storageState, + ExternalID: rawTargetID, + Permalink: post.Permalink, + HintText: post.Text, + } + replyToID, err := libplacement.ResolveReplyMediaForPublish(l.ctx, resolveInput) + if err != nil { + return nil, app.For(code.Brand).InputMissingRequired(err.Error()) + } + apiClient := libthreads.NewClient(cred.AccessToken) + if err := apiClient.PrimeReplyTargetForPublish(l.ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: rawTargetID, + Permalink: post.Permalink, + HintText: post.Text, + }, replyToID); err != nil { + return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 無法回覆此貼文:" + err.Error()) + } result, err := libthreads.PublishReply(l.ctx, libthreads.PublishReplyInput{ ThreadsUserID: cred.ThreadsUserID, AccessToken: cred.AccessToken, - ReplyToID: externalID, + ReplyToID: replyToID, Text: text, }) if err != nil { return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發送留言失敗:" + err.Error()) } - updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{ + patch := scanpostusecase.UpdateOutreachRequest{ TenantID: tenantID, OwnerUID: uid, BrandID: req.ID, @@ -84,7 +113,11 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre Status: scanpostentity.OutreachStatusPublished, PublishedReplyID: result.MediaID, PublishedPermalink: result.Permalink, - }) + } + if libthreads.IsNumericMediaID(replyToID) && !libthreads.IsNumericMediaID(post.ExternalID) { + patch.ExternalID = replyToID + } + updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, patch) if err != nil { return nil, err } diff --git a/backend/internal/logic/brand/start_brand_scan_job_logic.go b/backend/internal/logic/brand/start_brand_scan_job_logic.go index b6c8a81..21ba896 100644 --- a/backend/internal/logic/brand/start_brand_scan_job_logic.go +++ b/backend/internal/logic/brand/start_brand_scan_job_logic.go @@ -123,11 +123,8 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) { return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research)) } - if memberCtx.DevMode && !memberCtx.BrowserConnected { - return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session") - } - if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { - return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線") + if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡") } payload := map[string]any{ diff --git a/backend/internal/logic/brand/update_brand_product_logic.go b/backend/internal/logic/brand/update_brand_product_logic.go index 34e8b5d..185518e 100644 --- a/backend/internal/logic/brand/update_brand_product_logic.go +++ b/backend/internal/logic/brand/update_brand_product_logic.go @@ -5,6 +5,7 @@ package brand import ( "context" + "strings" domusecase "haixun-backend/internal/model/brand/domain/usecase" "haixun-backend/internal/svc" @@ -32,15 +33,18 @@ func (l *UpdateBrandProductLogic) UpdateBrandProduct(req *types.UpdateBrandProdu if err != nil { return nil, err } + placementURL := strings.TrimSpace(req.PlacementURL) item, err := l.svcCtx.Brand.UpdateProduct(l.ctx, domusecase.UpdateProductRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.ID, - ProductID: req.ProductID, - Label: req.Label, - ProductContext: req.ProductContext, - MatchTags: req.MatchTags, - MatchTagsSet: req.MatchTags != nil, + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + ProductID: req.ProductID, + Label: req.Label, + ProductContext: req.ProductContext, + PlacementURL: &placementURL, + PlacementURLSet: true, + MatchTags: req.MatchTags, + MatchTagsSet: req.MatchTags != nil, }) if err != nil { return nil, err diff --git a/backend/internal/logic/brand/update_outreach_draft_logic.go b/backend/internal/logic/brand/update_outreach_draft_logic.go new file mode 100644 index 0000000..dc95ca5 --- /dev/null +++ b/backend/internal/logic/brand/update_outreach_draft_logic.go @@ -0,0 +1,68 @@ +package brand + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + "haixun-backend/internal/library/threadspost" + outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateOutreachDraftLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOutreachDraftLogic { + return &UpdateOutreachDraftLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateOutreachDraftLogic) UpdateOutreachDraft(req *types.UpdateOutreachDraftHandlerReq) (*types.GenerateOutreachDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + draftID := strings.TrimSpace(req.DraftID) + if draftID == "" { + return nil, app.For(code.Brand).InputMissingRequired("draft_id is required") + } + text := liboutreach.NormalizeDraftText(req.Text) + if text == "" { + return nil, app.For(code.Brand).InputMissingRequired("text is required") + } + if err := threadspost.ValidateReply(text); err != nil { + return nil, app.For(code.Brand).InputInvalidFormat(err.Error()) + } + if req.DraftIndex < 0 { + return nil, app.For(code.Brand).InputInvalidFormat("draft_index must be >= 0") + } + + if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + + updated, err := l.svcCtx.OutreachDraft.UpdateDraftItem(l.ctx, outreachusecase.UpdateDraftItemRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + DraftID: draftID, + DraftIndex: req.DraftIndex, + Text: text, + }) + if err != nil { + return nil, err + } + return toOutreachDraftData(updated), nil +} diff --git a/backend/internal/logic/copy_mission/capabilities.go b/backend/internal/logic/copy_mission/capabilities.go deleted file mode 100644 index 98e8c57..0000000 --- a/backend/internal/logic/copy_mission/capabilities.go +++ /dev/null @@ -1,16 +0,0 @@ -package copy_mission - -import ( - "context" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/svc" -) - -func requireMemberAiReady(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid string) error { - if _, err := svcCtx.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, uid); err != nil { - return app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key") - } - return nil -} diff --git a/backend/internal/logic/copy_mission/create_copy_mission_logic.go b/backend/internal/logic/copy_mission/create_copy_mission_logic.go index cc652ca..86aea3b 100644 --- a/backend/internal/logic/copy_mission/create_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/create_copy_mission_logic.go @@ -1,10 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -25,33 +26,8 @@ func NewCreateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) } } -func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - initialMap, initialTags := seedMissionResearchFromPersona(*persona) - createReq := missiondomain.CreateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - Label: req.Label, - SeedQuery: req.SeedQuery, - Brief: req.Brief, - } - if initialMap != nil { - createReq.InitialResearchMap = initialMap - createReq.InitialSelectedTags = initialTags - } - item, err := l.svcCtx.CopyMission.Create(l.ctx, createReq) - if err != nil { - return nil, err - } - data := toCopyMissionData(*item) - return &data, nil +func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/delete_copy_mission_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_logic.go index 6c64daf..65d8414 100644 --- a/backend/internal/logic/copy_mission/delete_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/delete_copy_mission_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -25,9 +27,7 @@ func NewDeleteCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) } func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return err - } - return l.svcCtx.CopyMission.Delete(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID)) + // todo: add your logic here and delete this line + + return nil } diff --git a/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go index 6a96cef..960b831 100644 --- a/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go +++ b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go @@ -1,52 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "fmt" - "strings" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type DeleteCopyMissionMatrixDraftsLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic { - return &DeleteCopyMissionMatrixDraftsLogic{ctx: ctx, svcCtx: svcCtx} + return &DeleteCopyMissionMatrixDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts( - req *types.DeleteCopyMissionMatrixDraftsHandlerReq, -) (*types.DeleteCopyMissionMatrixDraftsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - deleted, err := l.svcCtx.CopyDraft.DeleteMissionMatrixDrafts(l.ctx, copydraftdomain.DeleteMissionMatrixDraftsRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - MissionID: missionID, - DraftIDs: req.DraftIDs, - }) - if err != nil { - return nil, err - } - message := fmt.Sprintf("已刪除 %d 篇矩陣草稿", deleted) - if len(req.DraftIDs) == 0 { - message = fmt.Sprintf("已刪除全部 %d 篇矩陣草稿", deleted) - } - return &types.DeleteCopyMissionMatrixDraftsData{ - Deleted: deleted, - Message: message, - }, nil -} \ No newline at end of file +func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(req *types.DeleteCopyMissionMatrixDraftsHandlerReq) (resp *types.DeleteCopyMissionMatrixDraftsData, err error) { + // todo: add your logic here and delete this line + + return +} diff --git a/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go b/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go index 0e7a121..e621f52 100644 --- a/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go +++ b/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go @@ -1,14 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" - "haixun-backend/internal/library/placement" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,68 +26,8 @@ func NewExpandCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceCont } } -func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (*types.ExpandKnowledgeGraphData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - seed := strings.TrimSpace(req.SeedQuery) - if seed == "" { - return nil, app.For(code.Persona).InputMissingRequired("seed_query is required") - } - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識") - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } +func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) { + // todo: add your logic here and delete this line - research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) - if err != nil { - return nil, err - } - expandStrategy := placement.EffectiveExpandStrategy(research) - if req.Supplemental && placement.WebSearchAvailable(research) { - expandStrategy = libkg.ExpandStrategyBrave - } - memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) - if err != nil { - return nil, err - } - - payload := map[string]any{ - "tenant_id": tenantID, - "owner_uid": uid, - "persona_id": personaID, - "copy_mission_id": missionID, - "seed_query": seed, - "supplemental": req.Supplemental, - "expand_strategy": expandStrategy.String(), - } - for key, value := range memberCtx.PayloadFields() { - payload[key] = value - } - - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "expand-copy-mission-graph", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.ExpandKnowledgeGraphData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "延伸知識擴展中,完成後可勾選節點用於產文", - }, nil -} \ No newline at end of file + return +} diff --git a/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go b/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go index 0971772..f949569 100644 --- a/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go +++ b/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go @@ -1,226 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "fmt" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libcopy "haixun-backend/internal/library/copymission" - libkg "haixun-backend/internal/library/knowledge" - libmatrix "haixun-backend/internal/library/matrix" - libweb "haixun-backend/internal/library/webpage" - libprompt "haixun-backend/internal/library/prompt" - "haixun-backend/internal/library/style8d" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type GenerateCopyMissionMatrixLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewGenerateCopyMissionMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCopyMissionMatrixLogic { - return &GenerateCopyMissionMatrixLogic{ctx: ctx, svcCtx: svcCtx} + return &GenerateCopyMissionMatrixLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix( - req *types.GenerateCopyMissionMatrixHandlerReq, -) (*types.GenerateCopyMissionMatrixData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.Status != string(missionentity.StatusMapped) && - mission.Status != string(missionentity.StatusScanned) && - mission.Status != string(missionentity.StatusDrafted) { - return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖") - } +func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(req *types.GenerateCopyMissionMatrixHandlerReq) (resp *types.GenerateCopyMissionMatrixData, err error) { + // todo: add your logic here and delete this line - persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - if err := ensureNoActiveMatrixJob(l.ctx, l.svcCtx, missionID); err != nil { - return nil, err - } - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) - - count := req.Count - if count <= 0 { - count = 5 - } - if count > 12 { - count = 12 - } - - posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - CopyMissionID: missionID, - Limit: 12, - }) - if err != nil { - return nil, err - } - samples := matrixSamplesFromScanPosts(posts) - graphNodes, graphSources := loadMatrixGraph(l.ctx, l.svcCtx, tenantID, uid, missionID) - knowledgeItems := libcopy.MatrixKnowledgeItems( - mission.ResearchMap.SelectedKnowledgeItems, - mission.ResearchMap.KnowledgeItems, - graphNodes, - mission.SelectedTags, - ) - researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems) - refURLs := libcopy.MergeReferenceURLs( - libcopy.ReferenceURLsFromSelection(graphNodes, graphSources), - researchSources, - ) - researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources) - referenceMarkdown := "" - if len(refURLs) > 0 { - digests := libweb.FetchDigests(l.ctx, refURLs, libweb.FetchOptions{}) - referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests) - } - researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences( - mission.ResearchMap.AudienceSummary, - mission.ResearchMap.ContentGoal, - mission.ResearchMap.Questions, - mission.ResearchMap.Pillars, - mission.ResearchMap.Exclusions, - knowledgeItems, - researchItemsBlock, - referenceMarkdown, - ) - userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{ - Count: count, - TopicLabel: mission.Label, - TopicBrief: mission.Brief, - ResearchMap: researchBlock, - SelectedTags: mission.SelectedTags, - ViralSamples: samples, - PersonaBlock: personaBlock, - }) - if err != nil { - return nil, app.For(code.AI).SysInternal("matrix user prompt load failed") - } - systemPrompt, err := libprompt.MatrixCopySystem() - if err != nil { - return nil, app.For(code.AI).SysInternal("matrix system prompt load failed") - } - credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) - if err != nil { - return nil, err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return nil, err - } - parsed, err := libmatrix.GenerateCopyOutput(l.ctx, l.svcCtx.AI, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - }, count) - if err != nil { - return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) - } - - createReqs := make([]copydraftdomain.CreateRequest, 0, len(parsed.Rows)) - for _, row := range parsed.Rows { - createReqs = append(createReqs, copydraftdomain.CreateRequest{ - CopyMissionID: missionID, - DraftType: copydraftentity.DraftTypeMatrix, - SortOrder: row.SortOrder, - Text: row.Text, - Angle: row.Angle, - Hook: row.Hook, - Rationale: row.Rationale, - ReferenceNotes: row.ReferenceNotes, - Sources: row.SourcePermalinks, - }) - } - saved, err := l.svcCtx.CopyDraft.ReplaceMissionMatrix(l.ctx, tenantID, uid, personaID, missionID, createReqs) - if err != nil { - return nil, err - } - - drafted := missionentity.StatusDrafted - _, _ = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - Status: &drafted, - }, - }) - - drafts := make([]types.CopyDraftData, 0, len(saved)) - for _, item := range saved { - drafts = append(drafts, toCopyDraftData(item)) - } - return &types.GenerateCopyMissionMatrixData{ - Drafts: drafts, - Message: fmt.Sprintf("已產出 %d 篇矩陣草稿", len(drafts)), - }, nil -} - -func loadMatrixGraph(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid, missionID string) ([]libkg.Node, []libkg.BraveSource) { - if svcCtx == nil || svcCtx.KnowledgeGraph == nil { - return nil, nil - } - graph, err := svcCtx.KnowledgeGraph.GetByCopyMission(ctx, tenantID, uid, missionID) - if err != nil || graph == nil { - return nil, nil - } - return graph.Nodes, graph.BraveSources -} - -func matrixSamplesFromScanPosts(posts []scanpostdomain.ScanPostSummary) string { - samples := make([]libmatrix.ViralPostSample, 0, len(posts)) - for _, post := range posts { - replies := make([]libmatrix.ViralReplySample, 0, len(post.Replies)) - for _, reply := range post.Replies { - replies = append(replies, libmatrix.ViralReplySample{ - Author: reply.Author, - Text: reply.Text, - }) - } - samples = append(samples, libmatrix.ViralPostSample{ - Author: post.Author, - LikeCount: post.LikeCount, - SearchTag: post.SearchTag, - Text: post.Text, - Replies: replies, - }) - } - return libmatrix.FormatViralSamples(samples) + return } diff --git a/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go index c58d7eb..f833033 100644 --- a/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go +++ b/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -24,20 +26,8 @@ func NewGetCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext } } -func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (*types.KnowledgeGraphData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - graph, err := l.svcCtx.KnowledgeGraph.GetByCopyMission(l.ctx, tenantID, uid, missionID) - if err != nil { - return nil, err - } - data := toKnowledgeGraphData(graph) - return &data, nil -} \ No newline at end of file +func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (resp *types.KnowledgeGraphData, err error) { + // todo: add your logic here and delete this line + + return +} diff --git a/backend/internal/logic/copy_mission/get_copy_mission_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_logic.go index 2bb8467..c0963a9 100644 --- a/backend/internal/logic/copy_mission/get_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/get_copy_mission_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -24,15 +26,8 @@ func NewGetCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ge } } -func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - item, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID)) - if err != nil { - return nil, err - } - data := toCopyMissionData(*item) - return &data, nil +func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go index 4b23184..42d3c9e 100644 --- a/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go +++ b/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go @@ -1,40 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type GetCopyMissionScanScheduleLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewGetCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionScanScheduleLogic { - return &GetCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx} + return &GetCopyMissionScanScheduleLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule( - req *types.CopyMissionPath, -) (*types.CopyMissionScanScheduleData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID) - if err != nil { - return nil, err - } - return toCopyMissionScanScheduleData(schedule, personaID, missionID), nil +func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(req *types.CopyMissionPath) (resp *types.CopyMissionScanScheduleData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go index c3d5fef..98fe6ea 100644 --- a/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go @@ -1,8 +1,12 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" "strings" + "time" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" @@ -12,91 +16,41 @@ import ( "haixun-backend/internal/library/websearch" domai "haixun-backend/internal/model/ai/domain/usecase" aiusecase "haixun-backend/internal/model/ai/usecase" + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type InspireCopyMissionLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic { - return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx} + return &InspireCopyMissionLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, error) { +func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) { tenantID, uid, err := actorFrom(l.ctx) if err != nil { return nil, err } personaID := strings.TrimSpace(req.PersonaID) - userKeyword := strings.TrimSpace(req.Keyword) + if personaID == "" { + return nil, app.For(code.Persona).InputMissingRequired("persona_id is required") + } persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) if err != nil { return nil, err } - - missionList, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - recentLabels := make([]string, 0, 8) - recentSeeds := make([]string, 0, 8) - for _, mission := range missionList.List { - if mission.Status == "archived" { - continue - } - if label := strings.TrimSpace(mission.Label); label != "" { - recentLabels = append(recentLabels, label) - } - if seed := strings.TrimSpace(mission.SeedQuery); seed != "" { - recentSeeds = append(recentSeeds, seed) - } - if len(recentLabels) >= 8 && len(recentSeeds) >= 8 { - break - } - } - - trendSnippets := []libviral.MissionInspireTrendSnippet{} - webSearchProvider := "" - trendSourceLabel := "" - llmOnly := true - - research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) - if researchErr == nil { - memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) - if memberErr == nil { - trendSnippets = libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{ - Keyword: userKeyword, - PersonaBrief: persona.Brief, - StyleBenchmark: persona.StyleBenchmark, - Pillars: append([]string(nil), persona.CopyResearchMap.Pillars...), - Questions: append([]string(nil), persona.CopyResearchMap.Questions...), - Member: memberCtx, - Crawler: l.makeCrawlerSearchFn(tenantID, uid), - }) - if len(trendSnippets) > 0 { - trendSourceLabel = memberCtx.DiscoverPathLabel() - llmOnly = false - } - if len(trendSnippets) == 0 && placement.WebSearchAvailable(research) { - webClient := websearch.New(memberCtx.WebSearchConfig()) - trendSnippets = libviral.CollectMissionInspireTrends( - l.ctx, - webClient, - memberCtx, - persona.Brief+" "+userKeyword, - persona.StyleBenchmark, - ) - webSearchProvider = memberCtx.WebSearchProviderLabel() - trendSourceLabel = webSearchProvider - llmOnly = len(trendSnippets) == 0 - } - } - } - webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != "" - credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) if err != nil { return nil, err @@ -106,47 +60,280 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi return nil, err } + keyword := strings.TrimSpace(req.Keyword) + trends, sources, searchUsed := []libviral.MissionInspireTrendSnippet(nil), []types.CopyMissionInspirationSourceData(nil), false + if req.UseWebSearch { + trends, sources, searchUsed = l.collectTrendSignals(tenantID, uid, persona, keyword) + } + llmOnly := len(trends) == 0 + if keyword != "" && llmOnly { + trends = append(trends, libviral.MissionInspireTrendSnippet{ + Query: keyword, + Title: "使用者指定方向", + Snippet: keyword, + }) + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + temp := 0.72 result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: libviral.BuildMissionInspireSystemPrompt(), - Messages: []domai.Message{ - { - Role: "user", - Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{ - PersonaDisplayName: persona.DisplayName, - PersonaBrief: persona.Brief, - PersonaBlock: personaBlock, - StyleBenchmark: persona.StyleBenchmark, - PersonaAudience: persona.CopyResearchMap.AudienceSummary, - PersonaContentGoal: persona.CopyResearchMap.ContentGoal, - PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...), - PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), - RecentMissionLabels: recentLabels, - RecentSeedQueries: recentSeeds, - UserKeyword: userKeyword, - TrendSnippets: trendSnippets, - WebSearchProvider: trendSourceLabel, - LLMOnly: llmOnly, - }), - }, - }, + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + System: libviral.BuildMissionInspireSystemPrompt(), + Temperature: &temp, + Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{ + PersonaDisplayName: persona.DisplayName, + PersonaBrief: persona.Brief, + PersonaBlock: personaBlock, + StyleBenchmark: persona.StyleBenchmark, + AvoidTopics: cleanAvoidTopics(req.AvoidTopics), + ContentDirection: req.ContentDirection, + UserKeyword: keyword, + TrendSnippets: trends, + WebSearchProvider: sourceLabel(searchUsed, sources), + LLMOnly: llmOnly, + })}}, }) if err != nil { - return nil, err + return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error()) } - parsed, err := libviral.ParseMissionInspireOutput(result.Text) if err != nil { - return nil, app.For(code.AI).SvcThirdParty("靈感骰子 LLM 回傳無法解析:" + err.Error()) + return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error()) } - sources := make([]types.CopyMissionInspirationSourceData, 0, len(trendSnippets)) - for _, item := range trendSnippets { + topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed) + return &types.CopyMissionInspirationData{ + TopicCandidateID: topicCandidate, + ContentPlanID: plan, + Label: parsed.Label, + SeedQuery: parsed.SeedQuery, + Brief: parsed.Brief, + TrendReason: parsed.TrendReason, + TrendKeywords: parsed.TrendKeywords, + Angles: parsed.Angles, + Mission: parsed.Mission, + TargetAudience: parsed.TargetAudience, + OpeningType: parsed.OpeningType, + BodyType: parsed.BodyType, + Emotion: parsed.Emotion, + CtaType: parsed.CtaType, + RiskLevel: parsed.RiskLevel, + Avoid: parsed.Avoid, + Sources: sources, + WebSearchUsed: searchUsed, + Message: "已找到一個可延伸的話題與內容計畫", + }, nil +} + +func (l *InspireCopyMissionLogic) persistInspiration(tenantID, uid, personaID string, parsed libviral.MissionInspireOutput, searchUsed bool) (string, string) { + if l.svcCtx.ContentOps == nil { + return "", "" + } + riskScore := riskScoreFromLevel(parsed.RiskLevel) + category := parsed.Mission + if category == "" { + category = "候選話題" + } + source := "llm" + if searchUsed { + source = "topic_radar" + } + topic, err := l.svcCtx.ContentOps.CreateTopicCandidate(l.ctx, contentops.TopicCandidateInput{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + Name: parsed.Label, + Source: source, + Category: category, + SeedQuery: parsed.SeedQuery, + TrendReason: parsed.TrendReason, + TrendKeywords: parsed.TrendKeywords, + HeatScore: scoreBySearch(searchUsed), + FitScore: 82, + InteractionScore: 70, + ExtendScore: 78, + RiskScore: riskScore, + RecommendedMissions: []string{parsed.Mission}, + }) + if err != nil { + l.Errorf("persist topic candidate: %v", err) + return "", "" + } + angle := "" + if len(parsed.Angles) > 0 { + angle = parsed.Angles[0] + } + plan, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + TopicCandidateID: topic.ID, + Topic: parsed.Label, + Mission: fallbackString(parsed.Mission, "互動文"), + TargetAudience: parsed.TargetAudience, + Angle: angle, + OpeningType: parsed.OpeningType, + BodyType: parsed.BodyType, + Emotion: parsed.Emotion, + CtaType: parsed.CtaType, + RiskLevel: parsed.RiskLevel, + RequiresHumanReview: parsed.RiskLevel != "low", + Avoid: parsed.Avoid, + }) + if err != nil { + l.Errorf("persist content plan: %v", err) + return topic.ID, "" + } + return topic.ID, plan.ID +} + +func scoreBySearch(searchUsed bool) int { + if searchUsed { + return 76 + } + return 55 +} + +func riskScoreFromLevel(level string) int { + switch strings.ToLower(strings.TrimSpace(level)) { + case "high": + return 75 + case "low": + return 20 + default: + return 45 + } +} + +func fallbackString(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} + +func (l *InspireCopyMissionLogic) collectTrendSignals(tenantID, uid string, persona *personadomain.PersonaSummary, keyword string) ([]libviral.MissionInspireTrendSnippet, []types.CopyMissionInspirationSourceData, bool) { + if persona == nil { + return nil, nil, false + } + trends := make([]libviral.MissionInspireTrendSnippet, 0, 12) + seen := map[string]struct{}{} + addTrend := func(item libviral.MissionInspireTrendSnippet) { + item.Query = strings.TrimSpace(item.Query) + item.Title = strings.TrimSpace(item.Title) + item.Snippet = strings.TrimSpace(item.Snippet) + item.URL = strings.TrimSpace(item.URL) + if item.Title == "" && item.Snippet == "" { + return + } + if isNewsLikeInspiration(item.Title, item.Snippet, item.URL) { + return + } + key := strings.ToLower(item.Query + "|" + item.Title + "|" + item.Snippet) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + trends = append(trends, item) + } + + for _, item := range libviral.CollectFreeTopicTrends(l.ctx, keyword, persona.Brief) { + addTrend(item) + } + + research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) + if err != nil { + sources := inspirationSourcesFromTrends(trends) + return trends, sources, len(sources) > 0 + } + memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) + if err != nil { + sources := inspirationSourcesFromTrends(trends) + return trends, sources, len(sources) > 0 + } + + if placement.WebSearchAvailable(research) { + client := websearch.New(memberCtx.WebSearchConfig()) + for _, item := range libviral.CollectMissionInspireTrends(l.ctx, client, memberCtx, strings.TrimSpace(persona.Brief+" "+keyword), persona.StyleBenchmark) { + addTrend(item) + } + if keyword != "" && client.Enabled() { + for _, query := range []string{ + "Threads 台灣 " + keyword + " 熱門 關鍵字 討論", + keyword + " 請問 心得 推薦 Threads", + keyword + " 痛點 經驗 怎麼辦 社群", + keyword + " Dcard PTT Threads 熱門 討論", + } { + res, searchErr := client.Search(l.ctx, websearch.SearchOptions{ + Query: query, + Limit: 4, + Mode: websearch.ModeKnowledgeExpand, + Country: memberCtx.BraveCountry, + SearchLang: memberCtx.BraveSearchLang, + UserLocation: memberCtx.ExaUserLocation, + StartPublishedDate: placement.FormatPublishedAfterISO(14, time.Now().UTC()), + }) + if searchErr != nil || res.Status != "success" { + continue + } + for _, result := range res.Results { + addTrend(libviral.MissionInspireTrendSnippet{ + Query: query, + Title: result.Title, + Snippet: result.Snippet, + URL: result.URL, + }) + } + } + } + } + + if keyword != "" && len(trends) < 8 { + for _, item := range libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{ + Keyword: keyword, + PersonaBrief: persona.Brief, + StyleBenchmark: persona.StyleBenchmark, + Pillars: persona.CopyResearchMap.Pillars, + Questions: persona.CopyResearchMap.Questions, + Member: memberCtx, + }) { + addTrend(item) + } + } + + if len(trends) > 10 { + trends = trends[:10] + } + sources := inspirationSourcesFromTrends(trends) + return trends, sources, len(sources) > 0 +} + +func isNewsLikeInspiration(title, snippet, url string) bool { + text := strings.ToLower(title + " " + snippet + " " + url) + newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"} + socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"} + newsScore := 0 + for _, signal := range newsSignals { + if strings.Contains(text, strings.ToLower(signal)) { + newsScore++ + } + } + if newsScore == 0 { + return false + } + for _, signal := range socialSignals { + if strings.Contains(text, strings.ToLower(signal)) { + return false + } + } + return true +} + +func inspirationSourcesFromTrends(trends []libviral.MissionInspireTrendSnippet) []types.CopyMissionInspirationSourceData { + sources := make([]types.CopyMissionInspirationSourceData, 0, len(trends)) + for _, item := range trends { sources = append(sources, types.CopyMissionInspirationSourceData{ Query: item.Query, Title: item.Title, @@ -154,37 +341,33 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi URL: item.URL, }) } - - message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務" - if webSearchUsed { - message = "已依近期網路趨勢產出任務靈感,可微調後建立任務" - } - if len(trendSnippets) == 0 { - message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜" - } - - return &types.CopyMissionInspirationData{ - Label: parsed.Label, - SeedQuery: parsed.SeedQuery, - Brief: parsed.Brief, - TrendReason: parsed.TrendReason, - TrendKeywords: parsed.TrendKeywords, - Sources: sources, - WebSearchUsed: webSearchUsed, - Message: message, - }, nil + return sources } -func (l *InspireCopyMissionLogic) makeCrawlerSearchFn(tenantID, ownerUID string) placement.CrawlerSearchFn { - return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) { - accountID := strings.TrimSpace(member.ActiveAccountID) - if accountID == "" { - return nil, app.For(code.Setting).InputMissingRequired("請先選定經營帳號並同步 Chrome Session") - } - session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID) - if err != nil { - return nil, err - } - return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit) +func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string { + if !used || len(sources) == 0 { + return "" } + return "Free Trend / Web / Threads Search" +} + +func cleanAvoidTopics(items []string) []string { + out := make([]string, 0, len(items)) + seen := map[string]struct{}{} + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + if len(out) >= 8 { + break + } + } + return out } diff --git a/backend/internal/logic/copy_mission/knowledge_graph_mapper.go b/backend/internal/logic/copy_mission/knowledge_graph_mapper.go deleted file mode 100644 index dfb98c3..0000000 --- a/backend/internal/logic/copy_mission/knowledge_graph_mapper.go +++ /dev/null @@ -1,67 +0,0 @@ -package copy_mission - -import ( - kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" - "haixun-backend/internal/types" -) - -func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphData { - if graph == nil { - return types.KnowledgeGraphData{} - } - nodes := make([]types.KnowledgeGraphNodeData, 0, len(graph.Nodes)) - for _, node := range graph.Nodes { - evidence := make([]types.KnowledgeGraphEvidenceData, 0, len(node.Evidence)) - for _, item := range node.Evidence { - evidence = append(evidence, types.KnowledgeGraphEvidenceData{ - URL: item.URL, - Snippet: item.Snippet, - Query: item.Query, - }) - } - nodes = append(nodes, types.KnowledgeGraphNodeData{ - ID: node.ID, - Label: node.Label, - NodeKind: node.NodeKind, - Type: node.Type, - Layer: node.Layer, - Relation: node.Relation, - PlacementValue: node.PlacementValue, - ProductFitScore: node.ProductFitScore, - SelectedForScan: node.SelectedForScan, - RelevanceTags: append([]string{}, node.DerivedTags.Relevance...), - RecencyTags: append([]string{}, node.DerivedTags.Recency...), - Evidence: evidence, - }) - } - edges := make([]types.KnowledgeGraphEdgeData, 0, len(graph.Edges)) - for _, edge := range graph.Edges { - edges = append(edges, types.KnowledgeGraphEdgeData{ - From: edge.From, - To: edge.To, - Relation: edge.Relation, - }) - } - sources := make([]types.BraveSourceData, 0, len(graph.BraveSources)) - for _, src := range graph.BraveSources { - sources = append(sources, types.BraveSourceData{ - Query: src.Query, - Title: src.Title, - URL: src.URL, - Snippet: src.Snippet, - }) - } - return types.KnowledgeGraphData{ - ID: graph.ID, - BrandID: graph.BrandID, - Seed: graph.Seed, - Nodes: nodes, - Edges: edges, - BraveSources: sources, - ExpandStrategy: graph.ExpandStrategy, - PainTagCount: graph.PainTagCount, - GeneratedAt: graph.GeneratedAt, - CreateAt: graph.CreateAt, - UpdateAt: graph.UpdateAt, - } -} \ No newline at end of file diff --git a/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go index 7acba5e..2057700 100644 --- a/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go +++ b/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go @@ -1,40 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type ListCopyMissionCopyDraftsLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewListCopyMissionCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionCopyDraftsLogic { - return &ListCopyMissionCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx} + return &ListCopyMissionCopyDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (*types.ListCopyMissionCopyDraftsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } +func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (resp *types.ListCopyMissionCopyDraftsData, err error) { + // todo: add your logic here and delete this line - drafts, err := l.svcCtx.CopyDraft.ListByMission(l.ctx, tenantID, uid, personaID, missionID, 50) - if err != nil { - return nil, err - } - list := make([]types.CopyDraftData, 0, len(drafts)) - for _, item := range drafts { - list = append(list, toCopyDraftData(item)) - } - return &types.ListCopyMissionCopyDraftsData{List: list, Total: len(list)}, nil + return } diff --git a/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go index 6281c70..b21ffcb 100644 --- a/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go +++ b/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go @@ -1,49 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type ListCopyMissionScanPostsLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewListCopyMissionScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionScanPostsLogic { - return &ListCopyMissionScanPostsLogic{ctx: ctx, svcCtx: svcCtx} + return &ListCopyMissionScanPostsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts( - req *types.ListCopyMissionScanPostsHandlerReq, -) (*types.ListPersonaViralScanPostsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } +func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(req *types.ListCopyMissionScanPostsHandlerReq) (resp *types.ListPersonaViralScanPostsData, err error) { + // todo: add your logic here and delete this line - limit := 100 - if req.Limit > 0 { - limit = req.Limit - } - posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - CopyMissionID: missionID, - Limit: limit, - }) - if err != nil { - return nil, err - } - return toViralScanPostsData(posts), nil + return } diff --git a/backend/internal/logic/copy_mission/list_copy_missions_logic.go b/backend/internal/logic/copy_mission/list_copy_missions_logic.go index f99b9f0..661225f 100644 --- a/backend/internal/logic/copy_mission/list_copy_missions_logic.go +++ b/backend/internal/logic/copy_mission/list_copy_missions_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -24,18 +26,8 @@ func NewListCopyMissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) * } } -func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (*types.ListCopyMissionsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - result, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - return toListData(result), nil +func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (resp *types.ListCopyMissionsData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/mapper.go b/backend/internal/logic/copy_mission/mapper.go deleted file mode 100644 index 8812738..0000000 --- a/backend/internal/logic/copy_mission/mapper.go +++ /dev/null @@ -1,299 +0,0 @@ -package copy_mission - -import ( - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" - "haixun-backend/internal/types" -) - -func seedMissionResearchFromPersona(persona personadomain.PersonaSummary) (*missionentity.ResearchMap, []string) { - src := persona.CopyResearchMap - if src.AudienceSummary == "" && src.ContentGoal == "" && len(src.SuggestedTags) == 0 { - return nil, nil - } - tags := make([]missionentity.SuggestedTag, 0, len(src.SuggestedTags)) - for _, tag := range src.SuggestedTags { - if tag == "" { - continue - } - tags = append(tags, missionentity.SuggestedTag{Tag: tag}) - } - selected := append([]string(nil), src.SuggestedTags...) - if len(selected) > 6 { - selected = selected[:6] - } - rm := &missionentity.ResearchMap{ - AudienceSummary: src.AudienceSummary, - ContentGoal: src.ContentGoal, - Questions: append([]string(nil), src.Questions...), - Pillars: append([]string(nil), src.Pillars...), - Exclusions: append([]string(nil), src.Exclusions...), - SuggestedTags: tags, - BenchmarkNotes: src.BenchmarkNotes, - } - return rm, selected -} - -func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch { - if req == nil { - return missiondomain.MissionPatch{} - } - patch := missiondomain.MissionPatch{ - Label: req.Label, - SeedQuery: req.SeedQuery, - Brief: req.Brief, - } - if req.AudienceSummary != nil { - patch.AudienceSummary = req.AudienceSummary - } - if req.ContentGoal != nil { - patch.ContentGoal = req.ContentGoal - } - if req.Questions != nil { - patch.QuestionsSet = true - patch.Questions = append([]string(nil), req.Questions...) - } - if req.Pillars != nil { - patch.PillarsSet = true - patch.Pillars = append([]string(nil), req.Pillars...) - } - if req.Exclusions != nil { - patch.ExclusionsSet = true - patch.Exclusions = append([]string(nil), req.Exclusions...) - } - if req.KnowledgeItems != nil { - patch.KnowledgeItemsSet = true - patch.KnowledgeItems = append([]string(nil), req.KnowledgeItems...) - } - if req.SelectedKnowledgeItems != nil { - patch.SelectedKnowledgeItemsSet = true - patch.SelectedKnowledgeItems = append([]string(nil), req.SelectedKnowledgeItems...) - } - if req.BenchmarkNotes != nil { - patch.BenchmarkNotes = req.BenchmarkNotes - } - if req.SelectedTags != nil { - patch.SelectedTagsSet = true - patch.SelectedTags = append([]string(nil), req.SelectedTags...) - } - return patch -} - -func toResearchItemData(items []missiondomain.ResearchItemSummary) []types.CopyMissionResearchItemData { - out := make([]types.CopyMissionResearchItemData, 0, len(items)) - for _, item := range items { - out = append(out, types.CopyMissionResearchItemData{ - Title: item.Title, - URL: item.URL, - Snippet: item.Snippet, - Query: item.Query, - }) - } - return out -} - -func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData { - tags := make([]types.CopySuggestedTagData, 0, len(item.ResearchMap.SuggestedTags)) - for _, tag := range item.ResearchMap.SuggestedTags { - tags = append(tags, types.CopySuggestedTagData{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]types.CopySimilarAccountData, 0, len(item.ResearchMap.SimilarAccounts)) - for _, acc := range item.ResearchMap.SimilarAccounts { - accounts = append(accounts, types.CopySimilarAccountData{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileUrl: acc.ProfileURL, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]types.CopyAudienceSampleData, 0, len(item.ResearchMap.AudienceSamples)) - for _, sample := range item.ResearchMap.AudienceSamples { - samples = append(samples, types.CopyAudienceSampleData{ - Username: sample.Username, - SamplePostId: sample.SamplePostID, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return types.CopyMissionData{ - ID: item.ID, - PersonaID: item.PersonaID, - Label: item.Label, - SeedQuery: item.SeedQuery, - Brief: item.Brief, - ResearchMap: types.CopyMissionResearchMapData{ - AudienceSummary: item.ResearchMap.AudienceSummary, - ContentGoal: item.ResearchMap.ContentGoal, - Questions: append([]string(nil), item.ResearchMap.Questions...), - Pillars: append([]string(nil), item.ResearchMap.Pillars...), - Exclusions: append([]string(nil), item.ResearchMap.Exclusions...), - KnowledgeItems: append([]string(nil), item.ResearchMap.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), item.ResearchMap.SelectedKnowledgeItems...), - ResearchItems: toResearchItemData(item.ResearchMap.ResearchItems), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: item.ResearchMap.BenchmarkNotes, - }, - SelectedTags: append([]string(nil), item.SelectedTags...), - LastScanJobID: item.LastScanJobID, - Status: item.Status, - CreateAt: item.CreateAt, - UpdateAt: item.UpdateAt, - } -} - -func toListData(result *missiondomain.ListResult) *types.ListCopyMissionsData { - if result == nil { - return &types.ListCopyMissionsData{List: []types.CopyMissionData{}} - } - list := make([]types.CopyMissionData, 0, len(result.List)) - for _, item := range result.List { - list = append(list, toCopyMissionData(item)) - } - return &types.ListCopyMissionsData{List: list} -} - -func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.ResearchMap { - tags := make([]missionentity.SuggestedTag, 0, len(data.SuggestedTags)) - for _, tag := range data.SuggestedTags { - tags = append(tags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]missionentity.SimilarAccount, 0, len(data.SimilarAccounts)) - for _, acc := range data.SimilarAccounts { - accounts = append(accounts, missionentity.SimilarAccount{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileURL: acc.ProfileUrl, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]missionentity.AudienceSample, 0, len(data.AudienceSamples)) - for _, sample := range data.AudienceSamples { - samples = append(samples, missionentity.AudienceSample{ - Username: sample.Username, - SamplePostID: sample.SamplePostId, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return missionentity.ResearchMap{ - AudienceSummary: data.AudienceSummary, - ContentGoal: data.ContentGoal, - Questions: append([]string(nil), data.Questions...), - Pillars: append([]string(nil), data.Pillars...), - Exclusions: append([]string(nil), data.Exclusions...), - KnowledgeItems: append([]string(nil), data.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), data.SelectedKnowledgeItems...), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: data.BenchmarkNotes, - } -} - -func toViralScanPostsData(posts []scanpostdomain.ScanPostSummary) *types.ListPersonaViralScanPostsData { - list := make([]types.ViralScanPostData, 0, len(posts)) - for _, post := range posts { - list = append(list, types.ViralScanPostData{ - ID: post.ID, - SearchTag: post.SearchTag, - Permalink: post.Permalink, - Author: post.Author, - AuthorVerified: post.AuthorVerified, - FollowerCount: post.FollowerCount, - Text: post.Text, - LikeCount: post.LikeCount, - ReplyCount: post.ReplyCount, - EngagementScore: post.EngagementScore, - Source: post.Source, - ScanJobID: post.ScanJobID, - Replies: toScanReplyData(post.Replies), - CreateAt: post.CreateAt, - }) - } - return &types.ListPersonaViralScanPostsData{List: list, Total: len(list)} -} - -func toScanReplyData(replies []scanpostdomain.ScanReplySummary) []types.ScanReplyData { - if len(replies) == 0 { - return nil - } - out := make([]types.ScanReplyData, 0, len(replies)) - for _, reply := range replies { - out = append(out, types.ScanReplyData{ - ExternalID: reply.ExternalID, - Author: reply.Author, - Text: reply.Text, - Permalink: reply.Permalink, - LikeCount: reply.LikeCount, - PostedAt: reply.PostedAt, - }) - } - return out -} - -func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData { - return types.CopyDraftData{ - ID: item.ID, - PersonaID: item.PersonaID, - CopyMissionID: item.CopyMissionID, - ScanPostID: item.ScanPostID, - DraftType: item.DraftType, - SortOrder: item.SortOrder, - Text: item.Text, - Angle: item.Angle, - Hook: item.Hook, - Rationale: item.Rationale, - ReferenceNotes: item.ReferenceNotes, - Sources: item.Sources, - Status: item.Status, - PublishedMediaID: item.PublishedMediaID, - PublishedPermalink: item.PublishedPermalink, - PublishedAt: item.PublishedAt, - CreateAt: item.CreateAt, - } -} diff --git a/backend/internal/logic/copy_mission/matrix_guard.go b/backend/internal/logic/copy_mission/matrix_guard.go deleted file mode 100644 index e2aa93c..0000000 --- a/backend/internal/logic/copy_mission/matrix_guard.go +++ /dev/null @@ -1,42 +0,0 @@ -package copy_mission - -import ( - "context" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/model/job/domain/enum" - domrepo "haixun-backend/internal/model/job/domain/repository" - "haixun-backend/internal/svc" -) - -func ensureNoActiveMatrixJob(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) error { - if svcCtx == nil || svcCtx.Job == nil { - return nil - } - missionID = strings.TrimSpace(missionID) - if missionID == "" { - return nil - } - runs, _, _, _, _, err := svcCtx.Job.ListRuns(ctx, domrepo.RunListFilter{ - Scope: "copy_mission", - ScopeID: missionID, - Statuses: []enum.RunStatus{ - enum.RunStatusPending, - enum.RunStatusQueued, - enum.RunStatusRunning, - enum.RunStatusWaitingWorker, - enum.RunStatusCancelRequested, - }, - }, 1, 20) - if err != nil { - return err - } - for _, run := range runs { - if run != nil && run.TemplateType == "generate-copy-matrix" { - return app.For(code.Job).ResInvalidState("內容矩陣背景任務進行中,請稍後再試") - } - } - return nil -} \ No newline at end of file diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go index f13a687..7c773a8 100644 --- a/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go +++ b/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go @@ -5,9 +5,7 @@ package copy_mission import ( "context" - "strings" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -28,22 +26,8 @@ func NewPatchCopyMissionAudienceSampleLogic(ctx context.Context, svcCtx *svc.Ser } } -func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - item, err := l.svcCtx.CopyMission.PatchAudienceSample(l.ctx, missiondomain.PatchAudienceSampleRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: strings.TrimSpace(req.PersonaID), - MissionID: strings.TrimSpace(req.ID), - Username: strings.TrimSpace(req.Username), - Status: req.Status, - }) - if err != nil { - return nil, err - } - data := toCopyMissionData(*item) - return &data, nil +func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go index a6f48d0..1c2598c 100644 --- a/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go +++ b/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go @@ -1,14 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - libcopy "haixun-backend/internal/library/copymission" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,73 +26,8 @@ func NewPatchCopyMissionGraphNodesLogic(ctx context.Context, svcCtx *svc.Service } } -func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (*types.KnowledgeGraphData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - if len(req.Updates) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("updates is required") - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } +func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) { + // todo: add your logic here and delete this line - updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates)) - for _, item := range req.Updates { - nodeID := strings.TrimSpace(item.NodeID) - if nodeID == "" { - continue - } - update := kgusecase.NodeUpdate{NodeID: nodeID} - if item.SelectedForScan != nil { - update.SelectedForScan = item.SelectedForScan - } - if item.RelevanceTags != nil { - update.RelevanceTagsSet = true - update.RelevanceTags = append([]string(nil), item.RelevanceTags...) - } - if item.RecencyTags != nil { - update.RecencyTagsSet = true - update.RecencyTags = append([]string(nil), item.RecencyTags...) - } - if update.SelectedForScan == nil && !update.RelevanceTagsSet && !update.RecencyTagsSet { - return nil, app.For(code.Persona).InputMissingRequired("each update needs selected_for_scan or patrol tags") - } - updates = append(updates, update) - } - if len(updates) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("updates is required") - } - - graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{ - TenantID: tenantID, - OwnerUID: uid, - CopyMissionID: missionID, - Updates: updates, - }) - if err != nil { - return nil, err - } - - prev := summaryToEntityResearchMap(mission.ResearchMap) - researchMap := libcopy.BuildResearchMapFromGraph(prev, graph.Nodes, graph.BraveSources) - _, err = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - ResearchMap: &researchMap, - }, - }) - if err != nil { - return nil, err - } - - data := toKnowledgeGraphData(graph) - return &data, nil -} \ No newline at end of file + return +} diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go index cf59b9d..b8a0637 100644 --- a/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go +++ b/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go @@ -5,11 +5,7 @@ package copy_mission import ( "context" - "strings" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -30,40 +26,8 @@ func NewPatchCopyMissionSimilarAccountLogic(ctx context.Context, svcCtx *svc.Ser } } -func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - item, err := l.svcCtx.CopyMission.PatchSimilarAccount(l.ctx, missiondomain.PatchSimilarAccountRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: strings.TrimSpace(req.PersonaID), - MissionID: strings.TrimSpace(req.ID), - Username: strings.TrimSpace(req.Username), - Status: req.Status, - }) - if err != nil { - return nil, err - } - if status := strings.TrimSpace(req.Status); status == missionentity.SimilarAccountStatusExcluded || status == missionentity.SimilarAccountStatusRecommended { - if research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid); researchErr == nil { - if memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research); memberErr == nil && memberCtx.ActiveAccountID != "" { - username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) - if username != "" { - _ = l.svcCtx.ThreadsAccount.UpsertKnownAccountsFromScan(l.ctx, threadsaccountdomain.UpsertKnownAccountsFromScanRequest{ - TenantID: tenantID, - OwnerUID: uid, - AccountID: memberCtx.ActiveAccountID, - SimilarAccounts: []missionentity.SimilarAccount{{ - Username: username, - Status: status, - }}, - }) - } - } - } - } - data := toCopyMissionData(*item) - return &data, nil +func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/research_map_entity.go b/backend/internal/logic/copy_mission/research_map_entity.go deleted file mode 100644 index 780b201..0000000 --- a/backend/internal/logic/copy_mission/research_map_entity.go +++ /dev/null @@ -1,78 +0,0 @@ -package copy_mission - -import ( - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" -) - -func summaryResearchItemsToEntity(items []missiondomain.ResearchItemSummary) []missionentity.ResearchItem { - out := make([]missionentity.ResearchItem, 0, len(items)) - for _, item := range items { - out = append(out, missionentity.ResearchItem{ - Title: item.Title, - URL: item.URL, - Snippet: item.Snippet, - Query: item.Query, - }) - } - return out -} - -func summaryToEntityResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap { - tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags)) - for _, tag := range summary.SuggestedTags { - tags = append(tags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts)) - for _, acc := range summary.SimilarAccounts { - accounts = append(accounts, missionentity.SimilarAccount{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileURL: acc.ProfileURL, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples)) - for _, sample := range summary.AudienceSamples { - samples = append(samples, missionentity.AudienceSample{ - Username: sample.Username, - SamplePostID: sample.SamplePostID, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return missionentity.ResearchMap{ - AudienceSummary: summary.AudienceSummary, - ContentGoal: summary.ContentGoal, - Questions: append([]string(nil), summary.Questions...), - Pillars: append([]string(nil), summary.Pillars...), - Exclusions: append([]string(nil), summary.Exclusions...), - KnowledgeItems: append([]string(nil), summary.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...), - ResearchItems: summaryResearchItemsToEntity(summary.ResearchItems), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: summary.BenchmarkNotes, - } -} \ No newline at end of file diff --git a/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go new file mode 100644 index 0000000..cca830a --- /dev/null +++ b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go @@ -0,0 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package copy_mission + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ScheduleCopyMissionDraftsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ScheduleCopyMissionDraftsLogic { + return &ScheduleCopyMissionDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.CopyMissionScheduleCopyDraftsData, err error) { + // todo: add your logic here and delete this line + + return +} diff --git a/backend/internal/logic/copy_mission/schedule_helper.go b/backend/internal/logic/copy_mission/schedule_helper.go deleted file mode 100644 index 23f59d7..0000000 --- a/backend/internal/logic/copy_mission/schedule_helper.go +++ /dev/null @@ -1,49 +0,0 @@ -package copy_mission - -import ( - "context" - - jobentity "haixun-backend/internal/model/job/domain/entity" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" -) - -const viralScanTemplate = "scan-viral" - -func findCopyMissionScanSchedule(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) (*jobentity.Schedule, error) { - schedules, _, _, _, _, err := svcCtx.Job.ListSchedules(ctx, "copy_mission", missionID, 1, 50) - if err != nil { - return nil, err - } - for _, schedule := range schedules { - if schedule != nil && schedule.TemplateType == viralScanTemplate { - return schedule, nil - } - } - return nil, nil -} - -func toCopyMissionScanScheduleData(schedule *jobentity.Schedule, personaID, missionID string) *types.CopyMissionScanScheduleData { - if schedule == nil { - return &types.CopyMissionScanScheduleData{ - PersonaID: personaID, - MissionID: missionID, - Cron: "0 9 * * *", - Timezone: "Asia/Taipei", - Enabled: false, - } - } - data := &types.CopyMissionScanScheduleData{ - ID: schedule.ID.Hex(), - PersonaID: personaID, - MissionID: missionID, - Cron: schedule.Cron, - Timezone: schedule.Timezone, - Enabled: schedule.Enabled, - NextRunAt: schedule.NextRunAt, - } - if schedule.LastRunAt != nil { - data.LastRunAt = *schedule.LastRunAt - } - return data -} diff --git a/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go index ec491ec..05e3fd6 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go @@ -1,67 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type StartCopyMissionAnalyzeJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionAnalyzeJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionAnalyzeJobLogic { - return &StartCopyMissionAnalyzeJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionAnalyzeJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob( - req *types.CopyMissionPath, -) (*types.StartCopyMissionAnalyzeJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } +func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionAnalyzeJobData, err error) { + // todo: add your logic here and delete this line - payload := map[string]any{ - "tenant_id": tenantID, - "owner_uid": uid, - "persona_id": personaID, - "copy_mission_id": missionID, - } - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "analyze-copy-mission", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - if run == nil { - return nil, app.For(code.Job).ResInvalidState("analyze job not created") - } - return &types.StartCopyMissionAnalyzeJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "受眾與研究地圖分析已在背景執行,完成後可微調標籤並開始海巡", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go index 9d1cd25..48582cb 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go @@ -1,80 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/style8d" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type StartCopyMissionCopyDraftJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionCopyDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionCopyDraftJobLogic { - return &StartCopyMissionCopyDraftJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionCopyDraftJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob( - req *types.StartCopyMissionCopyDraftJobHandlerReq, -) (*types.StartCopyMissionCopyDraftJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - scanPostID := strings.TrimSpace(req.ScanPostID) - if scanPostID == "" { - return nil, app.For(code.Persona).InputMissingRequired("scan_post_id is required") - } +func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(req *types.StartCopyMissionCopyDraftJobHandlerReq) (resp *types.StartCopyMissionCopyDraftJobData, err error) { + // todo: add your logic here and delete this line - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } - post, err := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID) - if err != nil { - return nil, err - } - if postMission := strings.TrimSpace(post.CopyMissionID); postMission != "" && postMission != missionID { - return nil, app.For(code.Persona).ResInvalidState("爆款不屬於此任務") - } - - payload := map[string]any{ - "persona_id": personaID, - "copy_mission_id": missionID, - "scan_post_id": scanPostID, - } - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "generate-copy-draft", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.StartCopyMissionCopyDraftJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "深仿寫已在背景執行", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go index 2315e07..31367aa 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go @@ -1,89 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/style8d" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type StartCopyMissionMatrixJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionMatrixJobLogic { - return &StartCopyMissionMatrixJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionMatrixJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob( - req *types.StartCopyMissionMatrixJobHandlerReq, -) (*types.StartCopyMissionMatrixJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.Status != string(missionentity.StatusMapped) && - mission.Status != string(missionentity.StatusScanned) && - mission.Status != string(missionentity.StatusDrafted) { - return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖") - } +func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(req *types.StartCopyMissionMatrixJobHandlerReq) (resp *types.StartCopyMissionMatrixJobData, err error) { + // todo: add your logic here and delete this line - persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } - - count := req.Count - if count <= 0 { - count = 5 - } - if count > 12 { - count = 12 - } - - payload := map[string]any{ - "tenant_id": tenantID, - "owner_uid": uid, - "persona_id": personaID, - "copy_mission_id": missionID, - "count": count, - } - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "generate-copy-matrix", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.StartCopyMissionMatrixJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "內容矩陣產出已在背景執行", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go index c4b0fc6..3681e2b 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go @@ -1,95 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libviral "haixun-backend/internal/library/viral" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type StartCopyMissionScanJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionScanJobLogic { - return &StartCopyMissionScanJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionScanJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob( - req *types.CopyMissionPath, -) (*types.StartCopyMissionScanJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.ResearchMap.AudienceSummary == "" && len(mission.ResearchMap.SuggestedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖") - } - if len(mission.SelectedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("請至少勾選一個搜尋標籤") - } - if len(mission.SelectedTags) > libviral.MaxScanTags { - return nil, app.For(code.Persona).InputMissingRequired("搜尋標籤最多 6 個,請減少勾選以節省搜尋配額") - } +func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionScanJobData, err error) { + // todo: add your logic here and delete this line - research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) - if err != nil { - return nil, err - } - memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) - if err != nil { - return nil, err - } - if !memberCtx.HasDiscoverPath() { - return nil, app.For(code.Setting).InputMissingRequired("爆款掃描需要 Threads API、Chrome Session 或 Web Search API(請檢查連線模式與搜尋來源)") - } - - if err := l.svcCtx.ScanPost.ClearCopyMissionViralScan(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - if err := l.svcCtx.CopyDraft.ClearByMission(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - - payload := map[string]any{ - "persona_id": personaID, - "copy_mission_id": missionID, - "keywords": mission.SelectedTags, - "bootstrap": false, - } - for key, value := range memberCtx.PayloadFields() { - payload[key] = value - } - - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "scan-viral", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.StartCopyMissionScanJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "已清除舊爆款與產文草稿,海巡已在背景執行", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/update_copy_mission_logic.go b/backend/internal/logic/copy_mission/update_copy_mission_logic.go index ef1c748..c936d7b 100644 --- a/backend/internal/logic/copy_mission/update_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/update_copy_mission_logic.go @@ -1,14 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,39 +26,8 @@ func NewUpdateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) } } -func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - patch := toMissionPatch(&req.UpdateCopyMissionReq) - if req.Status != nil { - status := strings.TrimSpace(*req.Status) - if status != string(missionentity.StatusArchived) && status != string(missionentity.StatusOpen) { - return nil, app.For(code.Persona).InputMissingRequired("status 僅支援 archived 或 open") - } - st := missionentity.Status(status) - patch.Status = &st - } - item, err := l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: strings.TrimSpace(req.PersonaID), - MissionID: strings.TrimSpace(req.ID), - Patch: patch, - }) - if err != nil { - return nil, err - } - if req.Status != nil && strings.TrimSpace(*req.Status) == string(missionentity.StatusArchived) { - if schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, strings.TrimSpace(req.ID)); err == nil && schedule != nil && schedule.Enabled { - disabled := false - _, _ = l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{ - ID: schedule.ID.Hex(), - Enabled: &disabled, - }) - } - } - data := toCopyMissionData(*item) - return &data, nil +func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go index 80b2c13..916139a 100644 --- a/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go +++ b/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go @@ -1,106 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type UpsertCopyMissionScanScheduleLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewUpsertCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertCopyMissionScanScheduleLogic { - return &UpsertCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx} + return &UpsertCopyMissionScanScheduleLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule( - req *types.UpsertCopyMissionScanScheduleHandlerReq, -) (*types.CopyMissionScanScheduleData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.Status == string(missionentity.StatusArchived) { - return nil, app.For(code.Persona).ResInvalidState("已封存的任務無法設定排程") - } - if req.Enabled && len(mission.SelectedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("啟用排程前請先勾選並儲存搜尋標籤") - } +func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(req *types.UpsertCopyMissionScanScheduleHandlerReq) (resp *types.CopyMissionScanScheduleData, err error) { + // todo: add your logic here and delete this line - cronExpr := "0 9 * * *" - timezone := "Asia/Taipei" - if strings.TrimSpace(req.Cron) != "" { - cronExpr = strings.TrimSpace(req.Cron) - } - if strings.TrimSpace(req.Timezone) != "" { - timezone = strings.TrimSpace(req.Timezone) - } - - research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) - if err != nil { - return nil, err - } - memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) - if err != nil { - return nil, err - } - - payload := map[string]any{ - "persona_id": personaID, - "copy_mission_id": missionID, - "bootstrap": false, - "scheduled": true, - } - for key, value := range memberCtx.PayloadFields() { - payload[key] = value - } - - existing, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID) - if err != nil { - return nil, err - } - if existing != nil { - updated, err := l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{ - ID: existing.ID.Hex(), - Cron: cronExpr, - Timezone: timezone, - PayloadTemplate: payload, - Enabled: &req.Enabled, - }) - if err != nil { - return nil, err - } - return toCopyMissionScanScheduleData(updated, personaID, missionID), nil - } - - created, err := l.svcCtx.Job.CreateSchedule(l.ctx, jobdom.CreateScheduleRequest{ - TemplateType: viralScanTemplate, - Scope: "copy_mission", - ScopeID: missionID, - Cron: cronExpr, - Timezone: timezone, - PayloadTemplate: payload, - Enabled: req.Enabled, - }) - if err != nil { - return nil, err - } - return toCopyMissionScanScheduleData(created, personaID, missionID), nil + return } diff --git a/backend/internal/logic/crm/actor.go b/backend/internal/logic/crm/actor.go deleted file mode 100644 index ff363fc..0000000 --- a/backend/internal/logic/crm/actor.go +++ /dev/null @@ -1,17 +0,0 @@ -package crm - -import ( - "context" - - "haixun-backend/internal/library/authctx" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" -) - -func actorFrom(ctx context.Context) (tenantID, uid string, err error) { - actor, ok := authctx.ActorFromContext(ctx) - if !ok { - return "", "", app.For(code.Auth).AuthUnauthorized("missing actor") - } - return actor.TenantID, actor.UID, nil -} diff --git a/backend/internal/logic/crm/create_crm_contact_logic.go b/backend/internal/logic/crm/create_crm_contact_logic.go deleted file mode 100644 index 6cc276b..0000000 --- a/backend/internal/logic/crm/create_crm_contact_logic.go +++ /dev/null @@ -1,46 +0,0 @@ -package crm - -import ( - "context" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type CreateCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewCreateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateCrmContactLogic { - return &CreateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *CreateCrmContactLogic) CreateCrmContact(req *types.CreateCrmContactReq) (*types.CrmContactData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - authorID := strings.TrimSpace(req.AuthorID) - authorName := strings.TrimSpace(req.AuthorName) - if authorID == "" { - return nil, app.For(code.Persona).InputMissingRequired("author_id is required") - } - if authorName == "" { - return nil, app.For(code.Persona).InputMissingRequired("author_name is required") - } - - result, err := l.svcCtx.CrmContact.Create(l.ctx, tenantID, uid, strings.TrimSpace(req.BrandID), authorID, authorName) - if err != nil { - return nil, err - } - - return toCrmContactData(result), nil -} diff --git a/backend/internal/logic/crm/delete_crm_contact_logic.go b/backend/internal/logic/crm/delete_crm_contact_logic.go deleted file mode 100644 index 669ebcf..0000000 --- a/backend/internal/logic/crm/delete_crm_contact_logic.go +++ /dev/null @@ -1,29 +0,0 @@ -package crm - -import ( - "context" - - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type DeleteCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewDeleteCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCrmContactLogic { - return &DeleteCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *DeleteCrmContactLogic) DeleteCrmContact(req *types.CrmContactIDPath) error { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return err - } - - return l.svcCtx.CrmContact.Delete(l.ctx, tenantID, uid, req.ID) -} diff --git a/backend/internal/logic/crm/get_crm_contact_logic.go b/backend/internal/logic/crm/get_crm_contact_logic.go deleted file mode 100644 index 943f6c6..0000000 --- a/backend/internal/logic/crm/get_crm_contact_logic.go +++ /dev/null @@ -1,34 +0,0 @@ -package crm - -import ( - "context" - - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type GetCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewGetCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCrmContactLogic { - return &GetCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *GetCrmContactLogic) GetCrmContact(req *types.CrmContactIDPath) (*types.CrmContactData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - result, err := l.svcCtx.CrmContact.Get(l.ctx, tenantID, uid, req.ID) - if err != nil { - return nil, err - } - - return toCrmContactData(result), nil -} diff --git a/backend/internal/logic/crm/list_crm_contacts_logic.go b/backend/internal/logic/crm/list_crm_contacts_logic.go deleted file mode 100644 index ce16203..0000000 --- a/backend/internal/logic/crm/list_crm_contacts_logic.go +++ /dev/null @@ -1,54 +0,0 @@ -package crm - -import ( - "context" - - crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type ListCrmContactsLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewListCrmContactsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCrmContactsLogic { - return &ListCrmContactsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *ListCrmContactsLogic) ListCrmContacts(req *types.ListCrmContactsReq) (*types.CrmContactListData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - page := req.Page - pageSize := req.PageSize - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - - listReq := crmusecase.ListRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.BrandID, - Status: req.Status, - Tag: req.Tag, - Page: page, - PageSize: pageSize, - } - - items, total, err := l.svcCtx.CrmContact.List(l.ctx, listReq) - if err != nil { - return nil, err - } - - return toCrmContactListData(items, total, page, pageSize), nil -} diff --git a/backend/internal/logic/crm/mapper.go b/backend/internal/logic/crm/mapper.go deleted file mode 100644 index 48a9738..0000000 --- a/backend/internal/logic/crm/mapper.go +++ /dev/null @@ -1,48 +0,0 @@ -package crm - -import ( - crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" - "haixun-backend/internal/types" -) - -func toCrmContactData(item *crmusecase.CRMContactSummary) *types.CrmContactData { - if item == nil { - return nil - } - return &types.CrmContactData{ - ID: item.ID, - BrandID: item.BrandID, - AuthorID: item.AuthorID, - AuthorName: item.AuthorName, - AuthorAvatar: item.AuthorAvatar, - AuthorFollowers: item.AuthorFollowers, - ScanPostID: item.ScanPostID, - Notes: item.Notes, - Tags: item.Tags, - Status: item.Status, - OutreachCount: item.OutreachCount, - LastContactedAt: item.LastContactedAt, - CreateAt: item.CreateAt, - UpdateAt: item.UpdateAt, - } -} - -func toCrmContactListData(items []crmusecase.CRMContactSummary, total, page, pageSize int) *types.CrmContactListData { - totalPages := 0 - if total > 0 { - totalPages = (total + pageSize - 1) / pageSize - } - data := make([]types.CrmContactData, 0, len(items)) - for _, item := range items { - data = append(data, *toCrmContactData(&item)) - } - return &types.CrmContactListData{ - List: data, - Pagination: types.PaginationData{ - Total: int64(total), - Page: int64(page), - PageSize: int64(pageSize), - TotalPages: int64(totalPages), - }, - } -} diff --git a/backend/internal/logic/crm/update_crm_contact_logic.go b/backend/internal/logic/crm/update_crm_contact_logic.go deleted file mode 100644 index 853c5e4..0000000 --- a/backend/internal/logic/crm/update_crm_contact_logic.go +++ /dev/null @@ -1,42 +0,0 @@ -package crm - -import ( - "context" - - crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type UpdateCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewUpdateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateCrmContactLogic { - return &UpdateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *UpdateCrmContactLogic) UpdateCrmContact(req *types.UpdateCrmContactReq) (*types.CrmContactData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - result, err := l.svcCtx.CrmContact.Update(l.ctx, crmusecase.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - ContactID: req.ID, - Notes: req.Notes, - Tags: req.Tags, - Status: req.Status, - }) - if err != nil { - return nil, err - } - - return toCrmContactData(result), nil -} diff --git a/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go b/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go index d27b13a..c51bf43 100644 --- a/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go +++ b/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go @@ -15,6 +15,7 @@ import ( jobenum "haixun-backend/internal/model/job/domain/enum" jobusecase "haixun-backend/internal/model/job/domain/usecase" personausecase "haixun-backend/internal/model/persona/domain/usecase" + tausecase "haixun-backend/internal/model/threads_account/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -36,12 +37,22 @@ func NewAnalyzeStyle8DFromWorkerLogic(ctx context.Context, svcCtx *svc.ServiceCo } func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.AnalyzeStyle8DReq) (resp *types.AnalyzeStyle8DData, err error) { - credential, err := l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential( - l.ctx, - req.TenantID, - req.OwnerUID, - req.ThreadsAccountID, - ) + var credential *tausecase.WorkerAiCredential + accountID := strings.TrimSpace(req.ThreadsAccountID) + if accountID != "" { + credential, err = l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential( + l.ctx, + req.TenantID, + req.OwnerUID, + accountID, + ) + } else { + credential, err = l.svcCtx.ThreadsAccount.ResolveMemberResearchAiCredential( + l.ctx, + req.TenantID, + req.OwnerUID, + ) + } if err != nil { return nil, err } @@ -79,10 +90,16 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal }) username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@") + input := style8d.AnalyzeInput{ + Mode: style8d.AnalyzeModeBenchmark, + Username: username, + Posts: posts, + } systemPrompt, err := libprompt.Style8DSystem() if err != nil { return nil, app.For(code.AI).SysInternal("prompt config load failed") } + maxTokens := 8192 result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, @@ -91,8 +108,9 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal }, System: systemPrompt, Messages: []domai.Message{ - {Role: "user", Content: style8d.BuildUserPrompt(username, posts)}, + {Role: "user", Content: input.BuildUserPrompt()}, }, + MaxTokens: &maxTokens, }) if err != nil { if strings.Contains(err.Error(), "HTTP 401") { @@ -108,7 +126,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal if err != nil { return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error()) } - profile := style8d.BuildStoredProfile(username, posts, parsed) + profile := input.BuildStoredProfile(parsed) profileJSON, err := profile.JSON() if err != nil { return nil, err @@ -126,9 +144,10 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal }) personaDraft := strings.TrimSpace(profile.PersonaDraft) + benchmark := input.StyleBenchmark() patch := personausecase.PersonaPatch{ StyleProfile: &profileJSON, - StyleBenchmark: &username, + StyleBenchmark: &benchmark, } if personaDraft != "" { patch.Persona = &personaDraft diff --git a/backend/internal/logic/member/mapper.go b/backend/internal/logic/member/mapper.go index ae262f9..44edd5e 100644 --- a/backend/internal/logic/member/mapper.go +++ b/backend/internal/logic/member/mapper.go @@ -33,6 +33,7 @@ func toPlacementSettingsData(settings *placementusecase.Settings) types.MemberPl BraveSearchLang: settings.BraveSearchLang, ExaUserLocation: settings.ExaUserLocation, ExpandStrategy: settings.ExpandStrategy, + DevModeEnabled: settings.DevModeEnabled, } } diff --git a/backend/internal/logic/member/update_member_placement_settings_logic.go b/backend/internal/logic/member/update_member_placement_settings_logic.go index 14e918f..a0defad 100644 --- a/backend/internal/logic/member/update_member_placement_settings_logic.go +++ b/backend/internal/logic/member/update_member_placement_settings_logic.go @@ -40,6 +40,7 @@ func (l *UpdateMemberPlacementSettingsLogic) UpdateMemberPlacementSettings(req * BraveSearchLang: req.BraveSearchLang, ExaUserLocation: req.ExaUserLocation, ExpandStrategy: req.ExpandStrategy, + DevModeEnabled: req.DevModeEnabled, }) if err != nil { return nil, err diff --git a/backend/internal/logic/persona/content_ops_mapper.go b/backend/internal/logic/persona/content_ops_mapper.go new file mode 100644 index 0000000..595c460 --- /dev/null +++ b/backend/internal/logic/persona/content_ops_mapper.go @@ -0,0 +1,30 @@ +package persona + +import ( + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/types" +) + +func toTopicCandidateData(item contentops.TopicCandidateSummary) types.TopicCandidateData { + return types.TopicCandidateData{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} + +func toContentPlanData(item contentops.ContentPlanSummary) types.ContentPlanData { + return types.ContentPlanData{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} + +func toFeedbackEventData(item contentops.FeedbackEventSummary) types.FeedbackEventData { + return types.FeedbackEventData{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt} +} + +func toKnowledgeSourceData(item contentops.KnowledgeSourceSummary) types.KnowledgeSourceData { + return types.KnowledgeSourceData{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt} +} + +func toKnowledgeChunkData(item contentops.KnowledgeChunkSummary) types.KnowledgeChunkData { + return types.KnowledgeChunkData{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt} +} + +func toFormulaPoolData(item contentops.FormulaPoolSummary) types.FormulaPoolData { + return types.FormulaPoolData{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} diff --git a/backend/internal/logic/persona/copy_draft_mapper.go b/backend/internal/logic/persona/copy_draft_mapper.go new file mode 100644 index 0000000..adf9610 --- /dev/null +++ b/backend/internal/logic/persona/copy_draft_mapper.go @@ -0,0 +1,40 @@ +package persona + +import ( + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + "haixun-backend/internal/types" +) + +func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData { + return types.CopyDraftData{ + ID: item.ID, + PersonaID: item.PersonaID, + ContentPlanID: item.ContentPlanID, + CopyMissionID: item.CopyMissionID, + ScanPostID: item.ScanPostID, + FormulaID: item.FormulaID, + DraftType: item.DraftType, + SortOrder: item.SortOrder, + Text: item.Text, + TopicTag: item.TopicTag, + Angle: item.Angle, + Hook: item.Hook, + Rationale: item.Rationale, + ReferenceNotes: item.ReferenceNotes, + Sources: item.Sources, + AiScore: item.AiScore, + FormulaScore: item.FormulaScore, + BrandFitScore: item.BrandFitScore, + RiskScore: item.RiskScore, + SimilarityScore: item.SimilarityScore, + EngagementPotential: item.EngagementPotential, + FreshnessScore: item.FreshnessScore, + ReviewSuggestion: item.ReviewSuggestion, + Status: item.Status, + PublishQueueID: item.PublishQueueID, + PublishedMediaID: item.PublishedMediaID, + PublishedPermalink: item.PublishedPermalink, + PublishedAt: item.PublishedAt, + CreateAt: item.CreateAt, + } +} diff --git a/backend/internal/logic/persona/create_content_plan_logic.go b/backend/internal/logic/persona/create_content_plan_logic.go new file mode 100644 index 0000000..b00ddde --- /dev/null +++ b/backend/internal/logic/persona/create_content_plan_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateContentPlanLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateContentPlanLogic { + return &CreateContentPlanLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateContentPlanLogic) CreateContentPlan(req *types.CreateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, TopicCandidateID: req.TopicCandidateID, Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge}) + if err != nil { + return nil, err + } + data := toContentPlanData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/create_feedback_event_logic.go b/backend/internal/logic/persona/create_feedback_event_logic.go new file mode 100644 index 0000000..aa1870e --- /dev/null +++ b/backend/internal/logic/persona/create_feedback_event_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateFeedbackEventLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateFeedbackEventLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFeedbackEventLogic { + return &CreateFeedbackEventLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateFeedbackEventLogic) CreateFeedbackEvent(req *types.CreateFeedbackEventHandlerReq) (resp *types.FeedbackEventData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateFeedbackEvent(l.ctx, contentops.FeedbackEventInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, ContentPlanID: req.ContentPlanID, DraftID: req.DraftID, Decision: req.Decision, Note: req.Note, Snapshot: req.Snapshot}) + if err != nil { + return nil, err + } + data := toFeedbackEventData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/create_formula_pool_logic.go b/backend/internal/logic/persona/create_formula_pool_logic.go new file mode 100644 index 0000000..b769f8c --- /dev/null +++ b/backend/internal/logic/persona/create_formula_pool_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateFormulaPoolLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateFormulaPoolLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFormulaPoolLogic { + return &CreateFormulaPoolLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateFormulaPoolLogic) CreateFormulaPool(req *types.CreateFormulaPoolHandlerReq) (resp *types.FormulaPoolData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateFormulaPool(l.ctx, contentops.FormulaPoolInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, Type: req.Type, Name: req.Name, Pattern: req.Pattern, UseCases: req.UseCases, Avoid: req.Avoid, Weight: req.Weight}) + if err != nil { + return nil, err + } + data := toFormulaPoolData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/create_knowledge_source_logic.go b/backend/internal/logic/persona/create_knowledge_source_logic.go new file mode 100644 index 0000000..4e83e74 --- /dev/null +++ b/backend/internal/logic/persona/create_knowledge_source_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateKnowledgeSourceLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateKnowledgeSourceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateKnowledgeSourceLogic { + return &CreateKnowledgeSourceLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateKnowledgeSourceLogic) CreateKnowledgeSource(req *types.CreateKnowledgeSourceHandlerReq) (resp *types.KnowledgeSourceData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateKnowledgeSource(l.ctx, contentops.KnowledgeSourceInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, SourceType: req.SourceType, Title: req.Title, Filename: req.Filename, Content: req.Content, ContentBase64: req.ContentBase64}) + if err != nil { + return nil, err + } + data := toKnowledgeSourceData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/delete_persona_copy_draft_logic.go b/backend/internal/logic/persona/delete_persona_copy_draft_logic.go index ee99435..f4298f7 100644 --- a/backend/internal/logic/persona/delete_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/delete_persona_copy_draft_logic.go @@ -34,4 +34,4 @@ func (l *DeletePersonaCopyDraftLogic) DeletePersonaCopyDraft(req *types.CopyDraf DraftID: draftID, Message: "草稿已刪除", }, nil -} \ No newline at end of file +} diff --git a/backend/internal/logic/persona/delete_style_preset_logic.go b/backend/internal/logic/persona/delete_style_preset_logic.go new file mode 100644 index 0000000..0327d5b --- /dev/null +++ b/backend/internal/logic/persona/delete_style_preset_logic.go @@ -0,0 +1,35 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteStylePresetLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteStylePresetLogic { + return &DeleteStylePresetLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteStylePresetLogic) DeleteStylePreset(req *types.StylePresetPath) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + return l.svcCtx.StylePreset.Delete(l.ctx, tenantID, uid, req.ID, req.PresetID) +} diff --git a/backend/internal/logic/persona/generate_from_content_formula_logic.go b/backend/internal/logic/persona/generate_from_content_formula_logic.go new file mode 100644 index 0000000..f0f3bb1 --- /dev/null +++ b/backend/internal/logic/persona/generate_from_content_formula_logic.go @@ -0,0 +1,143 @@ +package persona + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libformula "haixun-backend/internal/library/formula" + "haixun-backend/internal/library/placement" + "haixun-backend/internal/library/style8d" + "haixun-backend/internal/library/websearch" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GenerateFromContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateFromContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateFromContentFormulaLogic { + return &GenerateFromContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types.GenerateFromContentFormulaHandlerReq) (*types.GenerateFromContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + topic := strings.TrimSpace(req.Topic) + if topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic is required") + } + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if err != nil { + return nil, err + } + if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { + return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") + } + formula, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.AccountID, req.FormulaID) + if err != nil { + return nil, err + } + count := req.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + aiReq := domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + + researchNotes := "" + if req.UseWebSearch { + research, rerr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) + if rerr == nil && placement.WebSearchAvailable(research) { + memberCtx, merr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) + if merr == nil { + webClient := websearch.New(memberCtx.WebSearchConfig()) + if webClient.Enabled() { + resp, _ := webClient.Search(l.ctx, websearch.SearchOptions{ + Query: topic + " " + strings.TrimSpace(req.Brief), + Limit: 5, + Mode: websearch.ModeKnowledgeExpand, + }) + if len(resp.Results) > 0 { + var b strings.Builder + for _, snip := range resp.Results { + if snip.Title != "" { + b.WriteString("- ") + b.WriteString(snip.Title) + b.WriteString("\n") + } + if snip.Snippet != "" { + b.WriteString(snip.Snippet) + b.WriteString("\n") + } + } + researchNotes = strings.TrimSpace(b.String()) + } + } + } + } + } + + list := make([]types.CopyDraftData, 0, count) + for i := 0; i < count; i++ { + generated, genErr := libformula.GenerateDraft(l.ctx, l.svcCtx.AI, aiReq, libformula.GenerateInput{ + Topic: topic, + Brief: req.Brief, + PersonaBlock: personaBlock, + ResearchNotes: researchNotes, + Formula: *formula, + }) + if genErr != nil { + return nil, app.For(code.AI).SvcThirdParty("貼文生成失敗:" + genErr.Error()) + } + saved, saveErr := l.svcCtx.CopyDraft.Create(l.ctx, copydraftdomain.CreateRequest{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + FormulaID: formula.ID, + DraftType: copydraftentity.DraftTypeFormula, + Text: generated.Text, + TopicTag: generated.TopicTag, + Angle: generated.Angle, + Hook: generated.Hook, + Rationale: generated.Rationale, + ReferenceNotes: generated.StructureNotes, + Sources: []string{formula.Label}, + }) + if saveErr != nil { + return nil, saveErr + } + list = append(list, toCopyDraftData(*saved)) + } + return &types.GenerateFromContentFormulaData{ + List: list, + Message: fmt.Sprintf("已產生 %d 篇可編輯草稿,已加入排程區", len(list)), + }, nil +} diff --git a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go index fbfdf12..389d017 100644 --- a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go @@ -103,13 +103,15 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft( } } + replicateTemp := 0.82 result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, - System: libviral.BuildSystemPrompt(), + System: libviral.BuildSystemPrompt(), + Temperature: &replicateTemp, Messages: []domai.Message{ { Role: "user", @@ -121,6 +123,7 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft( OriginalText: post.Text, AuthorName: post.Author, StructureAnalysis: analysisText, + NarrativeSeed: scanPostID, }), }, }, @@ -153,22 +156,7 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft( } return &types.GeneratePersonaCopyDraftData{ - Draft: types.CopyDraftData{ - ID: saved.ID, - PersonaID: saved.PersonaID, - CopyMissionID: saved.CopyMissionID, - ScanPostID: saved.ScanPostID, - DraftType: saved.DraftType, - SortOrder: saved.SortOrder, - Text: saved.Text, - Angle: saved.Angle, - Hook: saved.Hook, - Rationale: saved.Rationale, - ReferenceNotes: saved.ReferenceNotes, - Sources: saved.Sources, - Status: saved.Status, - CreateAt: saved.CreateAt, - }, + Draft: toCopyDraftData(*saved), Message: "已產出仿寫草稿", }, nil } diff --git a/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go b/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go new file mode 100644 index 0000000..7557b97 --- /dev/null +++ b/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go @@ -0,0 +1,65 @@ +package persona + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libpersonacopy "haixun-backend/internal/library/personacopy" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GeneratePersonaTopicMatrixLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGeneratePersonaTopicMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePersonaTopicMatrixLogic { + return &GeneratePersonaTopicMatrixLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *GeneratePersonaTopicMatrixLogic) GeneratePersonaTopicMatrix(req *types.GeneratePersonaTopicMatrixHandlerReq) (*types.GeneratePersonaTopicMatrixData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + topic := strings.TrimSpace(req.Topic) + if topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic is required") + } + + created, err := libpersonacopy.RunTopicMatrix(l.ctx, libpersonacopy.TopicMatrixDeps{ + Persona: l.svcCtx.Persona, + CopyDraft: l.svcCtx.CopyDraft, + ContentOps: l.svcCtx.ContentOps, + ThreadsAccount: l.svcCtx.ThreadsAccount, + Placement: l.svcCtx.Placement, + AI: l.svcCtx.AI, + }, libpersonacopy.TopicMatrixInput{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + ContentPlanID: req.ContentPlanID, + Topic: topic, + Brief: req.Brief, + UseWebSearch: req.UseWebSearch, + DraftCount: req.DraftCount, + }, nil) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("矩陣草稿生成失敗:" + err.Error()) + } + + list := make([]types.CopyDraftData, 0, len(created)) + for _, item := range created { + list = append(list, toCopyDraftData(item)) + } + + return &types.GeneratePersonaTopicMatrixData{ + List: list, + Message: fmt.Sprintf("已產生 %d 篇矩陣草稿", len(list)), + }, nil +} diff --git a/backend/internal/logic/persona/list_content_plans_logic.go b/backend/internal/logic/persona/list_content_plans_logic.go new file mode 100644 index 0000000..9fbe8fd --- /dev/null +++ b/backend/internal/logic/persona/list_content_plans_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListContentPlansLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListContentPlansLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentPlansLogic { + return &ListContentPlansLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListContentPlansLogic) ListContentPlans(req *types.PersonaPath) (resp *types.ListContentPlansData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListContentPlans(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.ContentPlanData, 0, len(items)) + for _, item := range items { + list = append(list, toContentPlanData(item)) + } + return &types.ListContentPlansData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_formula_pools_logic.go b/backend/internal/logic/persona/list_formula_pools_logic.go new file mode 100644 index 0000000..6eabec9 --- /dev/null +++ b/backend/internal/logic/persona/list_formula_pools_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListFormulaPoolsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListFormulaPoolsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListFormulaPoolsLogic { + return &ListFormulaPoolsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListFormulaPoolsLogic) ListFormulaPools(req *types.PersonaPath) (resp *types.ListFormulaPoolsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListFormulaPools(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.FormulaPoolData, 0, len(items)) + for _, item := range items { + list = append(list, toFormulaPoolData(item)) + } + return &types.ListFormulaPoolsData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_knowledge_chunks_logic.go b/backend/internal/logic/persona/list_knowledge_chunks_logic.go new file mode 100644 index 0000000..110e052 --- /dev/null +++ b/backend/internal/logic/persona/list_knowledge_chunks_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListKnowledgeChunksLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListKnowledgeChunksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeChunksLogic { + return &ListKnowledgeChunksLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListKnowledgeChunksLogic) ListKnowledgeChunks(req *types.PersonaPath) (resp *types.ListKnowledgeChunksData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListKnowledgeChunks(l.ctx, tenantID, uid, req.ID, 80) + if err != nil { + return nil, err + } + list := make([]types.KnowledgeChunkData, 0, len(items)) + for _, item := range items { + list = append(list, toKnowledgeChunkData(item)) + } + return &types.ListKnowledgeChunksData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_knowledge_sources_logic.go b/backend/internal/logic/persona/list_knowledge_sources_logic.go new file mode 100644 index 0000000..c2ba664 --- /dev/null +++ b/backend/internal/logic/persona/list_knowledge_sources_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListKnowledgeSourcesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListKnowledgeSourcesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeSourcesLogic { + return &ListKnowledgeSourcesLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListKnowledgeSourcesLogic) ListKnowledgeSources(req *types.PersonaPath) (resp *types.ListKnowledgeSourcesData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListKnowledgeSources(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.KnowledgeSourceData, 0, len(items)) + for _, item := range items { + list = append(list, toKnowledgeSourceData(item)) + } + return &types.ListKnowledgeSourcesData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_persona_content_inbox_logic.go b/backend/internal/logic/persona/list_persona_content_inbox_logic.go new file mode 100644 index 0000000..ffe2e9c --- /dev/null +++ b/backend/internal/logic/persona/list_persona_content_inbox_logic.go @@ -0,0 +1,112 @@ +package persona + +import ( + "context" + "math" + + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ListPersonaContentInboxLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPersonaContentInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonaContentInboxLogic { + return &ListPersonaContentInboxLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPersonaContentInboxLogic) ListPersonaContentInbox(req *types.ListPersonaContentInboxHandlerReq) (*types.ListPersonaContentInboxData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := req.ID + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + page := int(req.Page) + if page <= 0 { + page = 1 + } + pageSize := int(req.PageSize) + if pageSize <= 0 { + pageSize = 12 + } + result, err := l.svcCtx.CopyDraft.ListInbox(l.ctx, copydraftdomain.InboxListRequest{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + Status: req.Status, + RangeStart: req.RangeStart, + RangeEnd: req.RangeEnd, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + + queueIDs := make([]string, 0) + for _, item := range result.Items { + if item.PublishQueueID != "" { + queueIDs = append(queueIDs, item.PublishQueueID) + } + } + queueByID := map[string]pqdomain.QueueItemSummary{} + if len(queueIDs) > 0 { + queueItems, qerr := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, queueIDs) + if qerr == nil { + for _, q := range queueItems { + queueByID[q.ID] = q + } + } + } + + list := make([]types.ContentInboxItemData, 0, len(result.Items)) + for _, item := range result.Items { + queueItem := queueByID[item.PublishQueueID] + scheduledAt := queueItem.ScheduledAt + lifecycle, groupDate := inboxLifecycle(item, scheduledAt) + draftData := toCopyDraftData(item) + if queueItem.TopicTag != "" { + draftData.TopicTag = queueItem.TopicTag + } + list = append(list, types.ContentInboxItemData{ + CopyDraftData: draftData, + ScheduledAt: scheduledAt, + Lifecycle: lifecycle, + GroupDate: groupDate, + }) + } + + totalPages := int64(0) + if pageSize > 0 { + totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize))) + } + return &types.ListPersonaContentInboxData{ + Pagination: types.PaginationData{ + Total: result.Total, + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: totalPages, + }, + List: list, + }, nil +} + +func inboxLifecycle(item copydraftdomain.CopyDraftSummary, scheduledAt int64) (lifecycle string, groupDate int64) { + if item.PublishedAt > 0 { + return "published", item.PublishedAt + } + if item.PublishQueueID != "" || scheduledAt > 0 { + if scheduledAt > 0 { + return "scheduled", scheduledAt + } + return "scheduled", item.CreateAt + } + return "draft", item.CreateAt +} diff --git a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go index de35a46..c3f5042 100644 --- a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go +++ b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go @@ -33,22 +33,7 @@ func (l *ListPersonaCopyDraftsLogic) ListPersonaCopyDrafts(req *types.PersonaPat } list := make([]types.CopyDraftData, 0, len(drafts)) for _, item := range drafts { - list = append(list, types.CopyDraftData{ - ID: item.ID, - PersonaID: item.PersonaID, - CopyMissionID: item.CopyMissionID, - ScanPostID: item.ScanPostID, - DraftType: item.DraftType, - SortOrder: item.SortOrder, - Text: item.Text, - Angle: item.Angle, - Hook: item.Hook, - Rationale: item.Rationale, - ReferenceNotes: item.ReferenceNotes, - Sources: item.Sources, - Status: item.Status, - CreateAt: item.CreateAt, - }) + list = append(list, toCopyDraftData(item)) } return &types.ListPersonaCopyDraftsData{List: list, Total: len(list)}, nil } diff --git a/backend/internal/logic/persona/list_style_presets_logic.go b/backend/internal/logic/persona/list_style_presets_logic.go new file mode 100644 index 0000000..c190192 --- /dev/null +++ b/backend/internal/logic/persona/list_style_presets_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListStylePresetsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListStylePresetsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListStylePresetsLogic { + return &ListStylePresetsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListStylePresetsLogic) ListStylePresets(req *types.PersonaPath) (resp *types.ListStylePresetsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.StylePreset.List(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + list := make([]types.StylePresetData, 0, len(items)) + for _, item := range items { + list = append(list, toStylePresetData(item)) + } + return &types.ListStylePresetsData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_topic_candidates_logic.go b/backend/internal/logic/persona/list_topic_candidates_logic.go new file mode 100644 index 0000000..b670d0f --- /dev/null +++ b/backend/internal/logic/persona/list_topic_candidates_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListTopicCandidatesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListTopicCandidatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListTopicCandidatesLogic { + return &ListTopicCandidatesLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListTopicCandidatesLogic) ListTopicCandidates(req *types.PersonaPath) (resp *types.ListTopicCandidatesData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListTopicCandidates(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.TopicCandidateData, 0, len(items)) + for _, item := range items { + list = append(list, toTopicCandidateData(item)) + } + return &types.ListTopicCandidatesData{List: list}, nil +} diff --git a/backend/internal/logic/persona/mapper.go b/backend/internal/logic/persona/mapper.go index 0f852e7..3d3c6dc 100644 --- a/backend/internal/logic/persona/mapper.go +++ b/backend/internal/logic/persona/mapper.go @@ -2,6 +2,7 @@ package persona import ( domusecase "haixun-backend/internal/model/persona/domain/usecase" + stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase" "haixun-backend/internal/types" ) @@ -55,3 +56,11 @@ func toPersonaPatch(req *types.UpdatePersonaReq) domusecase.PersonaPatch { StyleBenchmark: req.StyleBenchmark, } } + +func toStylePresetData(item stylepresetdomain.PresetSummary) types.StylePresetData { + return types.StylePresetData{ + ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone, + CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...), + Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, + } +} diff --git a/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go b/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go new file mode 100644 index 0000000..cfdc3c4 --- /dev/null +++ b/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go @@ -0,0 +1,46 @@ +package persona + +import ( + "context" + "fmt" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type PrunePersonaCopyDraftsLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPrunePersonaCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PrunePersonaCopyDraftsLogic { + return &PrunePersonaCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *PrunePersonaCopyDraftsLogic) PrunePersonaCopyDrafts(req *types.PrunePersonaCopyDraftsHandlerReq) (*types.PrunePersonaCopyDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := req.ID + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + keep := int(req.Keep) + if keep <= 0 { + keep = 40 + } + deleted, err := l.svcCtx.CopyDraft.PruneIdleDrafts(l.ctx, tenantID, uid, personaID, keep) + if err != nil { + return nil, err + } + message := fmt.Sprintf("已保留最近 %d 篇草稿", keep) + if deleted > 0 { + message = fmt.Sprintf("已刪除 %d 篇多餘舊草稿,保留最近 %d 篇", deleted, keep) + } + return &types.PrunePersonaCopyDraftsData{ + Deleted: deleted, + Kept: keep, + Message: message, + }, nil +} diff --git a/backend/internal/logic/persona/publish_persona_copy_draft_logic.go b/backend/internal/logic/persona/publish_persona_copy_draft_logic.go index 4536e00..78d08d6 100644 --- a/backend/internal/logic/persona/publish_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/publish_persona_copy_draft_logic.go @@ -3,12 +3,14 @@ package persona import ( "context" "strings" + "time" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libthreads "haixun-backend/internal/library/threadsapi" "haixun-backend/internal/library/threadspost" copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" ) @@ -57,19 +59,31 @@ func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft( if err := threadspost.ValidatePublish(text); err != nil { return nil, app.For(code.Persona).InputInvalidFormat(err.Error()) } + topicTag := firstNonEmpty(req.TopicTag, draft.TopicTag) + patch := copydraftdomain.CopyDraftPatch{} + needsUpdate := false if strings.TrimSpace(req.Text) != "" && req.Text != draft.Text { - updatedText, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{ + patch.Text = &req.Text + needsUpdate = true + } + if strings.TrimSpace(req.TopicTag) != "" && req.TopicTag != draft.TopicTag { + patch.TopicTag = &req.TopicTag + needsUpdate = true + } + if needsUpdate { + updated, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{ TenantID: tenantID, OwnerUID: uid, PersonaID: personaID, DraftID: draftID, - Patch: copydraftdomain.CopyDraftPatch{Text: &req.Text}, + Patch: patch, }) if err != nil { return nil, err } - draft = updatedText - text = strings.TrimSpace(updatedText.Text) + draft = updated + text = strings.TrimSpace(updated.Text) + topicTag = firstNonEmpty(updated.TopicTag, req.TopicTag) } cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsPublishCredential(l.ctx, tenantID, uid) @@ -80,11 +94,26 @@ func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft( ThreadsUserID: cred.ThreadsUserID, AccessToken: cred.AccessToken, Text: text, + TopicTag: topicTag, }) if err != nil { return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發布貼文失敗:" + err.Error()) } + now := time.Now().UnixNano() + if cred.AccountID != "" { + _, _ = l.svcCtx.PublishQueue.RecordPublished(l.ctx, publishqueuedomain.RecordPublishedRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: cred.AccountID, + Text: text, + TopicTag: topicTag, + MediaID: result.MediaID, + Permalink: result.Permalink, + PublishedAt: now, + }) + } + updated, err := l.svcCtx.CopyDraft.MarkPublished(l.ctx, copydraftdomain.MarkPublishedRequest{ TenantID: tenantID, OwnerUID: uid, diff --git a/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go new file mode 100644 index 0000000..55cdfca --- /dev/null +++ b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go @@ -0,0 +1,83 @@ +package persona + +import ( + "context" + "fmt" + + "haixun-backend/internal/library/clock" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type SchedulePersonaCopyDraftLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSchedulePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaCopyDraftLogic { + return &SchedulePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *SchedulePersonaCopyDraftLogic) SchedulePersonaCopyDraft(req *types.SchedulePersonaCopyDraftHandlerReq) (*types.ScheduleCopyDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil { + return nil, err + } + draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, req.DraftID) + if err != nil { + return nil, err + } + if draft.PublishQueueID != "" { + queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID}) + if err != nil { + return nil, err + } + if len(queueItems) > 0 { + return &types.ScheduleCopyDraftsData{ + Scheduled: 1, + List: []types.PublishQueueItemData{publishQueueData(&queueItems[0])}, + Message: fmt.Sprintf("草稿 %s 已排程", draft.ID), + }, nil + } + } + scheduledAt := req.ScheduledAt + if scheduledAt <= 0 { + scheduledAt = clock.NowUnixNano() + } + item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID, + PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID, + Text: draft.Text, TopicTag: firstNonEmpty(req.TopicTag, draft.TopicTag), ScheduledAt: scheduledAt, + }) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ + TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID, + }); err != nil { + _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID) + return nil, err + } + return &types.ScheduleCopyDraftsData{ + Scheduled: 1, + List: []types.PublishQueueItemData{publishQueueData(item)}, + Message: fmt.Sprintf("已排程草稿 %s", draft.ID), + }, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/logic/persona/schedule_persona_drafts_logic.go b/backend/internal/logic/persona/schedule_persona_drafts_logic.go new file mode 100644 index 0000000..fe22150 --- /dev/null +++ b/backend/internal/logic/persona/schedule_persona_drafts_logic.go @@ -0,0 +1,107 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + "fmt" + + "haixun-backend/internal/library/publishschedule" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SchedulePersonaDraftsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSchedulePersonaDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaDraftsLogic { + return &SchedulePersonaDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SchedulePersonaDraftsLogic) SchedulePersonaDrafts(req *types.SchedulePersonaDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil { + return nil, err + } + times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs)) + list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs)) + for idx, draftID := range req.DraftIDs { + draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, draftID) + if err != nil { + return nil, err + } + if draft.PublishQueueID != "" { + queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID}) + if err != nil { + return nil, err + } + if len(queueItems) > 0 { + list = append(list, publishQueueData(&queueItems[0])) + continue + } + } + scheduledAt := int64(0) + if idx < len(times) { + scheduledAt = times[idx] + } + item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID, + PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID, + Text: draft.Text, TopicTag: draft.TopicTag, ScheduledAt: scheduledAt, + }) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ + TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID, + }); err != nil { + _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID) + return nil, err + } + list = append(list, publishQueueData(item)) + } + return &types.ScheduleCopyDraftsData{ + Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)), + }, nil +} + +func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 { + converted := make([]publishschedule.Slot, 0, len(slots)) + for _, slot := range slots { + converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time}) + } + return publishschedule.BuildSchedule(startAt, timezone, converted, count) +} + +func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData { + if item == nil { + return types.PublishQueueItemData{} + } + return types.PublishQueueItemData{ + ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID, + Text: item.Text, TopicTag: item.TopicTag, ScheduledAt: item.ScheduledAt, Status: item.Status, + MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt, + ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt, + NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason, + CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, + } +} diff --git a/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go b/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go new file mode 100644 index 0000000..7faa3af --- /dev/null +++ b/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go @@ -0,0 +1,83 @@ +package persona + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type StartPersonaFormulaDraftJobLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaFormulaDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaFormulaDraftJobLogic { + return &StartPersonaFormulaDraftJobLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPersonaFormulaDraftJobLogic) StartPersonaFormulaDraftJob(req *types.StartPersonaFormulaDraftJobHandlerReq) (*types.StartPersonaFormulaDraftJobData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + accountID := strings.TrimSpace(req.AccountID) + formulaID := strings.TrimSpace(req.FormulaID) + topic := strings.TrimSpace(req.Topic) + if accountID == "" || formulaID == "" || topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("account_id, formula_id, and topic are required") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + + count := req.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-formula-draft", + Scope: "persona", + ScopeID: personaID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "persona_id": personaID, + "account_id": accountID, + "formula_id": formulaID, + "topic": topic, + "brief": strings.TrimSpace(req.Brief), + "use_web_search": req.UseWebSearch, + "draft_count": count, + "ai_provider": credential.Provider, + "ai_model": credential.Model, + }, + }) + if err != nil { + return nil, err + } + + return &types.StartPersonaFormulaDraftJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + AiProvider: credential.Provider, + AiModel: credential.Model, + Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model), + }, nil +} diff --git a/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go b/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go new file mode 100644 index 0000000..76dee67 --- /dev/null +++ b/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go @@ -0,0 +1,87 @@ +package persona + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type StartPersonaRewriteDraftJobLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaRewriteDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaRewriteDraftJobLogic { + return &StartPersonaRewriteDraftJobLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPersonaRewriteDraftJobLogic) StartPersonaRewriteDraftJob(req *types.StartPersonaRewriteDraftJobHandlerReq) (*types.StartPersonaRewriteDraftJobData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + accountID := strings.TrimSpace(req.AccountID) + referenceText := strings.TrimSpace(req.ReferenceText) + topic := strings.TrimSpace(req.Topic) + if accountID == "" || referenceText == "" || topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("account_id, reference_text, and topic are required") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, accountID); err != nil { + return nil, err + } + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + + count := req.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-rewrite-draft", + Scope: "persona", + ScopeID: personaID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "persona_id": personaID, + "account_id": accountID, + "reference_text": referenceText, + "topic": topic, + "brief": strings.TrimSpace(req.Brief), + "save_label": strings.TrimSpace(req.SaveLabel), + "use_web_search": req.UseWebSearch, + "draft_count": count, + "ai_provider": credential.Provider, + "ai_model": credential.Model, + }, + }) + if err != nil { + return nil, err + } + + return &types.StartPersonaRewriteDraftJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + AiProvider: credential.Provider, + AiModel: credential.Model, + Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model), + }, nil +} diff --git a/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go b/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go new file mode 100644 index 0000000..e806988 --- /dev/null +++ b/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go @@ -0,0 +1,154 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libprompt "haixun-backend/internal/library/prompt" + "haixun-backend/internal/library/style8d" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + personausecase "haixun-backend/internal/model/persona/domain/usecase" + tausecase "haixun-backend/internal/model/threads_account/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPersonaStyleAnalysisFromTextLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaStyleAnalysisFromTextLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaStyleAnalysisFromTextLogic { + return &StartPersonaStyleAnalysisFromTextLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *StartPersonaStyleAnalysisFromTextLogic) StartPersonaStyleAnalysisFromText( + req *types.StartPersonaStyleAnalysisFromTextHandlerReq, +) (resp *types.StartPersonaStyleAnalysisFromTextData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if req == nil { + return nil, app.For(code.Persona).InputMissingRequired("request is required") + } + + texts, err := style8d.NormalizeReferenceTexts(req.ReferenceTexts, req.RawText) + if err != nil { + return nil, app.For(code.Persona).InputInvalidFormat(err.Error()) + } + + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + activeAccountID := "" + if member, memberErr := l.svcCtx.Member.GetByUID(l.ctx, tenantID, uid); memberErr == nil { + activeAccountID = member.ActiveThreadsAccountID + } + + credential, err := l.resolveResearchCredential(tenantID, uid, activeAccountID) + if err != nil { + return nil, err + } + + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + posts := style8d.TextsToPosts(texts) + input := style8d.AnalyzeInput{ + Mode: style8d.AnalyzeModeManual, + SourceLabel: strings.TrimSpace(req.SourceLabel), + Brief: strings.TrimSpace(persona.Brief), + Posts: posts, + } + + systemPrompt, err := libprompt.Style8DSystem() + if err != nil { + return nil, app.For(code.AI).SysInternal("prompt config load failed") + } + maxTokens := 8192 + result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + System: systemPrompt, + Messages: []domai.Message{ + {Role: "user", Content: input.BuildUserPrompt()}, + }, + MaxTokens: &maxTokens, + }) + if err != nil { + if strings.Contains(err.Error(), "HTTP 401") { + err = app.For(code.AI).SvcThirdParty( + "8D AI 分析授權失敗:請到「設定 > 帳號 AI 設定」確認研究用 provider=" + + credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key", + ) + } + return nil, err + } + + parsed, err := style8d.ParseLLMOutput(result.Text) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error()) + } + profile := input.BuildStoredProfile(parsed) + profileJSON, err := profile.JSON() + if err != nil { + return nil, err + } + + benchmark := input.StyleBenchmark() + personaDraft := strings.TrimSpace(profile.PersonaDraft) + patch := personausecase.PersonaPatch{ + StyleProfile: &profileJSON, + StyleBenchmark: &benchmark, + } + if personaDraft != "" { + patch.Persona = &personaDraft + } + updated, err := l.svcCtx.Persona.Update(l.ctx, personausecase.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: req.ID, + Patch: patch, + }) + if err != nil { + return nil, err + } + + out := toPersonaData(*updated) + return &types.StartPersonaStyleAnalysisFromTextData{ + Persona: out, + PostCount: len(posts), + Message: "8D 分析完成,風格檔案已寫入人設", + }, nil +} + +func (l *StartPersonaStyleAnalysisFromTextLogic) resolveResearchCredential( + tenantID, uid, activeAccountID string, +) (*tausecase.WorkerAiCredential, error) { + accountID := strings.TrimSpace(activeAccountID) + if accountID != "" { + return l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential(l.ctx, tenantID, uid, accountID) + } + return l.svcCtx.ThreadsAccount.ResolveMemberResearchAiCredential(l.ctx, tenantID, uid) +} diff --git a/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go b/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go new file mode 100644 index 0000000..acf1f3b --- /dev/null +++ b/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go @@ -0,0 +1,80 @@ +package persona + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type StartPersonaTopicMatrixJobLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaTopicMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaTopicMatrixJobLogic { + return &StartPersonaTopicMatrixJobLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPersonaTopicMatrixJobLogic) StartPersonaTopicMatrixJob(req *types.StartPersonaTopicMatrixJobHandlerReq) (*types.StartPersonaTopicMatrixJobData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + topic := strings.TrimSpace(req.Topic) + if topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic is required") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + + count := req.DraftCount + if count <= 0 { + count = 5 + } + if count > 5 { + count = 5 + } + + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-topic-matrix", + Scope: "persona", + ScopeID: personaID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "persona_id": personaID, + "content_plan_id": strings.TrimSpace(req.ContentPlanID), + "topic": topic, + "brief": strings.TrimSpace(req.Brief), + "use_web_search": req.UseWebSearch, + "draft_count": count, + "ai_provider": credential.Provider, + "ai_model": credential.Model, + }, + }) + if err != nil { + return nil, err + } + + return &types.StartPersonaTopicMatrixJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + AiProvider: credential.Provider, + AiModel: credential.Model, + Message: fmt.Sprintf("話題矩陣已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model), + }, nil +} diff --git a/backend/internal/logic/persona/update_content_plan_logic.go b/backend/internal/logic/persona/update_content_plan_logic.go new file mode 100644 index 0000000..97d939a --- /dev/null +++ b/backend/internal/logic/persona/update_content_plan_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateContentPlanLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateContentPlanLogic { + return &UpdateContentPlanLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateContentPlanLogic) UpdateContentPlan(req *types.UpdateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.UpdateContentPlan(l.ctx, tenantID, uid, req.ID, req.ContentPlanID, contentops.ContentPlanPatch{Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: req.Status}) + if err != nil { + return nil, err + } + data := toContentPlanData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/update_persona_copy_draft_logic.go b/backend/internal/logic/persona/update_persona_copy_draft_logic.go index 07c78cb..5ce3bbb 100644 --- a/backend/internal/logic/persona/update_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/update_persona_copy_draft_logic.go @@ -45,33 +45,17 @@ func (l *UpdatePersonaCopyDraftLogic) UpdatePersonaCopyDraft( PersonaID: personaID, DraftID: draftID, Patch: copydraftdomain.CopyDraftPatch{ - Text: req.Text, - Hook: req.Hook, - Angle: req.Angle, - Status: req.Status, + Text: req.Text, + TopicTag: req.TopicTag, + Hook: req.Hook, + Angle: req.Angle, + Rationale: req.Rationale, + Status: req.Status, }, }) if err != nil { return nil, err } - data := types.CopyDraftData{ - ID: updated.ID, - PersonaID: updated.PersonaID, - CopyMissionID: updated.CopyMissionID, - ScanPostID: updated.ScanPostID, - DraftType: updated.DraftType, - SortOrder: updated.SortOrder, - Text: updated.Text, - Angle: updated.Angle, - Hook: updated.Hook, - Rationale: updated.Rationale, - ReferenceNotes: updated.ReferenceNotes, - Sources: updated.Sources, - Status: updated.Status, - PublishedMediaID: updated.PublishedMediaID, - PublishedPermalink: updated.PublishedPermalink, - PublishedAt: updated.PublishedAt, - CreateAt: updated.CreateAt, - } + data := toCopyDraftData(*updated) return &data, nil } diff --git a/backend/internal/logic/persona/upsert_style_preset_logic.go b/backend/internal/logic/persona/upsert_style_preset_logic.go new file mode 100644 index 0000000..33ca67a --- /dev/null +++ b/backend/internal/logic/persona/upsert_style_preset_logic.go @@ -0,0 +1,44 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpsertStylePresetLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpsertStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertStylePresetLogic { + return &UpsertStylePresetLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpsertStylePresetLogic) UpsertStylePreset(req *types.UpsertStylePresetHandlerReq) (resp *types.StylePresetData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.StylePreset.Upsert(l.ctx, stylepresetdomain.UpsertRequest{ + TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, PresetID: req.PresetID, + Name: req.Name, Tone: req.Tone, CTA: req.CTA, BannedWords: req.BannedWords, Notes: req.Notes, Apply: req.Apply, + }) + if err != nil { + return nil, err + } + data := toStylePresetData(*item) + return &data, nil +} diff --git a/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go b/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go index 61fe85e..a00aac5 100644 --- a/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go +++ b/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go @@ -2,7 +2,10 @@ package placement_topic import ( "context" + "strings" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" brandlogic "haixun-backend/internal/logic/brand" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,6 +32,13 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea if err != nil { return nil, err } + if strings.TrimSpace(req.VoicePersonaID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required") + } + productID := strings.TrimSpace(req.ProductID) + if productID == "" { + productID = strings.TrimSpace(scope.Topic.ProductID) + } return brandlogic.NewGenerateOutreachDraftsLogic(l.ctx, l.svcCtx).GenerateOutreachDrafts(&types.GenerateOutreachDraftsHandlerReq{ BrandPath: types.BrandPath{ID: scope.BrandID}, GenerateOutreachDraftsReq: types.GenerateOutreachDraftsReq{ @@ -36,7 +46,7 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea TopicID: scope.TopicID, Count: req.Count, VoicePersonaID: req.VoicePersonaID, - ProductID: scope.Topic.ProductID, + ProductID: productID, }, }) } diff --git a/backend/internal/logic/placement_topic/mapper.go b/backend/internal/logic/placement_topic/mapper.go index a25ae52..ab7f04b 100644 --- a/backend/internal/logic/placement_topic/mapper.go +++ b/backend/internal/logic/placement_topic/mapper.go @@ -163,6 +163,7 @@ func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData { GraphNodeID: post.GraphNodeID, SearchTag: post.SearchTag, QueryDimension: post.QueryDimension, + RecencyDays: post.RecencyDays, ExternalID: post.ExternalID, Permalink: post.Permalink, Author: post.Author, diff --git a/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go b/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go index 3c5c85a..143f18e 100644 --- a/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go +++ b/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go @@ -31,7 +31,8 @@ func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req * if len(req.Updates) == 0 { return nil, app.For(code.Brand).InputMissingRequired("updates is required") } - if _, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID); err != nil { + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { return nil, err } updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates)) @@ -63,6 +64,7 @@ func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req * graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{ TenantID: tenantID, OwnerUID: uid, + BrandID: scope.BrandID, TopicID: req.ID, Updates: updates, }) diff --git a/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go b/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go new file mode 100644 index 0000000..f190908 --- /dev/null +++ b/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go @@ -0,0 +1,127 @@ +package placement_topic + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPlacementTopicOutreachDraftJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPlacementTopicOutreachDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPlacementTopicOutreachDraftJobLogic { + return &StartPlacementTopicOutreachDraftJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPlacementTopicOutreachDraftJobLogic) StartPlacementTopicOutreachDraftJob( + req *types.StartPlacementTopicOutreachDraftJobHandlerReq, +) (*types.StartPlacementTopicOutreachDraftJobsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + scanPostIDs := append([]string{}, req.ScanPostIDs...) + if id := strings.TrimSpace(req.ScanPostID); id != "" { + scanPostIDs = append(scanPostIDs, id) + } + seen := map[string]struct{}{} + unique := make([]string, 0, len(scanPostIDs)) + for _, id := range scanPostIDs { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + if len(unique) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("scan_post_id or scan_post_ids is required") + } + if strings.TrimSpace(req.VoicePersonaID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.VoicePersonaID); err != nil { + return nil, err + } + + count := req.Count + if count <= 0 { + count = 3 + } + if count > 4 { + count = 4 + } + + productID := strings.TrimSpace(req.ProductID) + if productID == "" { + productID = strings.TrimSpace(scope.Topic.ProductID) + } + + jobs := make([]types.StartBrandScanJobData, 0, len(unique)) + for _, scanPostID := range unique { + if _, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, scope.BrandID, scanPostID); err != nil { + return nil, err + } + regenerate := false + if existing, existingErr := l.svcCtx.OutreachDraft.GetLatestByScanPost( + l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, scanPostID, + ); existingErr == nil && existing != nil && len(existing.Drafts) > 0 { + regenerate = true + } + payload := map[string]any{ + "brand_id": scope.BrandID, + "topic_id": scope.TopicID, + "scan_post_id": scanPostID, + "count": count, + "voice_persona_id": strings.TrimSpace(req.VoicePersonaID), + "regenerate": regenerate, + } + if productID != "" { + payload["product_id"] = productID + } + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-outreach-draft", + Scope: "placement_topic", + ScopeID: scope.TopicID, + TenantID: tenantID, + OwnerUID: uid, + Payload: payload, + }) + if err != nil { + return nil, err + } + jobs = append(jobs, types.StartBrandScanJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + Message: "獲客草稿背景任務已建立", + }) + } + + message := "獲客草稿已在背景執行,可繼續瀏覽其他頁面" + if len(jobs) > 1 { + message = fmt.Sprintf("已送出 %d 個獲客草稿背景任務,可繼續瀏覽其他頁面", len(jobs)) + } + return &types.StartPlacementTopicOutreachDraftJobsData{ + Jobs: jobs, + Message: message, + }, nil +} diff --git a/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go b/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go index f0688e3..7a27e83 100644 --- a/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go +++ b/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go @@ -68,6 +68,7 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. } dualTrack := true patrolMode := req.PatrolMode + testPatrol := req.TestPatrol brandForPatrol := scope.Brand brandForPatrol.ProductID = scope.Topic.ProductID brandForPatrol.ResearchMap = scope.Topic.ResearchMap @@ -80,15 +81,22 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. if graph != nil { patrolNodes = graph.Nodes } + if len(nodeIDs) == 0 && selected > 0 && graph != nil { + for _, node := range graph.Nodes { + if node.SelectedForScan { + nodeIDs = append(nodeIDs, node.ID) + } + } + } patrolKeywords := libkg.ResolveScanPatrolKeywords( req.PatrolKeywords, scope.Topic.ResearchMap.PatrolKeywords, patrolInput, patrolNodes, ) - if patrolMode || (len(nodeIDs) == 0 && selected == 0) { + if patrolMode || selected > 0 || len(nodeIDs) > 0 { if len(patrolKeywords) == 0 { - return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字") + return nil, app.For(code.Brand).InputMissingRequired("請先勾選痛點節點或產生研究地圖,系統會綜合知識圖譜與產品自動整理海巡關鍵字") } patrolMode = true } else if graph == nil { @@ -98,21 +106,33 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. if err != nil { return nil, err } + placementSettings, err := l.svcCtx.Placement.Get(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) if err != nil { return nil, err } - if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { - return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session") - } - if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) { - return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research)) - } - if memberCtx.DevMode && !memberCtx.BrowserConnected { - return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session") - } - if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { - return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線") + if testPatrol { + if placementSettings == nil || !placementSettings.DevModeEnabled { + return nil, app.For(code.Setting).InputMissingRequired("請先在設定頁開啟「開發模式(測試海巡)」") + } + if !memberCtx.BrowserConnected { + return nil, app.For(code.Setting).InputMissingRequired("測試海巡需先同步 Chrome Extension Session") + } + memberCtx = placement.MemberContextForCrawlerOnly(memberCtx) + patrolMode = true + } else { + if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { + return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session") + } + if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) { + return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research)) + } + if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡") + } } payload := map[string]any{ "topic_id": scope.TopicID, @@ -125,6 +145,9 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. payload["patrol_mode"] = true payload["patrol_keywords"] = patrolKeywords } + if testPatrol { + payload["test_patrol"] = true + } for key, value := range memberCtx.PayloadFields() { payload[key] = value } diff --git a/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go b/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go new file mode 100644 index 0000000..4a17552 --- /dev/null +++ b/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go @@ -0,0 +1,42 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdatePlacementTopicOutreachDraftLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdatePlacementTopicOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePlacementTopicOutreachDraftLogic { + return &UpdatePlacementTopicOutreachDraftLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdatePlacementTopicOutreachDraftLogic) UpdatePlacementTopicOutreachDraft( + req *types.UpdatePlacementTopicOutreachDraftHandlerReq, +) (*types.GenerateOutreachDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return brandlogic.NewUpdateOutreachDraftLogic(l.ctx, l.svcCtx).UpdateOutreachDraft(&types.UpdateOutreachDraftHandlerReq{ + BrandPath: types.BrandPath{ID: scope.BrandID}, + DraftID: req.DraftID, + UpdateOutreachDraftReq: types.UpdateOutreachDraftReq{ + DraftIndex: req.DraftIndex, + Text: req.Text, + }, + }) +} diff --git a/backend/internal/logic/scan_post/lookalike_logic.go b/backend/internal/logic/scan_post/lookalike_logic.go deleted file mode 100644 index 510b778..0000000 --- a/backend/internal/logic/scan_post/lookalike_logic.go +++ /dev/null @@ -1,126 +0,0 @@ -package scan_post - -import ( - "context" - "math" - "sort" - - "haixun-backend/internal/library/authctx" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type LookalikeLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewLookalikeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LookalikeLogic { - return &LookalikeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *LookalikeLogic) Lookalike(req *types.LookalikeReq) (*types.LookalikeData, error) { - actor, ok := authctx.ActorFromContext(l.ctx) - if !ok { - return nil, app.For(code.Auth).AuthUnauthorized("missing actor") - } - - source, err := l.svcCtx.ScanPost.Get(l.ctx, actor.TenantID, actor.UID, req.BrandID, req.SourcePostID) - if err != nil { - return nil, err - } - if len(source.Embedding) == 0 { - return &types.LookalikeData{List: nil}, nil - } - - brandID := req.BrandID - if brandID == "" { - brandID = source.BrandID - } - - limit := req.Limit - if limit < 1 { - limit = 10 - } - if limit > 50 { - limit = 50 - } - - candidates, err := l.svcCtx.ScanPost.List(l.ctx, scanpostusecase.ListRequest{ - TenantID: actor.TenantID, - OwnerUID: actor.UID, - BrandID: brandID, - Limit: 200, - }) - if err != nil { - return nil, err - } - - type scored struct { - post scanpostusecase.ScanPostSummary - sim float64 - } - var scoredList []scored - for _, c := range candidates { - if c.ID == req.SourcePostID { - continue - } - if len(c.Embedding) == 0 { - continue - } - sim := cosineSimilarity(source.Embedding, c.Embedding) - if sim < 0.3 { - continue - } - scoredList = append(scoredList, scored{post: c, sim: sim}) - } - - sort.Slice(scoredList, func(i, j int) bool { - return scoredList[i].sim > scoredList[j].sim - }) - - if len(scoredList) > limit { - scoredList = scoredList[:limit] - } - - out := make([]types.LookalikeItemData, 0, len(scoredList)) - for _, s := range scoredList { - out = append(out, types.LookalikeItemData{ - PostID: s.post.ID, - AuthorID: s.post.AuthorID, - AuthorName: s.post.Author, - AuthorAvatar: s.post.AuthorAvatar, - Permalink: s.post.Permalink, - Text: s.post.Text, - PlacementScore: s.post.PlacementScore, - SemanticScore: s.post.SemanticScore, - EngagementPredicted: s.post.EngagementPredicted, - AudienceQuality: s.post.AudienceQualityScore, - Similarity: math.Round(s.sim*1000) / 1000, - }) - } - - return &types.LookalikeData{List: out}, nil -} - -func cosineSimilarity(a, b []float32) float64 { - if len(a) == 0 || len(b) == 0 || len(a) != len(b) { - return 0 - } - var dot, na, nb float64 - for i := range a { - dot += float64(a[i]) * float64(b[i]) - na += float64(a[i]) * float64(a[i]) - nb += float64(b[i]) * float64(b[i]) - } - if na == 0 || nb == 0 { - return 0 - } - return dot / (math.Sqrt(na) * math.Sqrt(nb)) -} diff --git a/backend/internal/logic/threads_account/analyze_content_formula_logic.go b/backend/internal/logic/threads_account/analyze_content_formula_logic.go new file mode 100644 index 0000000..12075ec --- /dev/null +++ b/backend/internal/logic/threads_account/analyze_content_formula_logic.go @@ -0,0 +1,227 @@ +package threads_account + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libformula "haixun-backend/internal/library/formula" + liboutreach "haixun-backend/internal/library/outreach" + libownpost "haixun-backend/internal/library/ownpost" + libviral "haixun-backend/internal/library/viral" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type AnalyzeContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAnalyzeContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnalyzeContentFormulaLogic { + return &AnalyzeContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *AnalyzeContentFormulaLogic) AnalyzeContentFormula(req *types.AnalyzeContentFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + sourceType := strings.TrimSpace(req.SourceType) + if sourceType == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("source_type is required") + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + aiReq := domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + } + personaBlock := l.resolvePersonaBlock(tenantID, uid, req.PersonaID) + + var analysis *libformula.PastedAnalysis + var sourceRef, postText, author, permalink string + var metrics *cfentity.SourceMetrics + + switch sourceType { + case cfentity.SourcePaste: + postText = strings.TrimSpace(req.PostText) + if postText == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required for paste") + } + author = strings.TrimSpace(req.AuthorName) + analysis, err = libformula.AnalyzePasted(l.ctx, l.svcCtx.AI, aiReq, libformula.AnalyzePastedInput{ + PostText: postText, AuthorName: author, PersonaBlock: personaBlock, + LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, + }) + sourceRef = "paste" + case cfentity.SourceOwnPost: + mediaID := strings.TrimSpace(req.MediaID) + if mediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required for own_post") + } + review, rerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, mediaID) + if rerr != nil || review == nil || req.ForceRefresh { + gen := NewGenerateOwnPostFormulaLogic(l.ctx, l.svcCtx) + force := req.ForceRefresh + genData, genErr := gen.GenerateOwnPostFormula(&types.GenerateOwnPostFormulaHandlerReq{ + ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID}, + GenerateOwnPostFormulaReq: types.GenerateOwnPostFormulaReq{ + MediaID: mediaID, PersonaID: req.PersonaID, PostText: req.PostText, + LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, Views: req.Views, + Force: force, + }, + }) + if genErr != nil { + return nil, genErr + } + analysis = &libformula.PastedAnalysis{ + Summary: genData.Summary, Wins: genData.Wins, Improvements: genData.Improvements, + Formula: genData.Formula, PostTemplate: genData.PostTemplate, + HookPattern: genData.HookPattern, Structure: genData.Structure, + ReplicationTips: genData.ReplicationTips, Avoid: genData.Avoid, + } + } else { + analysis = libformula.FromOwnPostReview(&libownpost.PostFormulaReview{ + Summary: review.Summary, Wins: review.Wins, Improvements: review.Improvements, + Formula: review.Formula, PostTemplate: review.PostTemplate, + HookPattern: review.HookPattern, Structure: review.Structure, + ReplicationTips: review.ReplicationTips, Avoid: review.Avoid, + }) + } + postText = strings.TrimSpace(req.PostText) + if postText == "" && review != nil { + postText = review.PostText + } + sourceRef = mediaID + case cfentity.SourceScanPost: + scanPostID := strings.TrimSpace(req.ScanPostID) + personaID := strings.TrimSpace(req.PersonaID) + if scanPostID == "" || personaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("scan_post_id and persona_id are required for scan_post") + } + post, perr := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID) + if perr != nil { + return nil, perr + } + postText = post.Text + author = post.Author + permalink = post.Permalink + analysis, err = l.analyzeViralPost(aiReq, post.Text, post.Author, post.LikeCount, post.ReplyCount, post.SearchTag, personaBlock) + sourceRef = scanPostID + metrics = &cfentity.SourceMetrics{LikeCount: post.LikeCount, ReplyCount: post.ReplyCount} + case cfentity.SourceKeywordSearch: + postText = strings.TrimSpace(req.PostText) + if postText == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required after selecting a search result") + } + author = strings.TrimSpace(req.AuthorName) + permalink = strings.TrimSpace(req.Permalink) + analysis, err = l.analyzeViralPost(aiReq, postText, author, req.LikeCount, req.ReplyCount, req.Keyword, personaBlock) + sourceRef = strings.TrimSpace(req.Keyword) + metrics = &cfentity.SourceMetrics{ + LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, + Views: req.Views, RepostCount: req.RepostCount, + QuoteCount: req.QuoteCount, Shares: req.Shares, + } + default: + return nil, app.For(code.ThreadsAccount).InputMissingRequired("unsupported source_type") + } + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("分析失敗:" + err.Error()) + } + if analysis == nil { + return nil, app.For(code.AI).SvcThirdParty("分析未產出有效公式") + } + + label := strings.TrimSpace(req.SaveLabel) + if label == "" { + label = defaultFormulaLabel(sourceType, postText, author) + } + saved, err := l.svcCtx.ContentFormula.Create(l.ctx, cfdom.CreateRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, + Label: label, SourceType: sourceType, SourceRef: sourceRef, + SourcePostText: postText, SourceAuthor: author, SourcePermalink: permalink, + SourceMetrics: metrics, + Summary: analysis.Summary, Wins: analysis.Wins, Improvements: analysis.Improvements, + Formula: analysis.Formula, PostTemplate: analysis.PostTemplate, + HookPattern: analysis.HookPattern, Structure: analysis.Structure, + ReplicationTips: analysis.ReplicationTips, Avoid: analysis.Avoid, + }) + if err != nil { + return nil, err + } + data := toContentFormulaData(*saved) + return &types.AnalyzeContentFormulaData{ + Formula: data, + Message: fmt.Sprintf("已分析並存入公式庫:%s", data.Label), + }, nil +} + +func (l *AnalyzeContentFormulaLogic) resolvePersonaBlock(tenantID, uid, personaID string) string { + personaID = strings.TrimSpace(personaID) + if personaID == "" { + return "" + } + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if err != nil { + return "" + } + return liboutreach.FormatVoicePersonaBlock(persona) +} + +func (l *AnalyzeContentFormulaLogic) analyzeViralPost( + aiReq domai.GenerateRequest, + postText, author string, likes, replies int, tag, personaBlock string, +) (*libformula.PastedAnalysis, error) { + out, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ + Provider: aiReq.Provider, Model: aiReq.Model, Credential: aiReq.Credential, + System: libviral.BuildAnalyzeViralSystemPrompt(), + Messages: []domai.Message{{Role: "user", Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{ + PostText: postText, AuthorName: author, LikeCount: likes, ReplyCount: replies, + SearchTag: tag, TopicLabel: tag, Persona: personaBlock, + })}}, + }) + if err != nil { + return nil, err + } + parsed, err := libviral.ParseAnalyzeViralOutput(out.Text) + if err != nil { + return nil, err + } + return libformula.FromViralAnalysis( + parsed.HookPattern, parsed.StructurePattern, parsed.EmotionalTrigger, + parsed.ReplicationStrategy, parsed.KeyTakeaways, + ), nil +} + +func defaultFormulaLabel(sourceType, postText, author string) string { + preview := strings.TrimSpace(postText) + if len([]rune(preview)) > 24 { + preview = string([]rune(preview)[:24]) + "…" + } + if author != "" { + return fmt.Sprintf("%s · @%s", sourceType, author) + } + if preview != "" { + return preview + } + return sourceType + " 公式" +} diff --git a/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go b/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go new file mode 100644 index 0000000..5059d0d --- /dev/null +++ b/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type CancelPublishQueueItemLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCancelPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelPublishQueueItemLogic { + return &CancelPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *CancelPublishQueueItemLogic) CancelPublishQueueItem(req *types.PublishQueueItemPath) (*types.PublishQueueItemData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.Cancel(l.ctx, tenantID, uid, req.ID, req.QID) + if err != nil { + return nil, err + } + return toPublishQueueItemData(result), nil +} diff --git a/backend/internal/logic/threads_account/content_formula_mapper.go b/backend/internal/logic/threads_account/content_formula_mapper.go new file mode 100644 index 0000000..2103b2e --- /dev/null +++ b/backend/internal/logic/threads_account/content_formula_mapper.go @@ -0,0 +1,58 @@ +package threads_account + +import ( + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/types" +) + +func toContentFormulaData(item cfdom.FormulaSummary) types.ContentFormulaData { + var metrics *types.ContentFormulaSourceMetricsData + if item.SourceMetrics != nil { + metrics = &types.ContentFormulaSourceMetricsData{ + LikeCount: item.SourceMetrics.LikeCount, + ReplyCount: item.SourceMetrics.ReplyCount, + Views: item.SourceMetrics.Views, + RepostCount: item.SourceMetrics.RepostCount, + QuoteCount: item.SourceMetrics.QuoteCount, + Shares: item.SourceMetrics.Shares, + } + } + return types.ContentFormulaData{ + ID: item.ID, + AccountID: item.AccountID, + Label: item.Label, + SourceType: item.SourceType, + SourceRef: item.SourceRef, + SourcePostText: item.SourcePostText, + SourceAuthor: item.SourceAuthor, + SourcePermalink: item.SourcePermalink, + SourceMetrics: metrics, + Summary: item.Summary, + Wins: item.Wins, + Improvements: item.Improvements, + Formula: item.Formula, + PostTemplate: item.PostTemplate, + HookPattern: item.HookPattern, + Structure: item.Structure, + ReplicationTips: item.ReplicationTips, + Avoid: item.Avoid, + Tags: item.Tags, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toSourceMetrics(data *types.ContentFormulaSourceMetricsData) *cfentity.SourceMetrics { + if data == nil { + return nil + } + return &cfentity.SourceMetrics{ + LikeCount: data.LikeCount, + ReplyCount: data.ReplyCount, + Views: data.Views, + RepostCount: data.RepostCount, + QuoteCount: data.QuoteCount, + Shares: data.Shares, + } +} diff --git a/backend/internal/logic/threads_account/create_publish_queue_item_logic.go b/backend/internal/logic/threads_account/create_publish_queue_item_logic.go new file mode 100644 index 0000000..96fdfef --- /dev/null +++ b/backend/internal/logic/threads_account/create_publish_queue_item_logic.go @@ -0,0 +1,37 @@ +package threads_account + +import ( + "context" + + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type CreatePublishQueueItemLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreatePublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreatePublishQueueItemLogic { + return &CreatePublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreatePublishQueueItemLogic) CreatePublishQueueItem(req *types.CreatePublishQueueHandlerReq) (*types.PublishQueueItemData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + Text: req.Text, + TopicTag: req.TopicTag, + ScheduledAt: req.ScheduledAt, + }) + if err != nil { + return nil, err + } + return toPublishQueueItemData(result), nil +} diff --git a/backend/internal/logic/threads_account/delete_content_formula_logic.go b/backend/internal/logic/threads_account/delete_content_formula_logic.go new file mode 100644 index 0000000..b6b4af5 --- /dev/null +++ b/backend/internal/logic/threads_account/delete_content_formula_logic.go @@ -0,0 +1,32 @@ +package threads_account + +import ( + "context" + "fmt" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DeleteContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteContentFormulaLogic { + return &DeleteContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteContentFormulaLogic) DeleteContentFormula(req *types.GetContentFormulaHandlerReq) (*types.DeleteContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if err := l.svcCtx.ContentFormula.Delete(l.ctx, tenantID, uid, req.ID, req.FormulaID); err != nil { + return nil, err + } + return &types.DeleteContentFormulaData{ + FormulaID: req.FormulaID, + Message: fmt.Sprintf("已刪除公式 %s", req.FormulaID), + }, nil +} diff --git a/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go b/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go new file mode 100644 index 0000000..e9d9529 --- /dev/null +++ b/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go @@ -0,0 +1,25 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DeletePublishQueueItemLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeletePublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePublishQueueItemLogic { + return &DeletePublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeletePublishQueueItemLogic) DeletePublishQueueItem(req *types.PublishQueueItemPath) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + return l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.ID, req.QID) +} diff --git a/backend/internal/logic/threads_account/delete_threads_account_logic.go b/backend/internal/logic/threads_account/delete_threads_account_logic.go new file mode 100644 index 0000000..296aec4 --- /dev/null +++ b/backend/internal/logic/threads_account/delete_threads_account_logic.go @@ -0,0 +1,36 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DeleteThreadsAccountLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteThreadsAccountLogic { + return &DeleteThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteThreadsAccountLogic) DeleteThreadsAccount(req *types.ThreadsAccountPath) (*types.DeleteThreadsAccountData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if err := l.svcCtx.ThreadsAccount.Delete(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + return &types.DeleteThreadsAccountData{ + DeletedID: req.ID, + ActiveAccountID: accounts.ActiveAccountID, + Message: "帳號已刪除", + }, nil +} diff --git a/backend/internal/logic/threads_account/disconnect_threads_api_logic.go b/backend/internal/logic/threads_account/disconnect_threads_api_logic.go new file mode 100644 index 0000000..44c486a --- /dev/null +++ b/backend/internal/logic/threads_account/disconnect_threads_api_logic.go @@ -0,0 +1,30 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DisconnectThreadsApiLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDisconnectThreadsApiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisconnectThreadsApiLogic { + return &DisconnectThreadsApiLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *DisconnectThreadsApiLogic) DisconnectThreadsApi(req *types.ThreadsAccountPath) (*types.ThreadsAccountData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ThreadsAccount.DisconnectThreadsAPI(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + out := toThreadsAccountData(*item) + return &out, nil +} diff --git a/backend/internal/logic/threads_account/generate_own_post_formula_logic.go b/backend/internal/logic/threads_account/generate_own_post_formula_logic.go new file mode 100644 index 0000000..fb60458 --- /dev/null +++ b/backend/internal/logic/threads_account/generate_own_post_formula_logic.go @@ -0,0 +1,124 @@ +package threads_account + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + libownpost "haixun-backend/internal/library/ownpost" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GenerateOwnPostFormulaLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateOwnPostFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostFormulaLogic { + return &GenerateOwnPostFormulaLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GenerateOwnPostFormulaLogic) GenerateOwnPostFormula( + req *types.GenerateOwnPostFormulaHandlerReq, +) (*types.GenerateOwnPostFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if req.MediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required") + } + + account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + if !req.Force { + if cached, cerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, req.MediaID); cerr == nil && cached != nil { + data := toOwnPostFormulaReviewData(cached) + data.FromCache = true + return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil + } + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + personaBlock := "" + if personaID := strings.TrimSpace(req.PersonaID); personaID != "" { + persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if perr != nil { + return nil, perr + } + personaBlock = liboutreach.FormatVoicePersonaBlock(persona) + } else if personaID = strings.TrimSpace(account.PersonaID); personaID != "" { + persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if perr == nil { + personaBlock = liboutreach.FormatVoicePersonaBlock(persona) + } + } + + accountName := account.DisplayName + if accountName == "" { + accountName = account.Username + } + + review, err := libownpost.GeneratePostFormula(l.ctx, l.svcCtx.AI, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + }, libownpost.PostFormulaInput{ + AccountName: accountName, + PersonaBlock: personaBlock, + PostText: req.PostText, + TopicTag: req.TopicTag, + MediaType: req.MediaType, + LikeCount: req.LikeCount, + ReplyCount: req.ReplyCount, + Views: req.Views, + RepostCount: req.RepostCount, + QuoteCount: req.QuoteCount, + Shares: req.Shares, + Timestamp: req.Timestamp, + }) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("AI 貼文覆盤失敗:" + err.Error()) + } + saved, err := l.svcCtx.OwnPostFormula.SaveGenerated( + l.ctx, + tenantID, + uid, + req.ID, + req.MediaID, + strings.TrimSpace(req.PersonaID), + req.PostText, + review, + ) + if err != nil { + return nil, err + } + data := toOwnPostFormulaReviewData(saved) + data.FromCache = false + return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil +} diff --git a/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go b/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go new file mode 100644 index 0000000..fa36120 --- /dev/null +++ b/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go @@ -0,0 +1,98 @@ +package threads_account + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + libownpost "haixun-backend/internal/library/ownpost" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GenerateOwnPostReplyDraftLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateOwnPostReplyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostReplyDraftLogic { + return &GenerateOwnPostReplyDraftLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GenerateOwnPostReplyDraftLogic) GenerateOwnPostReplyDraft( + req *types.GenerateOwnPostReplyDraftHandlerReq, +) (*types.GenerateOwnPostReplyDraftData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if req.MediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required") + } + + account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + personaID := strings.TrimSpace(req.PersonaID) + if personaID == "" { + personaID = strings.TrimSpace(account.PersonaID) + } + if personaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先選擇人設(persona_id)") + } + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if err != nil { + return nil, err + } + personaBlock := liboutreach.FormatVoicePersonaBlock(persona) + if strings.TrimSpace(personaBlock) == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("所選人設尚未填寫語氣或 8D 風格,請先到人設頁補齊") + } + + accountName := account.DisplayName + if accountName == "" { + accountName = account.Username + } + + text, err := libownpost.GenerateReplyDraft(l.ctx, l.svcCtx.AI, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + }, libownpost.ReplyDraftInput{ + AccountName: accountName, + PersonaBlock: personaBlock, + PostText: req.PostText, + ReplyText: req.ReplyText, + ReplyToID: req.ReplyToID, + ThreadPostText: req.ThreadPostText, + ParentReplyText: req.ParentReplyText, + }) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("AI 回覆草稿生成失敗:" + err.Error()) + } + return &types.GenerateOwnPostReplyDraftData{Text: text}, nil +} diff --git a/backend/internal/logic/threads_account/get_content_formula_logic.go b/backend/internal/logic/threads_account/get_content_formula_logic.go new file mode 100644 index 0000000..31adef3 --- /dev/null +++ b/backend/internal/logic/threads_account/get_content_formula_logic.go @@ -0,0 +1,30 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GetContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetContentFormulaLogic { + return &GetContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetContentFormulaLogic) GetContentFormula(req *types.GetContentFormulaHandlerReq) (*types.ContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.ID, req.FormulaID) + if err != nil { + return nil, err + } + data := toContentFormulaData(*item) + return &data, nil +} diff --git a/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go new file mode 100644 index 0000000..da4d205 --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go @@ -0,0 +1,72 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishDashboardSummaryLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishDashboardSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishDashboardSummaryLogic { + return &GetPublishDashboardSummaryLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishDashboardSummaryLogic) GetPublishDashboardSummary() (resp *types.PublishDashboardSummaryData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + resp = &types.PublishDashboardSummaryData{List: []types.PublishDashboardAccountSummary{}} + for _, account := range accounts.List { + health, hErr := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, account.ID, 1, 10) + if hErr != nil { + continue + } + guard, _ := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, account.ID) + slots, _ := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, account.ID, "Asia/Taipei") + bestSlot, lowSlot := "", "" + for _, slot := range slots { + if bestSlot == "" && slot.Recommendation == "recommended" { + bestSlot = slot.Time + } + if lowSlot == "" && slot.Recommendation == "low" { + lowSlot = slot.Time + } + } + row := types.PublishDashboardAccountSummary{ + AccountID: account.ID, AccountName: firstNonEmpty(account.DisplayName, account.Username, account.ID), + PendingScheduled: health.Summary.PendingScheduled, FailedCount: health.Summary.FailedCount, + Published7d: health.Summary.Published7d, TotalLikes7d: health.Summary.TotalLikes7d, + TotalReplies7d: health.Summary.TotalReplies7d, BestSlot: bestSlot, LowSlot: lowSlot, + } + if guard != nil { + row.Paused = guard.Paused + } + resp.List = append(resp.List, row) + resp.TotalPending += row.PendingScheduled + resp.TotalFailed += row.FailedCount + resp.TotalPublished7d += row.Published7d + resp.TotalLikes7d += row.TotalLikes7d + resp.TotalReplies7d += row.TotalReplies7d + } + return resp, nil +} diff --git a/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go new file mode 100644 index 0000000..6e15f0b --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishGuardPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishGuardPolicyLogic { + return &GetPublishGuardPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishGuardPolicyLogic) GetPublishGuardPolicy(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return toGuardPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go new file mode 100644 index 0000000..cde2e9e --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishInventoryPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishInventoryPolicyLogic { + return &GetPublishInventoryPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishInventoryPolicyLogic) GetPublishInventoryPolicy(req *types.ThreadsAccountPath) (resp *types.PublishInventoryPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return toInventoryPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/get_publish_queue_item_logic.go b/backend/internal/logic/threads_account/get_publish_queue_item_logic.go new file mode 100644 index 0000000..ee2efec --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_queue_item_logic.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GetPublishQueueItemLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishQueueItemLogic { + return &GetPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPublishQueueItemLogic) GetPublishQueueItem(req *types.PublishQueueItemPath) (*types.PublishQueueItemData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.Get(l.ctx, tenantID, uid, req.ID, req.QID) + if err != nil { + return nil, err + } + return toPublishQueueItemData(result), nil +} diff --git a/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go new file mode 100644 index 0000000..7b84ba3 --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go @@ -0,0 +1,53 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishSlotInsightsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishSlotInsightsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishSlotInsightsLogic { + return &GetPublishSlotInsightsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishSlotInsightsLogic) GetPublishSlotInsights(req *types.ThreadsAccountPath) (resp *types.PublishSlotInsightsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + policy, _ := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID) + timezone := "Asia/Taipei" + if policy != nil && policy.Timezone != "" { + timezone = policy.Timezone + } + items, err := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, req.ID, timezone) + if err != nil { + return nil, err + } + list := make([]types.PublishSlotInsightData, 0, len(items)) + for _, item := range items { + list = append(list, types.PublishSlotInsightData{ + Weekday: item.Weekday, Time: item.Time, AvgLikes1h: item.AvgLikes1h, AvgReplies1h: item.AvgReplies1h, + AvgLikes24h: item.AvgLikes24h, AvgReplies24h: item.AvgReplies24h, + AvgLikes7d: item.AvgLikes7d, AvgReplies7d: item.AvgReplies7d, + SampleSize: item.SampleSize, Recommendation: item.Recommendation, + }) + } + return &types.PublishSlotInsightsData{AccountID: req.ID, Timezone: timezone, Slots: list}, nil +} diff --git a/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go new file mode 100644 index 0000000..c4a23d5 --- /dev/null +++ b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go @@ -0,0 +1,55 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/library/clock" + libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetThreadsDiagnosticsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetThreadsDiagnosticsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsDiagnosticsLogic { + return &GetThreadsDiagnosticsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetThreadsDiagnosticsLogic) GetThreadsDiagnostics(req *types.ThreadsAccountPath) (resp *types.ThreadsDiagnosticsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + smoke, smokeErr := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID) + items := make([]types.ThreadsAPISmokeTestItem, 0, len(smoke)) + for _, item := range smoke { + items = append(items, types.ThreadsAPISmokeTestItem{ + Scope: item.Scope, Name: item.Name, Status: item.Status, Message: item.Message, Count: item.Count, + }) + } + message := "診斷完成" + if smokeErr != nil { + message = smokeErr.Error() + } + return &types.ThreadsDiagnosticsData{ + AccountID: req.ID, CheckedAt: clock.NowUnixNano(), ApiConnected: account.ApiConnected, + TokenExpiresAt: account.APITokenExpiresAt, Scopes: libthreads.DefaultOAuthScopes, Items: items, Message: message, + }, nil +} diff --git a/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go b/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go new file mode 100644 index 0000000..162b872 --- /dev/null +++ b/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go @@ -0,0 +1,27 @@ +package threads_account + +import ( + "strings" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GetThreadsOAuthConfigLogic struct { + svcCtx *svc.ServiceContext +} + +func NewGetThreadsOAuthConfigLogic(svcCtx *svc.ServiceContext) *GetThreadsOAuthConfigLogic { + return &GetThreadsOAuthConfigLogic{svcCtx: svcCtx} +} + +func (l *GetThreadsOAuthConfigLogic) GetThreadsOAuthConfig() *types.ThreadsOAuthConfigData { + cfg := l.svcCtx.Config.ThreadsOAuth + redirect := strings.TrimSpace(cfg.RedirectURI) + return &types.ThreadsOAuthConfigData{ + RedirectURI: redirect, + SuccessRedirect: strings.TrimSpace(cfg.SuccessRedirect), + Enabled: strings.TrimSpace(cfg.AppID) != "" && strings.TrimSpace(cfg.AppSecret) != "" && redirect != "", + RequiresHTTPS: !strings.HasPrefix(strings.ToLower(redirect), "https://"), + } +} diff --git a/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go b/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go new file mode 100644 index 0000000..fde6ef1 --- /dev/null +++ b/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go @@ -0,0 +1,30 @@ +package threads_account + +import ( + "context" + + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ImportOwnPostToContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewImportOwnPostToContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportOwnPostToContentFormulaLogic { + return &ImportOwnPostToContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ImportOwnPostToContentFormulaLogic) ImportOwnPostToContentFormula(req *types.ImportOwnPostFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) { + analyze := NewAnalyzeContentFormulaLogic(l.ctx, l.svcCtx) + return analyze.AnalyzeContentFormula(&types.AnalyzeContentFormulaHandlerReq{ + ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID}, + AnalyzeContentFormulaReq: types.AnalyzeContentFormulaReq{ + SourceType: cfentity.SourceOwnPost, + MediaID: req.MediaID, + SaveLabel: req.Label, + }, + }) +} diff --git a/backend/internal/logic/threads_account/list_content_formulas_logic.go b/backend/internal/logic/threads_account/list_content_formulas_logic.go new file mode 100644 index 0000000..6399e96 --- /dev/null +++ b/backend/internal/logic/threads_account/list_content_formulas_logic.go @@ -0,0 +1,63 @@ +package threads_account + +import ( + "context" + "math" + + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ListContentFormulasLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListContentFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentFormulasLogic { + return &ListContentFormulasLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListContentFormulasLogic) ListContentFormulas(req *types.ListContentFormulasHandlerReq) (*types.ListContentFormulasData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + page := int(req.Page) + if page <= 0 { + page = 1 + } + pageSize := int(req.PageSize) + if pageSize <= 0 { + pageSize = 20 + } + result, err := l.svcCtx.ContentFormula.List(l.ctx, cfdom.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + SourceType: req.SourceType, + Tag: req.Tag, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + list := make([]types.ContentFormulaData, 0, len(result.Items)) + for _, item := range result.Items { + list = append(list, toContentFormulaData(item)) + } + totalPages := int64(0) + if pageSize > 0 { + totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize))) + } + return &types.ListContentFormulasData{ + Pagination: types.PaginationData{ + Total: result.Total, + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: totalPages, + }, + List: list, + }, nil +} diff --git a/backend/internal/logic/threads_account/list_mention_inbox_logic.go b/backend/internal/logic/threads_account/list_mention_inbox_logic.go new file mode 100644 index 0000000..9ce78d1 --- /dev/null +++ b/backend/internal/logic/threads_account/list_mention_inbox_logic.go @@ -0,0 +1,53 @@ +package threads_account + +import ( + "context" + + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListMentionInboxLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMentionInboxLogic { + return &ListMentionInboxLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListMentionInboxLogic) ListMentionInbox(req *types.ListMentionInboxHandlerReq) (*types.ListMentionInboxData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.MentionInbox.List(l.ctx, mentioninboxdomain.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + return &types.ListMentionInboxData{ + List: toMentionInboxItems(result.List), + Pagination: types.PaginationData{ + Total: result.Pagination.Total, + Page: result.Pagination.Page, + PageSize: result.Pagination.PageSize, + TotalPages: result.Pagination.TotalPages, + }, + ReadyTotal: result.Pagination.ReadyTotal, + NewestSyncedAt: result.Pagination.NewestSyncedAt, + }, nil +} diff --git a/backend/internal/logic/threads_account/list_own_post_formulas_logic.go b/backend/internal/logic/threads_account/list_own_post_formulas_logic.go new file mode 100644 index 0000000..23c3be3 --- /dev/null +++ b/backend/internal/logic/threads_account/list_own_post_formulas_logic.go @@ -0,0 +1,43 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListOwnPostFormulasLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListOwnPostFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOwnPostFormulasLogic { + return &ListOwnPostFormulasLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListOwnPostFormulasLogic) ListOwnPostFormulas(req *types.ListOwnPostFormulasHandlerReq) (*types.ListOwnPostFormulasData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + items, err := l.svcCtx.OwnPostFormula.ListByAccount(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + list := make([]types.OwnPostFormulaReviewData, 0, len(items)) + for i := range items { + list = append(list, toOwnPostFormulaReviewData(&items[i])) + } + return &types.ListOwnPostFormulasData{List: list}, nil +} diff --git a/backend/internal/logic/threads_account/list_publish_alerts_logic.go b/backend/internal/logic/threads_account/list_publish_alerts_logic.go new file mode 100644 index 0000000..1d239c2 --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_alerts_logic.go @@ -0,0 +1,46 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPublishAlertsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPublishAlertsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishAlertsLogic { + return &ListPublishAlertsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListPublishAlertsLogic) ListPublishAlerts(req *types.ThreadsAccountPath) (resp *types.PublishAlertsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.PublishQueue.ListAlerts(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + list := make([]types.PublishAlertData, 0, len(items)) + for _, item := range items { + list = append(list, types.PublishAlertData{ + Type: item.Type, Severity: item.Severity, Message: item.Message, + QueueID: item.QueueID, AccountID: item.AccountID, CreateAt: item.CreateAt, + }) + } + return &types.PublishAlertsData{List: list}, nil +} diff --git a/backend/internal/logic/threads_account/list_publish_queue_events_logic.go b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go new file mode 100644 index 0000000..327998a --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPublishQueueEventsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPublishQueueEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueEventsLogic { + return &ListPublishQueueEventsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListPublishQueueEventsLogic) ListPublishQueueEvents(req *types.PublishQueueItemPath) (resp *types.PublishQueueEventsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.PublishQueueEvent.ListByQueue(l.ctx, tenantID, uid, req.ID, req.QID, 100) + if err != nil { + return nil, err + } + return &types.PublishQueueEventsData{List: toQueueEventData(items)}, nil +} diff --git a/backend/internal/logic/threads_account/list_publish_queue_items_logic.go b/backend/internal/logic/threads_account/list_publish_queue_items_logic.go new file mode 100644 index 0000000..020661d --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_queue_items_logic.go @@ -0,0 +1,37 @@ +package threads_account + +import ( + "context" + + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ListPublishQueueItemsLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPublishQueueItemsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueItemsLogic { + return &ListPublishQueueItemsLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPublishQueueItemsLogic) ListPublishQueueItems(req *types.ListPublishQueueHandlerReq) (*types.PublishQueueListData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.List(l.ctx, pqdomain.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + Status: req.Status, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + return toPublishQueueListData(result), nil +} diff --git a/backend/internal/logic/threads_account/list_publish_queue_range_logic.go b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go new file mode 100644 index 0000000..db34dbc --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPublishQueueRangeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPublishQueueRangeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueRangeLogic { + return &ListPublishQueueRangeLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListPublishQueueRangeLogic) ListPublishQueueRange(req *types.PublishQueueRangeHandlerReq) (resp *types.PublishQueueListData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.ListRange(l.ctx, pqdomain.RangeRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, + Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt, Page: 1, PageSize: 200, + }) + if err != nil { + return nil, err + } + return toPublishQueueListData(result), nil +} diff --git a/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go b/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go new file mode 100644 index 0000000..ebad032 --- /dev/null +++ b/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go @@ -0,0 +1,32 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ListThreadsOAuthLogsLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListThreadsOAuthLogsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsOAuthLogsLogic { + return &ListThreadsOAuthLogsLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListThreadsOAuthLogsLogic) ListThreadsOAuthLogs() (*types.ListThreadsOAuthLogsData, error) { + items := l.svcCtx.ThreadsAccount.ListOAuthDebugLogs(50) + list := make([]types.ThreadsOAuthLogItem, 0, len(items)) + for _, item := range items { + list = append(list, types.ThreadsOAuthLogItem{ + At: item.At, + Level: item.Level, + Stage: item.Stage, + AccountID: item.AccountID, + Message: item.Message, + }) + } + return &types.ListThreadsOAuthLogsData{List: list}, nil +} diff --git a/backend/internal/logic/threads_account/list_threads_post_performance_logic.go b/backend/internal/logic/threads_account/list_threads_post_performance_logic.go new file mode 100644 index 0000000..6153768 --- /dev/null +++ b/backend/internal/logic/threads_account/list_threads_post_performance_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListThreadsPostPerformanceLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListThreadsPostPerformanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsPostPerformanceLogic { + return &ListThreadsPostPerformanceLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListThreadsPostPerformanceLogic) ListThreadsPostPerformance(req *types.ListThreadsPostPerformanceHandlerReq) (*types.ThreadsPostPerformanceData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.ThreadsAccount.ListPostPerformance(l.ctx, tenantID, uid, req.ID, req.Limit) + if err != nil { + return nil, err + } + return toPostPerformanceData(result), nil +} diff --git a/backend/internal/logic/threads_account/list_threads_publish_health_logic.go b/backend/internal/logic/threads_account/list_threads_publish_health_logic.go new file mode 100644 index 0000000..db640d6 --- /dev/null +++ b/backend/internal/logic/threads_account/list_threads_publish_health_logic.go @@ -0,0 +1,29 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ListThreadsPublishHealthLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListThreadsPublishHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsPublishHealthLogic { + return &ListThreadsPublishHealthLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListThreadsPublishHealthLogic) ListThreadsPublishHealth(req *types.ListThreadsPublishHealthHandlerReq) (*types.ThreadsPublishHealthData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, req.ID, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return toPublishHealthData(result), nil +} diff --git a/backend/internal/logic/threads_account/mapper.go b/backend/internal/logic/threads_account/mapper.go index 2e55f7f..f83071d 100644 --- a/backend/internal/logic/threads_account/mapper.go +++ b/backend/internal/logic/threads_account/mapper.go @@ -1,22 +1,62 @@ package threads_account import ( + guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase" domusecase "haixun-backend/internal/model/threads_account/domain/usecase" "haixun-backend/internal/types" ) func toThreadsAccountData(item domusecase.AccountSummary) types.ThreadsAccountData { return types.ThreadsAccountData{ - ID: item.ID, - DisplayName: item.DisplayName, - Username: item.Username, - ThreadsUserID: item.ThreadsUserID, - PersonaID: item.PersonaID, - BrowserConnected: item.BrowserConnected, - ApiConnected: item.ApiConnected, - Status: item.Status, - CreateAt: item.CreateAt, - UpdateAt: item.UpdateAt, + ID: item.ID, + DisplayName: item.DisplayName, + Username: item.Username, + ThreadsUserID: item.ThreadsUserID, + PersonaID: item.PersonaID, + BrowserConnected: item.BrowserConnected, + ApiConnected: item.ApiConnected, + APITokenExpiresAt: item.APITokenExpiresAt, + Status: item.Status, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toPostPerformanceData(result *domusecase.PostPerformanceResult) *types.ThreadsPostPerformanceData { + if result == nil { + return &types.ThreadsPostPerformanceData{Posts: []types.ThreadsPostPerformanceItem{}} + } + caps := make([]types.ThreadsPostMetricCapability, 0, len(result.Capabilities)) + for _, item := range result.Capabilities { + caps = append(caps, types.ThreadsPostMetricCapability{ + Scope: item.Scope, Name: item.Name, Metrics: item.Metrics, + Trackable: item.Trackable, Note: item.Note, + }) + } + metrics := make([]types.ThreadsInsightMetricItem, 0, len(result.AccountInsights.Metrics)) + for _, item := range result.AccountInsights.Metrics { + metrics = append(metrics, types.ThreadsInsightMetricItem{Name: item.Name, Value: item.Value}) + } + posts := make([]types.ThreadsPostPerformanceItem, 0, len(result.Posts)) + for _, item := range result.Posts { + posts = append(posts, types.ThreadsPostPerformanceItem{ + MediaID: item.MediaID, Text: item.Text, Permalink: item.Permalink, Timestamp: item.Timestamp, + MediaType: item.MediaType, MediaURL: item.MediaURL, ThumbnailURL: item.ThumbnailURL, + TopicTag: item.TopicTag, Shortcode: item.Shortcode, + LikeCount: item.LikeCount, ReplyCount: item.ReplyCount, RepostCount: item.RepostCount, QuoteCount: item.QuoteCount, + Views: item.Views, Shares: item.Shares, InsightsStatus: item.InsightsStatus, InsightsMessage: item.InsightsMessage, + }) + } + return &types.ThreadsPostPerformanceData{ + AccountID: result.AccountID, Username: result.Username, ThreadsUserID: result.ThreadsUserID, + FetchedAt: result.FetchedAt, Capabilities: caps, + AccountInsights: types.ThreadsAccountInsightsBlock{ + Status: result.AccountInsights.Status, Message: result.AccountInsights.Message, Metrics: metrics, + }, + Posts: posts, } } @@ -57,6 +97,142 @@ func toConnectionData(data *domusecase.ConnectionData) *types.ThreadsAccountConn } } +func toPublishQueueItemData(item *pqdomain.QueueItemSummary) *types.PublishQueueItemData { + if item == nil { + return nil + } + return &types.PublishQueueItemData{ + ID: item.ID, + AccountID: item.AccountID, + PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, + CopyDraftID: item.CopyDraftID, + Text: item.Text, + TopicTag: item.TopicTag, + ScheduledAt: item.ScheduledAt, + Status: item.Status, + MediaID: item.MediaID, + Permalink: item.Permalink, + PublishedAt: item.PublishedAt, + ErrorMessage: item.ErrorMessage, + RetryCount: item.RetryCount, + LastAttemptAt: item.LastAttemptAt, + NextAttemptAt: item.NextAttemptAt, + MissedAt: item.MissedAt, + PausedReason: item.PausedReason, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toInventoryPolicyData(item *inventorydomain.PolicySummary) *types.PublishInventoryPolicyData { + if item == nil { + return nil + } + slots := make([]types.PublishSlotData, 0, len(item.Slots)) + for _, slot := range item.Slots { + slots = append(slots, types.PublishSlotData{Weekday: slot.Weekday, Time: slot.Time}) + } + refs := make([]types.PublishSourceRefData, 0, len(item.SourceRefs)) + for _, ref := range item.SourceRefs { + refs = append(refs, types.PublishSourceRefData{ + Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID, ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed, + }) + } + return &types.PublishInventoryPolicyData{ + AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount, + LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays, + Timezone: item.Timezone, Slots: slots, SourceRefs: refs, UpdateAt: item.UpdateAt, + } +} + +func toGuardPolicyData(item *guarddomain.PolicySummary) *types.PublishGuardPolicyData { + if item == nil { + return nil + } + return &types.PublishGuardPolicyData{ + AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts, + MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit, + Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt, + } +} + +func toQueueEventData(items []eventdomain.EventSummary) []types.PublishQueueEventData { + out := make([]types.PublishQueueEventData, 0, len(items)) + for _, item := range items { + out = append(out, types.PublishQueueEventData{ + ID: item.ID, QueueID: item.QueueID, EventType: item.EventType, + FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt, + }) + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func toPublishQueueListData(result *pqdomain.ListResult) *types.PublishQueueListData { + if result == nil { + return &types.PublishQueueListData{List: []types.PublishQueueItemData{}} + } + list := make([]types.PublishQueueItemData, 0, len(result.Items)) + for _, item := range result.Items { + if data := toPublishQueueItemData(&item); data != nil { + list = append(list, *data) + } + } + return &types.PublishQueueListData{ + Pagination: types.PaginationData{ + Total: result.Total, + Page: int64(result.Page), + PageSize: int64(result.PageSize), + TotalPages: int64(result.TotalPages), + }, + List: list, + } +} + +func toPublishHealthData(result *pqdomain.PublishHealthResult) *types.ThreadsPublishHealthData { + if result == nil { + return &types.ThreadsPublishHealthData{List: []types.ThreadsPublishHealthItem{}} + } + list := make([]types.ThreadsPublishHealthItem, 0, len(result.Items)) + for _, item := range result.Items { + cps := make([]types.ThreadsPublishHealthCheckpoint, 0, len(item.Checkpoints)) + for _, cp := range item.Checkpoints { + cps = append(cps, types.ThreadsPublishHealthCheckpoint{ + Checkpoint: cp.Checkpoint, LikeCount: cp.LikeCount, ReplyCount: cp.ReplyCount, + RepostCount: cp.RepostCount, QuoteCount: cp.QuoteCount, Views: cp.Views, CheckedAt: cp.CheckedAt, + }) + } + list = append(list, types.ThreadsPublishHealthItem{ + MediaID: item.MediaID, Permalink: item.Permalink, Text: item.Text, + PublishedAt: item.PublishedAt, LikeCount: item.LikeCount, ReplyCount: item.ReplyCount, + RepostCount: item.RepostCount, QuoteCount: item.QuoteCount, + Views: item.Views, Shares: item.Shares, Checkpoints: cps, + }) + } + return &types.ThreadsPublishHealthData{ + Summary: types.ThreadsPublishHealthSummary{ + PendingScheduled: result.Summary.PendingScheduled, + FailedCount: result.Summary.FailedCount, + Published7d: result.Summary.Published7d, + TotalLikes7d: result.Summary.TotalLikes7d, + TotalReplies7d: result.Summary.TotalReplies7d, + }, + Pagination: types.PaginationData{ + Total: result.Total, Page: int64(result.Page), PageSize: int64(result.PageSize), TotalPages: int64(result.TotalPages), + }, + List: list, + } +} + func toConnectionPatch(req *types.UpdateThreadsAccountConnectionReq) domusecase.ConnectionPrefsPatch { if req == nil { return domusecase.ConnectionPrefsPatch{} diff --git a/backend/internal/logic/threads_account/mention_inbox_mapper.go b/backend/internal/logic/threads_account/mention_inbox_mapper.go new file mode 100644 index 0000000..867d844 --- /dev/null +++ b/backend/internal/logic/threads_account/mention_inbox_mapper.go @@ -0,0 +1,32 @@ +package threads_account + +import ( + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/types" +) + +func toMentionInboxItems(items []mentioninboxdomain.MentionSummary) []types.MentionInboxItemData { + out := make([]types.MentionInboxItemData, 0, len(items)) + for _, item := range items { + out = append(out, types.MentionInboxItemData{ + MediaID: item.MediaID, + AuthorUsername: item.AuthorUsername, + Text: item.Text, + Permalink: item.Permalink, + Shortcode: item.Shortcode, + Timestamp: item.Timestamp, + MediaType: item.MediaType, + IsReply: item.IsReply, + IsQuotePost: item.IsQuotePost, + HasReplies: item.HasReplies, + RootPostID: item.RootPostID, + ParentID: item.ParentID, + ThreadPostText: item.ThreadPostText, + ParentReplyText: item.ParentReplyText, + ReplyReady: item.ReplyReady, + ReplyReadyMsg: item.ReplyReadyMsg, + SyncedAt: item.SyncedAt, + }) + } + return out +} diff --git a/backend/internal/logic/threads_account/own_post_formula_mapper.go b/backend/internal/logic/threads_account/own_post_formula_mapper.go new file mode 100644 index 0000000..a6336c0 --- /dev/null +++ b/backend/internal/logic/threads_account/own_post_formula_mapper.go @@ -0,0 +1,25 @@ +package threads_account + +import ( + "haixun-backend/internal/model/own_post_formula/domain/entity" + "haixun-backend/internal/types" +) + +func toOwnPostFormulaReviewData(item *entity.Review) types.OwnPostFormulaReviewData { + if item == nil { + return types.OwnPostFormulaReviewData{} + } + return types.OwnPostFormulaReviewData{ + MediaID: item.MediaID, + ReviewedAt: item.UpdateAt, + Summary: item.Summary, + Wins: item.Wins, + Improvements: item.Improvements, + Formula: item.Formula, + PostTemplate: item.PostTemplate, + HookPattern: item.HookPattern, + Structure: item.Structure, + ReplicationTips: item.ReplicationTips, + Avoid: item.Avoid, + } +} diff --git a/backend/internal/logic/threads_account/patch_content_formula_logic.go b/backend/internal/logic/threads_account/patch_content_formula_logic.go new file mode 100644 index 0000000..7e994aa --- /dev/null +++ b/backend/internal/logic/threads_account/patch_content_formula_logic.go @@ -0,0 +1,42 @@ +package threads_account + +import ( + "context" + + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type PatchContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchContentFormulaLogic { + return &PatchContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *PatchContentFormulaLogic) PatchContentFormula(req *types.PatchContentFormulaHandlerReq) (*types.ContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentFormula.Update(l.ctx, cfdom.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + FormulaID: req.FormulaID, + Label: req.Label, + Formula: req.Formula, + PostTemplate: req.PostTemplate, + HookPattern: req.HookPattern, + Structure: req.Structure, + Tags: req.Tags, + }) + if err != nil { + return nil, err + } + data := toContentFormulaData(*item) + return &data, nil +} diff --git a/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go b/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go new file mode 100644 index 0000000..3e70636 --- /dev/null +++ b/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go @@ -0,0 +1,38 @@ +package threads_account + +import ( + "context" + + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type PatchPublishQueueItemLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchPublishQueueItemLogic { + return &PatchPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *PatchPublishQueueItemLogic) PatchPublishQueueItem(req *types.PatchPublishQueueHandlerReq) (*types.PublishQueueItemData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.Update(l.ctx, pqdomain.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + QueueID: req.QID, + Text: req.Text, + TopicTag: req.TopicTag, + ScheduledAt: req.ScheduledAt, + }) + if err != nil { + return nil, err + } + return toPublishQueueItemData(result), nil +} diff --git a/backend/internal/logic/threads_account/publish_mention_reply_logic.go b/backend/internal/logic/threads_account/publish_mention_reply_logic.go new file mode 100644 index 0000000..67b1797 --- /dev/null +++ b/backend/internal/logic/threads_account/publish_mention_reply_logic.go @@ -0,0 +1,46 @@ +package threads_account + +import ( + "context" + + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PublishMentionReplyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPublishMentionReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishMentionReplyLogic { + return &PublishMentionReplyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *PublishMentionReplyLogic) PublishMentionReply(req *types.PublishMentionReplyHandlerReq) (*types.PublishMentionReplyData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.MentionInbox.PublishReply(l.ctx, mentioninboxdomain.PublishReplyRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + MediaID: req.MediaID, + Text: req.Text, + }) + if err != nil { + return nil, err + } + return &types.PublishMentionReplyData{ + MediaID: result.MediaID, + Permalink: result.Permalink, + }, nil +} diff --git a/backend/internal/logic/threads_account/resume_publish_guard_logic.go b/backend/internal/logic/threads_account/resume_publish_guard_logic.go new file mode 100644 index 0000000..8a52f9d --- /dev/null +++ b/backend/internal/logic/threads_account/resume_publish_guard_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ResumePublishGuardLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewResumePublishGuardLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResumePublishGuardLogic { + return &ResumePublishGuardLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ResumePublishGuardLogic) ResumePublishGuard(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishGuard.Resume(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return toGuardPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go new file mode 100644 index 0000000..bd0b8e1 --- /dev/null +++ b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RetryPublishQueueItemLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRetryPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryPublishQueueItemLogic { + return &RetryPublishQueueItemLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RetryPublishQueueItemLogic) RetryPublishQueueItem(req *types.PublishQueueItemPath) (resp *types.PublishQueueItemData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishQueue.Retry(l.ctx, tenantID, uid, req.ID, req.QID) + if err != nil { + return nil, err + } + return toPublishQueueItemData(item), nil +} diff --git a/backend/internal/logic/threads_account/run_threads_api_playground_logic.go b/backend/internal/logic/threads_account/run_threads_api_playground_logic.go new file mode 100644 index 0000000..af9017f --- /dev/null +++ b/backend/internal/logic/threads_account/run_threads_api_playground_logic.go @@ -0,0 +1,44 @@ +package threads_account + +import ( + "context" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type RunThreadsAPIPlaygroundLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRunThreadsAPIPlaygroundLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunThreadsAPIPlaygroundLogic { + return &RunThreadsAPIPlaygroundLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *RunThreadsAPIPlaygroundLogic) RunThreadsAPIPlayground( + req *types.RunThreadsAPIPlaygroundHandlerReq, +) (*types.ThreadsAPIPlaygroundData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + body := req.RunThreadsAPIPlaygroundReq + result, err := l.svcCtx.ThreadsAccount.RunAPIPlayground(l.ctx, tenantID, uid, req.ID, domusecase.APIPlaygroundRequest{ + Action: body.Action, Query: body.Query, Username: body.Username, + MediaID: body.MediaID, ReplyID: body.ReplyID, ReplyToID: body.ReplyToID, + Text: body.Text, Permalink: body.Permalink, HintText: body.HintText, SkipPrime: body.SkipPrime, + LocationQuery: body.LocationQuery, Limit: body.Limit, + Hide: body.Hide, SearchType: body.SearchType, + }) + if err != nil { + return nil, err + } + data := "" + if len(result.Data) > 0 { + data = string(result.Data) + } + return &types.ThreadsAPIPlaygroundData{ + Action: result.Action, Ok: result.Ok, Message: result.Message, Data: data, + }, nil +} diff --git a/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go b/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go new file mode 100644 index 0000000..f91d22e --- /dev/null +++ b/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go @@ -0,0 +1,51 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type RunThreadsAPISmokeTestLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRunThreadsAPISmokeTestLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunThreadsAPISmokeTestLogic { + return &RunThreadsAPISmokeTestLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *RunThreadsAPISmokeTestLogic) RunThreadsAPISmokeTest(req *types.ThreadsAccountPath) (*types.RunThreadsAPISmokeTestData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + list := make([]types.ThreadsAPISmokeTestItem, 0, len(items)) + var passed, failed, skipped int + for _, item := range items { + list = append(list, types.ThreadsAPISmokeTestItem{ + Scope: item.Scope, Name: item.Name, Status: item.Status, + Message: item.Message, Count: item.Count, + }) + switch item.Status { + case "ok": + passed++ + case "error": + failed++ + default: + skipped++ + } + } + return &types.RunThreadsAPISmokeTestData{ + List: list, + Passed: passed, + Failed: failed, + Skipped: skipped, + Total: len(list), + }, nil +} diff --git a/backend/internal/logic/threads_account/search_content_formula_posts_logic.go b/backend/internal/logic/threads_account/search_content_formula_posts_logic.go new file mode 100644 index 0000000..7b8c99b --- /dev/null +++ b/backend/internal/logic/threads_account/search_content_formula_posts_logic.go @@ -0,0 +1,80 @@ +package threads_account + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + libviral "haixun-backend/internal/library/viral" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type SearchContentFormulaPostsLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSearchContentFormulaPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SearchContentFormulaPostsLogic { + return &SearchContentFormulaPostsLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *SearchContentFormulaPostsLogic) SearchContentFormulaPosts(req *types.SearchContentFormulaPostsHandlerReq) (*types.SearchContentFormulaPostsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + keyword := strings.TrimSpace(req.Keyword) + if keyword == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("keyword is required") + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + token, err := l.svcCtx.ThreadsAccount.ResolveAccountAPIAccessToken(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + client := libthreads.NewClient(token) + if !client.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") + } + limit := req.Limit + if limit <= 0 { + limit = 10 + } + searchType := strings.TrimSpace(req.SearchType) + if searchType == "" { + searchType = "TOP" + } + items, err := client.KeywordSearch(l.ctx, libthreads.KeywordSearchOptions{ + Query: keyword, Limit: limit, SearchType: searchType, + }) + if err != nil { + return nil, app.For(code.ThreadsAccount).SvcThirdParty("keyword_search 失敗:" + err.Error()) + } + list := make([]types.ContentFormulaSearchPostData, 0, len(items)) + for _, item := range items { + text := strings.TrimSpace(item.Text) + if text == "" { + continue + } + author := strings.TrimSpace(item.Username) + list = append(list, types.ContentFormulaSearchPostData{ + Text: text, + Author: author, + Permalink: item.Permalink, + MediaID: item.ID, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + EngagementScore: libviral.ScorePost(item.LikeCount, item.ReplyCount), + }) + } + return &types.SearchContentFormulaPostsData{ + List: list, + Message: fmt.Sprintf("找到 %d 篇相似貼文", len(list)), + }, nil +} diff --git a/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go new file mode 100644 index 0000000..dcda058 --- /dev/null +++ b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go @@ -0,0 +1,61 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPublishInventoryRefillJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPublishInventoryRefillJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPublishInventoryRefillJobLogic { + return &StartPublishInventoryRefillJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *StartPublishInventoryRefillJobLogic) StartPublishInventoryRefillJob(req *types.StartPublishInventoryRefillJobHandlerReq) (resp *types.StartPublishInventoryRefillJobData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + count := req.Count + if count <= 0 { + count = 3 + } + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "refill-publish-inventory", + Scope: "threads_account", + ScopeID: req.ID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "account_id": req.ID, + "count": count, + }, + }) + if err != nil { + return nil, err + } + return &types.StartPublishInventoryRefillJobData{ + JobID: run.ID.Hex(), Status: string(run.Status), Message: "補庫存任務已建立", + }, nil +} diff --git a/backend/internal/logic/threads_account/start_threads_oauth_logic.go b/backend/internal/logic/threads_account/start_threads_oauth_logic.go new file mode 100644 index 0000000..94a5b73 --- /dev/null +++ b/backend/internal/logic/threads_account/start_threads_oauth_logic.go @@ -0,0 +1,37 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type StartThreadsOAuthLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartThreadsOAuthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartThreadsOAuthLogic { + return &StartThreadsOAuthLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartThreadsOAuthLogic) StartThreadsOAuth(req *types.StartThreadsOAuthQuery) (*types.StartThreadsOAuthData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + accountID := "" + if req != nil { + accountID = req.AccountID + } + result, err := l.svcCtx.ThreadsAccount.StartThreadsOAuth(l.ctx, tenantID, uid, accountID) + if err != nil { + return nil, err + } + return &types.StartThreadsOAuthData{ + AuthorizeURL: result.AuthorizeURL, + State: result.State, + AccountID: result.AccountID, + }, nil +} diff --git a/backend/internal/logic/threads_account/sync_mention_inbox_logic.go b/backend/internal/logic/threads_account/sync_mention_inbox_logic.go new file mode 100644 index 0000000..3ed8f70 --- /dev/null +++ b/backend/internal/logic/threads_account/sync_mention_inbox_logic.go @@ -0,0 +1,47 @@ +package threads_account + +import ( + "context" + + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SyncMentionInboxLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSyncMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SyncMentionInboxLogic { + return &SyncMentionInboxLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SyncMentionInboxLogic) SyncMentionInbox(req *types.SyncMentionInboxHandlerReq) (*types.SyncMentionInboxData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.MentionInbox.SyncFromAPI(l.ctx, mentioninboxdomain.SyncRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + Limit: req.Limit, + MaxPages: req.MaxPages, + }) + if err != nil { + return nil, err + } + return &types.SyncMentionInboxData{ + Synced: result.Synced, + Ready: result.Ready, + Total: result.Total, + }, nil +} diff --git a/backend/internal/logic/threads_account/threads_oauth_callback_logic.go b/backend/internal/logic/threads_account/threads_oauth_callback_logic.go new file mode 100644 index 0000000..2215fdf --- /dev/null +++ b/backend/internal/logic/threads_account/threads_oauth_callback_logic.go @@ -0,0 +1,60 @@ +package threads_account + +import ( + "context" + "net/url" + "strings" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ThreadsOAuthCallbackLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewThreadsOAuthCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ThreadsOAuthCallbackLogic { + return &ThreadsOAuthCallbackLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ThreadsOAuthCallbackLogic) ThreadsOAuthCallback(req *types.ThreadsOAuthCallbackQuery) (string, error) { + code := "" + state := "" + if req != nil { + code = req.Code + state = req.State + if strings.TrimSpace(req.Error) != "" { + msg := strings.TrimSpace(req.Error) + if reason := strings.TrimSpace(req.ErrorReason); reason != "" { + msg += " (" + reason + ")" + } + if desc := strings.TrimSpace(req.ErrorDescription); desc != "" { + msg += ": " + desc + } + l.svcCtx.ThreadsAccount.RecordOAuthDebug("error", "meta_denied", "", msg) + return failureRedirect(l.svcCtx.Config.ThreadsOAuth.SuccessRedirect, msg), nil + } + } + result, err := l.svcCtx.ThreadsAccount.CompleteThreadsOAuthCallback(l.ctx, code, state) + if err != nil { + return failureRedirect(l.svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), nil + } + return result.RedirectURL, nil +} + +func failureRedirect(base, message string) string { + base = strings.TrimSpace(base) + if base == "" { + base = "/" + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + "threads_oauth=error&message=" + url.QueryEscape(message) +} + +func OAuthFailureRedirect(successRedirect, message string) string { + return failureRedirect(successRedirect, message) +} diff --git a/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go new file mode 100644 index 0000000..8eb83ad --- /dev/null +++ b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go @@ -0,0 +1,45 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpsertPublishGuardPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpsertPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishGuardPolicyLogic { + return &UpsertPublishGuardPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpsertPublishGuardPolicyLogic) UpsertPublishGuardPolicy(req *types.UpsertPublishGuardPolicyHandlerReq) (resp *types.PublishGuardPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishGuard.Upsert(l.ctx, guarddomain.UpsertRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled, + MaxDailyPosts: req.MaxDailyPosts, MinIntervalMinutes: req.MinIntervalMinutes, + ConsecutiveFailurePauseLimit: req.ConsecutiveFailurePauseLimit, + Paused: req.Paused, PausedReason: req.PausedReason, + }) + if err != nil { + return nil, err + } + return toGuardPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go new file mode 100644 index 0000000..a46c585 --- /dev/null +++ b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go @@ -0,0 +1,55 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpsertPublishInventoryPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpsertPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishInventoryPolicyLogic { + return &UpsertPublishInventoryPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpsertPublishInventoryPolicyLogic) UpsertPublishInventoryPolicy(req *types.UpsertPublishInventoryPolicyHandlerReq) (resp *types.PublishInventoryPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + slots := make([]inventorydomain.Slot, 0, len(req.Slots)) + for _, slot := range req.Slots { + slots = append(slots, inventorydomain.Slot{Weekday: slot.Weekday, Time: slot.Time}) + } + refs := make([]inventorydomain.SourceRef, 0, len(req.SourceRefs)) + for _, ref := range req.SourceRefs { + refs = append(refs, inventorydomain.SourceRef{ + Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID, + ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed, + }) + } + item, err := l.svcCtx.PublishInventory.Upsert(l.ctx, inventorydomain.UpsertRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled, + TargetDailyCount: req.TargetDailyCount, LowStockThreshold: req.LowStockThreshold, + LookaheadDays: req.LookaheadDays, Timezone: req.Timezone, Slots: slots, SourceRefs: refs, + }) + if err != nil { + return nil, err + } + return toInventoryPolicyData(item), nil +} diff --git a/backend/internal/model/brand/domain/entity/product.go b/backend/internal/model/brand/domain/entity/product.go index 573e57b..d08361b 100644 --- a/backend/internal/model/brand/domain/entity/product.go +++ b/backend/internal/model/brand/domain/entity/product.go @@ -4,6 +4,7 @@ type Product struct { ID string `bson:"id"` Label string `bson:"label"` ProductContext string `bson:"product_context"` + PlacementURL string `bson:"placement_url,omitempty"` MatchTags []string `bson:"match_tags,omitempty"` CreateAt int64 `bson:"create_at"` UpdateAt int64 `bson:"update_at"` diff --git a/backend/internal/model/brand/domain/usecase/usecase.go b/backend/internal/model/brand/domain/usecase/usecase.go index f9affa0..0e7cf4d 100644 --- a/backend/internal/model/brand/domain/usecase/usecase.go +++ b/backend/internal/model/brand/domain/usecase/usecase.go @@ -10,6 +10,7 @@ type ProductSummary struct { ID string Label string ProductContext string + PlacementURL string MatchTags []string CreateAt int64 UpdateAt int64 @@ -89,18 +90,21 @@ type CreateProductRequest struct { BrandID string Label string ProductContext string + PlacementURL string MatchTags []string } type UpdateProductRequest struct { - TenantID string - OwnerUID string - BrandID string - ProductID string - Label *string - ProductContext *string - MatchTags []string - MatchTagsSet bool + TenantID string + OwnerUID string + BrandID string + ProductID string + Label *string + ProductContext *string + PlacementURL *string + PlacementURLSet bool + MatchTags []string + MatchTagsSet bool } type UseCase interface { diff --git a/backend/internal/model/brand/usecase/products.go b/backend/internal/model/brand/usecase/products.go index 03dc6de..8270001 100644 --- a/backend/internal/model/brand/usecase/products.go +++ b/backend/internal/model/brand/usecase/products.go @@ -41,6 +41,7 @@ func (u *brandUseCase) CreateProduct(ctx context.Context, req domusecase.CreateP ID: uuid.NewString(), Label: label, ProductContext: contextRaw, + PlacementURL: strings.TrimSpace(req.PlacementURL), MatchTags: normalizeMatchTags(req.MatchTags), } created, err := u.repo.PushProduct(ctx, req.TenantID, req.OwnerUID, req.BrandID, product) @@ -77,6 +78,9 @@ func (u *brandUseCase) UpdateProduct(ctx context.Context, req domusecase.UpdateP if req.MatchTagsSet { patch["products.$.match_tags"] = normalizeMatchTags(req.MatchTags) } + if req.PlacementURLSet { + patch["products.$.placement_url"] = strings.TrimSpace(ptrString(req.PlacementURL)) + } if len(patch) == 0 { return nil, app.For(code.Brand).InputMissingRequired("product patch is empty") } @@ -134,12 +138,20 @@ func toProductSummary(item entity.Product) domusecase.ProductSummary { ID: item.ID, Label: item.Label, ProductContext: item.ProductContext, + PlacementURL: item.PlacementURL, MatchTags: append([]string(nil), item.MatchTags...), CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, } } +func ptrString(v *string) string { + if v == nil { + return "" + } + return *v +} + func normalizeMatchTags(tags []string) []string { if len(tags) == 0 { return nil diff --git a/backend/internal/model/brand/usecase/products_placement_url_test.go b/backend/internal/model/brand/usecase/products_placement_url_test.go new file mode 100644 index 0000000..a3f4155 --- /dev/null +++ b/backend/internal/model/brand/usecase/products_placement_url_test.go @@ -0,0 +1,115 @@ +package usecase + +import ( + "context" + "testing" + + "haixun-backend/internal/model/brand/domain/entity" + domusecase "haixun-backend/internal/model/brand/domain/usecase" +) + +type memoryBrandRepo struct { + brand *entity.Brand +} + +func (m *memoryBrandRepo) Create(ctx context.Context, brand *entity.Brand) (*entity.Brand, error) { + m.brand = brand + return brand, nil +} + +func (m *memoryBrandRepo) FindByID(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Brand, error) { + return m.brand, nil +} + +func (m *memoryBrandRepo) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Brand, error) { + return nil, nil +} + +func (m *memoryBrandRepo) Update(ctx context.Context, tenantID, ownerUID, brandID string, patch map[string]interface{}) (*entity.Brand, error) { + return m.brand, nil +} + +func (m *memoryBrandRepo) SoftDelete(ctx context.Context, tenantID, ownerUID, brandID string) error { + return nil +} + +func (m *memoryBrandRepo) PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error) { + m.brand.Products = append(m.brand.Products, product) + return &product, nil +} + +func (m *memoryBrandRepo) UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error) { + for i := range m.brand.Products { + if m.brand.Products[i].ID != productID { + continue + } + if v, ok := patch["products.$.label"].(string); ok { + m.brand.Products[i].Label = v + } + if v, ok := patch["products.$.product_context"].(string); ok { + m.brand.Products[i].ProductContext = v + } + if v, ok := patch["products.$.placement_url"].(string); ok { + m.brand.Products[i].PlacementURL = v + } + item := m.brand.Products[i] + return &item, nil + } + return nil, nil +} + +func (m *memoryBrandRepo) PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error { + return nil +} + +func (m *memoryBrandRepo) EnsureIndexes(ctx context.Context) error { + return nil +} + +func TestCreateAndUpdateProductPlacementURL(t *testing.T) { + repo := &memoryBrandRepo{ + brand: &entity.Brand{ + ID: "brand-1", + TenantID: "tenant-1", + OwnerUID: "user-1", + Status: entity.StatusOpen, + }, + } + uc := NewUseCase(repo) + ctx := context.Background() + + created, err := uc.CreateProduct(ctx, domusecase.CreateProductRequest{ + TenantID: "tenant-1", + OwnerUID: "user-1", + BrandID: "brand-1", + Label: "主力商品", + ProductContext: "賣點描述", + PlacementURL: "https://example.com/product", + }) + if err != nil { + t.Fatal(err) + } + if created.PlacementURL != "https://example.com/product" { + t.Fatalf("create placement_url = %q", created.PlacementURL) + } + + label := "主力商品" + contextRaw := "賣點描述" + placementURL := "https://example.com/updated" + updated, err := uc.UpdateProduct(ctx, domusecase.UpdateProductRequest{ + TenantID: "tenant-1", + OwnerUID: "user-1", + BrandID: "brand-1", + ProductID: created.ID, + Label: &label, + ProductContext: &contextRaw, + PlacementURL: &placementURL, + PlacementURLSet: true, + }) + if err != nil { + t.Fatal(err) + } + if updated.PlacementURL != placementURL { + t.Fatalf("update placement_url = %q, want %q", updated.PlacementURL, placementURL) + } +} diff --git a/backend/internal/model/brand/usecase/usecase.go b/backend/internal/model/brand/usecase/usecase.go index 57059f1..1b0edba 100644 --- a/backend/internal/model/brand/usecase/usecase.go +++ b/backend/internal/model/brand/usecase/usecase.go @@ -100,6 +100,9 @@ func (u *brandUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) return nil, err } patch := patchToMap(req.Patch) + if len(patch) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("brand patch is empty") + } if req.Patch.ProductID != nil { snapshot := placement.ResolveBrandProductContext(*brand, strings.TrimSpace(*req.Patch.ProductID), "") patch["product_context"] = snapshot diff --git a/backend/internal/model/content_formula/domain/entity/formula.go b/backend/internal/model/content_formula/domain/entity/formula.go new file mode 100644 index 0000000..0eff018 --- /dev/null +++ b/backend/internal/model/content_formula/domain/entity/formula.go @@ -0,0 +1,45 @@ +package entity + +const CollectionName = "content_formulas" + +const ( + SourcePaste = "paste" + SourceOwnPost = "own_post" + SourceScanPost = "scan_post" + SourceKeywordSearch = "keyword_search" +) + +type SourceMetrics struct { + LikeCount int `bson:"like_count,omitempty"` + ReplyCount int `bson:"reply_count,omitempty"` + Views int `bson:"views,omitempty"` + RepostCount int `bson:"repost_count,omitempty"` + QuoteCount int `bson:"quote_count,omitempty"` + Shares int `bson:"shares,omitempty"` +} + +type ContentFormula struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + Label string `bson:"label"` + SourceType string `bson:"source_type"` + SourceRef string `bson:"source_ref,omitempty"` + SourcePostText string `bson:"source_post_text,omitempty"` + SourceAuthor string `bson:"source_author,omitempty"` + SourcePermalink string `bson:"source_permalink,omitempty"` + SourceMetrics *SourceMetrics `bson:"source_metrics,omitempty"` + Summary string `bson:"summary"` + Wins []string `bson:"wins,omitempty"` + Improvements []string `bson:"improvements,omitempty"` + Formula string `bson:"formula"` + PostTemplate string `bson:"post_template"` + HookPattern string `bson:"hook_pattern"` + Structure string `bson:"structure"` + ReplicationTips []string `bson:"replication_tips,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + Tags []string `bson:"tags,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/content_formula/domain/repository/repository.go b/backend/internal/model/content_formula/domain/repository/repository.go new file mode 100644 index 0000000..3ad8962 --- /dev/null +++ b/backend/internal/model/content_formula/domain/repository/repository.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/content_formula/domain/entity" +) + +type ListFilter struct { + TenantID string + OwnerUID string + AccountID string + SourceType string + Tag string + Page int + PageSize int +} + +type ListResult struct { + Items []entity.ContentFormula + Total int64 +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, formula *entity.ContentFormula) error + Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error) + Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error + List(ctx context.Context, filter ListFilter) (*ListResult, error) +} diff --git a/backend/internal/model/content_formula/domain/usecase/usecase.go b/backend/internal/model/content_formula/domain/usecase/usecase.go new file mode 100644 index 0000000..3969de1 --- /dev/null +++ b/backend/internal/model/content_formula/domain/usecase/usecase.go @@ -0,0 +1,90 @@ +package usecase + +import ( + "context" + + "haixun-backend/internal/model/content_formula/domain/entity" +) + +type FormulaSummary struct { + ID string + AccountID string + Label string + SourceType string + SourceRef string + SourcePostText string + SourceAuthor string + SourcePermalink string + SourceMetrics *entity.SourceMetrics + Summary string + Wins []string + Improvements []string + Formula string + PostTemplate string + HookPattern string + Structure string + ReplicationTips []string + Avoid []string + Tags []string + CreateAt int64 + UpdateAt int64 +} + +type CreateRequest struct { + TenantID string + OwnerUID string + AccountID string + Label string + SourceType string + SourceRef string + SourcePostText string + SourceAuthor string + SourcePermalink string + SourceMetrics *entity.SourceMetrics + Summary string + Wins []string + Improvements []string + Formula string + PostTemplate string + HookPattern string + Structure string + ReplicationTips []string + Avoid []string + Tags []string +} + +type UpdateRequest struct { + TenantID string + OwnerUID string + AccountID string + FormulaID string + Label *string + Formula *string + PostTemplate *string + HookPattern *string + Structure *string + Tags []string +} + +type ListRequest struct { + TenantID string + OwnerUID string + AccountID string + SourceType string + Tag string + Page int + PageSize int +} + +type ListResult struct { + Items []FormulaSummary + Total int64 +} + +type UseCase interface { + Create(ctx context.Context, req CreateRequest) (*FormulaSummary, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*FormulaSummary, error) + Update(ctx context.Context, req UpdateRequest) (*FormulaSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error + List(ctx context.Context, req ListRequest) (*ListResult, error) +} diff --git a/backend/internal/model/content_formula/repository/mongo.go b/backend/internal/model/content_formula/repository/mongo.go new file mode 100644 index 0000000..b7336e3 --- /dev/null +++ b/backend/internal/model/content_formula/repository/mongo.go @@ -0,0 +1,165 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_formula/domain/entity" + domrepo "haixun-backend/internal/model/content_formula/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "update_at", Value: -1}, + }, + }, + }) + return err +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + } +} + +func normalizePage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func (r *mongoRepository) Create(ctx context.Context, formula *entity.ContentFormula) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if formula == nil { + return app.For(code.ThreadsAccount).InputMissingRequired("formula is required") + } + _, err := r.collection.InsertOne(ctx, formula) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + formulaID = strings.TrimSpace(formulaID) + if formulaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula_id is required") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = formulaID + var out entity.ContentFormula + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if len(patch) == 0 { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("patch is required") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = strings.TrimSpace(formulaID) + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var out entity.ContentFormula + err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = strings.TrimSpace(formulaID) + res, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.ThreadsAccount).ResNotFound("content formula not found") + } + return nil +} + +func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter) (*domrepo.ListResult, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + page, pageSize := normalizePage(filter.Page, filter.PageSize) + mongoFilter := actorFilter(filter.TenantID, filter.OwnerUID, filter.AccountID) + if sourceType := strings.TrimSpace(filter.SourceType); sourceType != "" { + mongoFilter["source_type"] = sourceType + } + if tag := strings.TrimSpace(filter.Tag); tag != "" { + mongoFilter["tags"] = tag + } + total, err := r.collection.CountDocuments(ctx, mongoFilter) + if err != nil { + return nil, err + } + opts := options.Find(). + SetSort(bson.D{{Key: "update_at", Value: -1}}). + SetSkip(int64((page - 1) * pageSize)). + SetLimit(int64(pageSize)) + cur, err := r.collection.Find(ctx, mongoFilter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []entity.ContentFormula + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return &domrepo.ListResult{Items: items, Total: total}, nil +} diff --git a/backend/internal/model/content_formula/usecase/usecase.go b/backend/internal/model/content_formula/usecase/usecase.go new file mode 100644 index 0000000..9cd4eb9 --- /dev/null +++ b/backend/internal/model/content_formula/usecase/usecase.go @@ -0,0 +1,184 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_formula/domain/entity" + domrepo "haixun-backend/internal/model/content_formula/domain/repository" + domusecase "haixun-backend/internal/model/content_formula/domain/usecase" + + "github.com/google/uuid" +) + +type useCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &useCase{repo: repo} +} + +func (u *useCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.FormulaSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + label := strings.TrimSpace(req.Label) + if label == "" { + label = "未命名公式" + } + sourceType := strings.TrimSpace(req.SourceType) + if sourceType == "" { + sourceType = entity.SourcePaste + } + formulaText := strings.TrimSpace(req.Formula) + if formulaText == "" && strings.TrimSpace(req.PostTemplate) == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula or post_template is required") + } + now := clock.NowUnixNano() + item := &entity.ContentFormula{ + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + AccountID: req.AccountID, + Label: label, + SourceType: sourceType, + SourceRef: strings.TrimSpace(req.SourceRef), + SourcePostText: strings.TrimSpace(req.SourcePostText), + SourceAuthor: strings.TrimSpace(req.SourceAuthor), + SourcePermalink: strings.TrimSpace(req.SourcePermalink), + SourceMetrics: req.SourceMetrics, + Summary: strings.TrimSpace(req.Summary), + Wins: req.Wins, + Improvements: req.Improvements, + Formula: formulaText, + PostTemplate: strings.TrimSpace(req.PostTemplate), + HookPattern: strings.TrimSpace(req.HookPattern), + Structure: strings.TrimSpace(req.Structure), + ReplicationTips: req.ReplicationTips, + Avoid: req.Avoid, + Tags: req.Tags, + CreateAt: now, + UpdateAt: now, + } + if err := u.repo.Create(ctx, item); err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*domusecase.FormulaSummary, error) { + if err := requireActor(tenantID, ownerUID, accountID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, formulaID) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *useCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.FormulaSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + patch := map[string]interface{}{"update_at": clock.NowUnixNano()} + if req.Label != nil { + patch["label"] = strings.TrimSpace(*req.Label) + } + if req.Formula != nil { + patch["formula"] = strings.TrimSpace(*req.Formula) + } + if req.PostTemplate != nil { + patch["post_template"] = strings.TrimSpace(*req.PostTemplate) + } + if req.HookPattern != nil { + patch["hook_pattern"] = strings.TrimSpace(*req.HookPattern) + } + if req.Structure != nil { + patch["structure"] = strings.TrimSpace(*req.Structure) + } + if req.Tags != nil { + patch["tags"] = req.Tags + } + if len(patch) == 1 { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("no fields to update") + } + item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.FormulaID, patch) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *useCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error { + if err := requireActor(tenantID, ownerUID, accountID); err != nil { + return err + } + return u.repo.Delete(ctx, tenantID, ownerUID, accountID, formulaID) +} + +func (u *useCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + result, err := u.repo.List(ctx, domrepo.ListFilter{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + AccountID: req.AccountID, + SourceType: req.SourceType, + Tag: req.Tag, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + out := make([]domusecase.FormulaSummary, 0, len(result.Items)) + for _, item := range result.Items { + out = append(out, toSummary(item)) + } + return &domusecase.ListResult{Items: out, Total: result.Total}, nil +} + +func requireActor(tenantID, ownerUID, accountID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(accountID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + return nil +} + +func toSummary(item entity.ContentFormula) domusecase.FormulaSummary { + return domusecase.FormulaSummary{ + ID: item.ID, + AccountID: item.AccountID, + Label: item.Label, + SourceType: item.SourceType, + SourceRef: item.SourceRef, + SourcePostText: item.SourcePostText, + SourceAuthor: item.SourceAuthor, + SourcePermalink: item.SourcePermalink, + SourceMetrics: item.SourceMetrics, + Summary: item.Summary, + Wins: item.Wins, + Improvements: item.Improvements, + Formula: item.Formula, + PostTemplate: item.PostTemplate, + HookPattern: item.HookPattern, + Structure: item.Structure, + ReplicationTips: item.ReplicationTips, + Avoid: item.Avoid, + Tags: item.Tags, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} diff --git a/backend/internal/model/content_ops/domain/entity/entity.go b/backend/internal/model/content_ops/domain/entity/entity.go new file mode 100644 index 0000000..5bafaf7 --- /dev/null +++ b/backend/internal/model/content_ops/domain/entity/entity.go @@ -0,0 +1,112 @@ +package entity + +const ( + TopicCandidateCollectionName = "topic_candidates" + ContentPlanCollectionName = "content_plans" + FeedbackEventCollectionName = "feedback_events" + KnowledgeSourceCollectionName = "knowledge_sources" + KnowledgeChunkCollectionName = "knowledge_chunks" + FormulaPoolCollectionName = "formula_pools" +) + +type TopicCandidate struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + Name string `bson:"name"` + Source string `bson:"source,omitempty"` + Category string `bson:"category,omitempty"` + SeedQuery string `bson:"seed_query,omitempty"` + TrendReason string `bson:"trend_reason,omitempty"` + TrendKeywords []string `bson:"trend_keywords,omitempty"` + HeatScore int `bson:"heat_score,omitempty"` + FitScore int `bson:"fit_score,omitempty"` + InteractionScore int `bson:"interaction_score,omitempty"` + ExtendScore int `bson:"extend_score,omitempty"` + RiskScore int `bson:"risk_score,omitempty"` + FinalScore float64 `bson:"final_score,omitempty"` + RecommendedMissions []string `bson:"recommended_missions,omitempty"` + Status string `bson:"status,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +type KnowledgeSource struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + SourceType string `bson:"source_type"` + Title string `bson:"title,omitempty"` + Filename string `bson:"filename,omitempty"` + RawContent string `bson:"raw_content,omitempty"` + ParsedStatus string `bson:"parsed_status,omitempty"` + ChunkCount int `bson:"chunk_count,omitempty"` + CreateAt int64 `bson:"create_at"` +} + +type KnowledgeChunk struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + SourceID string `bson:"source_id"` + Content string `bson:"content"` + Topics []string `bson:"topics,omitempty"` + StyleTags []string `bson:"style_tags,omitempty"` + RiskLevel string `bson:"risk_level,omitempty"` + CreateAt int64 `bson:"create_at"` +} + +type FormulaPool struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + Type string `bson:"type"` + Name string `bson:"name"` + Pattern string `bson:"pattern,omitempty"` + UseCases []string `bson:"use_cases,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + Weight int `bson:"weight,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +type ContentPlan struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + TopicCandidateID string `bson:"topic_candidate_id,omitempty"` + Topic string `bson:"topic"` + Mission string `bson:"mission"` + TargetAudience string `bson:"target_audience,omitempty"` + Angle string `bson:"angle,omitempty"` + OpeningType string `bson:"opening_type,omitempty"` + BodyType string `bson:"body_type,omitempty"` + Emotion string `bson:"emotion,omitempty"` + EndingType string `bson:"ending_type,omitempty"` + CtaType string `bson:"cta_type,omitempty"` + RiskLevel string `bson:"risk_level,omitempty"` + RequiresHumanReview bool `bson:"requires_human_review,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + SelectedKnowledge []string `bson:"selected_knowledge,omitempty"` + Status string `bson:"status,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +type FeedbackEvent struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + ContentPlanID string `bson:"content_plan_id,omitempty"` + DraftID string `bson:"draft_id,omitempty"` + Decision string `bson:"decision"` + Note string `bson:"note,omitempty"` + Snapshot string `bson:"snapshot,omitempty"` + CreateAt int64 `bson:"create_at"` +} diff --git a/backend/internal/model/content_ops/domain/repository/repository.go b/backend/internal/model/content_ops/domain/repository/repository.go new file mode 100644 index 0000000..768f9b8 --- /dev/null +++ b/backend/internal/model/content_ops/domain/repository/repository.go @@ -0,0 +1,24 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/content_ops/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error + ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error) + CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error + UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error) + GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error) + ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error) + CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error + ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error) + CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error + ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error) + ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error) + CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error + ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error) +} diff --git a/backend/internal/model/content_ops/domain/usecase/usecase.go b/backend/internal/model/content_ops/domain/usecase/usecase.go new file mode 100644 index 0000000..a715fa4 --- /dev/null +++ b/backend/internal/model/content_ops/domain/usecase/usecase.go @@ -0,0 +1,163 @@ +package usecase + +import "context" + +type TopicCandidateSummary struct { + ID string + PersonaID string + Name string + Source string + Category string + SeedQuery string + TrendReason string + TrendKeywords []string + HeatScore int + FitScore int + InteractionScore int + ExtendScore int + RiskScore int + FinalScore float64 + RecommendedMissions []string + Status string + CreateAt int64 + UpdateAt int64 +} + +type ContentPlanSummary struct { + ID string + PersonaID string + TopicCandidateID string + Topic string + Mission string + TargetAudience string + Angle string + OpeningType string + BodyType string + Emotion string + EndingType string + CtaType string + RiskLevel string + RequiresHumanReview bool + Avoid []string + SelectedKnowledge []string + Status string + CreateAt int64 + UpdateAt int64 +} + +type FeedbackEventSummary struct { + ID string + PersonaID string + ContentPlanID string + DraftID string + Decision string + Note string + Snapshot string + CreateAt int64 +} + +type KnowledgeSourceSummary struct { + ID, PersonaID, SourceType, Title, Filename, ParsedStatus string + ChunkCount int + CreateAt int64 +} +type KnowledgeChunkSummary struct { + ID, PersonaID, SourceID, Content, RiskLevel string + Topics, StyleTags []string + CreateAt int64 +} +type FormulaPoolSummary struct { + ID, PersonaID, Type, Name, Pattern string + UseCases, Avoid []string + Weight int + CreateAt, UpdateAt int64 +} + +type KnowledgeSourceInput struct{ TenantID, OwnerUID, PersonaID, SourceType, Title, Filename, Content, ContentBase64 string } +type FormulaPoolInput struct { + TenantID, OwnerUID, PersonaID, Type, Name, Pattern string + UseCases, Avoid []string + Weight int +} + +type TopicCandidateInput struct { + TenantID string + OwnerUID string + PersonaID string + Name string + Source string + Category string + SeedQuery string + TrendReason string + TrendKeywords []string + HeatScore int + FitScore int + InteractionScore int + ExtendScore int + RiskScore int + RecommendedMissions []string +} + +type ContentPlanInput struct { + TenantID string + OwnerUID string + PersonaID string + TopicCandidateID string + Topic string + Mission string + TargetAudience string + Angle string + OpeningType string + BodyType string + Emotion string + EndingType string + CtaType string + RiskLevel string + RequiresHumanReview bool + Avoid []string + SelectedKnowledge []string +} + +type ContentPlanPatch struct { + Topic *string + Mission *string + TargetAudience *string + Angle *string + OpeningType *string + BodyType *string + Emotion *string + EndingType *string + CtaType *string + RiskLevel *string + RequiresHumanReview *bool + Avoid []string + SelectedKnowledge []string + Status *string +} + +type FeedbackEventInput struct { + TenantID string + OwnerUID string + PersonaID string + ContentPlanID string + DraftID string + Decision string + Note string + Snapshot string +} + +type UseCase interface { + CreateTopicCandidate(ctx context.Context, req TopicCandidateInput) (*TopicCandidateSummary, error) + ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]TopicCandidateSummary, error) + CreateContentPlan(ctx context.Context, req ContentPlanInput) (*ContentPlanSummary, error) + UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch ContentPlanPatch) (*ContentPlanSummary, error) + GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*ContentPlanSummary, error) + ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]ContentPlanSummary, error) + CreateFeedbackEvent(ctx context.Context, req FeedbackEventInput) (*FeedbackEventSummary, error) + ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FeedbackEventSummary, error) + CreateKnowledgeSource(ctx context.Context, req KnowledgeSourceInput) (*KnowledgeSourceSummary, error) + ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeSourceSummary, error) + ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeChunkSummary, error) + CreateFormulaPool(ctx context.Context, req FormulaPoolInput) (*FormulaPoolSummary, error) + ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FormulaPoolSummary, error) +} diff --git a/backend/internal/model/content_ops/repository/mongo.go b/backend/internal/model/content_ops/repository/mongo.go new file mode 100644 index 0000000..9444d99 --- /dev/null +++ b/backend/internal/model/content_ops/repository/mongo.go @@ -0,0 +1,232 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_ops/domain/entity" + domrepo "haixun-backend/internal/model/content_ops/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + topics *mongo.Collection + plans *mongo.Collection + feedback *mongo.Collection + sources *mongo.Collection + chunks *mongo.Collection + formulas *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{ + topics: db.Collection(entity.TopicCandidateCollectionName), + plans: db.Collection(entity.ContentPlanCollectionName), + feedback: db.Collection(entity.FeedbackEventCollectionName), + sources: db.Collection(entity.KnowledgeSourceCollectionName), + chunks: db.Collection(entity.KnowledgeChunkCollectionName), + formulas: db.Collection(entity.FormulaPoolCollectionName), + } +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.topics == nil || r.plans == nil || r.feedback == nil || r.sources == nil || r.chunks == nil || r.formulas == nil { + return nil + } + if _, err := r.topics.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil { + return err + } + if _, err := r.plans.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "topic_candidate_id", Value: 1}}}}); err != nil { + return err + } + _, err := r.feedback.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "content_plan_id", Value: 1}}}, {Keys: bson.D{{Key: "draft_id", Value: 1}}}}) + if err != nil { + return err + } + if _, err := r.sources.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil { + return err + } + if _, err := r.chunks.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "source_id", Value: 1}}}}); err != nil { + return err + } + _, err = r.formulas.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "type", Value: 1}}}}) + return err +} + +func personaFilter(tenantID, ownerUID, personaID string) bson.M { + return bson.M{"tenant_id": strings.TrimSpace(tenantID), "owner_uid": strings.TrimSpace(ownerUID), "persona_id": strings.TrimSpace(personaID)} +} + +func normalizeLimit(limit int) int64 { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + return int64(limit) +} + +func (r *mongoRepository) CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error { + if r.topics == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.topics.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error) { + if r.topics == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.topics.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.TopicCandidate + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error { + if r.plans == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.plans.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error) { + if r.plans == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(id) + var out entity.ContentPlan + err := r.plans.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("找不到內容計畫") + } + return &out, err +} + +func (r *mongoRepository) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error) { + if r.plans == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(id) + var out entity.ContentPlan + err := r.plans.FindOne(ctx, filter).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("找不到內容計畫") + } + return &out, err +} + +func (r *mongoRepository) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error) { + if r.plans == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.plans.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.ContentPlan + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error { + if r.feedback == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.feedback.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error) { + if r.feedback == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.feedback.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.FeedbackEvent + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error { + if r.sources == nil || r.chunks == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if _, err := r.sources.InsertOne(ctx, item); err != nil { + return err + } + if len(chunks) == 0 { + return nil + } + docs := make([]any, 0, len(chunks)) + for _, c := range chunks { + docs = append(docs, c) + } + _, err := r.chunks.InsertMany(ctx, docs) + return err +} + +func (r *mongoRepository) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error) { + if r.sources == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.sources.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.KnowledgeSource + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error) { + if r.chunks == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.chunks.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.KnowledgeChunk + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error { + if r.formulas == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.formulas.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error) { + if r.formulas == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.formulas.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "weight", Value: -1}, {Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.FormulaPool + return out, cur.All(ctx, &out) +} diff --git a/backend/internal/model/content_ops/usecase/usecase.go b/backend/internal/model/content_ops/usecase/usecase.go new file mode 100644 index 0000000..898c0b8 --- /dev/null +++ b/backend/internal/model/content_ops/usecase/usecase.go @@ -0,0 +1,415 @@ +package usecase + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/xml" + "math" + "path/filepath" + "regexp" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_ops/domain/entity" + domrepo "haixun-backend/internal/model/content_ops/domain/repository" + domusecase "haixun-backend/internal/model/content_ops/domain/usecase" + + "github.com/google/uuid" +) + +type useCase struct{ repo domrepo.Repository } + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { return &useCase{repo: repo} } + +func requireActor(tenantID, ownerUID, personaID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" || strings.TrimSpace(personaID) == "" { + return app.For(code.Persona).InputMissingRequired("tenant_id, owner_uid, and persona_id are required") + } + return nil +} + +func clampScore(n int) int { + if n < 0 { + return 0 + } + if n > 100 { + return 100 + } + return n +} + +func finalScore(heat, fit, interaction, extend, risk int) float64 { + score := float64(clampScore(heat))*0.25 + float64(clampScore(fit))*0.30 + float64(clampScore(interaction))*0.15 + float64(clampScore(extend))*0.15 + 70*0.10 - float64(clampScore(risk))*0.20 + return math.Round(score*10) / 10 +} + +func (u *useCase) CreateTopicCandidate(ctx context.Context, req domusecase.TopicCandidateInput) (*domusecase.TopicCandidateSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic name is required") + } + now := clock.NowUnixNano() + item := &entity.TopicCandidate{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Name: name, Source: strings.TrimSpace(req.Source), Category: strings.TrimSpace(req.Category), SeedQuery: strings.TrimSpace(req.SeedQuery), TrendReason: strings.TrimSpace(req.TrendReason), TrendKeywords: req.TrendKeywords, HeatScore: clampScore(req.HeatScore), FitScore: clampScore(req.FitScore), InteractionScore: clampScore(req.InteractionScore), ExtendScore: clampScore(req.ExtendScore), RiskScore: clampScore(req.RiskScore), RecommendedMissions: req.RecommendedMissions, Status: "candidate", CreateAt: now, UpdateAt: now} + item.FinalScore = finalScore(item.HeatScore, item.FitScore, item.InteractionScore, item.ExtendScore, item.RiskScore) + if err := u.repo.CreateTopicCandidate(ctx, item); err != nil { + return nil, err + } + out := topicSummary(*item) + return &out, nil +} + +func (u *useCase) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.TopicCandidateSummary, error) { + items, err := u.repo.ListTopicCandidates(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.TopicCandidateSummary, 0, len(items)) + for _, item := range items { + out = append(out, topicSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateContentPlan(ctx context.Context, req domusecase.ContentPlanInput) (*domusecase.ContentPlanSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Mission) == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic and mission are required") + } + now := clock.NowUnixNano() + item := &entity.ContentPlan{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, TopicCandidateID: strings.TrimSpace(req.TopicCandidateID), Topic: strings.TrimSpace(req.Topic), Mission: strings.TrimSpace(req.Mission), TargetAudience: strings.TrimSpace(req.TargetAudience), Angle: strings.TrimSpace(req.Angle), OpeningType: strings.TrimSpace(req.OpeningType), BodyType: strings.TrimSpace(req.BodyType), Emotion: strings.TrimSpace(req.Emotion), EndingType: strings.TrimSpace(req.EndingType), CtaType: strings.TrimSpace(req.CtaType), RiskLevel: normalizeRisk(req.RiskLevel), RequiresHumanReview: req.RequiresHumanReview || normalizeRisk(req.RiskLevel) != "low", Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: "planned", CreateAt: now, UpdateAt: now} + if err := u.repo.CreateContentPlan(ctx, item); err != nil { + return nil, err + } + out := planSummary(*item) + return &out, nil +} + +func (u *useCase) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch domusecase.ContentPlanPatch) (*domusecase.ContentPlanSummary, error) { + m := map[string]any{"update_at": clock.NowUnixNano()} + setString := func(key string, value *string) { + if value != nil { + m[key] = strings.TrimSpace(*value) + } + } + setString("topic", patch.Topic) + setString("mission", patch.Mission) + setString("target_audience", patch.TargetAudience) + setString("angle", patch.Angle) + setString("opening_type", patch.OpeningType) + setString("body_type", patch.BodyType) + setString("emotion", patch.Emotion) + setString("ending_type", patch.EndingType) + setString("cta_type", patch.CtaType) + setString("status", patch.Status) + if patch.RiskLevel != nil { + m["risk_level"] = normalizeRisk(*patch.RiskLevel) + } + if patch.RequiresHumanReview != nil { + m["requires_human_review"] = *patch.RequiresHumanReview + } + if patch.Avoid != nil { + m["avoid"] = patch.Avoid + } + if patch.SelectedKnowledge != nil { + m["selected_knowledge"] = patch.SelectedKnowledge + } + item, err := u.repo.UpdateContentPlan(ctx, tenantID, ownerUID, personaID, id, m) + if err != nil { + return nil, err + } + out := planSummary(*item) + return &out, nil +} + +func (u *useCase) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*domusecase.ContentPlanSummary, error) { + item, err := u.repo.GetContentPlan(ctx, tenantID, ownerUID, personaID, id) + if err != nil { + return nil, err + } + out := planSummary(*item) + return &out, nil +} + +func (u *useCase) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.ContentPlanSummary, error) { + items, err := u.repo.ListContentPlans(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.ContentPlanSummary, 0, len(items)) + for _, item := range items { + out = append(out, planSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateFeedbackEvent(ctx context.Context, req domusecase.FeedbackEventInput) (*domusecase.FeedbackEventSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + decision := strings.TrimSpace(req.Decision) + if decision == "" { + return nil, app.For(code.Persona).InputMissingRequired("decision is required") + } + item := &entity.FeedbackEvent{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, ContentPlanID: strings.TrimSpace(req.ContentPlanID), DraftID: strings.TrimSpace(req.DraftID), Decision: decision, Note: strings.TrimSpace(req.Note), Snapshot: strings.TrimSpace(req.Snapshot), CreateAt: clock.NowUnixNano()} + if err := u.repo.CreateFeedbackEvent(ctx, item); err != nil { + return nil, err + } + out := feedbackSummary(*item) + return &out, nil +} + +func (u *useCase) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FeedbackEventSummary, error) { + items, err := u.repo.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.FeedbackEventSummary, 0, len(items)) + for _, item := range items { + out = append(out, feedbackSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateKnowledgeSource(ctx context.Context, req domusecase.KnowledgeSourceInput) (*domusecase.KnowledgeSourceSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + raw, err := decodeSourceContent(req) + if err != nil { + return nil, app.For(code.Persona).InputInvalidFormat(err.Error()) + } + chunksText := splitChunks(raw, 900) + now := clock.NowUnixNano() + sourceID := uuid.NewString() + item := &entity.KnowledgeSource{ID: sourceID, TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceType: strings.TrimSpace(req.SourceType), Title: strings.TrimSpace(req.Title), Filename: strings.TrimSpace(req.Filename), RawContent: raw, ParsedStatus: "parsed", ChunkCount: len(chunksText), CreateAt: now} + chunks := make([]*entity.KnowledgeChunk, 0, len(chunksText)) + for _, text := range chunksText { + chunks = append(chunks, &entity.KnowledgeChunk{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceID: sourceID, Content: text, Topics: inferTopics(text), StyleTags: inferStyleTags(text), RiskLevel: inferRisk(text), CreateAt: now}) + } + if err := u.repo.CreateKnowledgeSource(ctx, item, chunks); err != nil { + return nil, err + } + out := sourceSummary(*item) + return &out, nil +} + +func (u *useCase) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeSourceSummary, error) { + items, err := u.repo.ListKnowledgeSources(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.KnowledgeSourceSummary, 0, len(items)) + for _, item := range items { + out = append(out, sourceSummary(item)) + } + return out, nil +} + +func (u *useCase) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeChunkSummary, error) { + items, err := u.repo.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.KnowledgeChunkSummary, 0, len(items)) + for _, item := range items { + out = append(out, chunkSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateFormulaPool(ctx context.Context, req domusecase.FormulaPoolInput) (*domusecase.FormulaPoolSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Name) == "" { + return nil, app.For(code.Persona).InputMissingRequired("type and name are required") + } + now := clock.NowUnixNano() + weight := req.Weight + if weight <= 0 { + weight = 50 + } + item := &entity.FormulaPool{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Type: strings.TrimSpace(req.Type), Name: strings.TrimSpace(req.Name), Pattern: strings.TrimSpace(req.Pattern), UseCases: req.UseCases, Avoid: req.Avoid, Weight: weight, CreateAt: now, UpdateAt: now} + if err := u.repo.CreateFormulaPool(ctx, item); err != nil { + return nil, err + } + out := formulaSummary(*item) + return &out, nil +} + +func (u *useCase) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FormulaPoolSummary, error) { + items, err := u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + if len(items) == 0 { + _ = u.seedDefaultFormulas(ctx, tenantID, ownerUID, personaID) + items, _ = u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit) + } + out := make([]domusecase.FormulaPoolSummary, 0, len(items)) + for _, item := range items { + out = append(out, formulaSummary(item)) + } + return out, nil +} + +func (u *useCase) seedDefaultFormulas(ctx context.Context, tenantID, ownerUID, personaID string) error { + defs := []domusecase.FormulaPoolInput{{Type: "opening", Name: "問題開場", Pattern: "用讀者正在問的具體問題開場", UseCases: []string{"互動文"}, Weight: 70}, {Type: "body", Name: "先共鳴再整理", Pattern: "先接住情緒,再整理 2-3 個可執行觀點", UseCases: []string{"共鳴文", "知識文"}, Weight: 75}, {Type: "emotion", Name: "被理解感", Pattern: "講出讀者不一定說出口的焦慮或委屈", UseCases: []string{"情緒共鳴文"}, Weight: 70}, {Type: "cta", Name: "經驗募集型", Pattern: "邀請讀者補充自己的經驗,不硬導購", UseCases: []string{"互動文"}, Weight: 65}} + for _, def := range defs { + def.TenantID = tenantID + def.OwnerUID = ownerUID + def.PersonaID = personaID + if _, err := u.CreateFormulaPool(ctx, def); err != nil { + return err + } + } + return nil +} + +func decodeSourceContent(req domusecase.KnowledgeSourceInput) (string, error) { + if strings.TrimSpace(req.ContentBase64) != "" { + data, err := base64.StdEncoding.DecodeString(req.ContentBase64) + if err != nil { + return "", err + } + if strings.EqualFold(filepath.Ext(req.Filename), ".xlsx") { + return parseXLSX(data) + } + return string(data), nil + } + return strings.TrimSpace(req.Content), nil +} + +type xlsxText struct { + T string `xml:",chardata"` +} + +func parseXLSX(data []byte) (string, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return "", err + } + var b strings.Builder + for _, f := range zr.File { + if !strings.HasPrefix(f.Name, "xl/sharedStrings") && !strings.HasPrefix(f.Name, "xl/worksheets") { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + var texts []xlsxText + _ = xml.NewDecoder(rc).Decode(&texts) + _ = rc.Close() + for _, t := range texts { + if s := strings.TrimSpace(t.T); s != "" { + b.WriteString(s) + b.WriteString("\n") + } + } + } + text := strings.TrimSpace(b.String()) + if text == "" { + return "", nil + } + return text, nil +} + +func splitChunks(raw string, max int) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + paras := strings.Split(raw, "\n") + var out []string + var b strings.Builder + for _, p := range paras { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if b.Len()+len([]rune(p)) > max && b.Len() > 0 { + out = append(out, b.String()) + b.Reset() + } + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(p) + } + if b.Len() > 0 { + out = append(out, b.String()) + } + return out +} + +var topicRE = regexp.MustCompile(`[A-Za-z0-9_\p{Han}]{2,12}`) + +func inferTopics(text string) []string { + seen := map[string]bool{} + var out []string + for _, m := range topicRE.FindAllString(text, 20) { + if !seen[m] && len(out) < 8 { + seen[m] = true + out = append(out, m) + } + } + return out +} +func inferStyleTags(text string) []string { + var out []string + if strings.Contains(text, "我") { + out = append(out, "第一人稱") + } + if strings.Contains(text, "?") || strings.Contains(text, "?") { + out = append(out, "提問") + } + if strings.Contains(text, "。") { + out = append(out, "自然標點") + } + return out +} +func inferRisk(text string) string { + lower := strings.ToLower(text) + for _, k := range []string{"醫", "藥", "amh", "投資", "法律"} { + if strings.Contains(lower, k) { + return "medium" + } + } + return "low" +} + +func normalizeRisk(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "high" || raw == "low" { + return raw + } + return "medium" +} +func topicSummary(item entity.TopicCandidate) domusecase.TopicCandidateSummary { + return domusecase.TopicCandidateSummary{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} +func planSummary(item entity.ContentPlan) domusecase.ContentPlanSummary { + return domusecase.ContentPlanSummary{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} +func feedbackSummary(item entity.FeedbackEvent) domusecase.FeedbackEventSummary { + return domusecase.FeedbackEventSummary{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt} +} +func sourceSummary(item entity.KnowledgeSource) domusecase.KnowledgeSourceSummary { + return domusecase.KnowledgeSourceSummary{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt} +} +func chunkSummary(item entity.KnowledgeChunk) domusecase.KnowledgeChunkSummary { + return domusecase.KnowledgeChunkSummary{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt} +} +func formulaSummary(item entity.FormulaPool) domusecase.FormulaPoolSummary { + return domusecase.FormulaPoolSummary{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} diff --git a/backend/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go index 75e6eb8..2fb9a47 100644 --- a/backend/internal/model/copy_draft/domain/entity/draft.go +++ b/backend/internal/model/copy_draft/domain/entity/draft.go @@ -6,27 +6,40 @@ const ( DraftTypeViralReplica = "viral-replica" DraftTypeReplicate = "replicate" DraftTypeMatrix = "matrix" + DraftTypeFormula = "formula" ) type CopyDraft struct { - ID string `bson:"_id"` - TenantID string `bson:"tenant_id"` - OwnerUID string `bson:"owner_uid"` - PersonaID string `bson:"persona_id"` - CopyMissionID string `bson:"copy_mission_id,omitempty"` - ScanPostID string `bson:"scan_post_id,omitempty"` - DraftType string `bson:"draft_type"` - MatrixBatchID string `bson:"matrix_batch_id,omitempty"` - SortOrder int `bson:"sort_order,omitempty"` - Text string `bson:"text"` - Angle string `bson:"angle,omitempty"` - Hook string `bson:"hook,omitempty"` - Rationale string `bson:"rationale,omitempty"` - ReferenceNotes string `bson:"reference_notes,omitempty"` - Sources []string `bson:"sources,omitempty"` - Status string `bson:"status,omitempty"` - PublishedMediaID string `bson:"published_media_id,omitempty"` - PublishedPermalink string `bson:"published_permalink,omitempty"` - PublishedAt int64 `bson:"published_at,omitempty"` - CreateAt int64 `bson:"create_at"` + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + ContentPlanID string `bson:"content_plan_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty"` + ScanPostID string `bson:"scan_post_id,omitempty"` + FormulaID string `bson:"formula_id,omitempty"` + DraftType string `bson:"draft_type"` + MatrixBatchID string `bson:"matrix_batch_id,omitempty"` + SortOrder int `bson:"sort_order,omitempty"` + Text string `bson:"text"` + TopicTag string `bson:"topic_tag,omitempty"` + Angle string `bson:"angle,omitempty"` + Hook string `bson:"hook,omitempty"` + Rationale string `bson:"rationale,omitempty"` + ReferenceNotes string `bson:"reference_notes,omitempty"` + Sources []string `bson:"sources,omitempty"` + AiScore int `bson:"ai_score,omitempty"` + FormulaScore int `bson:"formula_score,omitempty"` + BrandFitScore int `bson:"brand_fit_score,omitempty"` + RiskScore int `bson:"risk_score,omitempty"` + SimilarityScore int `bson:"similarity_score,omitempty"` + EngagementPotential int `bson:"engagement_potential,omitempty"` + FreshnessScore int `bson:"freshness_score,omitempty"` + ReviewSuggestion string `bson:"review_suggestion,omitempty"` + Status string `bson:"status,omitempty"` + PublishQueueID string `bson:"publish_queue_id,omitempty"` + PublishedMediaID string `bson:"published_media_id,omitempty"` + PublishedPermalink string `bson:"published_permalink,omitempty"` + PublishedAt int64 `bson:"published_at,omitempty"` + CreateAt int64 `bson:"create_at"` } diff --git a/backend/internal/model/copy_draft/domain/repository/repository.go b/backend/internal/model/copy_draft/domain/repository/repository.go index 2dc27af..61d35ce 100644 --- a/backend/internal/model/copy_draft/domain/repository/repository.go +++ b/backend/internal/model/copy_draft/domain/repository/repository.go @@ -6,6 +6,22 @@ import ( "haixun-backend/internal/model/copy_draft/domain/entity" ) +type InboxFilter struct { + TenantID string + OwnerUID string + PersonaID string + Status string + RangeStart int64 + RangeEnd int64 + Page int + PageSize int +} + +type InboxListResult struct { + Items []entity.CopyDraft + Total int64 +} + type Repository interface { EnsureIndexes(ctx context.Context) error Create(ctx context.Context, draft *entity.CopyDraft) error @@ -17,10 +33,13 @@ type Repository interface { DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error) + ListInbox(ctx context.Context, filter InboxFilter) (*InboxListResult, error) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) ReplaceMissionMatrix( ctx context.Context, tenantID, ownerUID, personaID, missionID string, drafts []*entity.CopyDraft, ) error + // PruneOldestPendingBeyond deletes oldest idle pending drafts (no mission / queue / publish) beyond keep. + PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) } diff --git a/backend/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go index 386601f..ac717cd 100644 --- a/backend/internal/model/copy_draft/domain/usecase/usecase.go +++ b/backend/internal/model/copy_draft/domain/usecase/usecase.go @@ -5,23 +5,35 @@ import ( ) type CopyDraftSummary struct { - ID string - PersonaID string - CopyMissionID string - ScanPostID string - DraftType string - SortOrder int - Text string - Angle string - Hook string - Rationale string - ReferenceNotes string - Sources []string - Status string - PublishedMediaID string - PublishedPermalink string - PublishedAt int64 - CreateAt int64 + ID string + PersonaID string + ContentPlanID string + CopyMissionID string + ScanPostID string + FormulaID string + DraftType string + SortOrder int + Text string + TopicTag string + Angle string + Hook string + Rationale string + ReferenceNotes string + Sources []string + AiScore int + FormulaScore int + BrandFitScore int + RiskScore int + SimilarityScore int + EngagementPotential int + FreshnessScore int + ReviewSuggestion string + Status string + PublishQueueID string + PublishedMediaID string + PublishedPermalink string + PublishedAt int64 + CreateAt int64 } type MarkPublishedRequest struct { @@ -33,20 +45,39 @@ type MarkPublishedRequest struct { Permalink string } +type MarkScheduledRequest struct { + TenantID string + OwnerUID string + PersonaID string + DraftID string + QueueID string +} + type CreateRequest struct { - TenantID string - OwnerUID string - PersonaID string - CopyMissionID string - ScanPostID string - DraftType string - SortOrder int - Text string - Angle string - Hook string - Rationale string - ReferenceNotes string - Sources []string + TenantID string + OwnerUID string + PersonaID string + ContentPlanID string + CopyMissionID string + ScanPostID string + FormulaID string + DraftType string + SortOrder int + Text string + TopicTag string + Angle string + Hook string + Rationale string + ReferenceNotes string + Sources []string + AiScore int + FormulaScore int + BrandFitScore int + RiskScore int + SimilarityScore int + EngagementPotential int + FreshnessScore int + ReviewSuggestion string } type CreateManyRequest struct { @@ -57,10 +88,12 @@ type CreateManyRequest struct { } type CopyDraftPatch struct { - Text *string - Hook *string - Angle *string - Status *string + Text *string + TopicTag *string + Hook *string + Angle *string + Rationale *string + Status *string } type UpdateRequest struct { @@ -71,6 +104,22 @@ type UpdateRequest struct { Patch CopyDraftPatch } +type InboxListRequest struct { + TenantID string + OwnerUID string + PersonaID string + Status string + RangeStart int64 + RangeEnd int64 + Page int + PageSize int +} + +type InboxListResult struct { + Items []CopyDraftSummary + Total int64 +} + type DeleteMissionMatrixDraftsRequest struct { TenantID string OwnerUID string @@ -86,7 +135,10 @@ type UseCase interface { Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*CopyDraftSummary, error) Update(ctx context.Context, req UpdateRequest) (*CopyDraftSummary, error) MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error) + MarkScheduled(ctx context.Context, req MarkScheduledRequest) (*CopyDraftSummary, error) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, error) + ListInbox(ctx context.Context, req InboxListRequest) (*InboxListResult, error) + PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]CopyDraftSummary, error) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error DeleteMissionMatrixDrafts(ctx context.Context, req DeleteMissionMatrixDraftsRequest) (int, error) diff --git a/backend/internal/model/copy_draft/repository/mongo.go b/backend/internal/model/copy_draft/repository/mongo.go index 24929e5..aa15a4d 100644 --- a/backend/internal/model/copy_draft/repository/mongo.go +++ b/backend/internal/model/copy_draft/repository/mongo.go @@ -39,6 +39,15 @@ func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { {Key: "create_at", Value: -1}, }, }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "persona_id", Value: 1}, + {Key: "content_plan_id", Value: 1}, + {Key: "create_at", Value: -1}, + }, + }, { Keys: bson.D{ {Key: "tenant_id", Value: 1}, @@ -92,6 +101,97 @@ func (r *mongoRepository) Create(ctx context.Context, draft *entity.CopyDraft) e return err } +func inboxStatusFilter(status string) bson.M { + switch strings.TrimSpace(status) { + case "draft": + return bson.M{ + "$and": []bson.M{ + {"$or": []bson.M{{"publish_queue_id": ""}, {"publish_queue_id": bson.M{"$exists": false}}}}, + {"$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}}, + }, + } + case "scheduled": + return bson.M{ + "publish_queue_id": bson.M{"$ne": ""}, + "$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}, + } + case "published": + return bson.M{"published_at": bson.M{"$gt": 0}} + default: + return bson.M{} + } +} + +func normalizeInboxPage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 12 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func (r *mongoRepository) ListInbox(ctx context.Context, filter domrepo.InboxFilter) (*domrepo.InboxListResult, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + page, pageSize := normalizeInboxPage(filter.Page, filter.PageSize) + mongoFilter := personaFilter(filter.TenantID, filter.OwnerUID, filter.PersonaID) + if extra := inboxStatusFilter(filter.Status); len(extra) > 0 { + for k, v := range extra { + mongoFilter[k] = v + } + } + if filter.RangeStart > 0 || filter.RangeEnd > 0 { + rangeClauses := make([]bson.M, 0, 3) + createRange := bson.M{} + if filter.RangeStart > 0 { + createRange["$gte"] = filter.RangeStart + } + if filter.RangeEnd > 0 { + createRange["$lte"] = filter.RangeEnd + } + if len(createRange) > 0 { + rangeClauses = append(rangeClauses, bson.M{"create_at": createRange}) + } + publishedRange := bson.M{} + if filter.RangeStart > 0 { + publishedRange["$gte"] = filter.RangeStart + } + if filter.RangeEnd > 0 { + publishedRange["$lte"] = filter.RangeEnd + } + if len(publishedRange) > 0 { + rangeClauses = append(rangeClauses, bson.M{"published_at": publishedRange}) + } + if len(rangeClauses) > 0 { + mongoFilter["$or"] = rangeClauses + } + } + total, err := r.collection.CountDocuments(ctx, mongoFilter) + if err != nil { + return nil, err + } + opts := options.Find(). + SetSort(bson.D{{Key: "create_at", Value: -1}}). + SetSkip(int64((page - 1) * pageSize)). + SetLimit(int64(pageSize)) + cur, err := r.collection.Find(ctx, mongoFilter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.CopyDraft + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return &domrepo.InboxListResult{Items: out, Total: total}, nil +} + func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error) { if r.collection == nil { return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") @@ -338,6 +438,68 @@ func (r *mongoRepository) replaceMissionMatrixInsertFirst( return err } +func pendingIdleDraftFilter(tenantID, ownerUID, personaID string) bson.M { + filter := personaFilter(tenantID, ownerUID, personaID) + filter["$and"] = []bson.M{ + {"$or": []bson.M{{"publish_queue_id": ""}, {"publish_queue_id": bson.M{"$exists": false}}}}, + {"$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}}, + {"$or": []bson.M{{"copy_mission_id": ""}, {"copy_mission_id": bson.M{"$exists": false}}}}, + } + return filter +} + +func (r *mongoRepository) PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) { + if r.collection == nil { + return 0, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if keep <= 0 { + keep = 40 + } + filter := pendingIdleDraftFilter(tenantID, ownerUID, personaID) + total, err := r.collection.CountDocuments(ctx, filter) + if err != nil { + return 0, err + } + excess := total - int64(keep) + if excess <= 0 { + return 0, nil + } + opts := options.Find(). + SetSort(bson.D{{Key: "create_at", Value: 1}}). + SetLimit(excess). + SetProjection(bson.M{"_id": 1}) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return 0, err + } + defer cur.Close(ctx) + ids := make([]string, 0, excess) + for cur.Next(ctx) { + var row struct { + ID string `bson:"_id"` + } + if err := cur.Decode(&row); err != nil { + return 0, err + } + if row.ID != "" { + ids = append(ids, row.ID) + } + } + if err := cur.Err(); err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + deleteFilter := personaFilter(tenantID, ownerUID, personaID) + deleteFilter["_id"] = bson.M{"$in": ids} + res, err := r.collection.DeleteMany(ctx, deleteFilter) + if err != nil { + return 0, err + } + return res.DeletedCount, nil +} + func (r *mongoRepository) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) { if r.collection == nil { return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") diff --git a/backend/internal/model/copy_draft/usecase/usecase.go b/backend/internal/model/copy_draft/usecase/usecase.go index 8a65d96..e757cef 100644 --- a/backend/internal/model/copy_draft/usecase/usecase.go +++ b/backend/internal/model/copy_draft/usecase/usecase.go @@ -8,14 +8,17 @@ import ( libcopy "haixun-backend/internal/library/copymission" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/library/threadspost" "haixun-backend/internal/model/copy_draft/domain/entity" domrepo "haixun-backend/internal/model/copy_draft/domain/repository" domusecase "haixun-backend/internal/model/copy_draft/domain/usecase" - goredis "github.com/redis/go-redis/v9" "github.com/google/uuid" + goredis "github.com/redis/go-redis/v9" ) +const defaultIdleDraftKeep = 40 + type copyDraftUseCase struct { repo domrepo.Repository redis *goredis.Client @@ -29,7 +32,7 @@ func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } - text := strings.TrimSpace(req.Text) + text := threadspost.FormatDraftText(req.Text) if text == "" { return nil, app.For(code.Persona).InputMissingRequired("draft text is required") } @@ -38,26 +41,38 @@ func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequ draftType = entity.DraftTypeViralReplica } item := &entity.CopyDraft{ - ID: uuid.NewString(), - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - PersonaID: req.PersonaID, - CopyMissionID: strings.TrimSpace(req.CopyMissionID), - ScanPostID: strings.TrimSpace(req.ScanPostID), - DraftType: draftType, - SortOrder: req.SortOrder, - Text: text, - Angle: strings.TrimSpace(req.Angle), - Hook: strings.TrimSpace(req.Hook), - Rationale: strings.TrimSpace(req.Rationale), - ReferenceNotes: strings.TrimSpace(req.ReferenceNotes), - Sources: req.Sources, - Status: "pending", - CreateAt: clock.NowUnixNano(), + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + PersonaID: req.PersonaID, + ContentPlanID: strings.TrimSpace(req.ContentPlanID), + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + ScanPostID: strings.TrimSpace(req.ScanPostID), + FormulaID: strings.TrimSpace(req.FormulaID), + DraftType: draftType, + SortOrder: req.SortOrder, + Text: text, + TopicTag: normalizeTopicTag(req.TopicTag), + Angle: strings.TrimSpace(req.Angle), + Hook: strings.TrimSpace(req.Hook), + Rationale: strings.TrimSpace(req.Rationale), + ReferenceNotes: strings.TrimSpace(req.ReferenceNotes), + Sources: req.Sources, + AiScore: clampScore(req.AiScore), + FormulaScore: clampScore(req.FormulaScore), + BrandFitScore: clampScore(req.BrandFitScore), + RiskScore: clampScore(req.RiskScore), + SimilarityScore: clampScore(req.SimilarityScore), + EngagementPotential: clampScore(req.EngagementPotential), + FreshnessScore: clampScore(req.FreshnessScore), + ReviewSuggestion: strings.TrimSpace(req.ReviewSuggestion), + Status: "pending", + CreateAt: clock.NowUnixNano(), } if err := u.repo.Create(ctx, item); err != nil { return nil, err } + u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID) summary := toSummary(*item) return &summary, nil } @@ -71,7 +86,7 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create } items := make([]*entity.CopyDraft, 0, len(req.Drafts)) for idx, draftReq := range req.Drafts { - text := strings.TrimSpace(draftReq.Text) + text := threadspost.FormatDraftText(draftReq.Text) if text == "" { continue } @@ -84,22 +99,32 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create sortOrder = idx + 1 } items = append(items, &entity.CopyDraft{ - ID: uuid.NewString(), - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - PersonaID: req.PersonaID, - CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID), - ScanPostID: strings.TrimSpace(draftReq.ScanPostID), - DraftType: draftType, - SortOrder: sortOrder, - Text: text, - Angle: strings.TrimSpace(draftReq.Angle), - Hook: strings.TrimSpace(draftReq.Hook), - Rationale: strings.TrimSpace(draftReq.Rationale), - ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes), - Sources: draftReq.Sources, - Status: "pending", - CreateAt: clock.NowUnixNano(), + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + PersonaID: req.PersonaID, + ContentPlanID: strings.TrimSpace(draftReq.ContentPlanID), + CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID), + ScanPostID: strings.TrimSpace(draftReq.ScanPostID), + DraftType: draftType, + SortOrder: sortOrder, + Text: text, + TopicTag: normalizeTopicTag(draftReq.TopicTag), + Angle: strings.TrimSpace(draftReq.Angle), + Hook: strings.TrimSpace(draftReq.Hook), + Rationale: strings.TrimSpace(draftReq.Rationale), + ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes), + Sources: draftReq.Sources, + AiScore: clampScore(draftReq.AiScore), + FormulaScore: clampScore(draftReq.FormulaScore), + BrandFitScore: clampScore(draftReq.BrandFitScore), + RiskScore: clampScore(draftReq.RiskScore), + SimilarityScore: clampScore(draftReq.SimilarityScore), + EngagementPotential: clampScore(draftReq.EngagementPotential), + FreshnessScore: clampScore(draftReq.FreshnessScore), + ReviewSuggestion: strings.TrimSpace(draftReq.ReviewSuggestion), + Status: "pending", + CreateAt: clock.NowUnixNano(), }) } if len(items) == 0 { @@ -108,6 +133,7 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create if err := u.repo.CreateMany(ctx, items); err != nil { return nil, err } + u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID) out := make([]domusecase.CopyDraftSummary, 0, len(items)) for _, item := range items { out = append(out, toSummary(*item)) @@ -143,16 +169,22 @@ func (u *copyDraftUseCase) Update(ctx context.Context, req domusecase.UpdateRequ } patch["text"] = text } + if req.Patch.TopicTag != nil { + patch["topic_tag"] = normalizeTopicTag(*req.Patch.TopicTag) + } if req.Patch.Hook != nil { patch["hook"] = strings.TrimSpace(*req.Patch.Hook) } if req.Patch.Angle != nil { patch["angle"] = strings.TrimSpace(*req.Patch.Angle) } + if req.Patch.Rationale != nil { + patch["rationale"] = strings.TrimSpace(*req.Patch.Rationale) + } if req.Patch.Status != nil { status := strings.TrimSpace(*req.Patch.Status) - if status != "" && status != "pending" && status != "ready" { - return nil, app.For(code.Persona).InputMissingRequired("status must be pending or ready") + if status != "" && status != "pending" && status != "ready" && status != "rejected" && status != "scheduled" { + return nil, app.For(code.Persona).InputMissingRequired("status must be pending, ready, rejected or scheduled") } if status != "" { patch["status"] = status @@ -181,6 +213,7 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar now := clock.NowUnixNano() patch := map[string]interface{}{ "status": "published", + "publish_queue_id": "", "published_media_id": mediaID, "published_permalink": strings.TrimSpace(req.Permalink), "published_at": now, @@ -193,6 +226,26 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar return &summary, nil } +func (u *copyDraftUseCase) MarkScheduled(ctx context.Context, req domusecase.MarkScheduledRequest) (*domusecase.CopyDraftSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + draftID := strings.TrimSpace(req.DraftID) + queueID := strings.TrimSpace(req.QueueID) + if draftID == "" || queueID == "" { + return nil, app.For(code.Persona).InputMissingRequired("draft_id and queue_id are required") + } + item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, map[string]interface{}{ + "status": "scheduled", + "publish_queue_id": queueID, + }) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + func (u *copyDraftUseCase) ReplaceMissionMatrix( ctx context.Context, tenantID, ownerUID, personaID, missionID string, @@ -237,7 +290,7 @@ func buildMatrixDraftEntities( now := clock.NowUnixNano() items := make([]*entity.CopyDraft, 0, len(drafts)) for idx, draftReq := range drafts { - text := strings.TrimSpace(draftReq.Text) + text := threadspost.FormatDraftText(draftReq.Text) if text == "" { continue } @@ -256,6 +309,7 @@ func buildMatrixDraftEntities( MatrixBatchID: batchID, SortOrder: sortOrder, Text: text, + TopicTag: normalizeTopicTag(draftReq.TopicTag), Angle: strings.TrimSpace(draftReq.Angle), Hook: strings.TrimSpace(draftReq.Hook), Rationale: strings.TrimSpace(draftReq.Rationale), @@ -363,6 +417,47 @@ func (u *copyDraftUseCase) List(ctx context.Context, tenantID, ownerUID, persona return out, nil } +func (u *copyDraftUseCase) PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) { + if err := requireActor(tenantID, ownerUID, personaID); err != nil { + return 0, err + } + if keep <= 0 { + keep = defaultIdleDraftKeep + } + if keep > 200 { + keep = 200 + } + return u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, keep) +} + +func (u *copyDraftUseCase) pruneIdleDraftsBestEffort(ctx context.Context, tenantID, ownerUID, personaID string) { + _, _ = u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, defaultIdleDraftKeep) +} + +func (u *copyDraftUseCase) ListInbox(ctx context.Context, req domusecase.InboxListRequest) (*domusecase.InboxListResult, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + result, err := u.repo.ListInbox(ctx, domrepo.InboxFilter{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + PersonaID: req.PersonaID, + Status: req.Status, + RangeStart: req.RangeStart, + RangeEnd: req.RangeEnd, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + out := make([]domusecase.CopyDraftSummary, 0, len(result.Items)) + for _, item := range result.Items { + out = append(out, toSummary(item)) + } + return &domusecase.InboxListResult{Items: out, Total: result.Total}, nil +} + func requireActor(tenantID, ownerUID, personaID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Auth).AuthUnauthorized("missing actor") @@ -373,24 +468,57 @@ func requireActor(tenantID, ownerUID, personaID string) error { return nil } +func clampScore(score int) int { + if score < 0 { + return 0 + } + if score > 100 { + return 100 + } + return score +} + func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary { return domusecase.CopyDraftSummary{ - ID: item.ID, - PersonaID: item.PersonaID, - CopyMissionID: item.CopyMissionID, - ScanPostID: item.ScanPostID, - DraftType: item.DraftType, - SortOrder: item.SortOrder, - Text: item.Text, - Angle: item.Angle, - Hook: item.Hook, - Rationale: item.Rationale, - ReferenceNotes: item.ReferenceNotes, - Sources: item.Sources, - Status: item.Status, - PublishedMediaID: item.PublishedMediaID, - PublishedPermalink: item.PublishedPermalink, - PublishedAt: item.PublishedAt, - CreateAt: item.CreateAt, + ID: item.ID, + PersonaID: item.PersonaID, + ContentPlanID: item.ContentPlanID, + CopyMissionID: item.CopyMissionID, + ScanPostID: item.ScanPostID, + FormulaID: item.FormulaID, + DraftType: item.DraftType, + SortOrder: item.SortOrder, + Text: item.Text, + TopicTag: item.TopicTag, + Angle: item.Angle, + Hook: item.Hook, + Rationale: item.Rationale, + ReferenceNotes: item.ReferenceNotes, + Sources: item.Sources, + AiScore: item.AiScore, + FormulaScore: item.FormulaScore, + BrandFitScore: item.BrandFitScore, + RiskScore: item.RiskScore, + SimilarityScore: item.SimilarityScore, + EngagementPotential: item.EngagementPotential, + FreshnessScore: item.FreshnessScore, + ReviewSuggestion: item.ReviewSuggestion, + Status: item.Status, + PublishQueueID: item.PublishQueueID, + PublishedMediaID: item.PublishedMediaID, + PublishedPermalink: item.PublishedPermalink, + PublishedAt: item.PublishedAt, + CreateAt: item.CreateAt, } } + +func normalizeTopicTag(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "#") + value = strings.TrimSpace(value) + if len([]rune(value)) > 40 { + runes := []rune(value) + value = string(runes[:40]) + } + return value +} diff --git a/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go index 5d1113c..ccb3b88 100644 --- a/backend/internal/model/job/domain/usecase/job.go +++ b/backend/internal/model/job/domain/usecase/job.go @@ -14,6 +14,7 @@ type CreateRunRequest struct { TenantID string OwnerUID string Payload map[string]any + ScheduledAt *int64 } type CancelRunRequest struct { @@ -99,8 +100,13 @@ type UseCase interface { EnsureExpandCopyMissionGraphTemplate(ctx context.Context) error EnsureGenerateCopyMatrixTemplate(ctx context.Context) error EnsureGenerateCopyDraftTemplate(ctx context.Context) error + EnsureGenerateOutreachDraftTemplate(ctx context.Context) error + EnsureGenerateTopicMatrixTemplate(ctx context.Context) error + EnsureGenerateFormulaDraftTemplate(ctx context.Context) error + EnsureGenerateRewriteDraftTemplate(ctx context.Context) error EnsureRefreshThreadsTokenTemplate(ctx context.Context) error EnsurePublishAnalyticsTemplate(ctx context.Context) error + EnsureRefillPublishInventoryTemplate(ctx context.Context) error CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error) GetRun(ctx context.Context, jobID string) (*entity.Run, error) @@ -130,9 +136,9 @@ type UseCase interface { } type MaintenanceResult struct { - EnqueuedPending int - ReapedCancelGrace int - ReapedExpiredLocks int - RepairedStuckClaims int - ReapedOrphanedLocks int + EnqueuedPending int + ReapedCancelGrace int + ReapedExpiredLocks int + RepairedStuckClaims int + ReapedOrphanedLocks int } diff --git a/backend/internal/model/job/usecase/cancel_test.go b/backend/internal/model/job/usecase/cancel_test.go index 36faa96..ed54696 100644 --- a/backend/internal/model/job/usecase/cancel_test.go +++ b/backend/internal/model/job/usecase/cancel_test.go @@ -56,3 +56,53 @@ func TestRequestCancel_RunningSetsRedisCancelSignal(t *testing.T) { } _ = client.Del(ctx, "jobs:cancel:"+jobID) } + +// A pending/queued run has no worker executing it yet, so cancelling it is +// always safe. This must succeed even for templates like publish-analytics +// that opt out of *cooperative* cancel (CancelPolicy.Supported = false), +// otherwise a not-yet-started job can never be removed from the queue. +func TestRequestCancel_PendingAllowedEvenWhenTemplateDoesNotSupportCancel(t *testing.T) { + ctx := context.Background() + template := publishAnalyticsTemplate() + if template.CancelPolicy.Supported { + t.Fatal("test setup assumes publish-analytics does not support cooperative cancel") + } + runs := newMemoryRunRepo(&entity.Run{ + TemplateType: template.Type, + Status: enum.RunStatusPending, + WorkerType: template.Steps[0].WorkerType, + }) + uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo()) + + updated, err := uc.RequestCancel(ctx, domusecase.CancelRunRequest{ + JobID: "507f1f77bcf86cd799439011", + Reason: "user requested cancel", + }) + if err != nil { + t.Fatalf("RequestCancel() error = %v", err) + } + if updated.Status != enum.RunStatusCancelled { + t.Fatalf("status = %s, want cancelled", updated.Status) + } +} + +// Cooperative cancel (interrupting an already-running worker) must still +// respect the template's CancelPolicy. +func TestRequestCancel_RunningRejectedWhenTemplateDoesNotSupportCancel(t *testing.T) { + ctx := context.Background() + template := publishAnalyticsTemplate() + runs := newMemoryRunRepo(&entity.Run{ + TemplateType: template.Type, + Status: enum.RunStatusRunning, + WorkerType: template.Steps[0].WorkerType, + }) + uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo()) + + _, err := uc.RequestCancel(ctx, domusecase.CancelRunRequest{ + JobID: "507f1f77bcf86cd799439011", + Reason: "user requested cancel", + }) + if err == nil { + t.Fatal("expected error cancelling a running job whose template does not support cooperative cancel") + } +} diff --git a/backend/internal/model/job/usecase/dedupe_test.go b/backend/internal/model/job/usecase/dedupe_test.go index b43c6a4..8c4162c 100644 --- a/backend/internal/model/job/usecase/dedupe_test.go +++ b/backend/internal/model/job/usecase/dedupe_test.go @@ -72,6 +72,18 @@ func TestCreateRun_DedupeLeaseBlocksParallel(t *testing.T) { } } +func TestBuildDedupeKey_IncludesScanPostID(t *testing.T) { + template := &entity.Template{ + Type: "generate-outreach-draft", + DedupeKeys: []string{"scan_post_id"}, + } + key1 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-1"}) + key2 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-2"}) + if key1 == "" || key2 == "" || key1 == key2 { + t.Fatalf("dedupe keys should differ by scan_post_id: %q vs %q", key1, key2) + } +} + func TestBuildDedupeKey_IncludesScopeID(t *testing.T) { template := &entity.Template{ Type: "demo", diff --git a/backend/internal/model/job/usecase/enqueue_run_test.go b/backend/internal/model/job/usecase/enqueue_run_test.go index 567d9ab..777a68c 100644 --- a/backend/internal/model/job/usecase/enqueue_run_test.go +++ b/backend/internal/model/job/usecase/enqueue_run_test.go @@ -4,9 +4,9 @@ import ( "context" "testing" + "go.mongodb.org/mongo-driver/bson/primitive" "haixun-backend/internal/model/job/domain/entity" "haixun-backend/internal/model/job/domain/enum" - "go.mongodb.org/mongo-driver/bson/primitive" ) func TestEnqueueRun_DoesNotOverwriteRunningClaim(t *testing.T) { @@ -41,4 +41,4 @@ func TestEnqueueRun_DoesNotOverwriteRunningClaim(t *testing.T) { if len(queue.queues["go"]) != 0 { t.Fatalf("queue = %v, want duplicate enqueue removed", queue.queues["go"]) } -} \ No newline at end of file +} diff --git a/backend/internal/model/job/usecase/helpers.go b/backend/internal/model/job/usecase/helpers.go index 7a35576..222e8be 100644 --- a/backend/internal/model/job/usecase/helpers.go +++ b/backend/internal/model/job/usecase/helpers.go @@ -47,17 +47,13 @@ func buildDedupeKey(template *entity.Template, scope, scopeID string, payload ma switch key { case "scope_id": parts = append(parts, scopeID) - case "target": - if payload != nil { - if target, ok := payload["target"].(string); ok { - parts = append(parts, target) - } + default: + if payload == nil { + continue } - case "benchmark_username": - if payload != nil { - if username, ok := payload["benchmark_username"].(string); ok { - parts = append(parts, username) - } + value := dedupePayloadValue(payload, key) + if value != "" { + parts = append(parts, value) } } } @@ -66,6 +62,24 @@ func buildDedupeKey(template *entity.Template, scope, scopeID string, payload ma return hex.EncodeToString(sum[:]) } +func dedupePayloadValue(payload map[string]any, key string) string { + if payload == nil || key == "" { + return "" + } + if value := stringFromAny(payload[key]); value != "" { + return value + } + // Legacy aliases used by older payloads. + switch key { + case "target": + return stringFromAny(payload["target"]) + case "benchmark_username": + return stringFromAny(payload["benchmark_username"]) + default: + return "" + } +} + func firstPhaseFromTemplateSteps(steps []entity.StepProgress) string { if len(steps) == 0 { return "" diff --git a/backend/internal/model/job/usecase/maintenance.go b/backend/internal/model/job/usecase/maintenance.go index eef3e1f..0784929 100644 --- a/backend/internal/model/job/usecase/maintenance.go +++ b/backend/internal/model/job/usecase/maintenance.go @@ -36,18 +36,18 @@ func (u *jobUseCase) RunMaintenance(ctx context.Context) (domusecase.Maintenance continue } jobID := run.ID.Hex() - fromStatus := string(run.Status) + fromStatus := run.Status run.Status = enum.RunStatusCancelled run.CompletedAt = &now run.Progress.Summary = "cancelled after grace timeout" - updated, err := u.runs.Update(ctx, run) + updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{fromStatus}) if err != nil { continue } _ = u.queue.ReleaseLock(ctx, jobID, run.LockedBy) _ = u.queue.ClearCancelSignal(ctx, jobID) u.releaseDedupe(ctx, updated) - _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusCancelled), "cancel grace timeout", nil) + _ = u.appendEvent(ctx, jobID, "status_changed", string(fromStatus), string(enum.RunStatusCancelled), "cancel grace timeout", nil) result.ReapedCancelGrace++ } @@ -104,11 +104,12 @@ func (u *jobUseCase) RunMaintenance(ctx context.Context) (domusecase.Maintenance } for _, run := range expiredRuns { jobID := run.ID.Hex() - fromStatus := string(run.Status) + fromStatusEnum := run.Status + fromStatus := string(fromStatusEnum) run.Status = enum.RunStatusExpired run.CompletedAt = &now run.Progress.Summary = "job lock expired" - updated, err := u.runs.Update(ctx, run) + updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{fromStatusEnum}) if err != nil { continue } diff --git a/backend/internal/model/job/usecase/maintenance_stuck_test.go b/backend/internal/model/job/usecase/maintenance_stuck_test.go index 6e9d97f..668e737 100644 --- a/backend/internal/model/job/usecase/maintenance_stuck_test.go +++ b/backend/internal/model/job/usecase/maintenance_stuck_test.go @@ -37,4 +37,4 @@ func TestRunMaintenance_RepairsQueuedWithActiveLock(t *testing.T) { if runs.run.Status != enum.RunStatusRunning { t.Fatalf("status = %s, want running", runs.run.Status) } -} \ No newline at end of file +} diff --git a/backend/internal/model/job/usecase/maintenance_test.go b/backend/internal/model/job/usecase/maintenance_test.go index 631d184..7296729 100644 --- a/backend/internal/model/job/usecase/maintenance_test.go +++ b/backend/internal/model/job/usecase/maintenance_test.go @@ -63,6 +63,37 @@ func TestRunMaintenance_ReapCancelGrace(t *testing.T) { } } +func TestRunMaintenance_ReapCancelGrace_SkipsIfAlreadyResolved(t *testing.T) { + ctx := context.Background() + template := demoTemplate() + template.CancelPolicy.GraceSeconds = 1 + queue := newMemoryQueueRepo() + runs := newMemoryRunRepo(nil) + cancelAt := clock.Now().Add(-2 * time.Second).UnixNano() + runs.run = &entity.Run{ + TemplateType: template.Type, + Status: enum.RunStatusCancelRequested, + CancelRequestedAt: &cancelAt, + DedupeKey: "dedupe-1", + } + uc := testUseCaseFull(template, runs, nil, queue) + + // Simulate the worker acknowledging cancel (or completing/failing the job) + // concurrently, right after the reaper reads it but before it writes back. + runs.raceAfterFind = func() { runs.run.Status = enum.RunStatusSucceeded } + + result, err := uc.RunMaintenance(ctx) + if err != nil { + t.Fatalf("RunMaintenance() error = %v", err) + } + if result.ReapedCancelGrace != 0 { + t.Fatalf("ReapedCancelGrace = %d, want 0 (must not clobber concurrently resolved status)", result.ReapedCancelGrace) + } + if runs.run.Status != enum.RunStatusSucceeded { + t.Fatalf("status = %s, want succeeded (unchanged)", runs.run.Status) + } +} + func TestRunMaintenance_ReapExpiredLock(t *testing.T) { ctx := context.Background() template := demoTemplate() @@ -88,3 +119,33 @@ func TestRunMaintenance_ReapExpiredLock(t *testing.T) { t.Fatalf("status = %s, want expired", runs.run.Status) } } + +func TestRunMaintenance_ReapExpiredLock_SkipsIfAlreadyResolved(t *testing.T) { + ctx := context.Background() + template := demoTemplate() + queue := newMemoryQueueRepo() + runs := newMemoryRunRepo(nil) + lockedUntil := clock.Now().Add(-time.Second).UnixNano() + runs.run = &entity.Run{ + TemplateType: template.Type, + Status: enum.RunStatusRunning, + LockedUntil: &lockedUntil, + DedupeKey: "dedupe-2", + } + uc := testUseCaseFull(template, runs, nil, queue) + + // Simulate the worker completing the job right after the reaper reads it + // but before it writes back; the reaper must not overwrite it to expired. + runs.raceAfterFind = func() { runs.run.Status = enum.RunStatusSucceeded } + + result, err := uc.RunMaintenance(ctx) + if err != nil { + t.Fatalf("RunMaintenance() error = %v", err) + } + if result.ReapedExpiredLocks != 0 { + t.Fatalf("ReapedExpiredLocks = %d, want 0 (must not clobber concurrently resolved status)", result.ReapedExpiredLocks) + } + if runs.run.Status != enum.RunStatusSucceeded { + t.Fatalf("status = %s, want succeeded (unchanged)", runs.run.Status) + } +} diff --git a/backend/internal/model/job/usecase/retry_max_attempts_test.go b/backend/internal/model/job/usecase/retry_max_attempts_test.go new file mode 100644 index 0000000..8576049 --- /dev/null +++ b/backend/internal/model/job/usecase/retry_max_attempts_test.go @@ -0,0 +1,41 @@ +package usecase + +import ( + "context" + "testing" + + "haixun-backend/internal/model/job/domain/entity" + "haixun-backend/internal/model/job/domain/enum" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestRetryRun_AllowsManualRetryWhenAttemptReachedMaxAttempts(t *testing.T) { + ctx := context.Background() + template := generateRewriteDraftTemplate() + jobID := primitive.NewObjectID() + runs := newMemoryRunRepo(&entity.Run{ + ID: jobID, + TemplateType: template.Type, + Status: enum.RunStatusFailed, + WorkerType: workerTypeGenerateRewriteDraft, + Attempt: 1, + MaxAttempts: 1, + Progress: entity.RunProgress{ + Steps: []entity.StepProgress{{ID: "rewrite_draft_generate", Status: enum.StepStatusFailed}}, + }, + }) + queue := newMemoryQueueRepo() + uc := testUseCaseFull(template, runs, nil, queue) + + updated, err := uc.RetryRun(ctx, jobID.Hex()) + if err != nil { + t.Fatalf("RetryRun() error = %v", err) + } + if updated.Status != enum.RunStatusQueued && updated.Status != enum.RunStatusPending { + t.Fatalf("status = %s, want queued or pending", updated.Status) + } + if updated.Attempt != 2 { + t.Fatalf("attempt = %d, want 2", updated.Attempt) + } +} diff --git a/backend/internal/model/job/usecase/retry_test.go b/backend/internal/model/job/usecase/retry_test.go index 2c3611b..554014e 100644 --- a/backend/internal/model/job/usecase/retry_test.go +++ b/backend/internal/model/job/usecase/retry_test.go @@ -50,3 +50,39 @@ func TestRetryRun_SchedulesBackoff(t *testing.T) { t.Fatal("backoff retry should not enqueue immediately") } } + +func TestRetryRun_BackoffReschedule_DoesNotClobberConcurrentCancel(t *testing.T) { + ctx := context.Background() + template := demoTemplate() + template.RetryPolicy = entity.RetryPolicy{ + MaxAttempts: 3, + BackoffSeconds: []int{30, 60}, + } + jobID := primitive.NewObjectID() + runs := newMemoryRunRepo(&entity.Run{ + ID: jobID, + TemplateType: template.Type, + Status: enum.RunStatusFailed, + WorkerType: "go", + Attempt: 1, + MaxAttempts: 3, + Progress: entity.RunProgress{ + Steps: []entity.StepProgress{{ID: "prepare", Status: enum.StepStatusFailed}}, + }, + }) + queue := newMemoryQueueRepo() + uc := testUseCaseFull(template, runs, nil, queue) + + // Simulate a user cancelling the job right after RetryRun transitions it + // to "pending" but before the backoff reschedule writes ScheduledAt back. + // The reschedule write must not silently revive the cancelled job. + runs.raceAfterUpdateIfStatus = func() { runs.run.Status = enum.RunStatusCancelled } + + _, err := uc.RetryRun(ctx, jobID.Hex()) + if err == nil { + t.Fatal("expected error when backoff reschedule races with a concurrent cancel") + } + if runs.run.Status != enum.RunStatusCancelled { + t.Fatalf("status = %s, want cancelled (must not be revived to pending)", runs.run.Status) + } +} diff --git a/backend/internal/model/job/usecase/schedule_test.go b/backend/internal/model/job/usecase/schedule_test.go index 05fb629..5a7e850 100644 --- a/backend/internal/model/job/usecase/schedule_test.go +++ b/backend/internal/model/job/usecase/schedule_test.go @@ -36,7 +36,7 @@ func TestRunSchedulerTick_CreatesRun(t *testing.T) { if created != 1 { t.Fatalf("created = %d, want 1", created) } - if len(queue.queued("go")) == 0 { + if len(queue.queued(workerTypeDemo)) == 0 { t.Fatal("expected queued job from scheduler tick") } if runs.run == nil { diff --git a/backend/internal/model/job/usecase/test_mocks.go b/backend/internal/model/job/usecase/test_mocks.go index 91885c0..a383abd 100644 --- a/backend/internal/model/job/usecase/test_mocks.go +++ b/backend/internal/model/job/usecase/test_mocks.go @@ -38,6 +38,34 @@ type memoryRunRepo struct { mu sync.Mutex run *entity.Run succeeded map[string]*entity.Run + // raceAfterFind, when set, fires exactly once right after a Find* method + // captures its result snapshot. It lets tests simulate another writer + // (worker ack, complete, fail...) mutating the stored run concurrently, + // between the reaper's read and its guarded write. + raceAfterFind func() + // raceAfterUpdateIfStatus, when set, fires exactly once right after a + // UpdateIfStatus call succeeds. It lets tests simulate another writer + // racing in between two sequential guarded updates on the same run + // (e.g. RetryRun's pending transition followed by its backoff reschedule). + raceAfterUpdateIfStatus func() +} + +func (m *memoryRunRepo) fireRaceAfterFindLocked() { + if m.raceAfterFind == nil { + return + } + fn := m.raceAfterFind + m.raceAfterFind = nil + fn() +} + +func (m *memoryRunRepo) fireRaceAfterUpdateIfStatusLocked() { + if m.raceAfterUpdateIfStatus == nil { + return + } + fn := m.raceAfterUpdateIfStatus + m.raceAfterUpdateIfStatus = nil + fn() } func newMemoryRunRepo(run *entity.Run) *memoryRunRepo { @@ -87,6 +115,7 @@ func (m *memoryRunRepo) UpdateIfStatus(ctx context.Context, run *entity.Run, all if run.Status == enum.RunStatusSucceeded && run.DedupeKey != "" { m.succeeded[run.TemplateType+":"+run.DedupeKey] = run } + m.fireRaceAfterUpdateIfStatusLocked() return run, nil } func (m *memoryRunRepo) UpdateIfLocked(ctx context.Context, run *entity.Run, workerID string, allowed []enum.RunStatus) (*entity.Run, error) { @@ -147,7 +176,7 @@ func (m *memoryRunRepo) FindPendingDue(_ context.Context, now int64, _ int64) ([ return nil, nil } if *m.run.ScheduledAt <= now { - return []*entity.Run{m.run}, nil + return []*entity.Run{cloneRun(m.run)}, nil } return nil, nil } @@ -158,7 +187,9 @@ func (m *memoryRunRepo) FindCancelRequestedBefore(_ context.Context, before int6 return nil, nil } if *m.run.CancelRequestedAt <= before { - return []*entity.Run{m.run}, nil + snapshot := cloneRun(m.run) + m.fireRaceAfterFindLocked() + return []*entity.Run{snapshot}, nil } return nil, nil } @@ -171,7 +202,7 @@ func (m *memoryRunRepo) FindQueuedWithLock(_ context.Context, _ int64) ([]*entit if m.run.Status != enum.RunStatusPending && m.run.Status != enum.RunStatusQueued { return nil, nil } - return []*entity.Run{m.run}, nil + return []*entity.Run{cloneRun(m.run)}, nil } func (m *memoryRunRepo) FindRunningTimedOut(_ context.Context, now int64, _ int64) ([]*entity.Run, error) { @@ -184,7 +215,9 @@ func (m *memoryRunRepo) FindRunningTimedOut(_ context.Context, now int64, _ int6 return nil, nil } if *m.run.LockedUntil <= now { - return []*entity.Run{m.run}, nil + snapshot := cloneRun(m.run) + m.fireRaceAfterFindLocked() + return []*entity.Run{snapshot}, nil } return nil, nil } diff --git a/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go index 9ec57b8..75cb93a 100644 --- a/backend/internal/model/job/usecase/usecase.go +++ b/backend/internal/model/job/usecase/usecase.go @@ -14,18 +14,38 @@ import ( ) const ( - demoTemplateType = "demo_long_task" - style8DTemplateType = "style-8d" - expandGraphTemplateType = "expand-graph" - placementScanTemplateType = "placement-scan" - scanViralTemplateType = "scan-viral" - analyzeCopyMissionTemplateType = "analyze-copy-mission" - expandCopyMissionGraphTemplateType = "expand-copy-mission-graph" - generateCopyMatrixTemplateType = "generate-copy-matrix" - generateCopyDraftTemplateType = "generate-copy-draft" - refreshThreadsTokenTemplateType = "refresh-threads-token" - publishAnalyticsTemplateType = "publish-analytics" - style8DWorkerType = "node" + demoTemplateType = "demo_long_task" + style8DTemplateType = "style-8d" + expandGraphTemplateType = "expand-graph" + placementScanTemplateType = "placement-scan" + scanViralTemplateType = "scan-viral" + analyzeCopyMissionTemplateType = "analyze-copy-mission" + expandCopyMissionGraphTemplateType = "expand-copy-mission-graph" + generateCopyMatrixTemplateType = "generate-copy-matrix" + generateCopyDraftTemplateType = "generate-copy-draft" + generateOutreachDraftTemplateType = "generate-outreach-draft" + generateTopicMatrixTemplateType = "generate-topic-matrix" + generateFormulaDraftTemplateType = "generate-formula-draft" + generateRewriteDraftTemplateType = "generate-rewrite-draft" + refreshThreadsTokenTemplateType = "refresh-threads-token" + publishAnalyticsTemplateType = "publish-analytics" + refillPublishInventoryTemplateType = "refill-publish-inventory" + workerTypeDemo = "go-demo" + workerTypeExpandGraph = "go-expand-graph" + workerTypePlacementScan = "go-placement-scan" + workerTypeScanViral = "go-scan-viral" + workerTypeAnalyzeCopyMission = "go-analyze-copy-mission" + workerTypeExpandCopyMissionGraph = "go-expand-copy-mission-graph" + workerTypeGenerateCopyMatrix = "go-generate-copy-matrix" + workerTypeGenerateCopyDraft = "go-generate-copy-draft" + workerTypeGenerateOutreachDraft = "go-generate-outreach-draft" + workerTypeGenerateTopicMatrix = "go-generate-topic-matrix" + workerTypeGenerateFormulaDraft = "go-generate-formula-draft" + workerTypeGenerateRewriteDraft = "go-generate-rewrite-draft" + workerTypeRefreshThreadsToken = "go-refresh-threads-token" + workerTypePublishAnalytics = "go-publish-analytics" + workerTypeRefillPublishInventory = "go-refill-publish-inventory" + style8DWorkerType = "node-style-8d" ) type UseCase = domusecase.UseCase @@ -107,6 +127,26 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error return err } +func (u *jobUseCase) EnsureGenerateOutreachDraftTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, generateOutreachDraftTemplate()) + return err +} + +func (u *jobUseCase) EnsureGenerateTopicMatrixTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, generateTopicMatrixTemplate()) + return err +} + +func (u *jobUseCase) EnsureGenerateFormulaDraftTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, generateFormulaDraftTemplate()) + return err +} + +func (u *jobUseCase) EnsureGenerateRewriteDraftTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, generateRewriteDraftTemplate()) + return err +} + func (u *jobUseCase) EnsureRefreshThreadsTokenTemplate(ctx context.Context) error { _, err := u.templates.Upsert(ctx, refreshThreadsTokenTemplate()) return err @@ -117,6 +157,11 @@ func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error { return err } +func (u *jobUseCase) EnsureRefillPublishInventoryTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, refillPublishInventoryTemplate()) + return err +} + func expandCopyMissionGraphTemplate() *entity.Template { return &entity.Template{ Type: expandCopyMissionGraphTemplateType, @@ -138,7 +183,7 @@ func expandCopyMissionGraphTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "expand_copy_knowledge", Name: "Expand copy mission knowledge", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true}, + {ID: "expand_copy_knowledge", Name: "Expand copy mission knowledge", WorkerType: workerTypeExpandCopyMissionGraph, TimeoutSeconds: 600, Cancelable: true}, }, } } @@ -164,7 +209,7 @@ func analyzeCopyMissionTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "copy_mission_map", Name: "Copy mission research map", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 300, Cancelable: true}, + {ID: "copy_mission_map", Name: "Copy mission research map", WorkerType: workerTypeAnalyzeCopyMission, TimeoutSeconds: 300, Cancelable: true}, }, } } @@ -190,7 +235,111 @@ func generateCopyMatrixTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "copy_matrix_generate", Name: "Copy matrix generate", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true}, + {ID: "copy_matrix_generate", Name: "Copy matrix generate", WorkerType: workerTypeGenerateCopyMatrix, TimeoutSeconds: 600, Cancelable: true}, + }, + } +} + +func generateTopicMatrixTemplate() *entity.Template { + return &entity.Template{ + Type: generateTopicMatrixTemplateType, + Version: 1, + Name: "Generate Topic Matrix", + Description: "LLM topic matrix drafts for persona copy missions", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope), + DedupeKeys: []string{"scope_id", "topic"}, + TimeoutSeconds: 600, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 1, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "topic_matrix_generate", Name: "Topic matrix generate", WorkerType: workerTypeGenerateTopicMatrix, TimeoutSeconds: 600, Cancelable: true}, + }, + } +} + +func generateFormulaDraftTemplate() *entity.Template { + return &entity.Template{ + Type: generateFormulaDraftTemplateType, + Version: 1, + Name: "Generate Formula Draft", + Description: "LLM formula-based copy draft for persona rewrite flow", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope), + DedupeKeys: []string{"scope_id", "formula_id", "topic"}, + TimeoutSeconds: 600, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 1, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "formula_draft_generate", Name: "Formula draft generate", WorkerType: workerTypeGenerateFormulaDraft, TimeoutSeconds: 600, Cancelable: true}, + }, + } +} + +func generateRewriteDraftTemplate() *entity.Template { + return &entity.Template{ + Type: generateRewriteDraftTemplateType, + Version: 1, + Name: "Generate Rewrite Draft", + Description: "Analyze pasted reference post and generate persona rewrite draft", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel), + DedupeKeys: []string{}, + TimeoutSeconds: 900, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 3, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "rewrite_draft_generate", Name: "Rewrite draft generate", WorkerType: workerTypeGenerateRewriteDraft, TimeoutSeconds: 900, Cancelable: true}, + }, + } +} + +func generateOutreachDraftTemplate() *entity.Template { + return &entity.Template{ + Type: generateOutreachDraftTemplateType, + Version: 1, + Name: "Generate Outreach Draft", + Description: "LLM outreach reply drafts for a placement scan post", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel), + DedupeKeys: []string{"scan_post_id"}, + TimeoutSeconds: 600, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 1, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "outreach_draft_generate", Name: "Outreach draft generate", WorkerType: workerTypeGenerateOutreachDraft, TimeoutSeconds: 600, Cancelable: true}, }, } } @@ -216,7 +365,7 @@ func generateCopyDraftTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "copy_draft_generate", Name: "Copy draft generate", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true}, + {ID: "copy_draft_generate", Name: "Copy draft generate", WorkerType: workerTypeGenerateCopyDraft, TimeoutSeconds: 600, Cancelable: true}, }, } } @@ -240,7 +389,7 @@ func refreshThreadsTokenTemplate() *entity.Template { BackoffSeconds: []int{30, 120, 300}, }, Steps: []entity.TemplateStep{ - {ID: "refresh", Name: "Refresh token", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false}, + {ID: "refresh", Name: "Refresh token", WorkerType: workerTypeRefreshThreadsToken, TimeoutSeconds: 120, Cancelable: false}, }, } } @@ -264,7 +413,33 @@ func publishAnalyticsTemplate() *entity.Template { BackoffSeconds: []int{60}, }, Steps: []entity.TemplateStep{ - {ID: "check", Name: "Check analytics", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false}, + {ID: "check", Name: "Check analytics", WorkerType: workerTypePublishAnalytics, TimeoutSeconds: 120, Cancelable: false}, + }, + } +} + +func refillPublishInventoryTemplate() *entity.Template { + return &entity.Template{ + Type: refillPublishInventoryTemplateType, + Version: 1, + Name: "Refill Publish Inventory", + Description: "Generate and schedule drafts when Threads publish inventory is low", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope), + DedupeKeys: []string{"account_id"}, + TimeoutSeconds: 900, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 1, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "refill_publish_inventory", Name: "Refill publish inventory", WorkerType: workerTypeRefillPublishInventory, TimeoutSeconds: 900, Cancelable: true}, }, } } @@ -290,7 +465,7 @@ func scanViralTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "viral_crawl", Name: "Viral keyword crawl", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true}, + {ID: "viral_crawl", Name: "Viral keyword crawl", WorkerType: workerTypeScanViral, TimeoutSeconds: 600, Cancelable: true}, }, } } @@ -316,7 +491,7 @@ func placementScanTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "crawl", Name: "Dual-track crawl", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 7200, Cancelable: true}, + {ID: "crawl", Name: "Dual-track crawl", WorkerType: workerTypePlacementScan, TimeoutSeconds: 7200, Cancelable: true}, }, } } @@ -342,7 +517,7 @@ func expandGraphTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "expand", Name: "Expand knowledge graph", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true}, + {ID: "expand", Name: "Expand knowledge graph", WorkerType: workerTypeExpandGraph, TimeoutSeconds: 600, Cancelable: true}, }, } } @@ -368,10 +543,10 @@ func style8DTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "session", Name: "Confirm Threads connection", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: true}, - {ID: "samples", Name: "Fetch recent posts", WorkerType: style8DWorkerType, TimeoutSeconds: 180, Cancelable: true}, - {ID: "style", Name: "AI 8D analysis", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 240, Cancelable: true}, - {ID: "store", Name: "Save persona strategy", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 60, Cancelable: false}, + {ID: "session", Name: "Prepare public profile crawl", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: true}, + {ID: "samples", Name: "Fetch public posts", WorkerType: style8DWorkerType, TimeoutSeconds: 180, Cancelable: true}, + {ID: "style", Name: "AI 8D analysis", WorkerType: style8DWorkerType, TimeoutSeconds: 240, Cancelable: true}, + {ID: "store", Name: "Save persona strategy", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: false}, }, } } @@ -397,9 +572,9 @@ func demoTemplate() *entity.Template { BackoffSeconds: []int{30, 120}, }, Steps: []entity.TemplateStep{ - {ID: "prepare", Name: "Prepare data", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 60, Cancelable: true}, - {ID: "execute", Name: "Execute task", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 300, Cancelable: true}, - {ID: "finalize", Name: "Finalize result", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 30, Cancelable: false}, + {ID: "prepare", Name: "Prepare data", WorkerType: workerTypeDemo, TimeoutSeconds: 60, Cancelable: true}, + {ID: "execute", Name: "Execute task", WorkerType: workerTypeDemo, TimeoutSeconds: 300, Cancelable: true}, + {ID: "finalize", Name: "Finalize result", WorkerType: workerTypeDemo, TimeoutSeconds: 30, Cancelable: false}, }, } } @@ -449,6 +624,7 @@ func (u *jobUseCase) CreateRun(ctx context.Context, req domusecase.CreateRunRequ Attempt: 1, MaxAttempts: maxAttemptsOrDefault(template.RetryPolicy.MaxAttempts), DedupeKey: dedupeKey, + ScheduledAt: req.ScheduledAt, } created, err := u.runs.Create(ctx, run) @@ -538,9 +714,6 @@ func (u *jobUseCase) RequestCancel(ctx context.Context, req domusecase.CancelRun if err != nil { return nil, err } - if !template.CancelPolicy.Supported { - return nil, app.For(code.Job).ResInvalidState("job template does not support cancel") - } reason := strings.TrimSpace(req.Reason) if reason == "" { @@ -551,6 +724,13 @@ func (u *jobUseCase) RequestCancel(ctx context.Context, req domusecase.CancelRun switch run.Status { case enum.RunStatusPending, enum.RunStatusQueued: + // A pending/queued run has no worker executing it yet, so there is + // nothing to cooperatively interrupt: cancelling is always safe here + // regardless of the template's CancelPolicy (that policy only governs + // interrupting an in-flight worker below). Gating this on + // CancelPolicy.Supported used to make e.g. a not-yet-started + // publish-analytics checkpoint job permanently uncancellable, even + // though it was just sitting in the queue. _ = u.queue.RemoveJob(ctx, run.WorkerType, jobID) run.Status = enum.RunStatusCancelled run.CancelReason = reason @@ -567,6 +747,9 @@ func (u *jobUseCase) RequestCancel(ctx context.Context, req domusecase.CancelRun return updated, nil case enum.RunStatusRunning, enum.RunStatusWaitingWorker: + if !template.CancelPolicy.Supported { + return nil, app.For(code.Job).ResInvalidState("job template does not support cancel") + } run.Status = enum.RunStatusCancelRequested run.CancelRequestedAt = &now run.CancelReason = reason @@ -599,9 +782,6 @@ func (u *jobUseCase) RetryRun(ctx context.Context, jobID string) (*entity.Run, e run.Status != enum.RunStatusExpired { return nil, app.For(code.Job).ResInvalidState("only failed, cancelled, or expired jobs can be retried") } - if run.Attempt >= run.MaxAttempts { - return nil, app.For(code.Job).ResInvalidState("job has reached max attempts") - } fromStatus := string(run.Status) template, err := u.templates.FindByType(ctx, run.TemplateType) @@ -642,11 +822,12 @@ func (u *jobUseCase) RetryRun(ctx context.Context, jobID string) (*entity.Run, e if backoff > 0 { scheduled := clock.AddSecondsFromNow(backoff) updated.ScheduledAt = &scheduled - updated, err = u.runs.Update(ctx, updated) + rescheduled, err := u.runs.UpdateIfStatus(ctx, updated, []enum.RunStatus{enum.RunStatusPending}) if err != nil { u.releaseDedupe(ctx, updated) return nil, err } + updated = rescheduled _ = u.appendEvent(ctx, jobID, "retried", fromStatus, string(enum.RunStatusPending), "job scheduled for retry", map[string]any{"attempt": updated.Attempt, "backoff_seconds": backoff}) return updated, nil } @@ -888,7 +1069,11 @@ func (u *jobUseCase) FailRun(ctx context.Context, req domusecase.FailRunRequest) run.Phase = req.Phase } run.CompletedAt = &now - run.Progress.Summary = "job failed" + if strings.TrimSpace(req.Error) != "" { + run.Progress.Summary = req.Error + } else { + run.Progress.Summary = "任務失敗" + } run.Progress.Percentage = calcProgressPercentage(run.Progress.Steps) run.LockedBy = "" run.LockedUntil = nil diff --git a/backend/internal/model/knowledge_graph/usecase/topic_graph.go b/backend/internal/model/knowledge_graph/usecase/topic_graph.go new file mode 100644 index 0000000..93de925 --- /dev/null +++ b/backend/internal/model/knowledge_graph/usecase/topic_graph.go @@ -0,0 +1,57 @@ +package usecase + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/knowledge_graph/domain/entity" +) + +func isGraphNotFound(err error) bool { + if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound { + return true + } + return false +} + +// loadTopicGraph resolves a placement-topic graph, including legacy brand-scoped graphs. +func (u *knowledgeGraphUseCase) loadTopicGraph(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*entity.Graph, error) { + topicID = strings.TrimSpace(topicID) + if topicID == "" { + return nil, app.For(code.Brand).InputMissingRequired("topic id is required") + } + item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID) + if err != nil && !isGraphNotFound(err) { + return nil, err + } + if item != nil { + return item, nil + } + brandID = strings.TrimSpace(brandID) + if brandID == "" { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + legacy, legacyErr := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID) + if legacyErr != nil { + if isGraphNotFound(legacyErr) { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + return nil, legacyErr + } + if legacy == nil { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + legacyTopicID := strings.TrimSpace(legacy.TopicID) + if legacyTopicID != "" && legacyTopicID != topicID { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + if legacyTopicID == "" { + if err := u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID); err != nil { + return nil, err + } + legacy.TopicID = topicID + } + return legacy, nil +} diff --git a/backend/internal/model/knowledge_graph/usecase/usecase.go b/backend/internal/model/knowledge_graph/usecase/usecase.go index 5680b2e..32063d7 100644 --- a/backend/internal/model/knowledge_graph/usecase/usecase.go +++ b/backend/internal/model/knowledge_graph/usecase/usecase.go @@ -49,31 +49,10 @@ func (u *knowledgeGraphUseCase) GetByTopic(ctx context.Context, tenantID, ownerU if err := requireScope(tenantID, ownerUID, "", topicID, ""); err != nil { return nil, err } - item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID) + item, err := u.loadTopicGraph(ctx, tenantID, ownerUID, topicID, brandID) if err != nil { return nil, err } - if item == nil { - brandID = strings.TrimSpace(brandID) - if brandID != "" { - legacy, legacyErr := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID) - if legacyErr != nil { - return nil, legacyErr - } - if legacy != nil { - legacyTopicID := strings.TrimSpace(legacy.TopicID) - if legacyTopicID == "" || legacyTopicID == topicID { - if legacyTopicID == "" { - if err := u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID); err != nil { - return nil, err - } - legacy.TopicID = topicID - } - item = legacy - } - } - } - } summary := toSummary(item) return &summary, nil } @@ -140,7 +119,7 @@ func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase. case copyMissionID != "": current, err = u.repo.FindByCopyMission(ctx, req.TenantID, req.OwnerUID, copyMissionID) case topicID != "": - current, err = u.repo.FindByTopic(ctx, req.TenantID, req.OwnerUID, topicID) + current, err = u.loadTopicGraph(ctx, req.TenantID, req.OwnerUID, topicID, brandID) default: current, err = u.repo.FindByBrand(ctx, req.TenantID, req.OwnerUID, brandID) } diff --git a/backend/internal/model/member/domain/repository/member.go b/backend/internal/model/member/domain/repository/member.go index e4734f7..d01a182 100644 --- a/backend/internal/model/member/domain/repository/member.go +++ b/backend/internal/model/member/domain/repository/member.go @@ -22,4 +22,5 @@ type Repository interface { UpdateProfile(ctx context.Context, tenantID, uid string, update ProfileUpdate) (*entity.Member, error) SetRoles(ctx context.Context, tenantID, uid string, roles []string) error SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error + UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error } diff --git a/backend/internal/model/member/repository/mongo.go b/backend/internal/model/member/repository/mongo.go index d28ef09..162ddd2 100644 --- a/backend/internal/model/member/repository/mongo.go +++ b/backend/internal/model/member/repository/mongo.go @@ -148,6 +148,27 @@ func (r *mongoRepository) UpdateProfile(ctx context.Context, tenantID, uid strin return &out, err } +func (r *mongoRepository) UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error { + if r.collection == nil { + return app.For(code.Member).DBUnavailable("Mongo is not configured") + } + if tenantID == "" || uid == "" { + return app.For(code.Member).InputMissingRequired("tenant_id and uid are required") + } + res, err := r.collection.UpdateOne( + ctx, + bson.M{"tenant_id": tenantID, "uid": uid}, + bson.M{"$set": bson.M{"password_hash": passwordHash, "update_at": clock.NowUnixNano()}}, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return app.For(code.Member).ResNotFound("member not found") + } + return nil +} + func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Member, error) { if r.collection == nil { return nil, app.For(code.Member).DBUnavailable("Mongo is not configured") diff --git a/backend/internal/model/mention_inbox/domain/entity/mention.go b/backend/internal/model/mention_inbox/domain/entity/mention.go new file mode 100644 index 0000000..71042fb --- /dev/null +++ b/backend/internal/model/mention_inbox/domain/entity/mention.go @@ -0,0 +1,32 @@ +package entity + +const CollectionName = "threads_mention_inbox" + +type Mention struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + MediaID string `bson:"media_id"` + AuthorUsername string `bson:"author_username"` + Text string `bson:"text"` + Permalink string `bson:"permalink"` + Shortcode string `bson:"shortcode,omitempty"` + Timestamp string `bson:"timestamp,omitempty"` + MediaType string `bson:"media_type,omitempty"` + IsReply bool `bson:"is_reply"` + IsQuotePost bool `bson:"is_quote_post"` + HasReplies bool `bson:"has_replies"` + RootPostID string `bson:"root_post_id,omitempty"` + ParentID string `bson:"parent_id,omitempty"` + ThreadPostText string `bson:"thread_post_text,omitempty"` + ParentReplyText string `bson:"parent_reply_text,omitempty"` + ReplyReady bool `bson:"reply_ready"` + ReplyReadyMsg string `bson:"reply_ready_msg,omitempty"` + SyncedAt int64 `bson:"synced_at"` + UpdateAt int64 `bson:"update_at"` +} + +func DocID(accountID, mediaID string) string { + return accountID + ":" + mediaID +} diff --git a/backend/internal/model/mention_inbox/domain/repository/repository.go b/backend/internal/model/mention_inbox/domain/repository/repository.go new file mode 100644 index 0000000..902a05f --- /dev/null +++ b/backend/internal/model/mention_inbox/domain/repository/repository.go @@ -0,0 +1,23 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/mention_inbox/domain/entity" +) + +type ListResult struct { + Items []entity.Mention + Total int64 + ReadyTotal int64 + NewestSyncedAt int64 +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error) + CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) + CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) + ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error) +} diff --git a/backend/internal/model/mention_inbox/domain/usecase/usecase.go b/backend/internal/model/mention_inbox/domain/usecase/usecase.go new file mode 100644 index 0000000..a5a8be4 --- /dev/null +++ b/backend/internal/model/mention_inbox/domain/usecase/usecase.go @@ -0,0 +1,78 @@ +package usecase + +import "context" + +type MentionSummary struct { + MediaID string + AuthorUsername string + Text string + Permalink string + Shortcode string + Timestamp string + MediaType string + IsReply bool + IsQuotePost bool + HasReplies bool + RootPostID string + ParentID string + ThreadPostText string + ParentReplyText string + ReplyReady bool + ReplyReadyMsg string + SyncedAt int64 +} + +type Pagination struct { + Total int64 + ReadyTotal int64 + Page int64 + PageSize int64 + TotalPages int64 + NewestSyncedAt int64 +} + +type SyncRequest struct { + TenantID string + OwnerUID string + AccountID string + Limit int + MaxPages int +} + +type SyncResult struct { + Synced int + Ready int + Total int64 +} + +type ListRequest struct { + TenantID string + OwnerUID string + AccountID string + Page int + PageSize int +} + +type ListResult struct { + List []MentionSummary + Pagination Pagination +} + +type PublishReplyRequest struct { + TenantID string + OwnerUID string + AccountID string + MediaID string + Text string +} + +type PublishReplyResult struct { + MediaID string + Permalink string +} + +type UseCase interface { + SyncFromAPI(ctx context.Context, req SyncRequest) (*SyncResult, error) + List(ctx context.Context, req ListRequest) (*ListResult, error) + PublishReply(ctx context.Context, req PublishReplyRequest) (*PublishReplyResult, error) +} diff --git a/backend/internal/model/mention_inbox/repository/mongo.go b/backend/internal/model/mention_inbox/repository/mongo.go new file mode 100644 index 0000000..a639a80 --- /dev/null +++ b/backend/internal/model/mention_inbox/repository/mongo.go @@ -0,0 +1,184 @@ +package repository + +import ( + "context" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/mention_inbox/domain/entity" + domrepo "haixun-backend/internal/model/mention_inbox/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "media_id", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "synced_at", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "timestamp", Value: -1}, + }, + }, + }) + return err +} + +func (r *mongoRepository) Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if item == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("mention is required") + } + filter := actorMediaFilter(item.TenantID, item.OwnerUID, item.AccountID, item.MediaID) + opts := options.Replace().SetUpsert(true) + if _, err := r.collection.ReplaceOne(ctx, filter, item, opts); err != nil { + return nil, err + } + return item, nil +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Mention + err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("mention inbox item not found") + } + return nil, err + } + return &out, nil +} + +func normalizeMentionPage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func accountFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "account_id": accountID, + } +} + +func (r *mongoRepository) CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + return r.collection.CountDocuments(ctx, accountFilter(tenantID, ownerUID, accountID)) +} + +func (r *mongoRepository) CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := accountFilter(tenantID, ownerUID, accountID) + filter["reply_ready"] = true + return r.collection.CountDocuments(ctx, filter) +} + +func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domrepo.ListResult, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + page, pageSize = normalizeMentionPage(page, pageSize) + filter := accountFilter(tenantID, ownerUID, accountID) + total, err := r.collection.CountDocuments(ctx, filter) + if err != nil { + return nil, err + } + sort := bson.D{{Key: "timestamp", Value: -1}, {Key: "synced_at", Value: -1}} + opts := options.Find(). + SetSort(sort). + SetSkip(int64((page - 1) * pageSize)). + SetLimit(int64(pageSize)) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + out := make([]entity.Mention, 0, pageSize) + for cur.Next(ctx) { + var item entity.Mention + if err := cur.Decode(&item); err != nil { + return nil, err + } + out = append(out, item) + } + if err := cur.Err(); err != nil { + return nil, err + } + var newest entity.Mention + newestErr := r.collection.FindOne(ctx, filter, options.FindOne().SetSort(bson.D{{Key: "synced_at", Value: -1}})).Decode(&newest) + newestSyncedAt := int64(0) + if newestErr == nil { + newestSyncedAt = newest.SyncedAt + } + readyTotal, err := r.CountReplyReadyByAccount(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + return &domrepo.ListResult{ + Items: out, + Total: total, + ReadyTotal: readyTotal, + NewestSyncedAt: newestSyncedAt, + }, nil +} + +func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M { + return bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "account_id": accountID, + "media_id": mediaID, + } +} diff --git a/backend/internal/model/mention_inbox/usecase/usecase.go b/backend/internal/model/mention_inbox/usecase/usecase.go new file mode 100644 index 0000000..8ae69c0 --- /dev/null +++ b/backend/internal/model/mention_inbox/usecase/usecase.go @@ -0,0 +1,347 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/model/mention_inbox/domain/entity" + domrepo "haixun-backend/internal/model/mention_inbox/domain/repository" + domusecase "haixun-backend/internal/model/mention_inbox/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type mentionInboxUseCase struct { + repo domrepo.Repository + threadsAccounts threadsaccountdomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, threadsAccounts threadsaccountdomain.UseCase) domusecase.UseCase { + return &mentionInboxUseCase{repo: repo, threadsAccounts: threadsAccounts} +} + +func (u *mentionInboxUseCase) SyncFromAPI(ctx context.Context, req domusecase.SyncRequest) (*domusecase.SyncResult, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + + account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + client := libthreads.NewClient(token) + if !client.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") + } + + userID := strings.TrimSpace(account.ThreadsUserID) + if userID == "" { + if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { + userID = profile.ID.String() + } + } + if userID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id") + } + + perPage := req.Limit + if perPage <= 0 { + perPage = 25 + } + if perPage > 100 { + perPage = 100 + } + maxPages := req.MaxPages + if maxPages <= 0 { + maxPages = 4 + } + if maxPages > 20 { + maxPages = 20 + } + + now := clock.NowUnixNano() + synced := 0 + ready := 0 + after := "" + for page := 0; page < maxPages; page++ { + mentions, nextAfter, err := client.UserMentionsPage(ctx, userID, perPage, after) + if err != nil { + return nil, err + } + for _, hit := range mentions { + item := u.buildMentionEntity(tenantID, ownerUID, accountID, hit, now) + u.fillContext(ctx, client, hit, item) + u.primeReplyTarget(ctx, client, hit, item) + if _, err := u.repo.Upsert(ctx, item); err != nil { + return nil, err + } + synced++ + if item.ReplyReady { + ready++ + } + } + if nextAfter == "" || len(mentions) == 0 { + break + } + after = nextAfter + } + + total, err := u.repo.CountByAccount(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + + return &domusecase.SyncResult{ + Synced: synced, + Ready: ready, + Total: total, + }, nil +} + +func (u *mentionInboxUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + if _, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + result, err := u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID, req.Page, req.PageSize) + if err != nil { + return nil, err + } + page, pageSize := normalizeListPage(req.Page, req.PageSize) + return &domusecase.ListResult{ + List: toSummaries(result.Items), + Pagination: domusecase.Pagination{ + Total: result.Total, + ReadyTotal: result.ReadyTotal, + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: mentionTotalPages(result.Total, pageSize), + NewestSyncedAt: result.NewestSyncedAt, + }, + }, nil +} + +func normalizeListPage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func mentionTotalPages(total int64, pageSize int) int64 { + if total <= 0 { + return 0 + } + return (total + int64(pageSize) - 1) / int64(pageSize) +} + +func (u *mentionInboxUseCase) PublishReply(ctx context.Context, req domusecase.PublishReplyRequest) (*domusecase.PublishReplyResult, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + mediaID := strings.TrimSpace(req.MediaID) + text := strings.TrimSpace(req.Text) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + if accountID == "" || mediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required") + } + if text == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required") + } + + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID) + if err != nil { + return nil, err + } + if !item.ReplyReady { + msg := strings.TrimSpace(item.ReplyReadyMsg) + if msg == "" { + msg = "此提及尚未完成回覆註冊,請先重新同步提及清單" + } + return nil, app.For(code.ThreadsAccount).InputInvalidFormat(msg) + } + + account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + userID := strings.TrimSpace(account.ThreadsUserID) + if userID == "" { + if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { + userID = profile.ID.String() + } + } + if userID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id") + } + + result, err := libthreads.PublishReply(ctx, libthreads.PublishReplyInput{ + ThreadsUserID: userID, + AccessToken: token, + ReplyToID: mediaID, + Text: text, + }) + if err != nil { + return nil, err + } + out := &domusecase.PublishReplyResult{} + if result != nil { + out.MediaID = strings.TrimSpace(result.MediaID) + out.Permalink = strings.TrimSpace(result.Permalink) + } + return out, nil +} + +func (u *mentionInboxUseCase) buildMentionEntity( + tenantID, ownerUID, accountID string, + hit libthreads.MentionItem, + now int64, +) *entity.Mention { + mediaID := strings.TrimSpace(hit.ID) + return &entity.Mention{ + ID: entity.DocID(accountID, mediaID), + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: accountID, + MediaID: mediaID, + AuthorUsername: strings.TrimSpace(hit.Username), + Text: strings.TrimSpace(hit.Text), + Permalink: strings.TrimSpace(hit.Permalink), + Shortcode: shortcodeFromPermalink(hit.Permalink), + Timestamp: strings.TrimSpace(hit.Timestamp), + MediaType: strings.TrimSpace(hit.MediaType), + IsReply: hit.IsReply, + IsQuotePost: hit.IsQuotePost, + HasReplies: hit.HasReplies, + RootPostID: strings.TrimSpace(hit.RootPostID), + ParentID: strings.TrimSpace(hit.ParentID), + SyncedAt: now, + UpdateAt: now, + } +} + +func (u *mentionInboxUseCase) fillContext(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) { + mentionText := strings.TrimSpace(hit.Text) + if !hit.IsReply && !hit.IsQuotePost { + item.ThreadPostText = mentionText + return + } + + rootID := strings.TrimSpace(hit.RootPostID) + if rootID == "" { + rootID = strings.TrimSpace(hit.ID) + } + if rootID != "" { + if detail, err := client.GetMediaDetail(ctx, rootID); err == nil && detail != nil { + item.ThreadPostText = strings.TrimSpace(detail.Text) + } + } + if item.ThreadPostText == "" { + item.ThreadPostText = mentionText + } + + parentID := strings.TrimSpace(hit.ParentID) + mediaID := strings.TrimSpace(hit.ID) + if hit.IsReply && parentID != "" && parentID != mediaID && parentID != rootID { + if detail, err := client.GetMediaDetail(ctx, parentID); err == nil && detail != nil { + item.ParentReplyText = strings.TrimSpace(detail.Text) + } + } +} + +func (u *mentionInboxUseCase) primeReplyTarget(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) { + permalink := strings.TrimSpace(hit.Permalink) + mediaID := strings.TrimSpace(hit.ID) + if permalink == "" { + item.ReplyReady = false + item.ReplyReadyMsg = "缺少 permalink,無法註冊回覆目標" + return + } + if err := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: mediaID, + Permalink: permalink, + HintText: strings.TrimSpace(hit.Text), + }, mediaID); err != nil { + item.ReplyReady = false + item.ReplyReadyMsg = err.Error() + return + } + item.ReplyReady = true + item.ReplyReadyMsg = "" +} + +func shortcodeFromPermalink(permalink string) string { + permalink = strings.Split(strings.TrimSpace(permalink), "?")[0] + permalink = strings.Split(permalink, "#")[0] + parts := strings.Split(permalink, "/post/") + if len(parts) < 2 { + return "" + } + return strings.TrimSpace(parts[len(parts)-1]) +} + +func toSummary(item *entity.Mention) domusecase.MentionSummary { + if item == nil { + return domusecase.MentionSummary{} + } + return domusecase.MentionSummary{ + MediaID: item.MediaID, + AuthorUsername: item.AuthorUsername, + Text: item.Text, + Permalink: item.Permalink, + Shortcode: item.Shortcode, + Timestamp: item.Timestamp, + MediaType: item.MediaType, + IsReply: item.IsReply, + IsQuotePost: item.IsQuotePost, + HasReplies: item.HasReplies, + RootPostID: item.RootPostID, + ParentID: item.ParentID, + ThreadPostText: item.ThreadPostText, + ParentReplyText: item.ParentReplyText, + ReplyReady: item.ReplyReady, + ReplyReadyMsg: item.ReplyReadyMsg, + SyncedAt: item.SyncedAt, + } +} + +func toSummaries(items []entity.Mention) []domusecase.MentionSummary { + out := make([]domusecase.MentionSummary, 0, len(items)) + for i := range items { + out = append(out, toSummary(&items[i])) + } + return out +} diff --git a/backend/internal/model/outreach_draft/domain/repository/repository.go b/backend/internal/model/outreach_draft/domain/repository/repository.go index 1a64c56..2023a3c 100644 --- a/backend/internal/model/outreach_draft/domain/repository/repository.go +++ b/backend/internal/model/outreach_draft/domain/repository/repository.go @@ -9,5 +9,7 @@ import ( type Repository interface { EnsureIndexes(ctx context.Context) error Create(ctx context.Context, draft *entity.OutreachDraft) error + GetByID(ctx context.Context, tenantID, ownerUID, brandID, draftID string) (*entity.OutreachDraft, error) GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*entity.OutreachDraft, error) + UpdateDrafts(ctx context.Context, tenantID, ownerUID, brandID, draftID string, drafts []entity.DraftItem) (*entity.OutreachDraft, error) } diff --git a/backend/internal/model/outreach_draft/domain/usecase/usecase.go b/backend/internal/model/outreach_draft/domain/usecase/usecase.go index 2892847..846eb85 100644 --- a/backend/internal/model/outreach_draft/domain/usecase/usecase.go +++ b/backend/internal/model/outreach_draft/domain/usecase/usecase.go @@ -31,7 +31,17 @@ type CreateRequest struct { Drafts []DraftItem } +type UpdateDraftItemRequest struct { + TenantID string + OwnerUID string + BrandID string + DraftID string + DraftIndex int + Text string +} + type UseCase interface { Create(ctx context.Context, req CreateRequest) (*DraftSummary, error) GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*DraftSummary, error) + UpdateDraftItem(ctx context.Context, req UpdateDraftItemRequest) (*DraftSummary, error) } diff --git a/backend/internal/model/outreach_draft/repository/mongo.go b/backend/internal/model/outreach_draft/repository/mongo.go index 6a54553..badee67 100644 --- a/backend/internal/model/outreach_draft/repository/mongo.go +++ b/backend/internal/model/outreach_draft/repository/mongo.go @@ -84,6 +84,57 @@ func draftScopeFilter(tenantID, ownerUID, brandID, topicID string) bson.M { return filter } +func (r *mongoRepository) GetByID( + ctx context.Context, + tenantID, ownerUID, brandID, draftID string, +) (*entity.OutreachDraft, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + draftID = strings.TrimSpace(draftID) + if draftID == "" { + return nil, app.For(code.Brand).InputMissingRequired("draft_id is required") + } + filter := brandOwnerFilter(tenantID, ownerUID, brandID) + filter["_id"] = draftID + var out entity.OutreachDraft + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("outreach draft not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) UpdateDrafts( + ctx context.Context, + tenantID, ownerUID, brandID, draftID string, + drafts []entity.DraftItem, +) (*entity.OutreachDraft, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + draftID = strings.TrimSpace(draftID) + if draftID == "" { + return nil, app.For(code.Brand).InputMissingRequired("draft_id is required") + } + filter := brandOwnerFilter(tenantID, ownerUID, brandID) + filter["_id"] = draftID + update := bson.M{"$set": bson.M{"drafts": drafts}} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var out entity.OutreachDraft + err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("outreach draft not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + func (r *mongoRepository) GetLatestByScanPost( ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string, diff --git a/backend/internal/model/outreach_draft/usecase/errors.go b/backend/internal/model/outreach_draft/usecase/errors.go index 75da245..be64b44 100644 --- a/backend/internal/model/outreach_draft/usecase/errors.go +++ b/backend/internal/model/outreach_draft/usecase/errors.go @@ -16,3 +16,19 @@ func errMissingBrand() error { func errMissingScanPost() error { return app.For(code.Brand).InputMissingRequired("scan_post_id is required") } + +func errMissingDraftID() error { + return app.For(code.Brand).InputMissingRequired("draft_id is required") +} + +func errMissingDraftText() error { + return app.For(code.Brand).InputMissingRequired("draft text cannot be empty") +} + +func errDraftNotFound() error { + return app.For(code.Brand).ResNotFound("outreach draft not found") +} + +func errInvalidDraftIndex() error { + return app.For(code.Brand).InputInvalidFormat("draft_index out of range") +} diff --git a/backend/internal/model/outreach_draft/usecase/usecase.go b/backend/internal/model/outreach_draft/usecase/usecase.go index 02175eb..bdf7b47 100644 --- a/backend/internal/model/outreach_draft/usecase/usecase.go +++ b/backend/internal/model/outreach_draft/usecase/usecase.go @@ -56,6 +56,39 @@ func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.Create return toSummary(record), nil } +func (u *outreachDraftUseCase) UpdateDraftItem( + ctx context.Context, + req domusecase.UpdateDraftItemRequest, +) (*domusecase.DraftSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { + return nil, err + } + draftID := strings.TrimSpace(req.DraftID) + if draftID == "" { + return nil, errMissingDraftID() + } + text := strings.TrimSpace(req.Text) + if text == "" { + return nil, errMissingDraftText() + } + record, err := u.repo.GetByID(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID) + if err != nil { + return nil, err + } + if record == nil || len(record.Drafts) == 0 { + return nil, errDraftNotFound() + } + if req.DraftIndex < 0 || req.DraftIndex >= len(record.Drafts) { + return nil, errInvalidDraftIndex() + } + record.Drafts[req.DraftIndex].Text = text + updated, err := u.repo.UpdateDrafts(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID, record.Drafts) + if err != nil { + return nil, err + } + return toSummary(updated), nil +} + func (u *outreachDraftUseCase) GetLatestByScanPost( ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string, diff --git a/backend/internal/model/own_post_formula/domain/entity/review.go b/backend/internal/model/own_post_formula/domain/entity/review.go new file mode 100644 index 0000000..b6376c5 --- /dev/null +++ b/backend/internal/model/own_post_formula/domain/entity/review.go @@ -0,0 +1,28 @@ +package entity + +const CollectionName = "own_post_formula_reviews" + +type Review struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + MediaID string `bson:"media_id"` + PersonaID string `bson:"persona_id,omitempty"` + PostText string `bson:"post_text,omitempty"` + Summary string `bson:"summary"` + Wins []string `bson:"wins,omitempty"` + Improvements []string `bson:"improvements,omitempty"` + Formula string `bson:"formula"` + PostTemplate string `bson:"post_template"` + HookPattern string `bson:"hook_pattern"` + Structure string `bson:"structure"` + ReplicationTips []string `bson:"replication_tips,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +func ReviewDocID(accountID, mediaID string) string { + return accountID + ":" + mediaID +} diff --git a/backend/internal/model/own_post_formula/domain/repository/repository.go b/backend/internal/model/own_post_formula/domain/repository/repository.go new file mode 100644 index 0000000..7b1601e --- /dev/null +++ b/backend/internal/model/own_post_formula/domain/repository/repository.go @@ -0,0 +1,14 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/own_post_formula/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) + ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) +} diff --git a/backend/internal/model/own_post_formula/repository/mongo.go b/backend/internal/model/own_post_formula/repository/mongo.go new file mode 100644 index 0000000..4c60838 --- /dev/null +++ b/backend/internal/model/own_post_formula/repository/mongo.go @@ -0,0 +1,115 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/own_post_formula/domain/entity" + domrepo "haixun-backend/internal/model/own_post_formula/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "media_id", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "update_at", Value: -1}, + }, + }, + }) + return err +} + +func (r *mongoRepository) Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if review == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required") + } + filter := actorMediaFilter(review.TenantID, review.OwnerUID, review.AccountID, review.MediaID) + opts := options.Replace().SetUpsert(true) + if _, err := r.collection.ReplaceOne(ctx, filter, review, opts); err != nil { + return nil, err + } + return review, nil +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Review + err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("own post formula review not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + cur, err := r.collection.Find( + ctx, + bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + }, + options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}), + ) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Review + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + "media_id": strings.TrimSpace(mediaID), + } +} diff --git a/backend/internal/model/own_post_formula/usecase/usecase.go b/backend/internal/model/own_post_formula/usecase/usecase.go new file mode 100644 index 0000000..eb9863b --- /dev/null +++ b/backend/internal/model/own_post_formula/usecase/usecase.go @@ -0,0 +1,93 @@ +package usecase + +import ( + "context" + "strings" + "time" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libownpost "haixun-backend/internal/library/ownpost" + "haixun-backend/internal/model/own_post_formula/domain/entity" + domrepo "haixun-backend/internal/model/own_post_formula/domain/repository" +) + +const FormulaCacheTTL = 5 * time.Minute + +type UseCase interface { + Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) + SaveGenerated(ctx context.Context, tenantID, ownerUID, accountID, mediaID, personaID, postText string, review *libownpost.PostFormulaReview) (*entity.Review, error) + ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) + IsFresh(review *entity.Review, now int64) bool +} + +func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) { + return u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID) +} + +func (u *useCase) IsFresh(review *entity.Review, now int64) bool { + if review == nil || review.UpdateAt <= 0 { + return false + } + if now <= 0 { + now = clock.NowUnixNano() + } + return now-review.UpdateAt < int64(FormulaCacheTTL) +} + +type useCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) UseCase { + return &useCase{repo: repo} +} + +func (u *useCase) SaveGenerated( + ctx context.Context, + tenantID, ownerUID, accountID, mediaID, personaID, postText string, + review *libownpost.PostFormulaReview, +) (*entity.Review, error) { + if review == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required") + } + accountID = strings.TrimSpace(accountID) + mediaID = strings.TrimSpace(mediaID) + if accountID == "" || mediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required") + } + now := clock.NowUnixNano() + doc := &entity.Review{ + ID: entity.ReviewDocID(accountID, mediaID), + TenantID: strings.TrimSpace(tenantID), + OwnerUID: strings.TrimSpace(ownerUID), + AccountID: accountID, + MediaID: mediaID, + PersonaID: strings.TrimSpace(personaID), + PostText: strings.TrimSpace(postText), + Summary: review.Summary, + Wins: review.Wins, + Improvements: review.Improvements, + Formula: review.Formula, + PostTemplate: review.PostTemplate, + HookPattern: review.HookPattern, + Structure: review.Structure, + ReplicationTips: review.ReplicationTips, + Avoid: review.Avoid, + CreateAt: now, + UpdateAt: now, + } + if existing, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID); err == nil && existing != nil { + doc.CreateAt = existing.CreateAt + } + return u.repo.Upsert(ctx, doc) +} + +func (u *useCase) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + return u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID) +} diff --git a/backend/internal/model/permission/usecase/usecase.go b/backend/internal/model/permission/usecase/usecase.go index 8fa0cd8..ad73322 100644 --- a/backend/internal/model/permission/usecase/usecase.go +++ b/backend/internal/model/permission/usecase/usecase.go @@ -95,14 +95,14 @@ func (u *permissionUseCase) Me(ctx context.Context, member *memberentity.Member, if err != nil { return nil, err } - if len(perms) == 0 { - perms, err = u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen}) - if err != nil { - return nil, err - } - if !hasRole(roles, "admin") { - perms = filterPermissionsByName(perms, defaultUserPermissionNames()) - } + openPerms, err := u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen}) + if err != nil { + return nil, err + } + if hasRole(roles, "admin") { + perms = mergePermissions(perms, openPerms) + } else { + perms = mergePermissions(perms, filterPermissionsByName(openPerms, defaultUserPermissionNames())) } nodes := permissionNodes(perms) result := &domusecase.MePermissions{ @@ -169,6 +169,28 @@ func filterPermissionsByName(items []*entity.Permission, names []string) []*enti return out } +func mergePermissions(base []*entity.Permission, extras []*entity.Permission) []*entity.Permission { + seen := map[string]struct{}{} + out := make([]*entity.Permission, 0, len(base)+len(extras)) + for _, group := range [][]*entity.Permission{base, extras} { + for _, item := range group { + if item == nil { + continue + } + key := item.Name + if key == "" { + key = item.ID.Hex() + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + } + return out +} + func defaultUserPermissionNames() []string { return []string{ "auth.logout", @@ -251,7 +273,7 @@ func defaultPermissions() []*entity.Permission { {Name: "brand.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/brands/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "persona.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/personas/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "placement_topic.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/placement/topics/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, - {Name: "threads_account.manage", HTTPMethods: "GET|POST|PUT|PATCH", HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, + {Name: "threads_account.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "job.manage", HTTPMethods: "GET|POST", HTTPPath: "/api/v1/jobs/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "job.schedule.manage", HTTPMethods: "GET|POST|PUT|DELETE", HTTPPath: "/api/v1/job/schedules/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, diff --git a/backend/internal/model/placement/usecase/settings.go b/backend/internal/model/placement/usecase/settings.go index 1e7f423..fee05de 100644 --- a/backend/internal/model/placement/usecase/settings.go +++ b/backend/internal/model/placement/usecase/settings.go @@ -28,6 +28,7 @@ type Settings struct { BraveSearchLang string ExaUserLocation string ExpandStrategy string + DevModeEnabled bool } type SettingsPatch struct { @@ -38,6 +39,7 @@ type SettingsPatch struct { BraveSearchLang *string ExaUserLocation *string ExpandStrategy *string + DevModeEnabled *bool } type UseCase interface { @@ -164,6 +166,7 @@ type storedSettings struct { BraveSearchLang string ExaUserLocation string ExpandStrategy string + DevModeEnabled bool } func defaultSettings() storedSettings { @@ -201,6 +204,9 @@ func mergeSettings(defaults storedSettings, raw map[string]interface{}) storedSe if v, ok := raw["expand_strategy"].(string); ok && strings.TrimSpace(v) != "" { defaults.ExpandStrategy = string(libkg.ParseExpandStrategy(v)) } + if v, ok := raw["dev_mode_enabled"].(bool); ok { + defaults.DevModeEnabled = v + } return defaults } @@ -232,6 +238,9 @@ func applyPatch(current storedSettings, patch SettingsPatch) storedSettings { if patch.ExpandStrategy != nil && strings.TrimSpace(*patch.ExpandStrategy) != "" { current.ExpandStrategy = string(libkg.ParseExpandStrategy(*patch.ExpandStrategy)) } + if patch.DevModeEnabled != nil { + current.DevModeEnabled = *patch.DevModeEnabled + } return current } @@ -244,6 +253,7 @@ func (s storedSettings) toMap() map[string]interface{} { "brave_search_lang": s.BraveSearchLang, "exa_user_location": s.ExaUserLocation, "expand_strategy": s.ExpandStrategy, + "dev_mode_enabled": s.DevModeEnabled, } } @@ -267,6 +277,7 @@ func toPublic(stored storedSettings) *Settings { BraveSearchLang: stored.BraveSearchLang, ExaUserLocation: stored.ExaUserLocation, ExpandStrategy: strategy, + DevModeEnabled: stored.DevModeEnabled, } } diff --git a/backend/internal/model/placement/usecase/settings_dev_mode_test.go b/backend/internal/model/placement/usecase/settings_dev_mode_test.go new file mode 100644 index 0000000..5dfb81d --- /dev/null +++ b/backend/internal/model/placement/usecase/settings_dev_mode_test.go @@ -0,0 +1,28 @@ +package usecase + +import "testing" + +func TestApplyPatchDevModeEnabled(t *testing.T) { + enabled := true + disabled := false + current := defaultSettings() + + next := applyPatch(current, SettingsPatch{DevModeEnabled: &enabled}) + if !next.DevModeEnabled { + t.Fatal("expected dev_mode_enabled true") + } + + next = applyPatch(next, SettingsPatch{DevModeEnabled: &disabled}) + if next.DevModeEnabled { + t.Fatal("expected dev_mode_enabled false after toggle off") + } +} + +func TestMergeSettingsDevModeEnabled(t *testing.T) { + merged := mergeSettings(defaultSettings(), map[string]interface{}{ + "dev_mode_enabled": true, + }) + if !merged.DevModeEnabled { + t.Fatal("expected merged dev_mode_enabled true") + } +} diff --git a/backend/internal/model/publish_analytics/domain/entity/analytics.go b/backend/internal/model/publish_analytics/domain/entity/analytics.go index c290a0b..9e8e39d 100644 --- a/backend/internal/model/publish_analytics/domain/entity/analytics.go +++ b/backend/internal/model/publish_analytics/domain/entity/analytics.go @@ -16,6 +16,8 @@ type PublishAnalytics struct { ReplyCount int `bson:"reply_count,omitempty"` RepostCount int `bson:"repost_count,omitempty"` QuoteCount int `bson:"quote_count,omitempty"` + ViewCount int `bson:"view_count,omitempty"` + ShareCount int `bson:"share_count,omitempty"` ImpressionCount int `bson:"impression_count,omitempty"` ReachCount int `bson:"reach_count,omitempty"` CheckedAt int64 `bson:"checked_at"` diff --git a/backend/internal/model/publish_analytics/domain/repository/repository.go b/backend/internal/model/publish_analytics/domain/repository/repository.go index dcd2ced..b54dd32 100644 --- a/backend/internal/model/publish_analytics/domain/repository/repository.go +++ b/backend/internal/model/publish_analytics/domain/repository/repository.go @@ -12,4 +12,5 @@ type Repository interface { Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error) Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error) + DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error) } diff --git a/backend/internal/model/publish_analytics/domain/usecase/usecase.go b/backend/internal/model/publish_analytics/domain/usecase/usecase.go index 4a8f3f1..6331a48 100644 --- a/backend/internal/model/publish_analytics/domain/usecase/usecase.go +++ b/backend/internal/model/publish_analytics/domain/usecase/usecase.go @@ -12,13 +12,25 @@ type PublishAnalyticsSummary struct { ReplyCount int RepostCount int QuoteCount int + ViewCount int + ShareCount int ImpressionCount int ReachCount int CheckedAt int64 CreateAt int64 } +type ScheduleCheckpointsRequest struct { + TenantID string + OwnerUID string + AccountID string + MediaID string + Permalink string + PublishedAt int64 +} + type UseCase interface { RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary PublishAnalyticsSummary) (*PublishAnalyticsSummary, error) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]PublishAnalyticsSummary, error) + ScheduleCheckpoints(ctx context.Context, req ScheduleCheckpointsRequest) error } diff --git a/backend/internal/model/publish_analytics/repository/mongo.go b/backend/internal/model/publish_analytics/repository/mongo.go index 6a5862d..29d78ae 100644 --- a/backend/internal/model/publish_analytics/repository/mongo.go +++ b/backend/internal/model/publish_analytics/repository/mongo.go @@ -115,7 +115,9 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA return nil, app.For(code.AI).InputMissingRequired("analytics id is required") } - filter := bson.M{"_id": analytics.ID} + filter := actorFilter(analytics.TenantID, analytics.OwnerUID) + filter["media_id"] = strings.TrimSpace(analytics.MediaID) + filter["checkpoint"] = strings.TrimSpace(analytics.Checkpoint) opts := options.Replace().SetUpsert(true) _, err := r.collection.ReplaceOne(ctx, filter, analytics, opts) if err != nil { @@ -123,3 +125,32 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA } return analytics, nil } + +func (r *mongoRepository) DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + ids := make([]string, 0, len(mediaIDs)) + seen := map[string]struct{}{} + for _, mediaID := range mediaIDs { + trimmed := strings.TrimSpace(mediaID) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + ids = append(ids, trimmed) + } + if len(ids) == 0 { + return 0, nil + } + filter := actorFilter(tenantID, ownerUID) + filter["media_id"] = bson.M{"$in": ids} + res, err := r.collection.DeleteMany(ctx, filter) + if err != nil { + return 0, err + } + return res.DeletedCount, nil +} diff --git a/backend/internal/model/publish_analytics/usecase/schedule.go b/backend/internal/model/publish_analytics/usecase/schedule.go new file mode 100644 index 0000000..3b33e06 --- /dev/null +++ b/backend/internal/model/publish_analytics/usecase/schedule.go @@ -0,0 +1,61 @@ +package usecase + +import ( + "context" + "strings" + "time" + + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/model/publish_analytics/domain/entity" + domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase" +) + +const publishAnalyticsTemplateType = "publish-analytics" + +func (u *publishAnalyticsUseCase) ScheduleCheckpoints(ctx context.Context, req domusecase.ScheduleCheckpointsRequest) error { + if u.jobs == nil { + return nil + } + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + mediaID := strings.TrimSpace(req.MediaID) + if tenantID == "" || ownerUID == "" || accountID == "" || mediaID == "" { + return nil + } + publishedAt := req.PublishedAt + if publishedAt <= 0 { + publishedAt = time.Now().UnixNano() + } + permalink := strings.TrimSpace(req.Permalink) + checkpoints := []struct { + name string + delay time.Duration + }{ + {entity.Checkpoint1h, time.Hour}, + {entity.Checkpoint24h, 24 * time.Hour}, + {entity.Checkpoint7d, 7 * 24 * time.Hour}, + } + for _, cp := range checkpoints { + at := publishedAt + cp.delay.Nanoseconds() + scheduled := at + _, _ = u.jobs.CreateRun(ctx, jobdom.CreateRunRequest{ + TemplateType: publishAnalyticsTemplateType, + Scope: "threads_account", + ScopeID: accountID, + TenantID: tenantID, + OwnerUID: ownerUID, + ScheduledAt: &scheduled, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "account_id": accountID, + "media_id": mediaID, + "permalink": permalink, + "published_at": publishedAt, + "checkpoint": cp.name, + }, + }) + } + return nil +} diff --git a/backend/internal/model/publish_analytics/usecase/usecase.go b/backend/internal/model/publish_analytics/usecase/usecase.go index fe8d3b3..aae8421 100644 --- a/backend/internal/model/publish_analytics/usecase/usecase.go +++ b/backend/internal/model/publish_analytics/usecase/usecase.go @@ -7,6 +7,7 @@ import ( "haixun-backend/internal/library/clock" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/model/publish_analytics/domain/entity" domrepo "haixun-backend/internal/model/publish_analytics/domain/repository" domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase" @@ -16,10 +17,11 @@ import ( type publishAnalyticsUseCase struct { repo domrepo.Repository + jobs jobdom.UseCase } -func NewUseCase(repo domrepo.Repository) domusecase.UseCase { - return &publishAnalyticsUseCase{repo: repo} +func NewUseCase(repo domrepo.Repository, jobs jobdom.UseCase) domusecase.UseCase { + return &publishAnalyticsUseCase{repo: repo, jobs: jobs} } func (u *publishAnalyticsUseCase) RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary domusecase.PublishAnalyticsSummary) (*domusecase.PublishAnalyticsSummary, error) { @@ -50,6 +52,8 @@ func (u *publishAnalyticsUseCase) RecordCheckpoint(ctx context.Context, tenantID ReplyCount: summary.ReplyCount, RepostCount: summary.RepostCount, QuoteCount: summary.QuoteCount, + ViewCount: summary.ViewCount, + ShareCount: summary.ShareCount, ImpressionCount: summary.ImpressionCount, ReachCount: summary.ReachCount, CheckedAt: now, @@ -97,6 +101,8 @@ func toSummary(item entity.PublishAnalytics) domusecase.PublishAnalyticsSummary ReplyCount: item.ReplyCount, RepostCount: item.RepostCount, QuoteCount: item.QuoteCount, + ViewCount: item.ViewCount, + ShareCount: item.ShareCount, ImpressionCount: item.ImpressionCount, ReachCount: item.ReachCount, CheckedAt: item.CheckedAt, diff --git a/backend/internal/model/publish_draft/domain/entity/draft.go b/backend/internal/model/publish_draft/domain/entity/draft.go new file mode 100644 index 0000000..c3071f8 --- /dev/null +++ b/backend/internal/model/publish_draft/domain/entity/draft.go @@ -0,0 +1,15 @@ +package entity + +const CollectionName = "publish_text_drafts" + +type Draft struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + Title string `bson:"title,omitempty"` + Text string `bson:"text"` + Tags []string `bson:"tags,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/publish_draft/domain/repository/repository.go b/backend/internal/model/publish_draft/domain/repository/repository.go new file mode 100644 index 0000000..0011df8 --- /dev/null +++ b/backend/internal/model/publish_draft/domain/repository/repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_draft/domain/entity" +) + +type ListResult struct { + Items []entity.Draft + Total int64 +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, draft *entity.Draft) error + Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*entity.Draft, error) + List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error) + Update(ctx context.Context, draft *entity.Draft) (*entity.Draft, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error +} diff --git a/backend/internal/model/publish_draft/domain/usecase/usecase.go b/backend/internal/model/publish_draft/domain/usecase/usecase.go new file mode 100644 index 0000000..e62e87d --- /dev/null +++ b/backend/internal/model/publish_draft/domain/usecase/usecase.go @@ -0,0 +1,48 @@ +package usecase + +import "context" + +type DraftSummary struct { + ID string + AccountID string + Title string + Text string + Tags []string + CreateAt int64 + UpdateAt int64 +} + +type CreateRequest struct { + TenantID string + OwnerUID string + AccountID string + Title string + Text string + Tags []string +} + +type UpdateRequest struct { + TenantID string + OwnerUID string + AccountID string + DraftID string + Title *string + Text *string + Tags *[]string +} + +type ListResult struct { + Items []DraftSummary + Total int64 + Page int + PageSize int + TotalPages int +} + +type UseCase interface { + Create(ctx context.Context, req CreateRequest) (*DraftSummary, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*DraftSummary, error) + List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error) + Update(ctx context.Context, req UpdateRequest) (*DraftSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error +} diff --git a/backend/internal/model/publish_guard/domain/entity/policy.go b/backend/internal/model/publish_guard/domain/entity/policy.go new file mode 100644 index 0000000..5e703e6 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/entity/policy.go @@ -0,0 +1,18 @@ +package entity + +const CollectionName = "publish_guard_policies" + +type Policy struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + Enabled bool `bson:"enabled"` + MaxDailyPosts int `bson:"max_daily_posts"` + MinIntervalMinutes int `bson:"min_interval_minutes"` + ConsecutiveFailurePauseLimit int `bson:"consecutive_failure_pause_limit"` + Paused bool `bson:"paused"` + PausedReason string `bson:"paused_reason,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/publish_guard/domain/repository/repository.go b/backend/internal/model/publish_guard/domain/repository/repository.go new file mode 100644 index 0000000..a958381 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/repository/repository.go @@ -0,0 +1,14 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_guard/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) + Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error +} diff --git a/backend/internal/model/publish_guard/domain/usecase/usecase.go b/backend/internal/model/publish_guard/domain/usecase/usecase.go new file mode 100644 index 0000000..4ec89b7 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/usecase/usecase.go @@ -0,0 +1,51 @@ +package usecase + +import "context" + +type PolicySummary struct { + AccountID string + Enabled bool + MaxDailyPosts int + MinIntervalMinutes int + ConsecutiveFailurePauseLimit int + Paused bool + PausedReason string + UpdateAt int64 +} + +type UpsertRequest struct { + TenantID string + OwnerUID string + AccountID string + Enabled bool + MaxDailyPosts int + MinIntervalMinutes int + ConsecutiveFailurePauseLimit int + Paused *bool + PausedReason string +} + +type CheckRequest struct { + TenantID string + OwnerUID string + AccountID string + Now int64 + PublishedToday int64 + LastPublishedAt int64 + FailureStreak int +} + +type CheckResult struct { + Allowed bool + DelayUntil int64 + Reason string + ShouldPause bool +} + +type UseCase interface { + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error) + Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error) + Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error) + Check(ctx context.Context, req CheckRequest) (*CheckResult, error) + Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*PolicySummary, error) +} diff --git a/backend/internal/model/publish_guard/repository/mongo.go b/backend/internal/model/publish_guard/repository/mongo.go new file mode 100644 index 0000000..dcc262e --- /dev/null +++ b/backend/internal/model/publish_guard/repository/mongo.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_guard/domain/entity" + domrepo "haixun-backend/internal/model/publish_guard/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Policy + err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("publish guard policy not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if policy == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required") + } + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID), policy, opts) + if err != nil { + return nil, err + } + return policy, nil +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID)) + return err +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + } +} diff --git a/backend/internal/model/publish_guard/usecase/usecase.go b/backend/internal/model/publish_guard/usecase/usecase.go new file mode 100644 index 0000000..e7777b6 --- /dev/null +++ b/backend/internal/model/publish_guard/usecase/usecase.go @@ -0,0 +1,171 @@ +package usecase + +import ( + "context" + "strings" + "time" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_guard/domain/entity" + domrepo "haixun-backend/internal/model/publish_guard/domain/repository" + domusecase "haixun-backend/internal/model/publish_guard/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" + + "github.com/google/uuid" +) + +type guardUseCase struct { + repo domrepo.Repository + threadsAccount threadsaccountdomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase { + return &guardUseCase{repo: repo, threadsAccount: threadsAccount} +} + +func (u *guardUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + item = defaultPolicy(tenantID, ownerUID, accountID) + } else { + return nil, err + } + } + out := toSummary(item) + return &out, nil +} + +func (u *guardUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID) + item.Enabled = req.Enabled + if req.MaxDailyPosts > 0 { + item.MaxDailyPosts = req.MaxDailyPosts + } + if req.MinIntervalMinutes > 0 { + item.MinIntervalMinutes = req.MinIntervalMinutes + } + if req.ConsecutiveFailurePauseLimit > 0 { + item.ConsecutiveFailurePauseLimit = req.ConsecutiveFailurePauseLimit + } + if req.Paused != nil { + item.Paused = *req.Paused + } + item.PausedReason = strings.TrimSpace(req.PausedReason) + item.UpdateAt = clock.NowUnixNano() + saved, err := u.repo.Upsert(ctx, item) + if err != nil { + return nil, err + } + out := toSummary(saved) + return &out, nil +} + +func (u *guardUseCase) Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) { + policy, err := u.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + paused := false + return u.Upsert(ctx, domusecase.UpsertRequest{ + TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled, + MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes, + ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit, + Paused: &paused, + }) +} + +func (u *guardUseCase) Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*domusecase.PolicySummary, error) { + policy, err := u.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + paused := true + return u.Upsert(ctx, domusecase.UpsertRequest{ + TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled, + MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes, + ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit, + Paused: &paused, PausedReason: reason, + }) +} + +func (u *guardUseCase) Check(ctx context.Context, req domusecase.CheckRequest) (*domusecase.CheckResult, error) { + policy, err := u.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID) + if err != nil { + return nil, err + } + if !policy.Enabled { + return &domusecase.CheckResult{Allowed: true}, nil + } + now := req.Now + if now <= 0 { + now = clock.NowUnixNano() + } + if policy.Paused { + return &domusecase.CheckResult{Allowed: false, Reason: firstNonEmpty(policy.PausedReason, "發文已暫停")}, nil + } + if policy.MaxDailyPosts > 0 && req.PublishedToday >= int64(policy.MaxDailyPosts) { + return &domusecase.CheckResult{Allowed: false, DelayUntil: now + int64(time.Hour), Reason: "已達每日發文上限"}, nil + } + if policy.MinIntervalMinutes > 0 && req.LastPublishedAt > 0 { + minNext := req.LastPublishedAt + int64(time.Duration(policy.MinIntervalMinutes)*time.Minute) + if minNext > now { + return &domusecase.CheckResult{Allowed: false, DelayUntil: minNext, Reason: "未達最小發文間隔"}, nil + } + } + if policy.ConsecutiveFailurePauseLimit > 0 && req.FailureStreak >= policy.ConsecutiveFailurePauseLimit { + return &domusecase.CheckResult{Allowed: false, Reason: "連續失敗達上限,已暫停帳號發文", ShouldPause: true}, nil + } + return &domusecase.CheckResult{Allowed: true}, nil +} + +func (u *guardUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(accountID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + if u.threadsAccount != nil { + _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID) + return err + } + return nil +} + +func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy { + now := clock.NowUnixNano() + return &entity.Policy{ + ID: uuid.NewString(), TenantID: strings.TrimSpace(tenantID), OwnerUID: strings.TrimSpace(ownerUID), AccountID: strings.TrimSpace(accountID), + Enabled: true, MaxDailyPosts: 6, MinIntervalMinutes: 90, ConsecutiveFailurePauseLimit: 3, + CreateAt: now, UpdateAt: now, + } +} + +func toSummary(item *entity.Policy) domusecase.PolicySummary { + if item == nil { + return domusecase.PolicySummary{} + } + return domusecase.PolicySummary{ + AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts, + MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit, + Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt, + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/internal/model/publish_guard/usecase/usecase_test.go b/backend/internal/model/publish_guard/usecase/usecase_test.go new file mode 100644 index 0000000..6c829ad --- /dev/null +++ b/backend/internal/model/publish_guard/usecase/usecase_test.go @@ -0,0 +1,100 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "haixun-backend/internal/model/publish_guard/domain/entity" + domusecase "haixun-backend/internal/model/publish_guard/domain/usecase" +) + +type guardRepoStub struct { + policy *entity.Policy +} + +func (r *guardRepoStub) EnsureIndexes(ctx context.Context) error { return nil } +func (r *guardRepoStub) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) { + if r.policy == nil { + r.policy = defaultPolicy(tenantID, ownerUID, accountID) + } + return r.policy, nil +} +func (r *guardRepoStub) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) { + r.policy = policy + return policy, nil +} +func (r *guardRepoStub) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + r.policy = nil + return nil +} + +func TestGuardCheckBlocksPausedPolicy(t *testing.T) { + repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")} + repo.policy.Paused = true + repo.policy.PausedReason = "token error" + uc := NewUseCase(repo, nil) + + result, err := uc.Check(context.Background(), checkReq()) + if err != nil { + t.Fatal(err) + } + if result.Allowed || result.Reason != "token error" { + t.Fatalf("expected paused block, got %#v", result) + } +} + +func TestGuardCheckDelaysWhenDailyLimitReached(t *testing.T) { + repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")} + repo.policy.MaxDailyPosts = 1 + uc := NewUseCase(repo, nil) + + result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) { + req.publishedToday = 1 + })) + if err != nil { + t.Fatal(err) + } + if result.Allowed || result.DelayUntil == 0 { + t.Fatalf("expected delayed daily limit, got %#v", result) + } +} + +func TestGuardCheckDelaysWhenIntervalNotReached(t *testing.T) { + repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")} + repo.policy.MinIntervalMinutes = 90 + uc := NewUseCase(repo, nil) + + now := time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano() + result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) { + req.now = now + req.lastPublishedAt = now - int64(30*time.Minute) + })) + if err != nil { + t.Fatal(err) + } + if result.Allowed || result.DelayUntil <= now { + t.Fatalf("expected interval delay, got %#v", result) + } +} + +type CheckRequestBuilder struct { + now int64 + publishedToday int64 + lastPublishedAt int64 +} + +func checkReq() domusecase.CheckRequest { + return checkReqWith(nil) +} + +func checkReqWith(update func(*CheckRequestBuilder)) domusecase.CheckRequest { + builder := &CheckRequestBuilder{now: time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()} + if update != nil { + update(builder) + } + return domusecase.CheckRequest{ + TenantID: "t", OwnerUID: "u", AccountID: "a", + Now: builder.now, PublishedToday: builder.publishedToday, LastPublishedAt: builder.lastPublishedAt, + } +} diff --git a/backend/internal/model/publish_inventory/domain/entity/policy.go b/backend/internal/model/publish_inventory/domain/entity/policy.go new file mode 100644 index 0000000..053d97e --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/entity/policy.go @@ -0,0 +1,32 @@ +package entity + +const CollectionName = "publish_inventory_policies" + +type Slot struct { + Weekday int `bson:"weekday" json:"weekday"` + Time string `bson:"time" json:"time"` +} + +type SourceRef struct { + Type string `bson:"type" json:"type"` + PersonaID string `bson:"persona_id,omitempty" json:"persona_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty" json:"copy_mission_id,omitempty"` + ScanPostID string `bson:"scan_post_id,omitempty" json:"scan_post_id,omitempty"` + ManualSeed string `bson:"manual_seed,omitempty" json:"manual_seed,omitempty"` +} + +type Policy struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + Enabled bool `bson:"enabled"` + TargetDailyCount int `bson:"target_daily_count"` + LowStockThreshold int `bson:"low_stock_threshold"` + LookaheadDays int `bson:"lookahead_days"` + Timezone string `bson:"timezone"` + Slots []Slot `bson:"slots,omitempty"` + SourceRefs []SourceRef `bson:"source_refs,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/publish_inventory/domain/repository/repository.go b/backend/internal/model/publish_inventory/domain/repository/repository.go new file mode 100644 index 0000000..e55a79b --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_inventory/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) + Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) + ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error +} diff --git a/backend/internal/model/publish_inventory/domain/usecase/usecase.go b/backend/internal/model/publish_inventory/domain/usecase/usecase.go new file mode 100644 index 0000000..68525d3 --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/usecase/usecase.go @@ -0,0 +1,47 @@ +package usecase + +import "context" + +type Slot struct { + Weekday int + Time string +} + +type SourceRef struct { + Type string + PersonaID string + CopyMissionID string + ScanPostID string + ManualSeed string +} + +type PolicySummary struct { + AccountID string + Enabled bool + TargetDailyCount int + LowStockThreshold int + LookaheadDays int + Timezone string + Slots []Slot + SourceRefs []SourceRef + UpdateAt int64 +} + +type UpsertRequest struct { + TenantID string + OwnerUID string + AccountID string + Enabled bool + TargetDailyCount int + LowStockThreshold int + LookaheadDays int + Timezone string + Slots []Slot + SourceRefs []SourceRef +} + +type UseCase interface { + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error) + Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error) + ListEnabled(ctx context.Context, limit int) ([]PolicySummary, error) +} diff --git a/backend/internal/model/publish_inventory/repository/mongo.go b/backend/internal/model/publish_inventory/repository/mongo.go new file mode 100644 index 0000000..3edcef1 --- /dev/null +++ b/backend/internal/model/publish_inventory/repository/mongo.go @@ -0,0 +1,102 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_inventory/domain/entity" + domrepo "haixun-backend/internal/model/publish_inventory/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "enabled", Value: 1}}}, + }) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Policy + err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("publish inventory policy not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if policy == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required") + } + filter := actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID) + opts := options.Replace().SetUpsert(true) + if _, err := r.collection.ReplaceOne(ctx, filter, policy, opts); err != nil { + return nil, err + } + return policy, nil +} + +func (r *mongoRepository) ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 100 + } + cur, err := r.collection.Find(ctx, bson.M{"enabled": true}, options.Find().SetLimit(int64(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Policy + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID)) + return err +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + } +} diff --git a/backend/internal/model/publish_inventory/usecase/usecase.go b/backend/internal/model/publish_inventory/usecase/usecase.go new file mode 100644 index 0000000..497c412 --- /dev/null +++ b/backend/internal/model/publish_inventory/usecase/usecase.go @@ -0,0 +1,174 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_inventory/domain/entity" + domrepo "haixun-backend/internal/model/publish_inventory/domain/repository" + domusecase "haixun-backend/internal/model/publish_inventory/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" + + "github.com/google/uuid" +) + +type inventoryUseCase struct { + repo domrepo.Repository + threadsAccount threadsaccountdomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase { + return &inventoryUseCase{repo: repo, threadsAccount: threadsAccount} +} + +func (u *inventoryUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + item = defaultPolicy(tenantID, ownerUID, accountID) + } else { + return nil, err + } + } + out := toSummary(item) + return &out, nil +} + +func (u *inventoryUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID) + item.Enabled = req.Enabled + if req.TargetDailyCount > 0 { + item.TargetDailyCount = req.TargetDailyCount + } + if req.LowStockThreshold > 0 { + item.LowStockThreshold = req.LowStockThreshold + } + if req.LookaheadDays > 0 { + item.LookaheadDays = req.LookaheadDays + } + if strings.TrimSpace(req.Timezone) != "" { + item.Timezone = strings.TrimSpace(req.Timezone) + } + item.Slots = slotsToEntity(req.Slots) + item.SourceRefs = sourceRefsToEntity(req.SourceRefs) + now := clock.NowUnixNano() + item.UpdateAt = now + saved, err := u.repo.Upsert(ctx, item) + if err != nil { + return nil, err + } + out := toSummary(saved) + return &out, nil +} + +func (u *inventoryUseCase) ListEnabled(ctx context.Context, limit int) ([]domusecase.PolicySummary, error) { + items, err := u.repo.ListEnabled(ctx, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.PolicySummary, 0, len(items)) + for _, item := range items { + summary := toSummary(&item) + out = append(out, summary) + } + return out, nil +} + +func (u *inventoryUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(accountID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + if u.threadsAccount != nil { + _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID) + return err + } + return nil +} + +func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy { + now := clock.NowUnixNano() + return &entity.Policy{ + ID: uuid.NewString(), + TenantID: strings.TrimSpace(tenantID), + OwnerUID: strings.TrimSpace(ownerUID), + AccountID: strings.TrimSpace(accountID), + Enabled: false, + TargetDailyCount: 2, + LowStockThreshold: 3, + LookaheadDays: 7, + Timezone: "Asia/Taipei", + Slots: []entity.Slot{ + {Weekday: 1, Time: "09:30"}, + {Weekday: 3, Time: "12:30"}, + {Weekday: 5, Time: "18:30"}, + }, + CreateAt: now, + UpdateAt: now, + } +} + +func toSummary(item *entity.Policy) domusecase.PolicySummary { + if item == nil { + return domusecase.PolicySummary{} + } + return domusecase.PolicySummary{ + AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount, + LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays, + Timezone: item.Timezone, Slots: slotsFromEntity(item.Slots), SourceRefs: sourceRefsFromEntity(item.SourceRefs), UpdateAt: item.UpdateAt, + } +} + +func slotsToEntity(items []domusecase.Slot) []entity.Slot { + out := make([]entity.Slot, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Time) == "" { + continue + } + out = append(out, entity.Slot{Weekday: item.Weekday, Time: strings.TrimSpace(item.Time)}) + } + return out +} + +func slotsFromEntity(items []entity.Slot) []domusecase.Slot { + out := make([]domusecase.Slot, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.Slot{Weekday: item.Weekday, Time: item.Time}) + } + return out +} + +func sourceRefsToEntity(items []domusecase.SourceRef) []entity.SourceRef { + out := make([]entity.SourceRef, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Type) == "" { + continue + } + out = append(out, entity.SourceRef{ + Type: strings.TrimSpace(item.Type), PersonaID: strings.TrimSpace(item.PersonaID), + CopyMissionID: strings.TrimSpace(item.CopyMissionID), ScanPostID: strings.TrimSpace(item.ScanPostID), ManualSeed: strings.TrimSpace(item.ManualSeed), + }) + } + return out +} + +func sourceRefsFromEntity(items []entity.SourceRef) []domusecase.SourceRef { + out := make([]domusecase.SourceRef, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.SourceRef{ + Type: item.Type, PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, ScanPostID: item.ScanPostID, ManualSeed: item.ManualSeed, + }) + } + return out +} diff --git a/backend/internal/model/publish_queue/domain/entity/queue_item.go b/backend/internal/model/publish_queue/domain/entity/queue_item.go new file mode 100644 index 0000000..295d93c --- /dev/null +++ b/backend/internal/model/publish_queue/domain/entity/queue_item.go @@ -0,0 +1,37 @@ +package entity + +const CollectionName = "publish_queue" + +const ( + StatusScheduled = "scheduled" + StatusPublishing = "publishing" + StatusPublished = "published" + StatusFailed = "failed" + StatusCancelled = "cancelled" + StatusMissed = "missed" +) + +type QueueItem struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + PersonaID string `bson:"persona_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty"` + CopyDraftID string `bson:"copy_draft_id,omitempty"` + Text string `bson:"text"` + TopicTag string `bson:"topic_tag,omitempty"` + ScheduledAt int64 `bson:"scheduled_at"` + Status string `bson:"status"` + MediaID string `bson:"media_id,omitempty"` + Permalink string `bson:"permalink,omitempty"` + PublishedAt int64 `bson:"published_at,omitempty"` + ErrorMessage string `bson:"error_message,omitempty"` + RetryCount int `bson:"retry_count,omitempty"` + LastAttemptAt int64 `bson:"last_attempt_at,omitempty"` + NextAttemptAt int64 `bson:"next_attempt_at,omitempty"` + MissedAt int64 `bson:"missed_at,omitempty"` + PausedReason string `bson:"paused_reason,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/publish_queue/domain/repository/repository.go b/backend/internal/model/publish_queue/domain/repository/repository.go new file mode 100644 index 0000000..68c85cf --- /dev/null +++ b/backend/internal/model/publish_queue/domain/repository/repository.go @@ -0,0 +1,39 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_queue/domain/entity" +) + +type ListFilter struct { + TenantID string + OwnerUID string + AccountID string + Status string + StartAt int64 + EndAt int64 +} + +type ListResult struct { + Items []entity.QueueItem + Total int64 +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, item *entity.QueueItem) error + Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error) + ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error) + List(ctx context.Context, filter ListFilter, page, pageSize int) (*ListResult, error) + ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error) + ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) + UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error) + Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error) + CountByStatus(ctx context.Context, tenantID, ownerUID, accountID string, status string) (int64, error) + CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error) + LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error + ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) +} diff --git a/backend/internal/model/publish_queue/domain/usecase/usecase.go b/backend/internal/model/publish_queue/domain/usecase/usecase.go new file mode 100644 index 0000000..7487ada --- /dev/null +++ b/backend/internal/model/publish_queue/domain/usecase/usecase.go @@ -0,0 +1,171 @@ +package usecase + +import "context" + +type QueueItemSummary struct { + ID string + AccountID string + PersonaID string + CopyMissionID string + CopyDraftID string + Text string + TopicTag string + ScheduledAt int64 + Status string + MediaID string + Permalink string + PublishedAt int64 + ErrorMessage string + RetryCount int + LastAttemptAt int64 + NextAttemptAt int64 + MissedAt int64 + PausedReason string + CreateAt int64 + UpdateAt int64 +} + +type CreateRequest struct { + TenantID string + OwnerUID string + AccountID string + PersonaID string + CopyMissionID string + CopyDraftID string + Text string + TopicTag string + ScheduledAt int64 +} + +type UpdateRequest struct { + TenantID string + OwnerUID string + AccountID string + QueueID string + Text *string + TopicTag *string + ScheduledAt *int64 +} + +type ListRequest struct { + TenantID string + OwnerUID string + AccountID string + Status string + Page int + PageSize int +} + +type RangeRequest struct { + TenantID string + OwnerUID string + AccountID string + Status string + StartAt int64 + EndAt int64 + Page int + PageSize int +} + +type ListResult struct { + Items []QueueItemSummary + Total int64 + Page int + PageSize int + TotalPages int +} + +type PublishHealthSummary struct { + PendingScheduled int64 + FailedCount int64 + Published7d int64 + TotalLikes7d int + TotalReplies7d int +} + +type PublishHealthCheckpoint struct { + Checkpoint string + LikeCount int + ReplyCount int + RepostCount int + QuoteCount int + Views int + CheckedAt int64 +} + +type PublishHealthItem struct { + MediaID string + Permalink string + Text string + PublishedAt int64 + LikeCount int + ReplyCount int + RepostCount int + QuoteCount int + Views int + Shares int + Checkpoints []PublishHealthCheckpoint +} + +type PublishHealthResult struct { + Summary PublishHealthSummary + Items []PublishHealthItem + Total int64 + Page int + PageSize int + TotalPages int +} + +type RecordPublishedRequest struct { + TenantID string + OwnerUID string + AccountID string + PersonaID string + CopyMissionID string + CopyDraftID string + Text string + TopicTag string + MediaID string + Permalink string + PublishedAt int64 +} + +type SlotInsight struct { + Weekday int + Time string + AvgLikes1h int + AvgReplies1h int + AvgLikes24h int + AvgReplies24h int + AvgLikes7d int + AvgReplies7d int + SampleSize int + Recommendation string +} + +type Alert struct { + Type string + Severity string + Message string + QueueID string + AccountID string + CreateAt int64 +} + +type UseCase interface { + Create(ctx context.Context, req CreateRequest) (*QueueItemSummary, error) + RecordPublished(ctx context.Context, req RecordPublishedRequest) (*QueueItemSummary, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error) + ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]QueueItemSummary, error) + List(ctx context.Context, req ListRequest) (*ListResult, error) + ListRange(ctx context.Context, req RangeRequest) (*ListResult, error) + Update(ctx context.Context, req UpdateRequest) (*QueueItemSummary, error) + Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error) + Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error + DispatchDue(ctx context.Context, limit int) (int, error) + MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) + ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]Alert, error) + ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]SlotInsight, error) + ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*PublishHealthResult, error) +} diff --git a/backend/internal/model/publish_queue/repository/mongo.go b/backend/internal/model/publish_queue/repository/mongo.go new file mode 100644 index 0000000..ab060ff --- /dev/null +++ b/backend/internal/model/publish_queue/repository/mongo.go @@ -0,0 +1,359 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_queue/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "status", Value: 1}, + {Key: "scheduled_at", Value: 1}, + }, + }, + }) + return err +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + filter := bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } + if accountID = strings.TrimSpace(accountID); accountID != "" { + filter["account_id"] = accountID + } + return filter +} + +func normalizePage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func (r *mongoRepository) Create(ctx context.Context, item *entity.QueueItem) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if item == nil { + return app.For(code.ThreadsAccount).InputMissingRequired("queue item is required") + } + _, err := r.collection.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + queueID = strings.TrimSpace(queueID) + if queueID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue id is required") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = queueID + var out entity.QueueItem + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("publish queue item not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + trimmed := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id != "" { + trimmed = append(trimmed, id) + } + } + if len(trimmed) == 0 { + return nil, nil + } + filter := actorFilter(tenantID, ownerUID, "") + filter["_id"] = bson.M{"$in": trimmed} + cur, err := r.collection.Find(ctx, filter) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.QueueItem + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter, page, pageSize int) (*domrepo.ListResult, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + page, pageSize = normalizePage(page, pageSize) + mongoFilter := actorFilter(filter.TenantID, filter.OwnerUID, filter.AccountID) + if status := strings.TrimSpace(filter.Status); status != "" { + mongoFilter["status"] = status + } + if filter.StartAt > 0 || filter.EndAt > 0 { + rangeFilter := bson.M{} + if filter.StartAt > 0 { + rangeFilter["$gte"] = filter.StartAt + } + if filter.EndAt > 0 { + rangeFilter["$lte"] = filter.EndAt + } + mongoFilter["scheduled_at"] = rangeFilter + } + total, err := r.collection.CountDocuments(ctx, mongoFilter) + if err != nil { + return nil, err + } + opts := options.Find(). + SetSort(bson.D{{Key: "scheduled_at", Value: -1}}). + SetSkip(int64((page - 1) * pageSize)). + SetLimit(int64(pageSize)) + cur, err := r.collection.Find(ctx, mongoFilter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []entity.QueueItem + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return &domrepo.ListResult{Items: items, Total: total}, nil +} + +func (r *mongoRepository) ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 20 + } + filter := bson.M{ + "status": entity.StatusScheduled, + "scheduled_at": bson.M{"$lte": now}, + } + opts := options.Find(). + SetSort(bson.D{{Key: "scheduled_at", Value: 1}}). + SetLimit(int64(limit)) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []entity.QueueItem + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + +func (r *mongoRepository) ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 20 + } + filter := bson.M{ + "status": entity.StatusScheduled, + "scheduled_at": bson.M{"$lte": before}, + } + opts := options.Find(). + SetSort(bson.D{{Key: "scheduled_at", Value: 1}}). + SetLimit(int64(limit)) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []entity.QueueItem + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + +func (r *mongoRepository) UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if item == nil || item.ID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue item is required") + } + filter := bson.M{ + "_id": item.ID, + "status": bson.M{"$in": allowed}, + } + res, err := r.collection.ReplaceOne(ctx, filter, item) + if err != nil { + return nil, err + } + if res.MatchedCount == 0 { + return nil, app.For(code.ThreadsAccount).ResInvalidState("queue item status changed; update rejected") + } + return item, nil +} + +func (r *mongoRepository) Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if item == nil || item.ID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue item is required") + } + filter := bson.M{"_id": item.ID} + res, err := r.collection.ReplaceOne(ctx, filter, item) + if err != nil { + return nil, err + } + if res.MatchedCount == 0 { + return nil, app.For(code.ThreadsAccount).ResNotFound("publish queue item not found") + } + return item, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + queueID = strings.TrimSpace(queueID) + if queueID == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("queue id is required") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = queueID + res, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.ThreadsAccount).ResNotFound("publish queue item not found") + } + return nil +} + +func (r *mongoRepository) CountByStatus(ctx context.Context, tenantID, ownerUID, accountID, status string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["status"] = strings.TrimSpace(status) + return r.collection.CountDocuments(ctx, filter) +} + +func (r *mongoRepository) CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["status"] = entity.StatusPublished + filter["published_at"] = bson.M{"$gte": since} + return r.collection.CountDocuments(ctx, filter) +} + +func (r *mongoRepository) LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["status"] = entity.StatusPublished + opts := options.FindOne().SetSort(bson.D{{Key: "published_at", Value: -1}}) + var out entity.QueueItem + err := r.collection.FindOne(ctx, filter, opts).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return 0, nil + } + return 0, err + } + return out.PublishedAt, nil +} + +func (r *mongoRepository) ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["media_id"] = bson.M{"$ne": ""} + cur, err := r.collection.Find(ctx, filter, options.Find().SetProjection(bson.M{"media_id": 1})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + seen := map[string]struct{}{} + var ids []string + for cur.Next(ctx) { + var row struct { + MediaID string `bson:"media_id"` + } + if err := cur.Decode(&row); err != nil { + return nil, err + } + if id := strings.TrimSpace(row.MediaID); id != "" { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + ids = append(ids, id) + } + } + } + if err := cur.Err(); err != nil { + return nil, err + } + return ids, nil +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + res, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID)) + if err != nil { + return 0, err + } + return res.DeletedCount, nil +} diff --git a/backend/internal/model/publish_queue/usecase/usecase.go b/backend/internal/model/publish_queue/usecase/usecase.go new file mode 100644 index 0000000..a09798a --- /dev/null +++ b/backend/internal/model/publish_queue/usecase/usecase.go @@ -0,0 +1,782 @@ +package usecase + +import ( + "context" + "strings" + "time" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/library/threadspost" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + padomain "haixun-backend/internal/model/publish_analytics/domain/usecase" + guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + "haixun-backend/internal/model/publish_queue/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue/domain/repository" + domusecase "haixun-backend/internal/model/publish_queue/domain/usecase" + evententity "haixun-backend/internal/model/publish_queue_event/domain/entity" + eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" + + "github.com/google/uuid" +) + +type publishQueueUseCase struct { + repo domrepo.Repository + threadsAccount threadsaccountdomain.UseCase + publishAnalytics padomain.UseCase + guard guarddomain.UseCase + events eventdomain.UseCase + copyDraft copydraftdomain.UseCase +} + +func NewUseCase( + repo domrepo.Repository, + threadsAccount threadsaccountdomain.UseCase, + publishAnalytics padomain.UseCase, + guard guarddomain.UseCase, + events eventdomain.UseCase, + copyDraft copydraftdomain.UseCase, +) domusecase.UseCase { + return &publishQueueUseCase{ + repo: repo, + threadsAccount: threadsAccount, + publishAnalytics: publishAnalytics, + guard: guard, + events: events, + copyDraft: copyDraft, + } +} + +func (u *publishQueueUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.QueueItemSummary, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + text := strings.TrimSpace(req.Text) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + if text == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required") + } + if err := threadspost.ValidatePublish(text); err != nil { + return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error()) + } + if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + + now := clock.NowUnixNano() + scheduledAt := req.ScheduledAt + if scheduledAt <= 0 { + scheduledAt = now + } + + item := &entity.QueueItem{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: accountID, + PersonaID: strings.TrimSpace(req.PersonaID), + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + CopyDraftID: strings.TrimSpace(req.CopyDraftID), + Text: text, + TopicTag: normalizeTopicTag(req.TopicTag), + ScheduledAt: scheduledAt, + Status: entity.StatusScheduled, + CreateAt: now, + UpdateAt: now, + } + if err := u.repo.Create(ctx, item); err != nil { + return nil, err + } + u.recordEvent(ctx, item, evententity.EventCreated, "", item.Status, "加入發文庫存") + + if scheduledAt <= now { + _, _ = u.DispatchDue(ctx, 1) + fresh, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, item.ID) + if err == nil && fresh != nil { + out := toSummary(*fresh) + return &out, nil + } + } + + out := toSummary(*item) + return &out, nil +} + +func (u *publishQueueUseCase) RecordPublished(ctx context.Context, req domusecase.RecordPublishedRequest) (*domusecase.QueueItemSummary, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + mediaID := strings.TrimSpace(req.MediaID) + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + if accountID == "" || mediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required") + } + if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + now := clock.NowUnixNano() + publishedAt := req.PublishedAt + if publishedAt <= 0 { + publishedAt = now + } + item := &entity.QueueItem{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: accountID, + PersonaID: strings.TrimSpace(req.PersonaID), + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + CopyDraftID: strings.TrimSpace(req.CopyDraftID), + Text: strings.TrimSpace(req.Text), + TopicTag: normalizeTopicTag(req.TopicTag), + ScheduledAt: publishedAt, + Status: entity.StatusPublished, + MediaID: mediaID, + Permalink: strings.TrimSpace(req.Permalink), + PublishedAt: publishedAt, + CreateAt: now, + UpdateAt: now, + } + if err := u.repo.Create(ctx, item); err != nil { + return nil, err + } + u.recordEvent(ctx, item, evententity.EventPublished, "", item.Status, "已記錄發布結果") + _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{ + TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, + MediaID: mediaID, Permalink: item.Permalink, PublishedAt: publishedAt, + }) + out := toSummary(*item) + return &out, nil +} + +func (u *publishQueueUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) + if err != nil { + return nil, err + } + out := toSummary(*item) + return &out, nil +} + +func (u *publishQueueUseCase) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]domusecase.QueueItemSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + items, err := u.repo.ListByIDs(ctx, tenantID, ownerUID, ids) + if err != nil { + return nil, err + } + out := make([]domusecase.QueueItemSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, nil +} + +func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, err + } + page, pageSize := req.Page, req.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 50 { + pageSize = 50 + } + result, err := u.repo.List(ctx, domrepo.ListFilter{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + AccountID: req.AccountID, + Status: req.Status, + }, page, pageSize) + if err != nil { + return nil, err + } + items := make([]domusecase.QueueItemSummary, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, toSummary(item)) + } + totalPages := 0 + if pageSize > 0 { + totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize)) + } + return &domusecase.ListResult{ + Items: items, + Total: result.Total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +func (u *publishQueueUseCase) ListRange(ctx context.Context, req domusecase.RangeRequest) (*domusecase.ListResult, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, err + } + page, pageSize := req.Page, req.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 200 { + pageSize = 200 + } + result, err := u.repo.List(ctx, domrepo.ListFilter{ + TenantID: req.TenantID, OwnerUID: req.OwnerUID, AccountID: req.AccountID, + Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt, + }, page, pageSize) + if err != nil { + return nil, err + } + items := make([]domusecase.QueueItemSummary, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, toSummary(item)) + } + totalPages := 0 + if pageSize > 0 { + totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize)) + } + return &domusecase.ListResult{Items: items, Total: result.Total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil +} + +func (u *publishQueueUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.QueueItemSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.QueueID) + if err != nil { + return nil, err + } + if item.Status != entity.StatusScheduled { + return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可修改") + } + if req.Text != nil { + text := strings.TrimSpace(*req.Text) + if text == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required") + } + if err := threadspost.ValidatePublish(text); err != nil { + return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error()) + } + item.Text = text + } + if req.ScheduledAt != nil && *req.ScheduledAt > 0 { + item.ScheduledAt = *req.ScheduledAt + } + if req.TopicTag != nil { + item.TopicTag = normalizeTopicTag(*req.TopicTag) + } + item.UpdateAt = clock.NowUnixNano() + updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) + if err != nil { + return nil, err + } + out := toSummary(*updated) + return &out, nil +} + +func (u *publishQueueUseCase) Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) + if err != nil { + return nil, err + } + if item.Status != entity.StatusScheduled { + return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可取消") + } + item.Status = entity.StatusCancelled + item.UpdateAt = clock.NowUnixNano() + updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) + if err != nil { + return nil, err + } + u.recordEvent(ctx, updated, evententity.EventCancelled, entity.StatusScheduled, updated.Status, "已取消排程") + out := toSummary(*updated) + return &out, nil +} + +func (u *publishQueueUseCase) Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) + if err != nil { + return nil, err + } + if item.Status != entity.StatusFailed && item.Status != entity.StatusCancelled && item.Status != entity.StatusMissed { + return nil, app.For(code.ThreadsAccount).ResInvalidState("僅 failed/cancelled/missed 項目可重試") + } + from := item.Status + item.Status = entity.StatusScheduled + item.ErrorMessage = "" + item.PausedReason = "" + item.MissedAt = 0 + item.RetryCount++ + item.NextAttemptAt = 0 + now := clock.NowUnixNano() + item.UpdateAt = now + if item.ScheduledAt < now { + item.ScheduledAt = now + } + updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusFailed, entity.StatusCancelled, entity.StatusMissed}) + if err != nil { + return nil, err + } + u.recordEvent(ctx, updated, evententity.EventRetry, from, updated.Status, "已重新排程") + out := toSummary(*updated) + return &out, nil +} + +func (u *publishQueueUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error { + if err := requireActor(tenantID, ownerUID); err != nil { + return err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) + if err != nil { + return err + } + switch item.Status { + case entity.StatusPublishing: + return app.For(code.ThreadsAccount).ResInvalidState("發佈進行中,無法刪除") + case entity.StatusPublished: + return app.For(code.ThreadsAccount).ResInvalidState("已發佈項目不可刪除") + case entity.StatusScheduled: + _, err = u.Cancel(ctx, tenantID, ownerUID, accountID, queueID) + return err + default: + return u.repo.Delete(ctx, tenantID, ownerUID, accountID, queueID) + } +} + +func (u *publishQueueUseCase) DispatchDue(ctx context.Context, limit int) (int, error) { + if u.repo == nil { + return 0, nil + } + now := clock.NowUnixNano() + due, err := u.repo.ListDue(ctx, now, limit) + if err != nil { + return 0, err + } + dispatched := 0 + for _, item := range due { + if err := u.dispatchOne(ctx, &item); err != nil { + continue + } + dispatched++ + } + return dispatched, nil +} + +func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.QueueItem) error { + if result, err := u.checkGuard(ctx, item); err == nil && result != nil && !result.Allowed { + if result.ShouldPause && u.guard != nil { + _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, result.Reason) + } + item.PausedReason = result.Reason + if result.DelayUntil > 0 { + item.ScheduledAt = result.DelayUntil + } else { + item.Status = entity.StatusFailed + item.ErrorMessage = result.Reason + } + item.UpdateAt = clock.NowUnixNano() + updated, updateErr := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) + if updateErr != nil { + return updateErr + } + u.recordEvent(ctx, updated, evententity.EventGuardDelayed, entity.StatusScheduled, updated.Status, result.Reason) + return nil + } + item.Status = entity.StatusPublishing + item.LastAttemptAt = clock.NowUnixNano() + item.UpdateAt = clock.NowUnixNano() + claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) + if err != nil { + return err + } + u.recordEvent(ctx, claimed, evententity.EventPublishing, entity.StatusScheduled, entity.StatusPublishing, "開始發布") + + account, err := u.threadsAccount.Get(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID) + if err != nil { + return u.markFailed(ctx, claimed, "取得帳號失敗:"+err.Error()) + } + token, err := u.threadsAccount.ResolveAccountAPIAccessToken(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID) + if err != nil { + return u.markFailed(ctx, claimed, err.Error()) + } + userID := strings.TrimSpace(account.ThreadsUserID) + if userID == "" || token == "" { + return u.markFailed(ctx, claimed, "Threads API 未連線") + } + + result, err := libthreads.PublishText(ctx, libthreads.PublishTextInput{ + ThreadsUserID: userID, + AccessToken: token, + Text: claimed.Text, + TopicTag: claimed.TopicTag, + }) + if err != nil { + return u.markFailed(ctx, claimed, "發文失敗:"+err.Error()) + } + + now := clock.NowUnixNano() + claimed.Status = entity.StatusPublished + claimed.MediaID = result.MediaID + claimed.Permalink = result.Permalink + claimed.PublishedAt = now + claimed.UpdateAt = now + claimed.ErrorMessage = "" + if _, err := u.repo.UpdateIfStatus(ctx, claimed, []string{entity.StatusPublishing}); err != nil { + return err + } + u.recordEvent(ctx, claimed, evententity.EventPublished, entity.StatusPublishing, entity.StatusPublished, "Threads API 發布完成") + if claimed.CopyDraftID != "" && u.copyDraft != nil { + _, _ = u.copyDraft.MarkPublished(ctx, copydraftdomain.MarkPublishedRequest{ + TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, PersonaID: claimed.PersonaID, + DraftID: claimed.CopyDraftID, MediaID: result.MediaID, Permalink: result.Permalink, + }) + } + _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{ + TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, AccountID: claimed.AccountID, + MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now, + }) + return nil +} + +func (u *publishQueueUseCase) markFailed(ctx context.Context, item *entity.QueueItem, message string) error { + item.Status = entity.StatusFailed + item.ErrorMessage = message + item.LastAttemptAt = clock.NowUnixNano() + item.UpdateAt = clock.NowUnixNano() + updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled}) + if err == nil { + u.recordEvent(ctx, updated, evententity.EventFailed, entity.StatusPublishing, entity.StatusFailed, message) + failureStreak := u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID) + if u.guard != nil && failureStreak >= 3 { + _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, "連續發文失敗,已暫停帳號") + } + } + return err +} + +func (u *publishQueueUseCase) ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domusecase.PublishHealthResult, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 30 { + pageSize = 30 + } + + pending, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusScheduled) + failed, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusFailed) + + publishedList, err := u.repo.List(ctx, domrepo.ListFilter{ + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: accountID, + Status: entity.StatusPublished, + }, page, pageSize) + if err != nil { + return nil, err + } + + cutoff7d := clock.NowUnixNano() - int64(7*24*time.Hour) + var published7d int64 + var totalLikes7d, totalReplies7d int + + items := make([]domusecase.PublishHealthItem, 0, len(publishedList.Items)) + for _, row := range publishedList.Items { + if row.PublishedAt >= cutoff7d { + published7d++ + } + checkpoints, err := u.publishAnalytics.ListByMedia(ctx, tenantID, ownerUID, row.MediaID) + if err != nil { + checkpoints = nil + } + healthCPs := make([]domusecase.PublishHealthCheckpoint, 0, len(checkpoints)) + var latestLike, latestReply, latestRepost, latestQuote, latestViews, latestShares int + var latestChecked int64 + for _, cp := range checkpoints { + healthCPs = append(healthCPs, domusecase.PublishHealthCheckpoint{ + Checkpoint: cp.Checkpoint, + LikeCount: cp.LikeCount, + ReplyCount: cp.ReplyCount, + RepostCount: cp.RepostCount, + QuoteCount: cp.QuoteCount, + Views: cp.ViewCount, + CheckedAt: cp.CheckedAt, + }) + if cp.CheckedAt >= latestChecked { + latestChecked = cp.CheckedAt + latestLike = cp.LikeCount + latestReply = cp.ReplyCount + latestRepost = cp.RepostCount + latestQuote = cp.QuoteCount + latestViews = cp.ViewCount + latestShares = cp.ShareCount + } + } + if row.PublishedAt >= cutoff7d { + totalLikes7d += latestLike + totalReplies7d += latestReply + } + items = append(items, domusecase.PublishHealthItem{ + MediaID: row.MediaID, + Permalink: row.Permalink, + Text: row.Text, + PublishedAt: row.PublishedAt, + LikeCount: latestLike, + ReplyCount: latestReply, + RepostCount: latestRepost, + QuoteCount: latestQuote, + Views: latestViews, + Shares: latestShares, + Checkpoints: healthCPs, + }) + } + + totalPages := 0 + if pageSize > 0 { + totalPages = int((publishedList.Total + int64(pageSize) - 1) / int64(pageSize)) + } + return &domusecase.PublishHealthResult{ + Summary: domusecase.PublishHealthSummary{ + PendingScheduled: pending, + FailedCount: failed, + Published7d: published7d, + TotalLikes7d: totalLikes7d, + TotalReplies7d: totalReplies7d, + }, + Items: items, + Total: publishedList.Total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +func (u *publishQueueUseCase) MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) { + if graceNs <= 0 { + graceNs = int64(10 * time.Minute) + } + before := clock.NowUnixNano() - graceNs + items, err := u.repo.ListMissedCandidates(ctx, before, limit) + if err != nil { + return 0, err + } + count := 0 + for _, item := range items { + item.Status = entity.StatusMissed + item.MissedAt = clock.NowUnixNano() + item.UpdateAt = item.MissedAt + updated, err := u.repo.UpdateIfStatus(ctx, &item, []string{entity.StatusScheduled}) + if err != nil { + continue + } + u.recordEvent(ctx, updated, evententity.EventMissed, entity.StatusScheduled, entity.StatusMissed, "超過 grace period 未發布") + count++ + } + return count, nil +} + +func (u *publishQueueUseCase) ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]domusecase.Alert, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 50) + if err != nil { + return nil, err + } + alerts := make([]domusecase.Alert, 0) + for _, item := range result.Items { + switch item.Status { + case entity.StatusFailed: + alerts = append(alerts, domusecase.Alert{Type: "failed", Severity: "danger", Message: firstNonEmpty(item.ErrorMessage, "發文失敗"), QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt}) + case entity.StatusMissed: + alerts = append(alerts, domusecase.Alert{Type: "missed", Severity: "warning", Message: "排程已漏發", QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.MissedAt}) + } + if item.PausedReason != "" { + alerts = append(alerts, domusecase.Alert{Type: "guard", Severity: "warning", Message: item.PausedReason, QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt}) + } + } + return alerts, nil +} + +func (u *publishQueueUseCase) ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]domusecase.SlotInsight, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + health, err := u.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 30) + if err != nil { + return nil, err + } + type agg struct { + likes7d int + replies7d int + samples int + } + slots := map[string]*agg{} + for _, item := range health.Items { + t := time.Unix(0, item.PublishedAt).UTC() + key := t.Format("15:04") + if _, ok := slots[key]; !ok { + slots[key] = &agg{} + } + slots[key].likes7d += item.LikeCount + slots[key].replies7d += item.ReplyCount + slots[key].samples++ + } + out := make([]domusecase.SlotInsight, 0, len(slots)) + for key, item := range slots { + avgLikes := 0 + avgReplies := 0 + if item.samples > 0 { + avgLikes = item.likes7d / item.samples + avgReplies = item.replies7d / item.samples + } + recommendation := "neutral" + if avgLikes+avgReplies >= 5 { + recommendation = "recommended" + } else if item.samples >= 2 { + recommendation = "low" + } + out = append(out, domusecase.SlotInsight{ + Weekday: -1, Time: key, AvgLikes1h: avgLikes, AvgReplies1h: avgReplies, + AvgLikes24h: avgLikes, AvgReplies24h: avgReplies, AvgLikes7d: avgLikes, AvgReplies7d: avgReplies, + SampleSize: item.samples, Recommendation: recommendation, + }) + } + if len(out) == 0 { + out = []domusecase.SlotInsight{ + {Weekday: 1, Time: "09:30", Recommendation: "recommended"}, + {Weekday: 3, Time: "12:30", Recommendation: "neutral"}, + {Weekday: 5, Time: "18:30", Recommendation: "recommended"}, + } + } + return out, nil +} + +func (u *publishQueueUseCase) checkGuard(ctx context.Context, item *entity.QueueItem) (*guarddomain.CheckResult, error) { + if u.guard == nil || item == nil { + return &guarddomain.CheckResult{Allowed: true}, nil + } + now := clock.NowUnixNano() + publishedToday, _ := u.repo.CountPublishedSince(ctx, item.TenantID, item.OwnerUID, item.AccountID, startOfUTCDay(now)) + lastPublishedAt, _ := u.repo.LastPublishedAt(ctx, item.TenantID, item.OwnerUID, item.AccountID) + return u.guard.Check(ctx, guarddomain.CheckRequest{ + TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, + Now: now, PublishedToday: publishedToday, LastPublishedAt: lastPublishedAt, FailureStreak: u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID), + }) +} + +func (u *publishQueueUseCase) failureStreak(ctx context.Context, tenantID, ownerUID, accountID string) int { + result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 10) + if err != nil { + return 0 + } + streak := 0 + for _, item := range result.Items { + if item.Status == entity.StatusFailed { + streak++ + continue + } + if item.Status == entity.StatusPublished { + break + } + } + return streak +} + +func (u *publishQueueUseCase) recordEvent(ctx context.Context, item *entity.QueueItem, eventType, fromStatus, toStatus, message string) { + if u.events == nil || item == nil { + return + } + _ = u.events.Record(ctx, eventdomain.RecordRequest{ + TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, QueueID: item.ID, + EventType: eventType, FromStatus: fromStatus, ToStatus: toStatus, Message: message, + }) +} + +func startOfUTCDay(ns int64) int64 { + t := time.Unix(0, ns).UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixNano() +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func requireActor(tenantID, ownerUID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + return nil +} + +func toSummary(item entity.QueueItem) domusecase.QueueItemSummary { + return domusecase.QueueItemSummary{ + ID: item.ID, + AccountID: item.AccountID, + PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, + CopyDraftID: item.CopyDraftID, + Text: item.Text, + TopicTag: item.TopicTag, + ScheduledAt: item.ScheduledAt, + Status: item.Status, + MediaID: item.MediaID, + Permalink: item.Permalink, + PublishedAt: item.PublishedAt, + ErrorMessage: item.ErrorMessage, + RetryCount: item.RetryCount, + LastAttemptAt: item.LastAttemptAt, + NextAttemptAt: item.NextAttemptAt, + MissedAt: item.MissedAt, + PausedReason: item.PausedReason, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func normalizeTopicTag(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "#") + value = strings.TrimSpace(value) + if len([]rune(value)) > 40 { + runes := []rune(value) + value = string(runes[:40]) + } + return value +} diff --git a/backend/internal/model/publish_queue_event/domain/entity/event.go b/backend/internal/model/publish_queue_event/domain/entity/event.go new file mode 100644 index 0000000..9a8c938 --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/entity/event.go @@ -0,0 +1,27 @@ +package entity + +const CollectionName = "publish_queue_events" + +const ( + EventCreated = "created" + EventRetry = "retry" + EventPublishing = "publishing" + EventPublished = "published" + EventFailed = "failed" + EventCancelled = "cancelled" + EventMissed = "missed" + EventGuardDelayed = "guard_delayed" +) + +type Event struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + QueueID string `bson:"queue_id"` + EventType string `bson:"event_type"` + FromStatus string `bson:"from_status,omitempty"` + ToStatus string `bson:"to_status,omitempty"` + Message string `bson:"message,omitempty"` + CreateAt int64 `bson:"create_at"` +} diff --git a/backend/internal/model/publish_queue_event/domain/repository/repository.go b/backend/internal/model/publish_queue_event/domain/repository/repository.go new file mode 100644 index 0000000..ec04c8d --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_queue_event/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, event *entity.Event) error + ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error) + ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error +} diff --git a/backend/internal/model/publish_queue_event/domain/usecase/usecase.go b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go new file mode 100644 index 0000000..704e271 --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go @@ -0,0 +1,31 @@ +package usecase + +import "context" + +type EventSummary struct { + ID string + QueueID string + AccountID string + EventType string + FromStatus string + ToStatus string + Message string + CreateAt int64 +} + +type RecordRequest struct { + TenantID string + OwnerUID string + AccountID string + QueueID string + EventType string + FromStatus string + ToStatus string + Message string +} + +type UseCase interface { + Record(ctx context.Context, req RecordRequest) error + ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]EventSummary, error) + ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]EventSummary, error) +} diff --git a/backend/internal/model/publish_queue_event/repository/mongo.go b/backend/internal/model/publish_queue_event/repository/mongo.go new file mode 100644 index 0000000..310513a --- /dev/null +++ b/backend/internal/model/publish_queue_event/repository/mongo.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_queue_event/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}, {Key: "queue_id", Value: 1}, {Key: "create_at", Value: -1}}}, + }) + return err +} + +func (r *mongoRepository) Create(ctx context.Context, event *entity.Event) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if event == nil { + return app.For(code.ThreadsAccount).InputMissingRequired("event is required") + } + _, err := r.collection.InsertOne(ctx, event) + return err +} + +func (r *mongoRepository) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error) { + filter := actorFilter(tenantID, ownerUID, accountID) + filter["queue_id"] = strings.TrimSpace(queueID) + return r.list(ctx, filter, limit) +} + +func (r *mongoRepository) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error) { + return r.list(ctx, actorFilter(tenantID, ownerUID, accountID), limit) +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID)) + return err +} + +func (r *mongoRepository) list(ctx context.Context, filter bson.M, limit int) ([]entity.Event, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + opts := options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(int64(limit)) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Event + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + filter := bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } + if accountID = strings.TrimSpace(accountID); accountID != "" { + filter["account_id"] = accountID + } + return filter +} diff --git a/backend/internal/model/publish_queue_event/usecase/usecase.go b/backend/internal/model/publish_queue_event/usecase/usecase.go new file mode 100644 index 0000000..b109522 --- /dev/null +++ b/backend/internal/model/publish_queue_event/usecase/usecase.go @@ -0,0 +1,75 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_queue_event/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" + domusecase "haixun-backend/internal/model/publish_queue_event/domain/usecase" + + "github.com/google/uuid" +) + +type eventUseCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &eventUseCase{repo: repo} +} + +func (u *eventUseCase) Record(ctx context.Context, req domusecase.RecordRequest) error { + if strings.TrimSpace(req.TenantID) == "" || strings.TrimSpace(req.OwnerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(req.AccountID) == "" || strings.TrimSpace(req.QueueID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id and queue_id are required") + } + eventType := strings.TrimSpace(req.EventType) + if eventType == "" { + eventType = entity.EventCreated + } + return u.repo.Create(ctx, &entity.Event{ + ID: uuid.NewString(), + TenantID: strings.TrimSpace(req.TenantID), + OwnerUID: strings.TrimSpace(req.OwnerUID), + AccountID: strings.TrimSpace(req.AccountID), + QueueID: strings.TrimSpace(req.QueueID), + EventType: eventType, + FromStatus: strings.TrimSpace(req.FromStatus), + ToStatus: strings.TrimSpace(req.ToStatus), + Message: strings.TrimSpace(req.Message), + CreateAt: clock.NowUnixNano(), + }) +} + +func (u *eventUseCase) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]domusecase.EventSummary, error) { + items, err := u.repo.ListByQueue(ctx, tenantID, ownerUID, accountID, queueID, limit) + if err != nil { + return nil, err + } + return toSummaries(items), nil +} + +func (u *eventUseCase) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]domusecase.EventSummary, error) { + items, err := u.repo.ListRecentByAccount(ctx, tenantID, ownerUID, accountID, limit) + if err != nil { + return nil, err + } + return toSummaries(items), nil +} + +func toSummaries(items []entity.Event) []domusecase.EventSummary { + out := make([]domusecase.EventSummary, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.EventSummary{ + ID: item.ID, QueueID: item.QueueID, AccountID: item.AccountID, EventType: item.EventType, + FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt, + }) + } + return out +} diff --git a/backend/internal/model/scan_post/domain/entity/outreach_patch.go b/backend/internal/model/scan_post/domain/entity/outreach_patch.go index 23381e6..2cf8893 100644 --- a/backend/internal/model/scan_post/domain/entity/outreach_patch.go +++ b/backend/internal/model/scan_post/domain/entity/outreach_patch.go @@ -4,4 +4,5 @@ type OutreachPatch struct { Status string PublishedReplyID string PublishedPermalink string + ExternalID string } diff --git a/backend/internal/model/scan_post/domain/entity/post.go b/backend/internal/model/scan_post/domain/entity/post.go index 1f0921f..37227aa 100644 --- a/backend/internal/model/scan_post/domain/entity/post.go +++ b/backend/internal/model/scan_post/domain/entity/post.go @@ -28,6 +28,7 @@ type ScanPost struct { GraphNodeID string `bson:"graph_node_id"` SearchTag string `bson:"search_tag"` QueryDimension string `bson:"query_dimension"` + RecencyDays int `bson:"recency_days,omitempty"` ExternalID string `bson:"external_id"` Permalink string `bson:"permalink"` Author string `bson:"author"` diff --git a/backend/internal/model/scan_post/domain/usecase/usecase.go b/backend/internal/model/scan_post/domain/usecase/usecase.go index 0e0ab16..93102eb 100644 --- a/backend/internal/model/scan_post/domain/usecase/usecase.go +++ b/backend/internal/model/scan_post/domain/usecase/usecase.go @@ -24,6 +24,7 @@ type ScanPostSummary struct { GraphNodeID string SearchTag string QueryDimension string + RecencyDays int ExternalID string Permalink string Author string @@ -102,6 +103,7 @@ type UpdateOutreachRequest struct { Status string PublishedReplyID string PublishedPermalink string + ExternalID string } type CheckpointRequest struct { diff --git a/backend/internal/model/scan_post/repository/mongo.go b/backend/internal/model/scan_post/repository/mongo.go index 5c955ff..d7bf37d 100644 --- a/backend/internal/model/scan_post/repository/mongo.go +++ b/backend/internal/model/scan_post/repository/mongo.go @@ -8,6 +8,7 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libmongo "haixun-backend/internal/library/mongo" + "haixun-backend/internal/library/placement" "haixun-backend/internal/model/scan_post/domain/entity" domrepo "haixun-backend/internal/model/scan_post/domain/repository" @@ -174,6 +175,7 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne err := r.collection.FindOne(ctx, filter).Decode(&existing) if err == nil { post.ID = existing.ID + post.ExternalID = placement.PreferReplyExternalID(existing.ExternalID, post.ExternalID) if existing.CreateAt > 0 { post.CreateAt = existing.CreateAt } @@ -291,6 +293,9 @@ func (r *mongoRepository) UpdateOutreach( if strings.TrimSpace(patch.PublishedPermalink) != "" { set["published_permalink"] = strings.TrimSpace(patch.PublishedPermalink) } + if strings.TrimSpace(patch.ExternalID) != "" { + set["external_id"] = strings.TrimSpace(patch.ExternalID) + } if len(set) == 0 { return r.Get(ctx, tenantID, ownerUID, brandID, postID) } @@ -450,6 +455,11 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f if filter.SemanticMin > 0 { query["semantic_score"] = bson.M{"$gte": filter.SemanticMin} } + if filter.Recent7dOnly { + for key, value := range recent7dListQuery(time.Now()) { + query[key] = value + } + } limit := filter.Limit if limit <= 0 { limit = 100 @@ -470,9 +480,10 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f return nil, err } if filter.Recent7dOnly { + now := time.Now() filtered := make([]entity.ScanPost, 0, len(out)) for _, item := range out { - if item.Priority == "gold" || item.Priority == "recent" { + if matchesRecent7dPost(item, now) { filtered = append(filtered, item) } } @@ -481,6 +492,36 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f return out, nil } +const recent7dWindowDays = placement.IdealMaxPostAgeDays + +// recent7dListQuery matches posts from the recency track or with posted_at in the last 7 days. +func recent7dListQuery(now time.Time) bson.M { + cutoff := now.AddDate(0, 0, -recent7dWindowDays).Format("2006-01-02") + return bson.M{ + "$or": []bson.M{ + {"posted_at": bson.M{"$gte": cutoff}}, + { + "$and": []bson.M{ + {"posted_at": bson.M{"$in": []interface{}{nil, ""}}}, + {"recency_days": bson.M{"$lte": recent7dWindowDays, "$gt": 0}}, + }, + }, + }, + } +} + +func matchesRecent7dPost(item entity.ScanPost, now time.Time) bool { + postedAt := strings.TrimSpace(item.PostedAt) + if postedAt != "" { + ts, ok := placement.ParsePostedAt(postedAt) + if ok { + cutoff := now.AddDate(0, 0, -recent7dWindowDays) + return !ts.Before(cutoff) + } + } + return item.RecencyDays > 0 && item.RecencyDays <= recent7dWindowDays +} + // priorityListFilter maps UI track filters to stored priority values. // gold posts hit both relevance and recency tracks, so they belong in either filter. func priorityListFilter(priority string) bson.M { diff --git a/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go b/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go new file mode 100644 index 0000000..7735c30 --- /dev/null +++ b/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go @@ -0,0 +1,54 @@ +package repository + +import ( + "testing" + "time" + + "haixun-backend/internal/model/scan_post/domain/entity" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestMatchesRecent7dPost_Includes7dWindowWithoutDate(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{RecencyDays: 7} + if !matchesRecent7dPost(item, now) { + t.Fatal("expected 7d-window post without date to match recent_7d filter") + } +} + +func TestMatchesRecent7dPost_Excludes14dWindowWithoutDate(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{RecencyDays: 14, QueryDimension: "recency"} + if matchesRecent7dPost(item, now) { + t.Fatal("expected 14d-window post without date to be excluded from recent_7d") + } +} + +func TestMatchesRecent7dPost_IncludesPostedWithin7Days(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{ + Priority: "relevant", + PostedAt: "2026-06-28", + } + if !matchesRecent7dPost(item, now) { + t.Fatal("expected post within 7 days to match") + } +} + +func TestMatchesRecent7dPost_ExcludesOldRelevantWithoutDate(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{Priority: "relevant", QueryDimension: "relevance"} + if matchesRecent7dPost(item, now) { + t.Fatal("expected old relevance-only post without date to be excluded") + } +} + +func TestRecent7dListQuery_HasExpectedClauses(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + query := recent7dListQuery(now) + orClause, ok := query["$or"].([]bson.M) + if !ok || len(orClause) != 2 { + t.Fatalf("expected 2 OR branches, got %T %v", query["$or"], query["$or"]) + } +} diff --git a/backend/internal/model/scan_post/usecase/usecase.go b/backend/internal/model/scan_post/usecase/usecase.go index 05988f8..558eac2 100644 --- a/backend/internal/model/scan_post/usecase/usecase.go +++ b/backend/internal/model/scan_post/usecase/usecase.go @@ -104,15 +104,9 @@ func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.Repla if err != nil { return 0, err } - keep := make([]string, 0, len(entities)) - for _, item := range entities { - if strings.TrimSpace(item.Permalink) != "" { - keep = append(keep, item.Permalink) - } - } - if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.ScanJobID, keep); err != nil { - return count, err - } + // Do not prune checkpoint posts after finalize. Crawl checkpoints may include + // candidates that fail strict final filters; pruning them made patrol results + // disappear from the results tab right after a job completed. return count, nil } @@ -146,6 +140,7 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID GraphNodeID: item.GraphNodeID, SearchTag: item.SearchTag, QueryDimension: string(item.QueryDimension), + RecencyDays: item.RecencyDays, ExternalID: item.ExternalID, Permalink: item.Permalink, Author: item.Author, @@ -203,6 +198,7 @@ func (u *scanPostUseCase) UpdateOutreach(ctx context.Context, req domusecase.Upd Status: status, PublishedReplyID: req.PublishedReplyID, PublishedPermalink: req.PublishedPermalink, + ExternalID: req.ExternalID, }) if err != nil { return nil, err @@ -384,6 +380,7 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary { GraphNodeID: item.GraphNodeID, SearchTag: item.SearchTag, QueryDimension: item.QueryDimension, + RecencyDays: item.RecencyDays, ExternalID: item.ExternalID, Permalink: item.Permalink, Author: item.Author, diff --git a/backend/internal/model/style_preset/domain/entity/preset.go b/backend/internal/model/style_preset/domain/entity/preset.go new file mode 100644 index 0000000..43fc367 --- /dev/null +++ b/backend/internal/model/style_preset/domain/entity/preset.go @@ -0,0 +1,17 @@ +package entity + +const CollectionName = "style_presets" + +type Preset struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + Name string `bson:"name"` + Tone string `bson:"tone,omitempty"` + CTA []string `bson:"cta,omitempty"` + BannedWords []string `bson:"banned_words,omitempty"` + Notes string `bson:"notes,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/style_preset/domain/repository/repository.go b/backend/internal/model/style_preset/domain/repository/repository.go new file mode 100644 index 0000000..7cb0dcd --- /dev/null +++ b/backend/internal/model/style_preset/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/style_preset/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) + Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) + Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) + Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error +} diff --git a/backend/internal/model/style_preset/domain/usecase/usecase.go b/backend/internal/model/style_preset/domain/usecase/usecase.go new file mode 100644 index 0000000..7486c20 --- /dev/null +++ b/backend/internal/model/style_preset/domain/usecase/usecase.go @@ -0,0 +1,34 @@ +package usecase + +import "context" + +type PresetSummary struct { + ID string + PersonaID string + Name string + Tone string + CTA []string + BannedWords []string + Notes string + CreateAt int64 + UpdateAt int64 +} + +type UpsertRequest struct { + TenantID string + OwnerUID string + PersonaID string + PresetID string + Name string + Tone string + CTA []string + BannedWords []string + Notes string + Apply bool +} + +type UseCase interface { + List(ctx context.Context, tenantID, ownerUID, personaID string) ([]PresetSummary, error) + Upsert(ctx context.Context, req UpsertRequest) (*PresetSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error +} diff --git a/backend/internal/model/style_preset/repository/mongo.go b/backend/internal/model/style_preset/repository/mongo.go new file mode 100644 index 0000000..63385a6 --- /dev/null +++ b/backend/internal/model/style_preset/repository/mongo.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/style_preset/domain/entity" + domrepo "haixun-backend/internal/model/style_preset/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, + }) + return err +} + +func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.collection.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Preset + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(presetID) + var out entity.Preset + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("style preset not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if preset == nil { + return nil, app.For(code.Persona).InputMissingRequired("preset is required") + } + filter := personaFilter(preset.TenantID, preset.OwnerUID, preset.PersonaID) + filter["_id"] = preset.ID + _, err := r.collection.ReplaceOne(ctx, filter, preset, options.Replace().SetUpsert(true)) + if err != nil { + return nil, err + } + return preset, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(presetID) + res, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.Persona).ResNotFound("style preset not found") + } + return nil +} + +func personaFilter(tenantID, ownerUID, personaID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "persona_id": strings.TrimSpace(personaID), + } +} diff --git a/backend/internal/model/style_preset/usecase/usecase.go b/backend/internal/model/style_preset/usecase/usecase.go new file mode 100644 index 0000000..94a7893 --- /dev/null +++ b/backend/internal/model/style_preset/usecase/usecase.go @@ -0,0 +1,135 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + "haixun-backend/internal/model/style_preset/domain/entity" + domrepo "haixun-backend/internal/model/style_preset/domain/repository" + domusecase "haixun-backend/internal/model/style_preset/domain/usecase" + + "github.com/google/uuid" +) + +type stylePresetUseCase struct { + repo domrepo.Repository + persona personadomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, persona personadomain.UseCase) domusecase.UseCase { + return &stylePresetUseCase{repo: repo, persona: persona} +} + +func (u *stylePresetUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]domusecase.PresetSummary, error) { + if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil { + return nil, err + } + items, err := u.repo.List(ctx, tenantID, ownerUID, personaID) + if err != nil { + return nil, err + } + out := make([]domusecase.PresetSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, nil +} + +func (u *stylePresetUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PresetSummary, error) { + if err := u.require(ctx, req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, app.For(code.Persona).InputMissingRequired("name is required") + } + now := clock.NowUnixNano() + id := strings.TrimSpace(req.PresetID) + item := &entity.Preset{ID: id} + if id == "" || id == "new" { + item.ID = uuid.NewString() + item.CreateAt = now + } else if existing, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.PersonaID, id); err == nil { + item = existing + } + item.TenantID = req.TenantID + item.OwnerUID = req.OwnerUID + item.PersonaID = req.PersonaID + item.Name = name + item.Tone = strings.TrimSpace(req.Tone) + item.CTA = cleanList(req.CTA) + item.BannedWords = cleanList(req.BannedWords) + item.Notes = strings.TrimSpace(req.Notes) + item.UpdateAt = now + if item.CreateAt == 0 { + item.CreateAt = now + } + saved, err := u.repo.Upsert(ctx, item) + if err != nil { + return nil, err + } + if req.Apply && u.persona != nil { + profile := formatPresetProfile(saved) + _, _ = u.persona.Update(ctx, personadomain.UpdateRequest{ + TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, + Patch: personadomain.PersonaPatch{StyleProfile: &profile}, + }) + } + out := toSummary(*saved) + return &out, nil +} + +func (u *stylePresetUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error { + if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil { + return err + } + return u.repo.Delete(ctx, tenantID, ownerUID, personaID, presetID) +} + +func (u *stylePresetUseCase) require(ctx context.Context, tenantID, ownerUID, personaID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(personaID) == "" { + return app.For(code.Persona).InputMissingRequired("persona_id is required") + } + if u.persona != nil { + _, err := u.persona.Get(ctx, tenantID, ownerUID, personaID) + return err + } + return nil +} + +func toSummary(item entity.Preset) domusecase.PresetSummary { + return domusecase.PresetSummary{ + ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone, + CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...), + Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, + } +} + +func cleanList(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func formatPresetProfile(item *entity.Preset) string { + return fmt.Sprintf("語調:%s\nCTA:%s\n禁用詞:%s\n備註:%s", item.Tone, strings.Join(item.CTA, "、"), strings.Join(item.BannedWords, "、"), item.Notes) +} diff --git a/backend/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go index e6514a8..b50ca8a 100644 --- a/backend/internal/model/threads_account/domain/repository/repository.go +++ b/backend/internal/model/threads_account/domain/repository/repository.go @@ -10,9 +10,14 @@ type Repository interface { EnsureIndexes(ctx context.Context) error Create(ctx context.Context, account *entity.Account) (*entity.Account, error) FindByID(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) + FindByIDGlobal(ctx context.Context, accountID string) (*entity.Account, error) + FindByThreadsUserID(ctx context.Context, tenantID, ownerUID, threadsUserID string) (*entity.Account, error) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error) UpdateShell(ctx context.Context, tenantID, ownerUID, accountID string, displayName, username, personaID *string) (*entity.Account, error) + UpdateAPIProfile(ctx context.Context, tenantID, ownerUID, accountID, username, threadsUserID, displayName string) (*entity.Account, error) + ClearAPIProfile(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error + Delete(ctx context.Context, tenantID, ownerUID, accountID string) error SetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string, known map[string]entity.KnownAccountProfile) error } @@ -21,4 +26,18 @@ type SecretsRepository interface { FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error) SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) + ClearAPIAccessToken(ctx context.Context, accountID string) error + DeleteByAccountID(ctx context.Context, accountID string) error + ListAPIAccessTokensExpiringBefore(ctx context.Context, beforeNs int64) ([]*entity.Secrets, error) +} + +type OAuthStateStore interface { + Save(ctx context.Context, state string, payload OAuthStatePayload) error + Consume(ctx context.Context, state string) (*OAuthStatePayload, error) +} + +type OAuthStatePayload struct { + TenantID string `json:"tenant_id"` + OwnerUID string `json:"owner_uid"` + AccountID string `json:"account_id,omitempty"` } diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 2160a40..7d41efd 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -2,6 +2,8 @@ package usecase import ( "context" + "encoding/json" + "time" "haixun-backend/internal/library/placement" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" @@ -9,16 +11,24 @@ import ( ) type AccountSummary struct { - ID string - DisplayName string - Username string - ThreadsUserID string - PersonaID string - BrowserConnected bool - ApiConnected bool - Status string - CreateAt int64 - UpdateAt int64 + ID string + DisplayName string + Username string + ThreadsUserID string + PersonaID string + BrowserConnected bool + ApiConnected bool + APITokenExpiresAt int64 + Status string + CreateAt int64 + UpdateAt int64 +} + +type TokenRefreshCandidate struct { + AccountID string + TenantID string + OwnerUID string + ExpiresAt int64 } type ConnectionPrefs struct { @@ -139,6 +149,108 @@ type ListResult struct { ActiveAccountID string } +type OAuthStartResult struct { + AuthorizeURL string + State string + AccountID string +} + +type OAuthCallbackResult struct { + AccountID string + Username string + ThreadsUserID string + RedirectURL string +} + +type OAuthDebugLogEntry struct { + At int64 + Level string + Stage string + AccountID string + Message string +} + +type APISmokeTestItem struct { + Scope string + Name string + Status string // ok, error, skip, manual + Message string + Count int +} + +type APIPlaygroundRequest struct { + Action string + Query string + Username string + MediaID string + ReplyID string + ReplyToID string + Text string + Permalink string + HintText string + SkipPrime bool + LocationQuery string + Limit int + Hide bool + SearchType string +} + +type APIPlaygroundResult struct { + Action string + Ok bool + Message string + Data json.RawMessage +} + +type PostMetricCapability struct { + Scope string + Name string + Metrics []string + Trackable bool + Note string +} + +type InsightMetricItem struct { + Name string + Value int +} + +type AccountInsightsBlock struct { + Status string + Message string + Metrics []InsightMetricItem +} + +type PostPerformanceItem struct { + MediaID string + Text string + Permalink string + Timestamp string + MediaType string + MediaURL string + ThumbnailURL string + TopicTag string + Shortcode string + LikeCount int + ReplyCount int + RepostCount int + QuoteCount int + Views int + Shares int + InsightsStatus string + InsightsMessage string +} + +type PostPerformanceResult struct { + AccountID string + Username string + ThreadsUserID string + FetchedAt int64 + Capabilities []PostMetricCapability + AccountInsights AccountInsightsBlock + Posts []PostPerformanceItem +} + type UpsertKnownAccountsFromScanRequest struct { TenantID string OwnerUID string @@ -155,6 +267,7 @@ type UseCase interface { Get(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error) Update(ctx context.Context, req UpdateAccountRequest) (*AccountSummary, error) Activate(ctx context.Context, tenantID, ownerUID, accountID string) error + Delete(ctx context.Context, tenantID, ownerUID, accountID string) error GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*ConnectionData, error) UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error) ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error) @@ -164,9 +277,22 @@ type UseCase interface { ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error) ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error) ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) + ResolveMemberResearchAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error) ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) + ResolveMemberThreadsReplyCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error) + StartThreadsOAuth(ctx context.Context, tenantID, ownerUID, accountID string) (*OAuthStartResult, error) + CompleteThreadsOAuthCallback(ctx context.Context, code, state string) (*OAuthCallbackResult, error) + DisconnectThreadsAPI(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error) + ListOAuthDebugLogs(limit int) []OAuthDebugLogEntry + RecordOAuthDebug(level, stage, accountID, message string) + RunAPISmokeTest(ctx context.Context, tenantID, ownerUID, accountID string) ([]APISmokeTestItem, error) + RunAPIPlayground(ctx context.Context, tenantID, ownerUID, accountID string, req APIPlaygroundRequest) (*APIPlaygroundResult, error) + ListPostPerformance(ctx context.Context, tenantID, ownerUID, accountID string, limit int) (*PostPerformanceResult, error) + ResolveAccountAPIAccessToken(ctx context.Context, tenantID, ownerUID, accountID string) (string, error) + RefreshAccountAPIAccessToken(ctx context.Context, tenantID, ownerUID, accountID string) (*SecretsData, error) + ListTokenRefreshCandidates(ctx context.Context, leadTime time.Duration) ([]TokenRefreshCandidate, error) ResolveMemberCapabilities(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberCapabilities, error) GetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string) (map[string]entity.KnownAccountProfile, error) UpsertKnownAccountsFromScan(ctx context.Context, req UpsertKnownAccountsFromScanRequest) error diff --git a/backend/internal/model/threads_account/repository/mongo.go b/backend/internal/model/threads_account/repository/mongo.go index dd559e4..9336b67 100644 --- a/backend/internal/model/threads_account/repository/mongo.go +++ b/backend/internal/model/threads_account/repository/mongo.go @@ -63,6 +63,25 @@ func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, acco }) } +func (r *mongoRepository) FindByIDGlobal(ctx context.Context, accountID string) (*entity.Account, error) { + return r.findOne(ctx, bson.M{ + "_id": strings.TrimSpace(accountID), + "status": entity.StatusOpen, + }) +} + +func (r *mongoRepository) FindByThreadsUserID( + ctx context.Context, + tenantID, ownerUID, threadsUserID string, +) (*entity.Account, error) { + return r.findOne(ctx, bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "threads_user_id": strings.TrimSpace(threadsUserID), + "status": entity.StatusOpen, + }) +} + func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error) { if r.collection == nil { return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") @@ -110,6 +129,64 @@ func (r *mongoRepository) UpdateShell(ctx context.Context, tenantID, ownerUID, a return &out, err } +func (r *mongoRepository) UpdateAPIProfile( + ctx context.Context, + tenantID, ownerUID, accountID, username, threadsUserID, displayName string, +) (*entity.Account, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + set := bson.M{ + "update_at": clock.NowUnixNano(), + } + if v := strings.TrimPrefix(strings.TrimSpace(username), "@"); v != "" { + set["username"] = v + } + if v := strings.TrimSpace(threadsUserID); v != "" { + set["threads_user_id"] = v + } + if v := strings.TrimSpace(displayName); v != "" { + set["display_name"] = v + } + var out entity.Account + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen}, + bson.M{"$set": set}, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found") + } + return &out, err +} + +func (r *mongoRepository) ClearAPIProfile( + ctx context.Context, + tenantID, ownerUID, accountID string, +) (*entity.Account, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Account + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen}, + bson.M{ + "$set": bson.M{"update_at": clock.NowUnixNano()}, + "$unset": bson.M{ + "username": "", + "threads_user_id": "", + }, + }, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found") + } + return &out, err +} + func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error { if r.collection == nil { return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") @@ -128,6 +205,23 @@ func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, ac return nil } +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + res, err := r.collection.DeleteOne( + ctx, + bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID}, + ) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.ThreadsAccount).ResNotFound("threads account not found") + } + return nil +} + func (r *mongoRepository) SetKnownAccounts( ctx context.Context, tenantID, ownerUID, accountID string, diff --git a/backend/internal/model/threads_account/repository/oauth_state_redis.go b/backend/internal/model/threads_account/repository/oauth_state_redis.go new file mode 100644 index 0000000..f5d7fc7 --- /dev/null +++ b/backend/internal/model/threads_account/repository/oauth_state_redis.go @@ -0,0 +1,67 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + domrepo "haixun-backend/internal/model/threads_account/domain/repository" + + goredis "github.com/redis/go-redis/v9" +) + +const oauthStateKeyPrefix = "threads_oauth_state:" +const oauthStateTTL = 10 * time.Minute + +type oauthStateRedisStore struct { + client *goredis.Client +} + +func NewOAuthStateRedisStore(client *goredis.Client) domrepo.OAuthStateStore { + return &oauthStateRedisStore{client: client} +} + +func (s *oauthStateRedisStore) Save(ctx context.Context, state string, payload domrepo.OAuthStatePayload) error { + if s.client == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Redis is not configured") + } + state = strings.TrimSpace(state) + if state == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("oauth state is required") + } + raw, err := json.Marshal(payload) + if err != nil { + return err + } + return s.client.Set(ctx, oauthStateKeyPrefix+state, raw, oauthStateTTL).Err() +} + +func (s *oauthStateRedisStore) Consume(ctx context.Context, state string) (*domrepo.OAuthStatePayload, error) { + if s.client == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis is not configured") + } + state = strings.TrimSpace(state) + if state == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("oauth state is required") + } + key := oauthStateKeyPrefix + state + raw, err := s.client.GetDel(ctx, key).Bytes() + if err == goredis.Nil { + return nil, app.For(code.ThreadsAccount).ResNotFound("oauth state expired or invalid") + } + if err != nil { + return nil, err + } + var payload domrepo.OAuthStatePayload + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("parse oauth state: %w", err) + } + if strings.TrimSpace(payload.TenantID) == "" || strings.TrimSpace(payload.OwnerUID) == "" { + return nil, app.For(code.ThreadsAccount).InputInvalidFormat("oauth state payload incomplete") + } + return &payload, nil +} diff --git a/backend/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go index 11fb167..dc527a9 100644 --- a/backend/internal/model/threads_account/repository/secrets_mongo.go +++ b/backend/internal/model/threads_account/repository/secrets_mongo.go @@ -56,6 +56,13 @@ func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID } out.BrowserStorageState = plain } + if r.cipher != nil && out.APIAccessToken != "" { + plain, decErr := r.cipher.Decrypt(out.APIAccessToken) + if decErr != nil { + return nil, decErr + } + out.APIAccessToken = plain + } return &out, nil } @@ -98,13 +105,21 @@ func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, account return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") } now := clock.NowUnixNano() + storedToken := accessToken + if r.cipher != nil { + enc, encErr := r.cipher.Encrypt(accessToken) + if encErr != nil { + return nil, encErr + } + storedToken = enc + } var out entity.Secrets err := r.collection.FindOneAndUpdate( ctx, bson.M{"_id": accountID}, bson.M{ "$set": bson.M{ - "api_access_token": accessToken, + "api_access_token": storedToken, "api_token_expires_at": expiresAt, "update_at": now, }, @@ -119,3 +134,76 @@ func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, account out.APITokenExpiresAt = expiresAt return &out, nil } + +func (r *secretsMongoRepository) ListAPIAccessTokensExpiringBefore( + ctx context.Context, + beforeNs int64, +) ([]*entity.Secrets, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if beforeNs <= 0 { + return nil, nil + } + cursor, err := r.collection.Find(ctx, bson.M{ + "api_access_token": bson.M{"$ne": ""}, + "api_token_expires_at": bson.M{ + "$gt": int64(0), + "$lte": beforeNs, + }, + }) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var items []*entity.Secrets + if err := cursor.All(ctx, &items); err != nil { + return nil, err + } + for _, item := range items { + if item == nil { + continue + } + if r.cipher != nil && item.APIAccessToken != "" { + plain, decErr := r.cipher.Decrypt(item.APIAccessToken) + if decErr != nil { + return nil, decErr + } + item.APIAccessToken = plain + } + } + return items, nil +} + +func (r *secretsMongoRepository) ClearAPIAccessToken(ctx context.Context, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + res, err := r.collection.UpdateOne( + ctx, + bson.M{"_id": accountID}, + bson.M{ + "$set": bson.M{ + "api_access_token": "", + "api_token_expires_at": int64(0), + "update_at": now, + }, + }, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return nil + } + return nil +} + +func (r *secretsMongoRepository) DeleteByAccountID(ctx context.Context, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": accountID}) + return err +} diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index 6b1e1ea..b146c91 100644 --- a/backend/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -56,6 +56,36 @@ func (u *threadsAccountUseCase) ResolveMemberAiCredential( }, nil } +func (u *threadsAccountUseCase) ResolveMemberResearchAiCredential( + ctx context.Context, + tenantID, ownerUID string, +) (*domusecase.WorkerAiCredential, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + stored, err := u.loadAiCredentials(ctx, ownerUID, "") + if err != nil { + return nil, err + } + provider := strings.TrimSpace(stored.ResearchProvider) + model := strings.TrimSpace(stored.ResearchModel) + if provider == "" { + provider = strings.TrimSpace(stored.Provider) + } + if model == "" { + model = strings.TrimSpace(stored.Model) + } + apiKey := strings.TrimSpace(stored.ApiKeys[provider]) + if provider == "" || apiKey == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定研究用 AI API key") + } + return &domusecase.WorkerAiCredential{ + Provider: provider, + Model: model, + APIKey: apiKey, + }, nil +} + func (u *threadsAccountUseCase) ResolveWorkerAiCredential( ctx context.Context, tenantID, ownerUID, accountID string, diff --git a/backend/internal/model/threads_account/usecase/api_playground.go b/backend/internal/model/threads_account/usecase/api_playground.go new file mode 100644 index 0000000..c1f0883 --- /dev/null +++ b/backend/internal/model/threads_account/usecase/api_playground.go @@ -0,0 +1,263 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" +) + +func (u *threadsAccountUseCase) RunAPIPlayground( + ctx context.Context, + tenantID, ownerUID, accountID string, + req domusecase.APIPlaygroundRequest, +) (*domusecase.APIPlaygroundResult, error) { + account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + client := libthreads.NewClient(token) + if !client.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") + } + + action := strings.TrimSpace(strings.ToLower(req.Action)) + if action == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("action 為必填") + } + + userID := strings.TrimSpace(account.ThreadsUserID) + if userID == "" { + if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { + userID = profile.ID.String() + } + } + + limit := req.Limit + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + result := &domusecase.APIPlaygroundResult{Action: action} + var payload any + var runErr error + + switch action { + case "me": + payload, runErr = libthreads.GetMeProfile(ctx, token) + case "user_threads": + if userID == "" { + runErr = fmt.Errorf("缺少 threads_user_id") + } else { + payload, runErr = client.UserThreads(ctx, userID, limit) + } + case "keyword_search": + q := strings.TrimSpace(req.Query) + if q == "" { + runErr = fmt.Errorf("query 為必填") + } else { + searchType := strings.TrimSpace(req.SearchType) + if searchType == "" { + searchType = "TOP" + } + payload, runErr = client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{ + Query: q, Limit: limit, SearchType: searchType, + }) + } + case "profile_lookup": + u := strings.TrimPrefix(strings.TrimSpace(req.Username), "@") + if u == "" { + runErr = fmt.Errorf("username 為必填") + } else { + payload, runErr = client.ProfileLookup(ctx, u) + } + case "mentions": + if userID == "" { + runErr = fmt.Errorf("缺少 threads_user_id") + } else { + payload, runErr = client.UserMentions(ctx, userID, limit) + } + case "insights": + if userID == "" { + runErr = fmt.Errorf("缺少 threads_user_id") + } else { + payload, runErr = client.UserInsights(ctx, userID, nil) + } + case "location_search": + q := strings.TrimSpace(req.LocationQuery) + if q == "" { + q = strings.TrimSpace(req.Query) + } + if q == "" { + runErr = fmt.Errorf("location 查詢字串為必填") + } else { + payload, runErr = client.LocationSearch(ctx, q, limit) + } + case "media_replies", "media_conversation": + mid := strings.TrimSpace(req.MediaID) + if mid == "" { + runErr = fmt.Errorf("media_id 為必填") + } else { + if limit < 30 { + limit = 30 + } + payload, runErr = client.MediaConversation(ctx, mid, limit) + } + case "media_stats": + mid := strings.TrimSpace(req.MediaID) + if mid == "" { + runErr = fmt.Errorf("media_id 為必填") + } else { + payload, runErr = client.GetMediaStats(ctx, mid) + } + case "media_detail": + mid := strings.TrimSpace(req.MediaID) + if mid == "" { + runErr = fmt.Errorf("media_id 為必填") + } else { + payload, runErr = client.GetMediaDetail(ctx, mid) + } + case "media_insights": + mid := strings.TrimSpace(req.MediaID) + if mid == "" { + runErr = fmt.Errorf("media_id 為必填") + } else { + payload, runErr = client.MediaInsights(ctx, mid, nil) + } + case "publish_text": + text := strings.TrimSpace(req.Text) + if userID == "" || text == "" { + runErr = fmt.Errorf("threads_user_id 與 text 為必填") + } else { + payload, runErr = libthreads.PublishText(ctx, libthreads.PublishTextInput{ + ThreadsUserID: userID, AccessToken: token, Text: text, + }) + } + case "prime_reply_target": + replyTo := strings.TrimSpace(req.ReplyToID) + permalink := strings.TrimSpace(req.Permalink) + if replyTo == "" || permalink == "" { + runErr = fmt.Errorf("reply_to_id 與 permalink 為必填") + } else { + hint := strings.TrimSpace(req.HintText) + if hint == "" { + hint = strings.TrimSpace(req.Query) + } + if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: replyTo, + Permalink: permalink, + HintText: hint, + }, replyTo); primeErr != nil { + runErr = primeErr + } else { + payload = map[string]string{"primed": replyTo} + } + } + case "publish_reply": + text := strings.TrimSpace(req.Text) + replyTo := strings.TrimSpace(req.ReplyToID) + if userID == "" || text == "" || replyTo == "" { + runErr = fmt.Errorf("threads_user_id、reply_to_id、text 為必填") + } else { + permalink := strings.TrimSpace(req.Permalink) + if permalink != "" && !req.SkipPrime { + hint := strings.TrimSpace(req.HintText) + if hint == "" { + hint = strings.TrimSpace(req.Query) + } + if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: replyTo, + Permalink: permalink, + HintText: hint, + }, replyTo); primeErr != nil { + runErr = fmt.Errorf("回覆他人貼文前需先透過 keyword_search 註冊目標:%w", primeErr) + } + } + if runErr == nil { + payload, runErr = libthreads.PublishReply(ctx, libthreads.PublishReplyInput{ + ThreadsUserID: userID, AccessToken: token, ReplyToID: replyTo, Text: text, + }) + } + } + case "delete_media": + mid := strings.TrimSpace(req.MediaID) + if mid == "" { + runErr = fmt.Errorf("media_id 為必填") + } else { + runErr = client.DeleteMedia(ctx, mid) + if runErr == nil { + payload = map[string]string{"deleted": mid} + } + } + case "hide_reply": + rid := strings.TrimSpace(req.ReplyID) + if rid == "" { + runErr = fmt.Errorf("reply_id 為必填") + } else { + runErr = client.HideReply(ctx, rid, req.Hide) + if runErr == nil { + payload = map[string]any{"reply_id": rid, "hide": req.Hide} + } + } + case "share_to_instagram": + mid := strings.TrimSpace(req.MediaID) + if mid == "" { + runErr = fmt.Errorf("media_id 為必填") + } else { + runErr = client.ShareToInstagram(ctx, mid) + if runErr == nil { + payload = map[string]string{"crossposted": mid} + } + } + case "refresh_token": + refreshed, rerr := libthreads.RefreshAccessToken(ctx, token) + if rerr != nil { + runErr = rerr + } else { + expiresAt := int64(0) + if refreshed.ExpiresIn >= 300 { + expiresAt = libthreads.NsFromNow(refreshed.ExpiresIn) + } + if _, serr := u.secretsRepo.SaveAPIAccessToken(ctx, account.ID, refreshed.AccessToken, expiresAt); serr != nil { + runErr = serr + } else { + payload = map[string]any{ + "expires_in": refreshed.ExpiresIn, + "saved": true, + } + } + } + default: + return nil, app.For(code.ThreadsAccount).InputInvalidFormat("不支援的 action: " + action) + } + + if runErr != nil { + result.Ok = false + result.Message = runErr.Error() + if libthreads.IsPermissionError(runErr) { + result.Message += "(請確認 Meta App 已授權此 scope,且帳號在測試人員名單)" + } + return result, nil + } + + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + result.Ok = true + result.Message = "ok" + result.Data = raw + return result, nil +} diff --git a/backend/internal/model/threads_account/usecase/api_smoke.go b/backend/internal/model/threads_account/usecase/api_smoke.go new file mode 100644 index 0000000..ecb011e --- /dev/null +++ b/backend/internal/model/threads_account/usecase/api_smoke.go @@ -0,0 +1,168 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" +) + +func (u *threadsAccountUseCase) RunAPISmokeTest( + ctx context.Context, + tenantID, ownerUID, accountID string, +) ([]domusecase.APISmokeTestItem, error) { + account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + client := libthreads.NewClient(token) + if !client.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API token 無效") + } + + var out []domusecase.APISmokeTestItem + add := func(scope, name, status, message string, count int) { + out = append(out, domusecase.APISmokeTestItem{ + Scope: scope, Name: name, Status: status, Message: message, Count: count, + }) + } + run := func(scope, name string, fn func() (int, string, error)) { + count, detail, err := fn() + if err != nil { + msg := err.Error() + if libthreads.IsPermissionError(err) { + msg += "(請確認 Meta App 已授權此 scope,且帳號在測試人員名單)" + } + add(scope, name, "error", msg, 0) + return + } + if detail != "" { + add(scope, name, "ok", detail, count) + return + } + add(scope, name, "ok", fmt.Sprintf("成功(%d 筆)", count), count) + } + + profile, profileErr := libthreads.GetMeProfile(ctx, token) + userID := strings.TrimSpace(account.ThreadsUserID) + username := strings.TrimSpace(account.Username) + if profileErr == nil && profile != nil { + if userID == "" { + userID = profile.ID.String() + } + if username == "" { + username = profile.Username + } + } + + run("threads_basic", "GET /me 個人資料", func() (int, string, error) { + if profileErr != nil { + return 0, "", profileErr + } + return 1, "user_id=" + profile.ID.String() + " @" + profile.Username, nil + }) + + if userID == "" { + add("threads_basic", "GET /{user-id}/threads 貼文列表", "error", "缺少 threads_user_id,請重新 OAuth", 0) + } else { + var sampleMediaID string + run("threads_basic", "GET /{user-id}/threads 貼文列表", func() (int, string, error) { + items, err := client.UserThreads(ctx, userID, 5) + if err != nil { + return 0, "", err + } + if len(items) > 0 { + sampleMediaID = items[0].ID + } + return len(items), "", nil + }) + if sampleMediaID != "" { + run("threads_manage_insights", "GET /{media-id}/insights 貼文互動", func() (int, string, error) { + stats, err := client.GetMediaStats(ctx, sampleMediaID) + if err != nil { + return 0, "", err + } + return 1, fmt.Sprintf("likes=%d replies=%d views=%d", stats.LikeCount, stats.ReplyCount, stats.Views), nil + }) + run("threads_read_replies", "GET /{media-id}/replies 留言列表", func() (int, string, error) { + items, err := client.MediaReplies(ctx, sampleMediaID, 5) + if err != nil { + return 0, "", err + } + return len(items), "", nil + }) + } else { + add("threads_manage_insights", "GET /{media-id}/insights 貼文互動", "skip", "帳號尚無貼文,略過", 0) + add("threads_read_replies", "GET /{media-id}/replies 留言列表", "skip", "帳號尚無貼文,略過", 0) + } + } + + run("threads_keyword_search", "GET /keyword_search 關鍵字搜尋", func() (int, string, error) { + items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{ + Query: "threads", Limit: 5, SearchType: "TOP", + }) + if err != nil { + return 0, "", err + } + return len(items), "", nil + }) + + if username != "" { + run("threads_profile_discovery", "GET /profile_lookup 帳號查詢", func() (int, string, error) { + res, err := client.ProfileLookup(ctx, username) + if err != nil { + return 0, "", err + } + return 1, "@" + res.Username + " name=" + res.Name, nil + }) + } else { + add("threads_profile_discovery", "GET /profile_lookup 帳號查詢", "skip", "缺少 username,略過", 0) + } + + if userID != "" { + run("threads_manage_mentions", "GET /{user-id}/mentions 提及", func() (int, string, error) { + items, err := client.UserMentions(ctx, userID, 5) + if err != nil { + return 0, "", err + } + return len(items), "", nil + }) + run("threads_manage_insights", "GET /{user-id}/threads_insights 洞察", func() (int, string, error) { + items, err := client.UserInsights(ctx, userID, nil) + if err != nil { + return 0, "", err + } + return len(items), fmt.Sprintf("%d 項指標", len(items)), nil + }) + } else { + add("threads_manage_mentions", "GET /{user-id}/mentions 提及", "skip", "缺少 threads_user_id", 0) + add("threads_manage_insights", "GET /{user-id}/threads_insights 洞察", "skip", "缺少 threads_user_id", 0) + } + + run("threads_location_tagging", "GET /location_search 地點搜尋", func() (int, string, error) { + items, err := client.LocationSearch(ctx, "Taipei", 5) + if err != nil { + return 0, "", err + } + return len(items), "", nil + }) + + add("threads_content_publish", "POST 發文 / 回覆", "manual", + "請用產品 API:POST /personas/:id/copy-drafts/:draftId/publish 或 outreach-drafts/publish", 0) + add("threads_delete", "DELETE /{media-id} 刪文", "manual", + "會真的刪除貼文,請在 Threads 後台或自行呼叫 DeleteMedia 測試", 0) + add("threads_manage_replies", "POST 隱藏留言", "manual", + "會改變留言狀態,請用 HideReply(reply_id) 手動測試", 0) + add("threads_share_to_instagram", "POST 同步到 IG", "manual", + "需已連結 IG 且會真的 crosspost,請用 ShareToInstagram(media_id) 手動測試", 0) + + return out, nil +} diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go index 073aafe..2f259e5 100644 --- a/backend/internal/model/threads_account/usecase/connection_prefs_test.go +++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go @@ -6,39 +6,40 @@ import ( domusecase "haixun-backend/internal/model/threads_account/domain/usecase" ) -func TestApplyConnectionPatchSearchSourceInDevMode(t *testing.T) { - current := deriveConnectionPrefsFromDevMode(true) - patch := domusecase.ConnectionPrefsPatch{ - SearchSourceMode: strPtr("brave"), - } - next := applyConnectionPatch(current, patch) - if next.SearchSourceMode != "brave" { - t.Fatalf("expected brave, got %s", next.SearchSourceMode) - } - if !next.DevMode { - t.Fatal("expected dev mode to stay enabled") - } -} - func TestApplyConnectionPatchSearchSourceInFormalMode(t *testing.T) { - current := deriveConnectionPrefsFromDevMode(false) + current := formalConnectionPrefs() patch := domusecase.ConnectionPrefsPatch{ SearchSourceMode: strPtr("threads_crawler"), } next := applyConnectionPatch(current, patch) - if next.SearchSourceMode != "threads_crawler" { - t.Fatalf("expected threads_crawler, got %s", next.SearchSourceMode) + if next.SearchSourceMode != "threads_brave" { + t.Fatalf("expected crawler mode stripped to threads_brave, got %s", next.SearchSourceMode) + } + if next.DevMode { + t.Fatal("expected dev_mode to stay disabled") } } -func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) { - prefs := domusecase.ConnectionPrefs{ - DevMode: false, - SearchSourceMode: "crawler", - } +func TestNormalizeConnectionPrefsKeepsScrapeReplies(t *testing.T) { + prefs := formalConnectionPrefs() + prefs.ScrapeReplies = true next := normalizeConnectionPrefs(prefs) - if next.SearchSourceMode != "crawler" { - t.Fatalf("expected crawler, got %s", next.SearchSourceMode) + if !next.ScrapeReplies { + t.Fatal("expected scrape_replies to persist") + } + if next.PublishViaApi != true || !next.SearchViaApi { + t.Fatal("expected formal API routing") + } +} + +func TestApplyConnectionPatchIgnoresLegacyDevMode(t *testing.T) { + current := formalConnectionPrefs() + enabled := true + next := applyConnectionPatch(current, domusecase.ConnectionPrefsPatch{ + DevMode: &enabled, + }) + if next.DevMode { + t.Fatal("account dev_mode should not enable crawler routing") } } diff --git a/backend/internal/model/threads_account/usecase/oauth.go b/backend/internal/model/threads_account/usecase/oauth.go new file mode 100644 index 0000000..e0b07cf --- /dev/null +++ b/backend/internal/model/threads_account/usecase/oauth.go @@ -0,0 +1,255 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/model/threads_account/domain/entity" + domrepo "haixun-backend/internal/model/threads_account/domain/repository" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" + + "github.com/google/uuid" +) + +func (u *threadsAccountUseCase) StartThreadsOAuth( + ctx context.Context, + tenantID, ownerUID, accountID string, +) (*domusecase.OAuthStartResult, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + if !u.oauth.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定(THREADS_APP_ID / THREADS_APP_SECRET / THREADS_REDIRECT_URI)") + } + if u.oauthState == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定,無法保存 OAuth state") + } + + accountID = strings.TrimSpace(accountID) + var account *entity.Account + var err error + if accountID != "" { + account, err = u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + } else { + existing, listErr := u.repo.ListByOwner(ctx, tenantID, ownerUID) + if listErr != nil { + return nil, listErr + } + created, createErr := u.Create(ctx, domusecase.CreateRequest{ + TenantID: tenantID, + OwnerUID: ownerUID, + DisplayName: "Threads 帳號 " + itoa(len(existing)+1), + Activate: false, + }) + if createErr != nil { + return nil, createErr + } + account, err = u.assertOwned(ctx, tenantID, ownerUID, created.ID) + if err != nil { + return nil, err + } + } + + state := uuid.NewString() + if err := u.oauthState.Save(ctx, state, domrepo.OAuthStatePayload{ + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: account.ID, + }); err != nil { + return nil, err + } + authorizeURL, err := libthreads.BuildAuthorizeURL(u.oauth, state) + if err != nil { + return nil, err + } + redirectURI := strings.TrimSpace(u.oauth.RedirectURI) + if !strings.HasPrefix(strings.ToLower(redirectURI), "https://") { + recordOAuthDebug("error", "start", account.ID, "redirect_uri must be https:// (Meta error 1349187): "+redirectURI) + return nil, app.For(code.ThreadsAccount).InputInvalidFormat("THREADS_REDIRECT_URI 必須為 https://(Meta 會拒絕 http://,錯誤碼 1349187)") + } + recordOAuthDebug("info", "start", account.ID, "redirect_uri="+redirectURI) + return &domusecase.OAuthStartResult{ + AuthorizeURL: authorizeURL, + State: state, + AccountID: account.ID, + }, nil +} + +func (u *threadsAccountUseCase) CompleteThreadsOAuthCallback( + ctx context.Context, + authCode, state string, +) (*domusecase.OAuthCallbackResult, error) { + if !u.oauth.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定") + } + if u.oauthState == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定") + } + recordOAuthDebug("info", "callback", "", "received code len="+itoa(len(strings.TrimSpace(authCode)))+" state len="+itoa(len(strings.TrimSpace(state)))) + payload, err := u.oauthState.Consume(ctx, state) + if err != nil { + recordOAuthDebug("error", "consume_state", "", err.Error()) + return nil, err + } + short, err := libthreads.ExchangeCodeForToken(ctx, u.oauth, authCode) + if err != nil { + recordOAuthDebug("error", "exchange_code", payload.AccountID, err.Error()) + return nil, err + } + recordOAuthDebug("info", "exchange_code", payload.AccountID, "short-lived ok expires_in="+itoa(short.ExpiresIn)) + longLived, err := libthreads.ExchangeLongLivedToken(ctx, u.oauth, short.AccessToken) + if err != nil { + recordOAuthDebug("warn", "long_lived", payload.AccountID, "fallback short-lived: "+err.Error()) + longLived = short + } else { + recordOAuthDebug("info", "long_lived", payload.AccountID, "ok expires_in="+itoa(longLived.ExpiresIn)) + } + account, err := u.assertOwned(ctx, payload.TenantID, payload.OwnerUID, payload.AccountID) + if err != nil { + return nil, err + } + + profile, err := libthreads.GetMeProfile(ctx, longLived.AccessToken) + threadsUserID := "" + username := "" + displayName := "" + if err != nil { + threadsUserID = libthreads.ThreadsUserIDFromToken(short, longLived) + if threadsUserID == "" { + recordOAuthDebug("error", "profile_me", payload.AccountID, err.Error()) + if libthreads.IsPermissionError(err) { + return nil, app.For(code.ThreadsAccount).AuthForbidden( + "Threads API 需要 threads_basic:請到 Meta App Dashboard → 應用程式角色,把你的 Threads 帳號加為「測試人員」或管理員,並確認已啟用「Access the Threads API」", + ) + } + return nil, err + } + recordOAuthDebug("warn", "profile_me", payload.AccountID, "fallback token user_id="+threadsUserID+": "+err.Error()) + displayName = strings.TrimSpace(account.DisplayName) + if displayName == "" { + displayName = "Threads " + threadsUserID + } + } else { + threadsUserID = profile.ID.String() + username = profile.Username + recordOAuthDebug("info", "profile_me", payload.AccountID, "username="+username+" threads_user_id="+threadsUserID) + displayName = strings.TrimSpace(profile.Name) + if displayName == "" { + displayName = "@" + strings.TrimPrefix(username, "@") + } + } + + if err := u.assertNoAPIBindingConflict(ctx, payload.TenantID, payload.OwnerUID, account.ID, threadsUserID); err != nil { + return nil, err + } + + updated, err := u.repo.UpdateAPIProfile( + ctx, + payload.TenantID, + payload.OwnerUID, + account.ID, + username, + threadsUserID, + displayName, + ) + if err != nil { + return nil, err + } + expiresAt := int64(0) + if longLived.ExpiresIn >= 300 { + expiresAt = libthreads.NsFromNow(longLived.ExpiresIn) + } else if longLived.ExpiresIn > 0 { + recordOAuthDebug("warn", "expires_in", payload.AccountID, "suspicious expires_in="+itoa(longLived.ExpiresIn)+"s; store without expiry deadline") + } + if _, err := u.secretsRepo.SaveAPIAccessToken(ctx, updated.ID, longLived.AccessToken, expiresAt); err != nil { + recordOAuthDebug("error", "save_token", updated.ID, err.Error()) + return nil, err + } + recordOAuthDebug("info", "success", updated.ID, "api_connected=true username="+updated.Username) + if err := u.members.SetActiveThreadsAccountID(ctx, payload.TenantID, payload.OwnerUID, updated.ID); err != nil { + return nil, err + } + + redirectURL := strings.TrimSpace(u.oauth.SuccessRedirect) + if redirectURL == "" { + redirectURL = "/" + } + sep := "?" + if strings.Contains(redirectURL, "?") { + sep = "&" + } + return &domusecase.OAuthCallbackResult{ + AccountID: updated.ID, + Username: updated.Username, + ThreadsUserID: updated.ThreadsUserID, + RedirectURL: redirectURL + sep + "threads_oauth=ok&account_id=" + updated.ID, + }, nil +} + +func (u *threadsAccountUseCase) DisconnectThreadsAPI( + ctx context.Context, + tenantID, ownerUID, accountID string, +) (*domusecase.AccountSummary, error) { + account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + if err := u.secretsRepo.ClearAPIAccessToken(ctx, account.ID); err != nil { + return nil, err + } + if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, account.ID); err != nil { + return nil, err + } + return u.toSummary(ctx, account) +} + +func (u *threadsAccountUseCase) assertNoAPIBindingConflict( + ctx context.Context, + tenantID, ownerUID, accountID, threadsUserID string, +) error { + dup, err := u.repo.FindByThreadsUserID(ctx, tenantID, ownerUID, threadsUserID) + if err != nil || dup == nil || dup.ID == accountID { + return nil + } + secrets, err := u.secretsRepo.FindByAccountID(ctx, dup.ID) + if err != nil { + return err + } + if isValidAPIToken(secrets) { + label := dup.DisplayName + if label == "" && dup.Username != "" { + label = "@" + dup.Username + } + if label == "" { + label = dup.ID + } + return app.For(code.ThreadsAccount).ResConflict( + "此 Threads 帳號已綁定在「" + label + "」;請先中斷該帳號 API,或選該帳號按「連線選中帳號」重新授權", + ) + } + if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, dup.ID); err != nil { + return err + } + return nil +} + +func isValidAPIToken(secrets *entity.Secrets) bool { + if secrets == nil { + return false + } + token := strings.TrimSpace(secrets.APIAccessToken) + if token == "" { + return false + } + if secrets.APITokenExpiresAt > 0 && secrets.APITokenExpiresAt <= clock.NowUnixNano() { + return false + } + return true +} diff --git a/backend/internal/model/threads_account/usecase/oauth_debug_log.go b/backend/internal/model/threads_account/usecase/oauth_debug_log.go new file mode 100644 index 0000000..a0d8302 --- /dev/null +++ b/backend/internal/model/threads_account/usecase/oauth_debug_log.go @@ -0,0 +1,102 @@ +package usecase + +import ( + "log" + "sync" + + "haixun-backend/internal/library/clock" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" +) + +const oauthDebugLogCap = 80 + +// OAuthDebugEntry is a recent OAuth event for Dev Console debugging. +type OAuthDebugEntry struct { + At int64 `json:"at"` + Level string `json:"level"` + Stage string `json:"stage"` + AccountID string `json:"account_id,omitempty"` + Message string `json:"message"` +} + +var ( + oauthDebugMu sync.Mutex + oauthDebugRing []OAuthDebugEntry + oauthDebugSeq int +) + +func recordOAuthDebug(level, stage, accountID, message string) { + at := clock.NowUnixNano() + entry := OAuthDebugEntry{ + At: at, + Level: level, + Stage: stage, + AccountID: accountID, + Message: message, + } + log.Printf("threads oauth [%s] %s account=%s %s", stage, level, accountID, message) + + oauthDebugMu.Lock() + defer oauthDebugMu.Unlock() + if len(oauthDebugRing) < oauthDebugLogCap { + oauthDebugRing = append(oauthDebugRing, entry) + } else { + oauthDebugRing[oauthDebugSeq%oauthDebugLogCap] = entry + } + oauthDebugSeq++ +} + +func (u *threadsAccountUseCase) RecordOAuthDebug(level, stage, accountID, message string) { + recordOAuthDebug(level, stage, accountID, message) +} + +func (u *threadsAccountUseCase) ListOAuthDebugLogs(limit int) []domusecase.OAuthDebugLogEntry { + raw := listOAuthDebugLogs(limit) + out := make([]domusecase.OAuthDebugLogEntry, 0, len(raw)) + for _, item := range raw { + out = append(out, domusecase.OAuthDebugLogEntry{ + At: item.At, + Level: item.Level, + Stage: item.Stage, + AccountID: item.AccountID, + Message: item.Message, + }) + } + return out +} + +func listOAuthDebugLogs(limit int) []OAuthDebugEntry { + if limit <= 0 || limit > oauthDebugLogCap { + limit = oauthDebugLogCap + } + oauthDebugMu.Lock() + defer oauthDebugMu.Unlock() + n := len(oauthDebugRing) + if n == 0 { + return nil + } + out := make([]OAuthDebugEntry, 0, min(n, limit)) + if n < oauthDebugLogCap { + start := n - min(n, limit) + if start < 0 { + start = 0 + } + out = append(out, oauthDebugRing[start:n]...) + return out + } + start := oauthDebugSeq - min(oauthDebugSeq, limit) + if start < 0 { + start = 0 + } + for i := start; i < oauthDebugSeq; i++ { + out = append(out, oauthDebugRing[i%oauthDebugLogCap]) + } + return out +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/model/threads_account/usecase/placement_context.go b/backend/internal/model/threads_account/usecase/placement_context.go index 6492b5a..deb35aa 100644 --- a/backend/internal/model/threads_account/usecase/placement_context.go +++ b/backend/internal/model/threads_account/usecase/placement_context.go @@ -53,5 +53,5 @@ func (u *threadsAccountUseCase) ResolveMemberPlacementContext( prefs.RepliesPerPost, ) ctxOut.ThreadsAPIAccessToken = apiToken - return ctxOut, nil + return placement.MemberContextForAPIOnly(ctxOut), nil } diff --git a/backend/internal/model/threads_account/usecase/post_performance.go b/backend/internal/model/threads_account/usecase/post_performance.go new file mode 100644 index 0000000..bb29605 --- /dev/null +++ b/backend/internal/model/threads_account/usecase/post_performance.go @@ -0,0 +1,189 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + "time" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" +) + +func (u *threadsAccountUseCase) ListPostPerformance( + ctx context.Context, + tenantID, ownerUID, accountID string, + limit int, +) (*domusecase.PostPerformanceResult, error) { + account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + client := libthreads.NewClient(token) + if !client.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") + } + + if limit <= 0 { + limit = 15 + } + if limit > 30 { + limit = 30 + } + + userID := strings.TrimSpace(account.ThreadsUserID) + username := strings.TrimSpace(account.Username) + if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { + if userID == "" { + userID = profile.ID.String() + } + if username == "" { + username = profile.Username + } + } + + result := &domusecase.PostPerformanceResult{ + AccountID: account.ID, + Username: username, + ThreadsUserID: userID, + FetchedAt: time.Now().UnixNano(), + Capabilities: postPerformanceCapabilities(), + AccountInsights: domusecase.AccountInsightsBlock{ + Status: "skip", + Message: "缺少 threads_user_id", + }, + Posts: []domusecase.PostPerformanceItem{}, + } + + if userID != "" { + result.AccountInsights = fetchAccountInsights(ctx, client, userID) + posts, err := client.UserThreads(ctx, userID, limit) + if err != nil { + return nil, err + } + for _, post := range posts { + item := domusecase.PostPerformanceItem{ + MediaID: post.ID, + Text: post.Text, + Permalink: post.Permalink, + Timestamp: post.Timestamp, + MediaType: post.MediaType, + MediaURL: post.MediaURL, + ThumbnailURL: post.ThumbnailURL, + TopicTag: post.TopicTag, + Shortcode: post.Shortcode, + LikeCount: post.LikeCount, + ReplyCount: post.ReplyCount, + } + if stats, serr := client.GetMediaStats(ctx, post.ID); serr == nil && stats != nil { + item.LikeCount = stats.LikeCount + item.ReplyCount = stats.ReplyCount + item.RepostCount = stats.RepostCount + item.QuoteCount = stats.QuoteCount + item.Views = stats.Views + item.Shares = stats.Shares + item.InsightsStatus = "ok" + } else { + item.InsightsStatus = "error" + if serr != nil { + item.InsightsMessage = serr.Error() + } else { + item.InsightsMessage = "無法讀取貼文洞察" + } + } + result.Posts = append(result.Posts, item) + } + } + + return result, nil +} + +func postPerformanceCapabilities() []domusecase.PostMetricCapability { + return []domusecase.PostMetricCapability{ + { + Scope: "threads_manage_insights", Name: "貼文互動數", + Metrics: []string{"likes", "replies", "reposts", "quotes", "views", "shares"}, + Trackable: true, Note: "GET /{media-id}/insights(非 media 物件欄位)", + }, + { + Scope: "threads_manage_insights", Name: "貼文洞察", + Metrics: []string{"views", "likes", "replies", "reposts", "quotes", "shares"}, + Trackable: true, Note: "GET /{media-id}/insights(lifetime)", + }, + { + Scope: "threads_manage_insights", Name: "帳號洞察", + Metrics: []string{"views", "likes", "replies", "reposts", "quotes", "followers_count", "clicks"}, + Trackable: true, Note: "GET /{user-id}/threads_insights", + }, + { + Scope: "threads_read_replies", Name: "留言內容", + Metrics: []string{"reply_list"}, + Trackable: true, Note: "GET /{media-id}/replies(非數字統計)", + }, + { + Scope: "threads_keyword_search", Name: "關鍵字搜尋", + Trackable: false, Note: "搜尋他人貼文,非你的發文成效", + }, + { + Scope: "threads_profile_discovery", Name: "帳號查詢", + Trackable: false, Note: "查他人 profile,非成效統計", + }, + { + Scope: "threads_manage_mentions", Name: "提及", + Trackable: false, Note: "誰 @ 你,非單篇貼文成效", + }, + { + Scope: "threads_location_tagging", Name: "地點", + Trackable: false, Note: "地點標籤搜尋", + }, + { + Scope: "threads_content_publish", Name: "發文", + Trackable: false, Note: "寫入類;發文後用本頁追蹤成效", + }, + { + Scope: "threads_delete", Name: "刪文", + Trackable: false, Note: "寫入類", + }, + { + Scope: "threads_manage_replies", Name: "留言管理", + Trackable: false, Note: "隱藏留言,非統計", + }, + { + Scope: "threads_share_to_instagram", Name: "同步 IG", + Trackable: false, Note: "crosspost 動作", + }, + } +} + +func fetchAccountInsights(ctx context.Context, client *libthreads.Client, userID string) domusecase.AccountInsightsBlock { + items, err := client.UserInsights(ctx, userID, nil) + if err != nil { + msg := err.Error() + if libthreads.IsPermissionError(err) { + msg += "(需 threads_manage_insights)" + } + return domusecase.AccountInsightsBlock{Status: "error", Message: msg} + } + metrics := make([]domusecase.InsightMetricItem, 0, len(items)) + for _, item := range items { + name := strings.TrimSpace(item.Name) + if name == "" || len(item.Values) == 0 { + continue + } + metrics = append(metrics, domusecase.InsightMetricItem{Name: name, Value: item.Values[0].Value}) + } + if len(metrics) == 0 { + return domusecase.AccountInsightsBlock{Status: "ok", Message: "尚無帳號洞察資料"} + } + return domusecase.AccountInsightsBlock{ + Status: "ok", + Message: fmt.Sprintf("%d 項指標", len(metrics)), + Metrics: metrics, + } +} diff --git a/backend/internal/model/threads_account/usecase/publish_credentials.go b/backend/internal/model/threads_account/usecase/publish_credentials.go index 84c7e87..9f4e701 100644 --- a/backend/internal/model/threads_account/usecase/publish_credentials.go +++ b/backend/internal/model/threads_account/usecase/publish_credentials.go @@ -12,6 +12,20 @@ import ( func (u *threadsAccountUseCase) ResolveMemberThreadsPublishCredential( ctx context.Context, tenantID, ownerUID string, +) (*domusecase.ThreadsPublishCredential, error) { + return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID) +} + +func (u *threadsAccountUseCase) ResolveMemberThreadsReplyCredential( + ctx context.Context, + tenantID, ownerUID string, +) (*domusecase.ThreadsPublishCredential, error) { + return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID) +} + +func (u *threadsAccountUseCase) resolveMemberThreadsAPICredential( + ctx context.Context, + tenantID, ownerUID string, ) (*domusecase.ThreadsPublishCredential, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err @@ -41,10 +55,6 @@ func (u *threadsAccountUseCase) ResolveMemberThreadsPublishCredential( if !apiConnected { return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先完成 Threads API 連線後再發送留言") } - prefs, _ := u.loadConnectionPrefs(ctx, account.ID) - if prefs.DevMode && !prefs.PublishViaApi { - return nil, app.For(code.ThreadsAccount).InputMissingRequired("開發模式請在連線設定啟用 API 發文,或改用手動貼上留言") - } return &domusecase.ThreadsPublishCredential{ AccountID: account.ID, ThreadsUserID: userID, diff --git a/backend/internal/model/threads_account/usecase/token_refresh.go b/backend/internal/model/threads_account/usecase/token_refresh.go new file mode 100644 index 0000000..1870126 --- /dev/null +++ b/backend/internal/model/threads_account/usecase/token_refresh.go @@ -0,0 +1,83 @@ +package usecase + +import ( + "context" + "strings" + "time" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" +) + +const defaultTokenRefreshLeadTime = 7 * 24 * time.Hour + +func (u *threadsAccountUseCase) ResolveAccountAPIAccessToken( + ctx context.Context, + tenantID, ownerUID, accountID string, +) (string, error) { + account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return "", err + } + secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID) + if err != nil { + return "", err + } + if secrets == nil || !isValidAPIToken(secrets) { + return "", app.For(code.ThreadsAccount).InputMissingRequired("此帳號尚未完成 Threads API 連線或 token 已過期") + } + return strings.TrimSpace(secrets.APIAccessToken), nil +} + +func (u *threadsAccountUseCase) RefreshAccountAPIAccessToken( + ctx context.Context, + tenantID, ownerUID, accountID string, +) (*domusecase.SecretsData, error) { + token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + result, err := libthreads.RefreshAccessToken(ctx, token) + if err != nil { + return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads token refresh 失敗").WithCause(err) + } + expiresAt := int64(0) + if result.ExpiresIn > 0 { + expiresAt = libthreads.NsFromNow(result.ExpiresIn) + } + return u.SaveAPIAccessToken(ctx, accountID, result.AccessToken, expiresAt) +} + +func (u *threadsAccountUseCase) ListTokenRefreshCandidates( + ctx context.Context, + leadTime time.Duration, +) ([]domusecase.TokenRefreshCandidate, error) { + if leadTime <= 0 { + leadTime = defaultTokenRefreshLeadTime + } + deadline := clock.NowUnixNano() + leadTime.Nanoseconds() + secretsList, err := u.secretsRepo.ListAPIAccessTokensExpiringBefore(ctx, deadline) + if err != nil { + return nil, err + } + out := make([]domusecase.TokenRefreshCandidate, 0, len(secretsList)) + for _, secrets := range secretsList { + if secrets == nil || strings.TrimSpace(secrets.APIAccessToken) == "" { + continue + } + account, err := u.repo.FindByIDGlobal(ctx, secrets.AccountID) + if err != nil || account == nil { + continue + } + out = append(out, domusecase.TokenRefreshCandidate{ + AccountID: account.ID, + TenantID: account.TenantID, + OwnerUID: account.OwnerUID, + ExpiresAt: secrets.APITokenExpiresAt, + }) + } + return out, nil +} diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index 297fd34..2c671bc 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -11,8 +11,14 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/placement" + libthreads "haixun-backend/internal/library/threadsapi" memberdomain "haixun-backend/internal/model/member/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" + analyticsrepo "haixun-backend/internal/model/publish_analytics/domain/repository" + guardrepo "haixun-backend/internal/model/publish_guard/domain/repository" + inventoryrepo "haixun-backend/internal/model/publish_inventory/domain/repository" + queuerepo "haixun-backend/internal/model/publish_queue/domain/repository" + eventrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" settingdomain "haixun-backend/internal/model/setting/domain/usecase" "haixun-backend/internal/model/threads_account/domain/entity" domrepo "haixun-backend/internal/model/threads_account/domain/repository" @@ -32,27 +38,48 @@ const ( type threadsAccountUseCase struct { repo domrepo.Repository secretsRepo domrepo.SecretsRepository + oauthState domrepo.OAuthStateStore members memberdomain.UseCase settings settingdomain.UseCase personas personadomain.UseCase + queueRepo queuerepo.Repository + analytics analyticsrepo.Repository + inventory inventoryrepo.Repository + guard guardrepo.Repository + queueEvents eventrepo.Repository cipher *crypto.Cipher + oauth libthreads.OAuthConfig } func NewUseCase( repo domrepo.Repository, secretsRepo domrepo.SecretsRepository, + oauthState domrepo.OAuthStateStore, members memberdomain.UseCase, settings settingdomain.UseCase, personas personadomain.UseCase, + queueRepo queuerepo.Repository, + analytics analyticsrepo.Repository, + inventory inventoryrepo.Repository, + guard guardrepo.Repository, + queueEvents eventrepo.Repository, cipher *crypto.Cipher, + oauth libthreads.OAuthConfig, ) domusecase.UseCase { return &threadsAccountUseCase{ repo: repo, secretsRepo: secretsRepo, + oauthState: oauthState, members: members, settings: settings, personas: personas, + queueRepo: queueRepo, + analytics: analytics, + inventory: inventory, + guard: guard, + queueEvents: queueEvents, cipher: cipher, + oauth: oauth, } } @@ -152,6 +179,59 @@ func (u *threadsAccountUseCase) Activate(ctx context.Context, tenantID, ownerUID return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, accountID) } +func (u *threadsAccountUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error { + if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil { + return err + } + mediaIDs := []string{} + if u.queueRepo != nil { + if ids, err := u.queueRepo.ListMediaIDsByAccount(ctx, tenantID, ownerUID, accountID); err == nil { + mediaIDs = ids + } + } + if u.analytics != nil { + _, _ = u.analytics.DeleteByMediaIDs(ctx, tenantID, ownerUID, mediaIDs) + } + if u.queueEvents != nil { + _ = u.queueEvents.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.queueRepo != nil { + _, _ = u.queueRepo.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.inventory != nil { + _ = u.inventory.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.guard != nil { + _ = u.guard.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.secretsRepo != nil { + _ = u.secretsRepo.DeleteByAccountID(ctx, accountID) + } + if u.settings != nil { + settings, _, _, _, err := u.settings.List(ctx, settingScopeAccount, accountID, 1, 200) + if err == nil { + for _, item := range settings { + if item == nil { + continue + } + _ = u.settings.Delete(ctx, settingScopeAccount, accountID, item.Key) + } + } + } + if err := u.repo.Delete(ctx, tenantID, ownerUID, accountID); err != nil { + return err + } + remaining, err := u.repo.ListByOwner(ctx, tenantID, ownerUID) + if err != nil { + return err + } + nextActiveID := "" + if len(remaining) > 0 && remaining[0] != nil { + nextActiveID = remaining[0].ID + } + return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, nextActiveID) +} + func (u *threadsAccountUseCase) GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.ConnectionData, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { @@ -263,17 +343,22 @@ func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.A if err != nil { return nil, err } + apiTokenExpiresAt := int64(0) + if secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID); err == nil && secrets != nil { + apiTokenExpiresAt = secrets.APITokenExpiresAt + } return &domusecase.AccountSummary{ - ID: account.ID, - DisplayName: account.DisplayName, - Username: account.Username, - ThreadsUserID: account.ThreadsUserID, - PersonaID: account.PersonaID, - BrowserConnected: browserConnected, - ApiConnected: apiConnected, - Status: string(account.Status), - CreateAt: account.CreateAt, - UpdateAt: account.UpdateAt, + ID: account.ID, + DisplayName: account.DisplayName, + Username: account.Username, + ThreadsUserID: account.ThreadsUserID, + PersonaID: account.PersonaID, + BrowserConnected: browserConnected, + ApiConnected: apiConnected, + APITokenExpiresAt: apiTokenExpiresAt, + Status: string(account.Status), + CreateAt: account.CreateAt, + UpdateAt: account.UpdateAt, }, nil } @@ -356,28 +441,14 @@ func accountLabel(account *entity.Account) string { } func defaultConnectionPrefs() domusecase.ConnectionPrefs { - return deriveConnectionPrefsFromDevMode(false) + return formalConnectionPrefs() } -// deriveConnectionPrefsFromDevMode maps the single dev_mode switch to concrete routing prefs. -// dev_mode off → everything via Threads API; dev_mode on → everything via browser crawler. -func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs { - if devMode { - return domusecase.ConnectionPrefs{ - DevMode: true, - SearchViaApi: false, - SearchSourceMode: string(placement.SearchSourceThreadsCrawler), - PublishViaApi: false, - ScrapeReplies: true, - RepliesPerPost: defaultRepliesPerPost, - PublishHeaded: false, - PlaywrightDebug: false, - } - } +func formalConnectionPrefs() domusecase.ConnectionPrefs { return domusecase.ConnectionPrefs{ DevMode: false, SearchViaApi: true, - SearchSourceMode: string(placement.DefaultSearchSourceMode), + SearchSourceMode: string(placement.WithoutCrawler(placement.DefaultSearchSourceMode)), PublishViaApi: true, ScrapeReplies: false, RepliesPerPost: defaultRepliesPerPost, @@ -387,40 +458,12 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs { } func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs { - if patch.DevMode != nil { - if *patch.DevMode { - next := deriveConnectionPrefsFromDevMode(true) - if patch.SearchSourceMode != nil { - next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) - } else if strings.TrimSpace(current.SearchSourceMode) != "" { - next.SearchSourceMode = current.SearchSourceMode - } - return normalizeConnectionPrefs(next) - } - next := current - next.DevMode = false - if patch.SearchSourceMode != nil { - next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) - } - return normalizeConnectionPrefs(next) + next := normalizeConnectionPrefs(current) + if patch.ScrapeReplies != nil { + next.ScrapeReplies = *patch.ScrapeReplies } - // Legacy callers may still send granular fields; normalize by current dev flag. - devMode := current.DevMode - if patch.SearchViaApi != nil && !*patch.SearchViaApi { - devMode = true - } - if patch.PublishViaApi != nil && !*patch.PublishViaApi { - devMode = true - } - if patch.ScrapeReplies != nil && *patch.ScrapeReplies { - devMode = true - } - next := current - if devMode { - next = deriveConnectionPrefsFromDevMode(true) - if strings.TrimSpace(current.SearchSourceMode) != "" && patch.SearchSourceMode == nil { - next.SearchSourceMode = current.SearchSourceMode - } + if patch.RepliesPerPost != nil && *patch.RepliesPerPost > 0 { + next.RepliesPerPost = *patch.RepliesPerPost } if patch.SearchSourceMode != nil { next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) @@ -429,20 +472,14 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C } func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs { - mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode) - if prefs.DevMode { - out := deriveConnectionPrefsFromDevMode(true) - out.SearchSourceMode = string(mode) - return out + out := formalConnectionPrefs() + out.ScrapeReplies = prefs.ScrapeReplies + if prefs.RepliesPerPost > 0 { + out.RepliesPerPost = prefs.RepliesPerPost } - prefs.DevMode = false - prefs.SearchViaApi = true - prefs.PublishViaApi = true - prefs.ScrapeReplies = false - prefs.PublishHeaded = false - prefs.PlaywrightDebug = false - prefs.SearchSourceMode = string(mode) - return prefs + mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode) + out.SearchSourceMode = string(placement.WithoutCrawler(mode)) + return out } func mergeConnectionPrefs(defaults domusecase.ConnectionPrefs, value map[string]interface{}) domusecase.ConnectionPrefs { diff --git a/backend/internal/svc/job_workers.go b/backend/internal/svc/job_workers.go new file mode 100644 index 0000000..f0e6e2d --- /dev/null +++ b/backend/internal/svc/job_workers.go @@ -0,0 +1,62 @@ +package svc + +import ( + "context" + "fmt" + "log" + + jobworker "haixun-backend/internal/worker/job" +) + +var inlineCopyWorkerTypes = []string{ + "go-generate-topic-matrix", + "go-generate-formula-draft", + "go-generate-rewrite-draft", +} + +func registerCopyJobHandlers(runner *jobworker.Runner, sc *ServiceContext) { + if runner == nil || sc == nil { + return + } + jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{ + Jobs: sc.Job, + Persona: sc.Persona, + CopyDraft: sc.CopyDraft, + ContentOps: sc.ContentOps, + ThreadsAccount: sc.ThreadsAccount, + Placement: sc.Placement, + AI: sc.AI, + }) + jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{ + Jobs: sc.Job, + Persona: sc.Persona, + ContentFormula: sc.ContentFormula, + CopyDraft: sc.CopyDraft, + ThreadsAccount: sc.ThreadsAccount, + Placement: sc.Placement, + AI: sc.AI, + }) + jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{ + Jobs: sc.Job, + Persona: sc.Persona, + ContentFormula: sc.ContentFormula, + CopyDraft: sc.CopyDraft, + ThreadsAccount: sc.ThreadsAccount, + Placement: sc.Placement, + AI: sc.AI, + }) +} + +func (sc *ServiceContext) startInlineCopyWorkers(ctx context.Context, hostname string) { + if sc == nil || sc.Redis == nil { + return + } + for _, workerType := range inlineCopyWorkerTypes { + workerType := workerType + workerID := fmt.Sprintf("%s-inline-%s", hostname, workerType) + runner := jobworker.NewRunner(workerID, workerType, sc.Job) + registerCopyJobHandlers(runner, sc) + go runner.Start(ctx) + log.Printf("inline copy job worker started: id=%s type=%s", workerID, workerType) + } +} diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index 4e9ecd5..a1596be 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -11,6 +11,7 @@ import ( libcrypto "haixun-backend/internal/library/crypto" libmongo "haixun-backend/internal/library/mongo" libredis "haixun-backend/internal/library/redis" + libthreads "haixun-backend/internal/library/threadsapi" "haixun-backend/internal/library/validate" "haixun-backend/internal/middleware" aisettings "haixun-backend/internal/model/ai/usecase" @@ -21,9 +22,15 @@ import ( branddomain "haixun-backend/internal/model/brand/domain/usecase" brandrepo "haixun-backend/internal/model/brand/repository" brandusecase "haixun-backend/internal/model/brand/usecase" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + contentformularepo "haixun-backend/internal/model/content_formula/repository" + contentformulausecase "haixun-backend/internal/model/content_formula/usecase" cmatrixdomain "haixun-backend/internal/model/content_matrix/domain/usecase" cmatrixrepo "haixun-backend/internal/model/content_matrix/repository" cmatrixusecase "haixun-backend/internal/model/content_matrix/usecase" + contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase" + contentopsrepo "haixun-backend/internal/model/content_ops/repository" + contentopsusecase "haixun-backend/internal/model/content_ops/usecase" copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" copydraftrepo "haixun-backend/internal/model/copy_draft/repository" copydraftusecase "haixun-backend/internal/model/copy_draft/usecase" @@ -41,9 +48,14 @@ import ( memberdomain "haixun-backend/internal/model/member/domain/usecase" memberrepo "haixun-backend/internal/model/member/repository" memberuc "haixun-backend/internal/model/member/usecase" + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository" + mentioninboxusecase "haixun-backend/internal/model/mention_inbox/usecase" outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase" outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository" outreachdraftusecase "haixun-backend/internal/model/outreach_draft/usecase" + ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository" + ownpostformuladomain "haixun-backend/internal/model/own_post_formula/usecase" permissiondomain "haixun-backend/internal/model/permission/domain/usecase" permissionrepo "haixun-backend/internal/model/permission/repository" permissionuc "haixun-backend/internal/model/permission/usecase" @@ -57,11 +69,27 @@ import ( publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase" publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository" publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase" + publishguarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + publishguardrepo "haixun-backend/internal/model/publish_guard/repository" + publishguardusecase "haixun-backend/internal/model/publish_guard/usecase" + publishinventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository" + publishinventoryusecase "haixun-backend/internal/model/publish_inventory/usecase" + publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase" + publishqueuerepo "haixun-backend/internal/model/publish_queue/repository" + publishqueueusecase "haixun-backend/internal/model/publish_queue/usecase" + publishqueueeventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase" + publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository" + publishqueueeventusecase "haixun-backend/internal/model/publish_queue_event/usecase" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" scanpostrepo "haixun-backend/internal/model/scan_post/repository" scanpostusecase "haixun-backend/internal/model/scan_post/usecase" settingrepo "haixun-backend/internal/model/setting/repository" settingusecase "haixun-backend/internal/model/setting/usecase" + stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase" + stylepresetrepo "haixun-backend/internal/model/style_preset/repository" + stylepresetusecase "haixun-backend/internal/model/style_preset/usecase" + threadsaccountrepodomain "haixun-backend/internal/model/threads_account/domain/repository" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" threadsaccountrepo "haixun-backend/internal/model/threads_account/repository" threadsaccountusecase "haixun-backend/internal/model/threads_account/usecase" @@ -77,34 +105,45 @@ type ServiceContext struct { Mongo *libmongo.Client Redis *goredis.Client - Setting settingusecase.UseCase - AI aisettings.UseCase - Job jobusecase.UseCase - AuthToken authdomain.TokenUseCase - Member memberdomain.UseCase - Permission permissiondomain.UseCase - Persona personadomain.UseCase - CopyMission copymissiondomain.UseCase - Brand branddomain.UseCase - PlacementTopic placementtopicdomain.UseCase - KnowledgeGraph kgdomain.UseCase - Placement placementusecase.UseCase - ScanPost scanpostdomain.UseCase - OutreachDraft outreachdraftdomain.UseCase - ContentMatrix cmatrixdomain.UseCase - CopyDraft copydraftdomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase - CrmContact crmcontactdomain.UseCase - PublishAnalytics publishanalyticsdomain.UseCase + Setting settingusecase.UseCase + AI aisettings.UseCase + Job jobusecase.UseCase + AuthToken authdomain.TokenUseCase + Member memberdomain.UseCase + Permission permissiondomain.UseCase + Persona personadomain.UseCase + CopyMission copymissiondomain.UseCase + Brand branddomain.UseCase + PlacementTopic placementtopicdomain.UseCase + KnowledgeGraph kgdomain.UseCase + Placement placementusecase.UseCase + ScanPost scanpostdomain.UseCase + OutreachDraft outreachdraftdomain.UseCase + ContentMatrix cmatrixdomain.UseCase + ContentOps contentopsdomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + CrmContact crmcontactdomain.UseCase + PublishAnalytics publishanalyticsdomain.UseCase + PublishQueue publishqueuedomain.UseCase + PublishInventory publishinventorydomain.UseCase + PublishGuard publishguarddomain.UseCase + PublishQueueEvent publishqueueeventdomain.UseCase + StylePreset stylepresetdomain.UseCase + OwnPostFormula ownpostformuladomain.UseCase + ContentFormula contentformuladomain.UseCase + MentionInbox mentioninboxdomain.UseCase // Middlewares mounted per route group via generate/api `middleware:` directive. AuthJWT rest.Middleware MemberAuth rest.Middleware WorkerSecret rest.Middleware - stopWorker context.CancelFunc - stopScheduler context.CancelFunc - stopReaper context.CancelFunc + stopWorker context.CancelFunc + stopScheduler context.CancelFunc + stopReaper context.CancelFunc + stopThreadsTokenSweep context.CancelFunc + stopPublishQueueSweep context.CancelFunc } func NewServiceContext(c config.Config) *ServiceContext { @@ -188,16 +227,16 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsureScanViralTemplate(ctx); err != nil { panic(err) } - if err := jobUseCase.EnsureAnalyzeCopyMissionTemplate(ctx); err != nil { + if err := jobUseCase.EnsureGenerateOutreachDraftTemplate(ctx); err != nil { panic(err) } - if err := jobUseCase.EnsureExpandCopyMissionGraphTemplate(ctx); err != nil { + if err := jobUseCase.EnsureGenerateTopicMatrixTemplate(ctx); err != nil { panic(err) } - if err := jobUseCase.EnsureGenerateCopyMatrixTemplate(ctx); err != nil { + if err := jobUseCase.EnsureGenerateFormulaDraftTemplate(ctx); err != nil { panic(err) } - if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil { + if err := jobUseCase.EnsureGenerateRewriteDraftTemplate(ctx); err != nil { panic(err) } if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil { @@ -206,7 +245,6 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil { panic(err) } - copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database()) if err := copyMissionRepository.EnsureIndexes(ctx); err != nil { panic(err) @@ -266,20 +304,81 @@ func NewServiceContext(c config.Config) *ServiceContext { ) threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database()) threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher) + publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) + publishQueueRepository := publishqueuerepo.NewMongoRepository(mongoClient.Database()) + publishInventoryRepository := publishinventoryrepo.NewMongoRepository(mongoClient.Database()) + publishGuardRepository := publishguardrepo.NewMongoRepository(mongoClient.Database()) + publishQueueEventRepository := publishqueueeventrepo.NewMongoRepository(mongoClient.Database()) + stylePresetRepository := stylepresetrepo.NewMongoRepository(mongoClient.Database()) + ownPostFormulaRepository := ownpostformularepo.NewMongoRepository(mongoClient.Database()) + contentFormulaRepository := contentformularepo.NewMongoRepository(mongoClient.Database()) + contentOpsRepository := contentopsrepo.NewMongoRepository(mongoClient.Database()) + mentionInboxRepository := mentioninboxrepo.NewMongoRepository(mongoClient.Database()) + var threadsOAuthStateStore threadsaccountrepodomain.OAuthStateStore + if redisClient != nil { + threadsOAuthStateStore = threadsaccountrepo.NewOAuthStateRedisStore(redisClient) + } + threadsOAuthConfig := libthreads.OAuthConfig{ + AppID: c.ThreadsOAuth.AppID, + AppSecret: c.ThreadsOAuth.AppSecret, + RedirectURI: c.ThreadsOAuth.RedirectURI, + SuccessRedirect: c.ThreadsOAuth.SuccessRedirect, + } if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil { panic(err) } if err := threadsAccountSecretsRepository.EnsureIndexes(ctx); err != nil { panic(err) } + if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishQueueRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishInventoryRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishGuardRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishQueueEventRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := stylePresetRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := ownPostFormulaRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := contentFormulaRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := contentOpsRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := mentionInboxRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + ownPostFormulaUseCase := ownpostformuladomain.NewUseCase(ownPostFormulaRepository) + contentFormulaUseCase := contentformulausecase.NewUseCase(contentFormulaRepository) + contentOpsUseCase := contentopsusecase.NewUseCase(contentOpsRepository) threadsAccountUseCase := threadsaccountusecase.NewUseCase( threadsAccountRepository, threadsAccountSecretsRepository, + threadsOAuthStateStore, memberUseCase, settingUseCase, personaUseCase, + publishQueueRepository, + publishAnalyticsRepository, + publishInventoryRepository, + publishGuardRepository, + publishQueueEventRepository, secretsCipher, + threadsOAuthConfig, ) + mentionInboxUseCase := mentioninboxusecase.NewUseCase(mentionInboxRepository, threadsAccountUseCase) placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher) @@ -289,36 +388,54 @@ func NewServiceContext(c config.Config) *ServiceContext { } crmContactUseCase := crmcontactusecase.NewUseCase(crmContactRepository) - publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) - if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { - panic(err) - } - publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository) + publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository, jobUseCase) + publishQueueEventUseCase := publishqueueeventusecase.NewUseCase(publishQueueEventRepository) + publishInventoryUseCase := publishinventoryusecase.NewUseCase(publishInventoryRepository, threadsAccountUseCase) + publishGuardUseCase := publishguardusecase.NewUseCase(publishGuardRepository, threadsAccountUseCase) + stylePresetUseCase := stylepresetusecase.NewUseCase(stylePresetRepository, personaUseCase) + + publishQueueUseCase := publishqueueusecase.NewUseCase( + publishQueueRepository, + threadsAccountUseCase, + publishAnalyticsUseCase, + publishGuardUseCase, + publishQueueEventUseCase, + copyDraftUseCase, + ) sc := &ServiceContext{ - Config: c, - Validator: validate.New(), - Mongo: mongoClient, - Redis: redisClient, - Setting: settingUseCase, - AI: aisettings.NewUseCase(), - Job: jobUseCase, - AuthToken: authTokenUseCase, - Member: memberUseCase, - Permission: permissionUseCase, - Persona: personaUseCase, - CopyMission: copyMissionUseCase, - Brand: brandUseCase, - PlacementTopic: placementTopicUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - Placement: placementUseCase, - ScanPost: scanPostUseCase, - OutreachDraft: outreachDraftUseCase, - ContentMatrix: contentMatrixUseCase, - CopyDraft: copyDraftUseCase, - ThreadsAccount: threadsAccountUseCase, - CrmContact: crmContactUseCase, - PublishAnalytics: publishAnalyticsUseCase, + Config: c, + Validator: validate.New(), + Mongo: mongoClient, + Redis: redisClient, + Setting: settingUseCase, + AI: aisettings.NewUseCase(), + Job: jobUseCase, + AuthToken: authTokenUseCase, + Member: memberUseCase, + Permission: permissionUseCase, + Persona: personaUseCase, + CopyMission: copyMissionUseCase, + Brand: brandUseCase, + PlacementTopic: placementTopicUseCase, + KnowledgeGraph: knowledgeGraphUseCase, + Placement: placementUseCase, + ScanPost: scanPostUseCase, + OutreachDraft: outreachDraftUseCase, + ContentMatrix: contentMatrixUseCase, + ContentOps: contentOpsUseCase, + CopyDraft: copyDraftUseCase, + ThreadsAccount: threadsAccountUseCase, + CrmContact: crmContactUseCase, + PublishAnalytics: publishAnalyticsUseCase, + PublishQueue: publishQueueUseCase, + PublishInventory: publishInventoryUseCase, + PublishGuard: publishGuardUseCase, + PublishQueueEvent: publishQueueEventUseCase, + StylePreset: stylePresetUseCase, + OwnPostFormula: ownPostFormulaUseCase, + ContentFormula: contentFormulaUseCase, + MentionInbox: mentionInboxUseCase, } hostname, _ := os.Hostname() @@ -362,40 +479,41 @@ func NewServiceContext(c config.Config) *ServiceContext { Placement: placementUseCase, AI: sc.AI, }) - jobworker.RegisterAnalyzeCopyMissionHandler(runner, jobworker.AnalyzeCopyMissionDeps{ + jobworker.RegisterGenerateOutreachDraftHandler(runner, jobworker.GenerateOutreachDraftDeps{ Jobs: jobUseCase, - CopyMission: copyMissionUseCase, + Brand: brandUseCase, Persona: personaUseCase, + ScanPost: scanPostUseCase, + KnowledgeGraph: knowledgeGraphUseCase, + ThreadsAccount: threadsAccountUseCase, + AI: sc.AI, + OutreachDraft: outreachDraftUseCase, + }) + jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{ + Jobs: jobUseCase, + Persona: personaUseCase, + CopyDraft: copyDraftUseCase, + ContentOps: contentOpsUseCase, ThreadsAccount: threadsAccountUseCase, Placement: placementUseCase, AI: sc.AI, }) - jobworker.RegisterExpandCopyMissionGraphHandler(runner, jobworker.ExpandCopyMissionGraphDeps{ + jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{ Jobs: jobUseCase, - CopyMission: copyMissionUseCase, Persona: personaUseCase, - KnowledgeGraph: knowledgeGraphUseCase, + ContentFormula: contentFormulaUseCase, + CopyDraft: copyDraftUseCase, ThreadsAccount: threadsAccountUseCase, Placement: placementUseCase, AI: sc.AI, }) - jobworker.RegisterGenerateCopyMatrixHandler(runner, jobworker.GenerateCopyMatrixDeps{ + jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{ Jobs: jobUseCase, - CopyMission: copyMissionUseCase, Persona: personaUseCase, - ScanPost: scanPostUseCase, - CopyDraft: copyDraftUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - ThreadsAccount: threadsAccountUseCase, - AI: sc.AI, - }) - jobworker.RegisterGenerateCopyDraftHandler(runner, jobworker.GenerateCopyDraftDeps{ - Jobs: jobUseCase, - CopyMission: copyMissionUseCase, - Persona: personaUseCase, - ScanPost: scanPostUseCase, + ContentFormula: contentFormulaUseCase, CopyDraft: copyDraftUseCase, ThreadsAccount: threadsAccountUseCase, + Placement: placementUseCase, AI: sc.AI, }) jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{ @@ -408,6 +526,10 @@ func NewServiceContext(c config.Config) *ServiceContext { PublishAnalytics: publishAnalyticsUseCase, }) go runner.Start(workerCtx) + } else if redisClient != nil { + inlineCtx, cancel := context.WithCancel(context.Background()) + sc.stopWorker = cancel + sc.startInlineCopyWorkers(inlineCtx, hostname) } if c.JobScheduler.Enabled && redisClient != nil { @@ -422,6 +544,44 @@ func NewServiceContext(c config.Config) *ServiceContext { go scheduler.Start(schedulerCtx, interval) } + if c.ThreadsTokenRefresh.Enabled && redisClient != nil { + leadDays := c.ThreadsTokenRefresh.LeadDays + if leadDays <= 0 { + leadDays = 7 + } + interval := time.Duration(c.ThreadsTokenRefresh.IntervalSeconds) * time.Second + if interval <= 0 { + interval = time.Hour + } + sweepCtx, cancel := context.WithCancel(context.Background()) + sc.stopThreadsTokenSweep = cancel + sweeper := jobworker.NewThreadsTokenSweeper( + threadsAccountUseCase, + jobUseCase, + time.Duration(leadDays)*24*time.Hour, + ) + go sweeper.Start(sweepCtx, interval) + } + + pqEnabled := c.ThreadsPublishQueue.Enabled + if !pqEnabled && c.ThreadsPublishQueue.IntervalSeconds == 0 && c.ThreadsPublishQueue.BatchSize == 0 { + pqEnabled = true + } + if pqEnabled { + interval := time.Duration(c.ThreadsPublishQueue.IntervalSeconds) * time.Second + if interval <= 0 { + interval = time.Minute + } + batchSize := c.ThreadsPublishQueue.BatchSize + if batchSize <= 0 { + batchSize = 20 + } + pqCtx, cancel := context.WithCancel(context.Background()) + sc.stopPublishQueueSweep = cancel + pqSweeper := jobworker.NewPublishQueueSweeper(publishQueueUseCase, batchSize) + go pqSweeper.Start(pqCtx, interval) + } + if c.JobReaper.Enabled && redisClient != nil { interval := time.Duration(c.JobReaper.IntervalSeconds) * time.Second if interval <= 0 { @@ -459,6 +619,9 @@ func (sc *ServiceContext) Close(ctx context.Context) { if sc.stopReaper != nil { sc.stopReaper() } + if sc.stopThreadsTokenSweep != nil { + sc.stopThreadsTokenSweep() + } _ = sc.Mongo.Close(ctx) if sc.Redis != nil { _ = sc.Redis.Close() diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 47efae0..4efaa08 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -44,6 +44,35 @@ type AIProvidersData struct { Providers []AIProviderOption `json:"providers"` } +type AnalyzeContentFormulaData struct { + Formula ContentFormulaData `json:"formula"` + Message string `json:"message"` +} + +type AnalyzeContentFormulaHandlerReq struct { + ThreadsAccountPath + AnalyzeContentFormulaReq +} + +type AnalyzeContentFormulaReq struct { + SourceType string `json:"source_type" validate:"required"` + PersonaID string `json:"persona_id,optional"` + PostText string `json:"post_text,optional"` + MediaID string `json:"media_id,optional"` + ScanPostID string `json:"scan_post_id,optional"` + Keyword string `json:"keyword,optional"` + SaveLabel string `json:"save_label,optional"` + AuthorName string `json:"author_name,optional"` + Permalink string `json:"permalink,optional"` + LikeCount int `json:"like_count,optional"` + ReplyCount int `json:"reply_count,optional"` + Views int `json:"views,optional"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Shares int `json:"shares,optional"` + ForceRefresh bool `json:"force_refresh,optional"` +} + type AnalyzeStyle8DData struct { PersonaID string `json:"persona_id"` PostCount int `json:"post_count"` @@ -134,6 +163,7 @@ type BrandProductData struct { ID string `json:"id"` Label string `json:"label"` ProductContext string `json:"product_context"` + PlacementURL string `json:"placement_url,omitempty"` MatchTags []string `json:"match_tags,omitempty"` CreateAt int64 `json:"create_at"` UpdateAt int64 `json:"update_at"` @@ -171,6 +201,67 @@ type ClaimWorkerJobReq struct { WorkerID string `json:"worker_id" validate:"required"` } +type ContentFormulaData struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + Label string `json:"label"` + SourceType string `json:"source_type"` + SourceRef string `json:"source_ref,omitempty"` + SourcePostText string `json:"source_post_text,omitempty"` + SourceAuthor string `json:"source_author,omitempty"` + SourcePermalink string `json:"source_permalink,omitempty"` + SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"` + Summary string `json:"summary"` + Wins []string `json:"wins,omitempty"` + Improvements []string `json:"improvements,omitempty"` + Formula string `json:"formula"` + PostTemplate string `json:"post_template"` + HookPattern string `json:"hook_pattern"` + Structure string `json:"structure"` + ReplicationTips []string `json:"replication_tips,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Tags []string `json:"tags,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type ContentFormulaItemPath struct { + ID string `path:"id" validate:"required"` + FormulaID string `path:"formulaId" validate:"required"` +} + +type ContentFormulaPath struct { + ID string `path:"id" validate:"required"` + FormulaID string `path:"formulaId" validate:"required"` +} + +type ContentFormulaSearchPostData struct { + Text string `json:"text"` + Author string `json:"author"` + Permalink string `json:"permalink,omitempty"` + MediaID string `json:"media_id,omitempty"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + EngagementScore int `json:"engagement_score"` +} + +type ContentFormulaSourceMetricsData struct { + LikeCount int `json:"like_count,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + Views int `json:"views,omitempty"` + RepostCount int `json:"repost_count,omitempty"` + QuoteCount int `json:"quote_count,omitempty"` + Shares int `json:"shares,omitempty"` +} + +type ContentInboxItemData struct { + CopyDraftData + ScheduledAt int64 `json:"scheduled_at,omitempty"` + Lifecycle string `json:"lifecycle"` + GroupDate int64 `json:"group_date"` + FormulaLabel string `json:"formula_label,omitempty"` +} + type ContentMatrixData struct { ID string `json:"id,omitempty"` BrandID string `json:"brand_id"` @@ -191,6 +282,33 @@ type ContentMatrixRowData struct { Rationale string `json:"rationale"` } +type ContentPlanData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + Topic string `json:"topic"` + Mission string `json:"mission"` + TargetAudience string `json:"target_audience,omitempty"` + Angle string `json:"angle,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + EndingType string `json:"ending_type,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + RequiresHumanReview bool `json:"requires_human_review,omitempty"` + Avoid []string `json:"avoid,omitempty"` + SelectedKnowledge []string `json:"selected_knowledge,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type ContentPlanPath struct { + ID string `path:"id" validate:"required"` + ContentPlanID string `path:"contentPlanId" validate:"required"` +} + type CopyAudienceSampleData struct { Username string `json:"username"` SamplePostId string `json:"sample_post_id,omitempty"` @@ -203,23 +321,35 @@ type CopyAudienceSampleData struct { } type CopyDraftData struct { - ID string `json:"id"` - PersonaID string `json:"persona_id"` - CopyMissionID string `json:"copy_mission_id,omitempty"` - ScanPostID string `json:"scan_post_id,omitempty"` - DraftType string `json:"draft_type"` - SortOrder int `json:"sort_order,omitempty"` - Text string `json:"text"` - Angle string `json:"angle,omitempty"` - Hook string `json:"hook,omitempty"` - Rationale string `json:"rationale,omitempty"` - ReferenceNotes string `json:"reference_notes,omitempty"` - Sources []string `json:"sources,omitempty"` - Status string `json:"status,omitempty"` - PublishedMediaID string `json:"published_media_id,omitempty"` - PublishedPermalink string `json:"published_permalink,omitempty"` - PublishedAt int64 `json:"published_at,omitempty"` - CreateAt int64 `json:"create_at"` + ID string `json:"id"` + PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + ScanPostID string `json:"scan_post_id,omitempty"` + FormulaID string `json:"formula_id,omitempty"` + DraftType string `json:"draft_type"` + SortOrder int `json:"sort_order,omitempty"` + Text string `json:"text"` + TopicTag string `json:"topic_tag,omitempty"` + Angle string `json:"angle,omitempty"` + Hook string `json:"hook,omitempty"` + Rationale string `json:"rationale,omitempty"` + ReferenceNotes string `json:"reference_notes,omitempty"` + Sources []string `json:"sources,omitempty"` + AiScore int `json:"ai_score,omitempty"` + FormulaScore int `json:"formula_score,omitempty"` + BrandFitScore int `json:"brand_fit_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + SimilarityScore int `json:"similarity_score,omitempty"` + EngagementPotential int `json:"engagement_potential,omitempty"` + FreshnessScore int `json:"freshness_score,omitempty"` + ReviewSuggestion string `json:"review_suggestion,omitempty"` + Status string `json:"status,omitempty"` + PublishQueueID string `json:"publish_queue_id,omitempty"` + PublishedMediaID string `json:"published_media_id,omitempty"` + PublishedPermalink string `json:"published_permalink,omitempty"` + PublishedAt int64 `json:"published_at,omitempty"` + CreateAt int64 `json:"create_at"` } type CopyDraftPath struct { @@ -242,14 +372,25 @@ type CopyMissionData struct { } type CopyMissionInspirationData struct { - Label string `json:"label"` - SeedQuery string `json:"seed_query"` - Brief string `json:"brief"` - TrendReason string `json:"trend_reason,omitempty"` - TrendKeywords []string `json:"trend_keywords,omitempty"` - Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"` - WebSearchUsed bool `json:"web_search_used"` - Message string `json:"message"` + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + ContentPlanID string `json:"content_plan_id,omitempty"` + Label string `json:"label"` + SeedQuery string `json:"seed_query"` + Brief string `json:"brief"` + TrendReason string `json:"trend_reason,omitempty"` + TrendKeywords []string `json:"trend_keywords,omitempty"` + Angles []string `json:"angles,omitempty"` + Mission string `json:"mission,omitempty"` + TargetAudience string `json:"target_audience,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"` + WebSearchUsed bool `json:"web_search_used"` + Message string `json:"message"` } type CopyMissionInspirationHandlerReq struct { @@ -258,7 +399,10 @@ type CopyMissionInspirationHandlerReq struct { } type CopyMissionInspirationReq struct { - Keyword string `json:"keyword,optional"` + Keyword string `json:"keyword,optional"` + ContentDirection string `json:"content_direction,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + AvoidTopics []string `json:"avoid_topics,optional"` } type CopyMissionInspirationSourceData struct { @@ -306,6 +450,21 @@ type CopyMissionScanScheduleData struct { LastRunAt int64 `json:"last_run_at,omitempty"` } +type CopyMissionScheduleCopyDraftsData struct { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` +} + +type CopyMissionScheduleCopyDraftsReq struct { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` +} + type CopyMissionSimilarAccountPath struct { PersonaID string `path:"personaId" validate:"required"` ID string `path:"id" validate:"required"` @@ -355,6 +514,7 @@ type CreateBrandProductHandlerReq struct { type CreateBrandProductReq struct { Label string `json:"label" validate:"required"` ProductContext string `json:"product_context" validate:"required"` + PlacementURL string `json:"placement_url,optional"` MatchTags []string `json:"match_tags,optional"` } @@ -362,6 +522,28 @@ type CreateBrandReq struct { DisplayName string `json:"display_name,optional"` } +type CreateContentPlanHandlerReq struct { + PersonaPath + CreateContentPlanReq +} + +type CreateContentPlanReq struct { + TopicCandidateID string `json:"topic_candidate_id,optional"` + Topic string `json:"topic" validate:"required"` + Mission string `json:"mission" validate:"required"` + TargetAudience string `json:"target_audience,optional"` + Angle string `json:"angle,optional"` + OpeningType string `json:"opening_type,optional"` + BodyType string `json:"body_type,optional"` + Emotion string `json:"emotion,optional"` + EndingType string `json:"ending_type,optional"` + CtaType string `json:"cta_type,optional"` + RiskLevel string `json:"risk_level,optional"` + RequiresHumanReview bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` +} + type CreateCopyMissionHandlerReq struct { PersonaCopyMissionsPath CreateCopyMissionReq @@ -373,6 +555,33 @@ type CreateCopyMissionReq struct { Brief string `json:"brief" validate:"required"` } +type CreateFeedbackEventHandlerReq struct { + PersonaPath + CreateFeedbackEventReq +} + +type CreateFeedbackEventReq struct { + ContentPlanID string `json:"content_plan_id,optional"` + DraftID string `json:"draft_id,optional"` + Decision string `json:"decision" validate:"required"` + Note string `json:"note,optional"` + Snapshot string `json:"snapshot,optional"` +} + +type CreateFormulaPoolHandlerReq struct { + PersonaPath + CreateFormulaPoolReq +} + +type CreateFormulaPoolReq struct { + Type string `json:"type" validate:"required"` + Name string `json:"name" validate:"required"` + Pattern string `json:"pattern,optional"` + UseCases []string `json:"use_cases,optional"` + Avoid []string `json:"avoid,optional"` + Weight int `json:"weight,optional"` +} + type CreateJobReq struct { TemplateType string `json:"template_type" validate:"required"` // job template type Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // job scope @@ -390,6 +599,19 @@ type CreateJobScheduleReq struct { Enabled bool `json:"enabled"` // enabled flag } +type CreateKnowledgeSourceHandlerReq struct { + PersonaPath + CreateKnowledgeSourceReq +} + +type CreateKnowledgeSourceReq struct { + SourceType string `json:"source_type" validate:"required"` + Title string `json:"title,optional"` + Filename string `json:"filename,optional"` + Content string `json:"content,optional"` + ContentBase64 string `json:"content_base64,optional"` +} + type CreatePersonaReq struct { DisplayName string `json:"display_name,optional"` } @@ -406,11 +628,27 @@ type CreatePlacementTopicReq struct { ProductID string `json:"product_id,optional"` } +type CreatePublishQueueHandlerReq struct { + ThreadsAccountPath + CreatePublishQueueReq +} + +type CreatePublishQueueReq struct { + Text string `json:"text" validate:"required"` + TopicTag string `json:"topic_tag,optional"` + ScheduledAt int64 `json:"scheduled_at,optional"` +} + type CreateThreadsAccountReq struct { DisplayName string `json:"display_name,optional"` Activate *bool `json:"activate,optional"` } +type DeleteContentFormulaData struct { + FormulaID string `json:"formula_id"` + Message string `json:"message"` +} + type DeleteCopyDraftData struct { DraftID string `json:"draft_id"` Message string `json:"message"` @@ -435,6 +673,12 @@ type DeletePlacementTopicScanPostHandlerReq struct { PostID string `path:"postId" validate:"required"` } +type DeleteThreadsAccountData struct { + DeletedID string `json:"deleted_id"` + ActiveAccountID string `json:"active_account_id,omitempty"` + Message string `json:"message"` +} + type ErrorDetail struct { BizCode string `json:"biz_code,optional"` Scope int64 `json:"scope,optional"` @@ -470,6 +714,30 @@ type ExpandPlacementTopicGraphHandlerReq struct { ExpandKnowledgeGraphReq } +type FeedbackEventData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Decision string `json:"decision"` + Note string `json:"note,omitempty"` + Snapshot string `json:"snapshot,omitempty"` + CreateAt int64 `json:"create_at"` +} + +type FormulaPoolData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Type string `json:"type"` + Name string `json:"name"` + Pattern string `json:"pattern,omitempty"` + UseCases []string `json:"use_cases,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Weight int `json:"weight,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + type GenerateContentMatrixHandlerReq struct { BrandPath GenerateContentMatrixReq @@ -493,6 +761,24 @@ type GenerateCopyMissionMatrixReq struct { Count int `json:"count,optional"` } +type GenerateFromContentFormulaData struct { + List []CopyDraftData `json:"list"` + Message string `json:"message"` +} + +type GenerateFromContentFormulaHandlerReq struct { + ContentFormulaPath + GenerateFromContentFormulaReq +} + +type GenerateFromContentFormulaReq struct { + AccountID string `json:"account_id" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` +} + type GenerateOutreachDraftsData struct { ID string `json:"id"` ScanPostID string `json:"scan_post_id"` @@ -513,6 +799,51 @@ type GenerateOutreachDraftsReq struct { Count int `json:"count,optional"` VoicePersonaID string `json:"voice_persona_id,optional"` ProductID string `json:"product_id,optional"` + Regenerate bool `json:"regenerate,optional"` +} + +type GenerateOwnPostFormulaData struct { + OwnPostFormulaReviewData +} + +type GenerateOwnPostFormulaHandlerReq struct { + ThreadsAccountPath + GenerateOwnPostFormulaReq +} + +type GenerateOwnPostFormulaReq struct { + MediaID string `json:"media_id"` + PersonaID string `json:"persona_id,optional"` + PostText string `json:"post_text,optional"` + TopicTag string `json:"topic_tag,optional"` + MediaType string `json:"media_type,optional"` + Timestamp string `json:"timestamp,optional"` + LikeCount int `json:"like_count,optional"` + ReplyCount int `json:"reply_count,optional"` + Views int `json:"views,optional"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Shares int `json:"shares,optional"` + Force bool `json:"force,optional"` +} + +type GenerateOwnPostReplyDraftData struct { + Text string `json:"text"` +} + +type GenerateOwnPostReplyDraftHandlerReq struct { + ThreadsAccountPath + GenerateOwnPostReplyDraftReq +} + +type GenerateOwnPostReplyDraftReq struct { + MediaID string `json:"media_id"` + PersonaID string `json:"persona_id,optional"` + ReplyToID string `json:"reply_to_id,optional"` + PostText string `json:"post_text,optional"` + ReplyText string `json:"reply_text,optional"` + ThreadPostText string `json:"thread_post_text,optional"` + ParentReplyText string `json:"parent_reply_text,optional"` } type GeneratePersonaCopyDraftData struct { @@ -529,6 +860,24 @@ type GeneratePersonaCopyDraftReq struct { ScanPostID string `json:"scan_post_id" validate:"required"` } +type GeneratePersonaTopicMatrixData struct { + List []CopyDraftData `json:"list"` + Message string `json:"message"` +} + +type GeneratePersonaTopicMatrixHandlerReq struct { + PersonaPath + GeneratePersonaTopicMatrixReq +} + +type GeneratePersonaTopicMatrixReq struct { + Topic string `json:"topic" validate:"required"` + ContentPlanID string `json:"content_plan_id,optional"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` +} + type GeneratePlacementTopicContentMatrixHandlerReq struct { PlacementTopicPath GenerateContentMatrixReq @@ -539,10 +888,24 @@ type GeneratePlacementTopicOutreachDraftsHandlerReq struct { GenerateOutreachDraftsReq } +type GetContentFormulaHandlerReq struct { + ContentFormulaItemPath +} + type HealthData struct { Pong string `json:"pong"` } +type ImportOwnPostFormulaHandlerReq struct { + ThreadsAccountPath + ImportOwnPostFormulaReq +} + +type ImportOwnPostFormulaReq struct { + MediaID string `json:"media_id" validate:"required"` + Label string `json:"label,optional"` +} + type ImportThreadsAccountSessionData struct { Success bool `json:"success"` Valid bool `json:"valid"` @@ -694,6 +1057,17 @@ type JobTemplateStepData struct { Cancelable bool `json:"cancelable"` } +type KnowledgeChunkData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceID string `json:"source_id"` + Content string `json:"content"` + Topics []string `json:"topics,omitempty"` + StyleTags []string `json:"style_tags,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + CreateAt int64 `json:"create_at"` +} + type KnowledgeGraphData struct { ID string `json:"id"` BrandID string `json:"brand_id"` @@ -742,6 +1116,17 @@ type KnowledgeGraphNodeUpdate struct { RecencyTags []string `json:"recency_tags,optional"` } +type KnowledgeSourceData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceType string `json:"source_type"` + Title string `json:"title,omitempty"` + Filename string `json:"filename,omitempty"` + ParsedStatus string `json:"parsed_status,omitempty"` + ChunkCount int `json:"chunk_count,omitempty"` + CreateAt int64 `json:"create_at"` +} + type ListBrandProductsData struct { List []BrandProductData `json:"list"` } @@ -767,6 +1152,27 @@ type ListBrandsData struct { List []BrandData `json:"list"` } +type ListContentFormulasData struct { + Pagination PaginationData `json:"pagination"` + List []ContentFormulaData `json:"list"` +} + +type ListContentFormulasHandlerReq struct { + ThreadsAccountPath + ListContentFormulasReq +} + +type ListContentFormulasReq struct { + Page int64 `form:"page,optional"` + PageSize int64 `form:"pageSize,optional"` + SourceType string `form:"source_type,optional"` + Tag string `form:"tag,optional"` +} + +type ListContentPlansData struct { + List []ContentPlanData `json:"list"` +} + type ListCopyMissionCopyDraftsData struct { List []CopyDraftData `json:"list"` Total int `json:"total"` @@ -785,6 +1191,10 @@ type ListCopyMissionsData struct { List []CopyMissionData `json:"list"` } +type ListFormulaPoolsData struct { + List []FormulaPoolData `json:"list"` +} + type ListJobEventsReq struct { ID string `path:"id" validate:"required"` // job run id Limit int64 `form:"limit,optional"` // max events @@ -804,6 +1214,57 @@ type ListJobsReq struct { PageSize int64 `form:"pageSize,optional"` // page size } +type ListKnowledgeChunksData struct { + List []KnowledgeChunkData `json:"list"` +} + +type ListKnowledgeSourcesData struct { + List []KnowledgeSourceData `json:"list"` +} + +type ListMentionInboxData struct { + List []MentionInboxItemData `json:"list"` + Pagination PaginationData `json:"pagination"` + ReadyTotal int64 `json:"ready_total"` + NewestSyncedAt int64 `json:"newest_synced_at,omitempty"` +} + +type ListMentionInboxHandlerReq struct { + ThreadsAccountPath + ListMentionInboxQuery +} + +type ListMentionInboxQuery struct { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` +} + +type ListOwnPostFormulasData struct { + List []OwnPostFormulaReviewData `json:"list"` +} + +type ListOwnPostFormulasHandlerReq struct { + ThreadsAccountPath +} + +type ListPersonaContentInboxData struct { + Pagination PaginationData `json:"pagination"` + List []ContentInboxItemData `json:"list"` +} + +type ListPersonaContentInboxHandlerReq struct { + PersonaPath + ListPersonaContentInboxReq +} + +type ListPersonaContentInboxReq struct { + Page int64 `form:"page,optional"` + PageSize int64 `form:"pageSize,optional"` + Status string `form:"status,optional"` + RangeStart int64 `form:"rangeStart,optional"` + RangeEnd int64 `form:"rangeEnd,optional"` +} + type ListPersonaCopyDraftsData struct { List []CopyDraftData `json:"list"` Total int `json:"total"` @@ -836,6 +1297,21 @@ type ListPlacementTopicsData struct { List []PlacementTopicData `json:"list"` } +type ListPublishQueueHandlerReq struct { + ThreadsAccountPath + ListPublishQueueQuery +} + +type ListPublishQueueQuery struct { + Status string `form:"status,optional"` + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` +} + +type ListStylePresetsData struct { + List []StylePresetData `json:"list"` +} + type ListThreadsAccountAiProviderModelsReq struct { ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key } @@ -845,6 +1321,33 @@ type ListThreadsAccountsData struct { ActiveAccountID string `json:"active_account_id"` } +type ListThreadsOAuthLogsData struct { + List []ThreadsOAuthLogItem `json:"list"` +} + +type ListThreadsPostPerformanceHandlerReq struct { + ThreadsAccountPath + ListThreadsPostPerformanceQuery +} + +type ListThreadsPostPerformanceQuery struct { + Limit int `form:"limit,optional"` +} + +type ListThreadsPublishHealthHandlerReq struct { + ThreadsAccountPath + ListThreadsPublishHealthQuery +} + +type ListThreadsPublishHealthQuery struct { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` +} + +type ListTopicCandidatesData struct { + List []TopicCandidateData `json:"list"` +} + type LogoutData struct { OK bool `json:"ok"` } @@ -901,6 +1404,27 @@ type MemberPlacementSettingsData struct { BraveSearchLang string `json:"brave_search_lang"` ExaUserLocation string `json:"exa_user_location"` ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid + DevModeEnabled bool `json:"dev_mode_enabled"` +} + +type MentionInboxItemData struct { + MediaID string `json:"media_id"` + AuthorUsername string `json:"author_username,omitempty"` + Text string `json:"text,omitempty"` + Permalink string `json:"permalink,omitempty"` + Shortcode string `json:"shortcode,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + MediaType string `json:"media_type,omitempty"` + IsReply bool `json:"is_reply"` + IsQuotePost bool `json:"is_quote_post"` + HasReplies bool `json:"has_replies"` + RootPostID string `json:"root_post_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` + ThreadPostText string `json:"thread_post_text,omitempty"` + ParentReplyText string `json:"parent_reply_text,omitempty"` + ReplyReady bool `json:"reply_ready"` + ReplyReadyMsg string `json:"reply_ready_msg,omitempty"` + SyncedAt int64 `json:"synced_at"` } type OutreachDraftItemData struct { @@ -909,6 +1433,21 @@ type OutreachDraftItemData struct { Rationale string `json:"rationale"` } +type OwnPostFormulaReviewData struct { + MediaID string `json:"media_id"` + ReviewedAt int64 `json:"reviewed_at"` + FromCache bool `json:"from_cache,omitempty"` + Summary string `json:"summary"` + Wins []string `json:"wins"` + Improvements []string `json:"improvements"` + Formula string `json:"formula"` + PostTemplate string `json:"post_template"` + HookPattern string `json:"hook_pattern"` + Structure string `json:"structure"` + ReplicationTips []string `json:"replication_tips"` + Avoid []string `json:"avoid"` +} + type PaginationData struct { Total int64 `json:"total"` Page int64 `json:"page"` @@ -925,6 +1464,20 @@ type PatchAudienceSampleReq struct { Status string `json:"status"` } +type PatchContentFormulaHandlerReq struct { + ContentFormulaItemPath + PatchContentFormulaReq +} + +type PatchContentFormulaReq struct { + Label *string `json:"label,optional"` + Formula *string `json:"formula,optional"` + PostTemplate *string `json:"post_template,optional"` + HookPattern *string `json:"hook_pattern,optional"` + Structure *string `json:"structure,optional"` + Tags []string `json:"tags,optional"` +} + type PatchCopyMissionGraphNodesHandlerReq struct { CopyMissionPath PatchKnowledgeGraphNodesReq @@ -950,6 +1503,17 @@ type PatchPlacementTopicScanPostOutreachHandlerReq struct { PatchScanPostOutreachReq } +type PatchPublishQueueHandlerReq struct { + PublishQueueItemPath + PatchPublishQueueReq +} + +type PatchPublishQueueReq struct { + Text *string `json:"text,optional"` + TopicTag *string `json:"topic_tag,optional"` + ScheduledAt *int64 `json:"scheduled_at,optional"` +} + type PatchScanPostOutreachHandlerReq struct { BrandPath PostID string `path:"postId"` @@ -1029,6 +1593,34 @@ type PlacementTopicPath struct { ID string `path:"id" validate:"required"` } +type PrunePersonaCopyDraftsData struct { + Deleted int64 `json:"deleted"` + Kept int `json:"kept"` + Message string `json:"message"` +} + +type PrunePersonaCopyDraftsHandlerReq struct { + PersonaPath + PrunePersonaCopyDraftsReq +} + +type PrunePersonaCopyDraftsReq struct { + Keep int `json:"keep,optional"` +} + +type PublishAlertData struct { + Type string `json:"type"` + Severity string `json:"severity"` + Message string `json:"message"` + QueueID string `json:"queue_id,omitempty"` + AccountID string `json:"account_id"` + CreateAt int64 `json:"create_at"` +} + +type PublishAlertsData struct { + List []PublishAlertData `json:"list"` +} + type PublishCopyDraftData struct { DraftID string `json:"draft_id"` MediaID string `json:"media_id"` @@ -1043,8 +1635,73 @@ type PublishCopyDraftHandlerReq struct { } type PublishCopyDraftReq struct { - Text string `json:"text,optional"` - Confirm bool `json:"confirm"` + Text string `json:"text,optional"` + TopicTag string `json:"topic_tag,optional"` + Confirm bool `json:"confirm"` +} + +type PublishDashboardAccountSummary struct { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + PendingScheduled int64 `json:"pending_scheduled"` + FailedCount int64 `json:"failed_count"` + Published7d int64 `json:"published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` + Paused bool `json:"paused"` + BestSlot string `json:"best_slot,omitempty"` + LowSlot string `json:"low_slot,omitempty"` +} + +type PublishDashboardSummaryData struct { + List []PublishDashboardAccountSummary `json:"list"` + TotalPending int64 `json:"total_pending"` + TotalFailed int64 `json:"total_failed"` + TotalPublished7d int64 `json:"total_published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` +} + +type PublishGuardPolicyData struct { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts"` + MinIntervalMinutes int `json:"min_interval_minutes"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"` + Paused bool `json:"paused"` + PausedReason string `json:"paused_reason,omitempty"` + UpdateAt int64 `json:"update_at"` +} + +type PublishInventoryPolicyData struct { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count"` + LowStockThreshold int `json:"low_stock_threshold"` + LookaheadDays int `json:"lookahead_days"` + Timezone string `json:"timezone"` + Slots []PublishSlotData `json:"slots"` + SourceRefs []PublishSourceRefData `json:"source_refs"` + UpdateAt int64 `json:"update_at"` +} + +type PublishMentionReplyData struct { + MediaID string `json:"media_id,omitempty"` + Permalink string `json:"permalink,omitempty"` +} + +type PublishMentionReplyHandlerReq struct { + PublishMentionReplyPath + PublishMentionReplyReq +} + +type PublishMentionReplyPath struct { + ID string `path:"id" validate:"required"` + MediaID string `path:"media_id" validate:"required"` +} + +type PublishMentionReplyReq struct { + Text string `json:"text"` } type PublishOutreachDraftData struct { @@ -1072,6 +1729,96 @@ type PublishPlacementTopicOutreachDraftHandlerReq struct { PublishOutreachDraftReq } +type PublishQueueEventData struct { + ID string `json:"id"` + QueueID string `json:"queue_id"` + EventType string `json:"event_type"` + FromStatus string `json:"from_status,omitempty"` + ToStatus string `json:"to_status,omitempty"` + Message string `json:"message,omitempty"` + CreateAt int64 `json:"create_at"` +} + +type PublishQueueEventsData struct { + List []PublishQueueEventData `json:"list"` +} + +type PublishQueueItemData struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + CopyDraftID string `json:"copy_draft_id,omitempty"` + Text string `json:"text"` + TopicTag string `json:"topic_tag,omitempty"` + ScheduledAt int64 `json:"scheduled_at"` + Status string `json:"status"` + MediaID string `json:"media_id,optional"` + Permalink string `json:"permalink,optional"` + PublishedAt int64 `json:"published_at,optional"` + ErrorMessage string `json:"error_message,optional"` + RetryCount int `json:"retry_count,omitempty"` + LastAttemptAt int64 `json:"last_attempt_at,omitempty"` + NextAttemptAt int64 `json:"next_attempt_at,omitempty"` + MissedAt int64 `json:"missed_at,omitempty"` + PausedReason string `json:"paused_reason,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type PublishQueueItemPath struct { + ID string `path:"id" validate:"required"` + QID string `path:"qid" validate:"required"` +} + +type PublishQueueListData struct { + Pagination PaginationData `json:"pagination"` + List []PublishQueueItemData `json:"list"` +} + +type PublishQueueRangeHandlerReq struct { + ThreadsAccountPath + PublishQueueRangeQuery +} + +type PublishQueueRangeQuery struct { + StartAt int64 `form:"startAt,optional"` + EndAt int64 `form:"endAt,optional"` + Status string `form:"status,optional"` +} + +type PublishSlotData struct { + Weekday int `json:"weekday"` + Time string `json:"time"` +} + +type PublishSlotInsightData struct { + Weekday int `json:"weekday"` + Time string `json:"time"` + AvgLikes1h int `json:"avg_likes_1h"` + AvgReplies1h int `json:"avg_replies_1h"` + AvgLikes24h int `json:"avg_likes_24h"` + AvgReplies24h int `json:"avg_replies_24h"` + AvgLikes7d int `json:"avg_likes_7d"` + AvgReplies7d int `json:"avg_replies_7d"` + SampleSize int `json:"sample_size"` + Recommendation string `json:"recommendation"` +} + +type PublishSlotInsightsData struct { + AccountID string `json:"account_id"` + Timezone string `json:"timezone"` + Slots []PublishSlotInsightData `json:"slots"` +} + +type PublishSourceRefData struct { + Type string `json:"type"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + ScanPostID string `json:"scan_post_id,omitempty"` + ManualSeed string `json:"manual_seed,omitempty"` +} + type ResearchItemData struct { Title string `json:"title,omitempty"` URL string `json:"url,omitempty"` @@ -1090,6 +1837,36 @@ type ResearchMapData struct { PatrolKeywords []string `json:"patrol_keywords,omitempty"` } +type RunThreadsAPIPlaygroundHandlerReq struct { + ThreadsAccountPath + RunThreadsAPIPlaygroundReq +} + +type RunThreadsAPIPlaygroundReq struct { + Action string `json:"action" validate:"required"` + Query string `json:"query,optional"` + Username string `json:"username,optional"` + MediaID string `json:"media_id,optional"` + ReplyID string `json:"reply_id,optional"` + ReplyToID string `json:"reply_to_id,optional"` + Text string `json:"text,optional"` + Permalink string `json:"permalink,optional"` + HintText string `json:"hint_text,optional"` + SkipPrime bool `json:"skip_prime,optional"` + LocationQuery string `json:"location_query,optional"` + Limit int `json:"limit,optional"` + Hide bool `json:"hide,optional"` + SearchType string `json:"search_type,optional"` +} + +type RunThreadsAPISmokeTestData struct { + List []ThreadsAPISmokeTestItem `json:"list"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Total int `json:"total"` +} + type ScanPostData struct { ID string `json:"id"` GraphNodeID string `json:"graph_node_id"` @@ -1110,6 +1887,7 @@ type ScanPostData struct { PublishedPermalink string `json:"published_permalink,omitempty"` OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"` PostedAt string `json:"posted_at,omitempty"` + RecencyDays int `json:"recency_days,omitempty"` Replies []ScanReplyData `json:"replies,omitempty"` LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"` CreateAt int64 `json:"create_at"` @@ -1124,6 +1902,58 @@ type ScanReplyData struct { PostedAt string `json:"posted_at,omitempty"` } +type ScheduleCopyDraftsData struct { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` +} + +type ScheduleCopyDraftsReq struct { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` +} + +type ScheduleCopyMissionDraftsHandlerReq struct { + CopyMissionPath + CopyMissionScheduleCopyDraftsReq +} + +type SchedulePersonaCopyDraftHandlerReq struct { + CopyDraftPath + SchedulePersonaCopyDraftReq +} + +type SchedulePersonaCopyDraftReq struct { + AccountID string `json:"account_id" validate:"required"` + TopicTag string `json:"topic_tag,optional"` + ScheduledAt int64 `json:"scheduled_at,optional"` +} + +type SchedulePersonaDraftsHandlerReq struct { + PersonaPath + ScheduleCopyDraftsReq +} + +type SearchContentFormulaPostsData struct { + List []ContentFormulaSearchPostData `json:"list"` + Message string `json:"message"` +} + +type SearchContentFormulaPostsHandlerReq struct { + ThreadsAccountPath + SearchContentFormulaPostsReq +} + +type SearchContentFormulaPostsReq struct { + Keyword string `json:"keyword" validate:"required"` + SearchType string `json:"search_type,optional"` + Limit int `json:"limit,optional"` +} + type SettingData struct { ID string `json:"id"` Scope string `json:"scope"` @@ -1177,6 +2007,7 @@ type StartBrandScanJobReq struct { NodeIDs []string `json:"node_ids,optional"` DualTrack bool `json:"dual_track,optional"` PatrolMode bool `json:"patrol_mode,optional"` + TestPatrol bool `json:"test_patrol,optional"` PatrolKeywords []string `json:"patrol_keywords,optional"` } @@ -1222,12 +2053,74 @@ type StartCopyMissionScanJobData struct { Message string `json:"message"` } +type StartPersonaFormulaDraftJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` +} + +type StartPersonaFormulaDraftJobHandlerReq struct { + PersonaPath + StartPersonaFormulaDraftJobReq +} + +type StartPersonaFormulaDraftJobReq struct { + AccountID string `json:"account_id" validate:"required"` + FormulaID string `json:"formula_id" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` +} + +type StartPersonaRewriteDraftJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` +} + +type StartPersonaRewriteDraftJobHandlerReq struct { + PersonaPath + StartPersonaRewriteDraftJobReq +} + +type StartPersonaRewriteDraftJobReq struct { + AccountID string `json:"account_id" validate:"required"` + ReferenceText string `json:"reference_text" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + SaveLabel string `json:"save_label,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` +} + type StartPersonaStyleAnalysisData struct { JobID string `json:"job_id"` Status string `json:"status"` Message string `json:"message"` } +type StartPersonaStyleAnalysisFromTextData struct { + Persona PersonaData `json:"persona"` + PostCount int `json:"post_count"` + Message string `json:"message"` +} + +type StartPersonaStyleAnalysisFromTextHandlerReq struct { + PersonaPath + StartPersonaStyleAnalysisFromTextReq +} + +type StartPersonaStyleAnalysisFromTextReq struct { + ReferenceTexts []string `json:"reference_texts,optional"` + RawText string `json:"raw_text,optional"` + SourceLabel string `json:"source_label,optional"` +} + type StartPersonaStyleAnalysisHandlerReq struct { PersonaPath StartPersonaStyleAnalysisReq @@ -1237,6 +2130,19 @@ type StartPersonaStyleAnalysisReq struct { BenchmarkUsername string `json:"benchmark_username" validate:"required"` } +type StartPersonaTopicMatrixJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` +} + +type StartPersonaTopicMatrixJobHandlerReq struct { + PersonaPath + GeneratePersonaTopicMatrixReq +} + type StartPersonaViralScanJobData struct { JobID string `json:"job_id"` Status string `json:"status"` @@ -1252,11 +2158,55 @@ type StartPersonaViralScanJobReq struct { Keywords []string `json:"keywords,optional"` } +type StartPlacementTopicOutreachDraftJobHandlerReq struct { + PlacementTopicPath + StartPlacementTopicOutreachDraftJobReq +} + +type StartPlacementTopicOutreachDraftJobReq struct { + ScanPostID string `json:"scan_post_id,optional"` + ScanPostIDs []string `json:"scan_post_ids,optional"` + Count int `json:"count,optional"` + VoicePersonaID string `json:"voice_persona_id,optional"` + ProductID string `json:"product_id,optional"` + Regenerate bool `json:"regenerate,optional"` +} + +type StartPlacementTopicOutreachDraftJobsData struct { + Jobs []StartBrandScanJobData `json:"jobs"` + Message string `json:"message"` +} + type StartPlacementTopicScanJobHandlerReq struct { PlacementTopicPath StartBrandScanJobReq } +type StartPublishInventoryRefillJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + Message string `json:"message"` +} + +type StartPublishInventoryRefillJobHandlerReq struct { + ThreadsAccountPath + StartPublishInventoryRefillJobReq +} + +type StartPublishInventoryRefillJobReq struct { + Count int `json:"count,optional"` +} + +type StartThreadsOAuthData struct { + AuthorizeURL string `json:"authorize_url"` + State string `json:"state"` + AccountID string `json:"account_id"` +} + +type StartThreadsOAuthQuery struct { + AccountID string `form:"account_id,optional"` +} + type Status struct { Code int64 `json:"code"` Message string `json:"message"` @@ -1277,6 +2227,54 @@ type StorePersonaStyleProfileReq struct { StyleBenchmark string `json:"style_benchmark,optional"` } +type StylePresetData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Tone string `json:"tone,omitempty"` + CTA []string `json:"cta,omitempty"` + BannedWords []string `json:"banned_words,omitempty"` + Notes string `json:"notes,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type StylePresetPath struct { + ID string `path:"id" validate:"required"` + PresetID string `path:"presetId" validate:"required"` +} + +type SyncMentionInboxData struct { + Synced int `json:"synced"` + Ready int `json:"ready"` + Total int64 `json:"total"` +} + +type SyncMentionInboxHandlerReq struct { + ThreadsAccountPath + SyncMentionInboxReq +} + +type SyncMentionInboxReq struct { + Limit int `json:"limit,optional"` + MaxPages int `json:"max_pages,optional"` +} + +type ThreadsAPIPlaygroundData struct { + Action string `json:"action"` + Ok bool `json:"ok"` + Message string `json:"message"` + Data string `json:"data,optional"` +} + +type ThreadsAPISmokeTestItem struct { + Scope string `json:"scope"` + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` + Count int `json:"count"` +} + type ThreadsAccountAiProviderModelsData struct { ID string `json:"id"` Label string `json:"label"` @@ -1305,28 +2303,6 @@ type ThreadsAccountAiSettingsData struct { ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"` } -type ThreadsAccountAiProviderPath struct { - ID string `path:"id" validate:"required"` - Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` -} - -type ListThreadsAccountAiProviderModelsReq struct { - ApiKey string `json:"api_key,optional"` -} - -type ThreadsAccountAiProviderModelsHandlerReq struct { - ThreadsAccountAiProviderPath - ListThreadsAccountAiProviderModelsReq -} - -type ThreadsAccountAiProviderModelsData struct { - ID string `json:"id"` - Label string `json:"label"` - Models []string `json:"models"` - Streams bool `json:"streams"` - Error string `json:"error,optional"` -} - type ThreadsAccountConnectionData struct { AccountID string `json:"account_id"` AccountName string `json:"account_name"` @@ -1348,22 +2324,164 @@ type ThreadsAccountConnectionPrefs struct { } type ThreadsAccountData struct { - ID string `json:"id"` - DisplayName string `json:"display_name,omitempty"` - Username string `json:"username,omitempty"` - ThreadsUserID string `json:"threads_user_id,omitempty"` - PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account - BrowserConnected bool `json:"browser_connected"` - ApiConnected bool `json:"api_connected"` - Status string `json:"status"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Username string `json:"username,omitempty"` + ThreadsUserID string `json:"threads_user_id,omitempty"` + PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account + BrowserConnected bool `json:"browser_connected"` + ApiConnected bool `json:"api_connected"` + APITokenExpiresAt int64 `json:"api_token_expires_at,omitempty"` + Status string `json:"status"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type ThreadsAccountInsightsBlock struct { + Status string `json:"status"` + Message string `json:"message,optional"` + Metrics []ThreadsInsightMetricItem `json:"metrics,optional"` } type ThreadsAccountPath struct { ID string `path:"id" validate:"required"` } +type ThreadsDiagnosticsData struct { + AccountID string `json:"account_id"` + CheckedAt int64 `json:"checked_at"` + ApiConnected bool `json:"api_connected"` + TokenExpiresAt int64 `json:"token_expires_at,omitempty"` + Scopes []string `json:"scopes"` + Items []ThreadsAPISmokeTestItem `json:"items"` + Message string `json:"message"` +} + +type ThreadsInsightMetricItem struct { + Name string `json:"name"` + Value int `json:"value"` +} + +type ThreadsOAuthCallbackQuery struct { + Code string `form:"code,optional"` + State string `form:"state,optional"` + Error string `form:"error,optional"` + ErrorReason string `form:"error_reason,optional"` + ErrorDescription string `form:"error_description,optional"` +} + +type ThreadsOAuthConfigData struct { + RedirectURI string `json:"redirect_uri"` + SuccessRedirect string `json:"success_redirect"` + Enabled bool `json:"enabled"` + RequiresHTTPS bool `json:"requires_https"` +} + +type ThreadsOAuthLogItem struct { + At int64 `json:"at"` + Level string `json:"level"` + Stage string `json:"stage"` + AccountID string `json:"account_id,omitempty"` + Message string `json:"message"` +} + +type ThreadsPostMetricCapability struct { + Scope string `json:"scope"` + Name string `json:"name"` + Metrics []string `json:"metrics"` + Trackable bool `json:"trackable"` + Note string `json:"note,optional"` +} + +type ThreadsPostPerformanceData struct { + AccountID string `json:"account_id"` + Username string `json:"username,optional"` + ThreadsUserID string `json:"threads_user_id,optional"` + FetchedAt int64 `json:"fetched_at"` + Capabilities []ThreadsPostMetricCapability `json:"capabilities"` + AccountInsights ThreadsAccountInsightsBlock `json:"account_insights"` + Posts []ThreadsPostPerformanceItem `json:"posts"` +} + +type ThreadsPostPerformanceItem struct { + MediaID string `json:"media_id"` + Text string `json:"text,optional"` + Permalink string `json:"permalink,optional"` + Timestamp string `json:"timestamp,optional"` + MediaType string `json:"media_type,optional"` + MediaURL string `json:"media_url,optional"` + ThumbnailURL string `json:"thumbnail_url,optional"` + TopicTag string `json:"topic_tag,optional"` + Shortcode string `json:"shortcode,optional"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Views int `json:"views,optional"` + Shares int `json:"shares,optional"` + InsightsStatus string `json:"insights_status,optional"` + InsightsMessage string `json:"insights_message,optional"` +} + +type ThreadsPublishHealthCheckpoint struct { + Checkpoint string `json:"checkpoint"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` + Views int `json:"views,optional"` + CheckedAt int64 `json:"checked_at"` +} + +type ThreadsPublishHealthData struct { + Summary ThreadsPublishHealthSummary `json:"summary"` + Pagination PaginationData `json:"pagination"` + List []ThreadsPublishHealthItem `json:"list"` +} + +type ThreadsPublishHealthItem struct { + MediaID string `json:"media_id"` + Permalink string `json:"permalink,optional"` + Text string `json:"text,optional"` + PublishedAt int64 `json:"published_at"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count,optional"` + QuoteCount int `json:"quote_count,optional"` + Views int `json:"views,optional"` + Shares int `json:"shares,optional"` + Checkpoints []ThreadsPublishHealthCheckpoint `json:"checkpoints"` +} + +type ThreadsPublishHealthSummary struct { + PendingScheduled int64 `json:"pending_scheduled"` + FailedCount int64 `json:"failed_count"` + Published7d int64 `json:"published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` +} + +type TopicCandidateData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Source string `json:"source,omitempty"` + Category string `json:"category,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + TrendReason string `json:"trend_reason,omitempty"` + TrendKeywords []string `json:"trend_keywords,omitempty"` + HeatScore int `json:"heat_score,omitempty"` + FitScore int `json:"fit_score,omitempty"` + InteractionScore int `json:"interaction_score,omitempty"` + ExtendScore int `json:"extend_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + FinalScore float64 `json:"final_score,omitempty"` + RecommendedMissions []string `json:"recommended_missions,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + type UpdateBrandHandlerReq struct { BrandPath UpdateBrandReq @@ -1377,6 +2495,7 @@ type UpdateBrandProductHandlerReq struct { type UpdateBrandProductReq struct { Label *string `json:"label,optional"` ProductContext *string `json:"product_context,optional"` + PlacementURL string `json:"placement_url,optional"` MatchTags []string `json:"match_tags,optional"` } @@ -1398,16 +2517,40 @@ type UpdateBrandReq struct { PatrolKeywords []string `json:"patrol_keywords,optional"` } +type UpdateContentPlanHandlerReq struct { + ContentPlanPath + UpdateContentPlanReq +} + +type UpdateContentPlanReq struct { + Topic *string `json:"topic,optional"` + Mission *string `json:"mission,optional"` + TargetAudience *string `json:"target_audience,optional"` + Angle *string `json:"angle,optional"` + OpeningType *string `json:"opening_type,optional"` + BodyType *string `json:"body_type,optional"` + Emotion *string `json:"emotion,optional"` + EndingType *string `json:"ending_type,optional"` + CtaType *string `json:"cta_type,optional"` + RiskLevel *string `json:"risk_level,optional"` + RequiresHumanReview *bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` + Status *string `json:"status,optional"` +} + type UpdateCopyDraftHandlerReq struct { CopyDraftPath UpdateCopyDraftReq } type UpdateCopyDraftReq struct { - Text *string `json:"text,optional"` - Hook *string `json:"hook,optional"` - Angle *string `json:"angle,optional"` - Status *string `json:"status,optional"` + Text *string `json:"text,optional"` + TopicTag *string `json:"topic_tag,optional"` + Hook *string `json:"hook,optional"` + Angle *string `json:"angle,optional"` + Rationale *string `json:"rationale,optional"` + Status *string `json:"status,optional"` } type UpdateCopyMissionHandlerReq struct { @@ -1455,6 +2598,18 @@ type UpdateMemberPlacementSettingsReq struct { BraveSearchLang *string `json:"brave_search_lang,optional"` ExaUserLocation *string `json:"exa_user_location,optional"` ExpandStrategy *string `json:"expand_strategy,optional"` + DevModeEnabled *bool `json:"dev_mode_enabled,optional"` +} + +type UpdateOutreachDraftHandlerReq struct { + BrandPath + DraftID string `path:"draftId" validate:"required"` + UpdateOutreachDraftReq +} + +type UpdateOutreachDraftReq struct { + DraftIndex int `json:"draft_index"` + Text string `json:"text" validate:"required"` } type UpdatePersonaHandlerReq struct { @@ -1475,6 +2630,12 @@ type UpdatePlacementTopicHandlerReq struct { UpdatePlacementTopicReq } +type UpdatePlacementTopicOutreachDraftHandlerReq struct { + PlacementTopicPath + DraftID string `path:"draftId" validate:"required"` + UpdateOutreachDraftReq +} + type UpdatePlacementTopicReq struct { BrandID *string `json:"brand_id,optional"` TopicName *string `json:"topic_name,optional"` @@ -1570,95 +2731,47 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct { UpsertBrandScanScheduleReq } -type CrmContactData struct { - ID string `json:"id"` - BrandID string `json:"brand_id,omitempty"` - AuthorID string `json:"author_id"` - AuthorName string `json:"author_name"` - AuthorAvatar string `json:"author_avatar,omitempty"` - AuthorFollowers int `json:"author_followers,omitempty"` - ScanPostID string `json:"scan_post_id,omitempty"` - Notes string `json:"notes,omitempty"` - Tags []string `json:"tags,omitempty"` - Status string `json:"status"` - OutreachCount int `json:"outreach_count,omitempty"` - LastContactedAt int64 `json:"last_contacted_at,omitempty"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` +type UpsertPublishGuardPolicyHandlerReq struct { + ThreadsAccountPath + UpsertPublishGuardPolicyReq } -type CrmContactListData struct { - Pagination PaginationData `json:"pagination"` - List []CrmContactData `json:"list"` +type UpsertPublishGuardPolicyReq struct { + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts,optional"` + MinIntervalMinutes int `json:"min_interval_minutes,optional"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"` + Paused *bool `json:"paused,optional"` + PausedReason string `json:"paused_reason,optional"` } -type CrmContactIDPath struct { - ID string `path:"id" validate:"required"` +type UpsertPublishInventoryPolicyHandlerReq struct { + ThreadsAccountPath + UpsertPublishInventoryPolicyReq } -type ListCrmContactsReq struct { - BrandID string `form:"brand_id,optional"` - Status string `form:"status,optional"` - Tag string `form:"tag,optional"` - Page int `form:"page,optional"` - PageSize int `form:"pageSize,optional"` +type UpsertPublishInventoryPolicyReq struct { + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count,optional"` + LowStockThreshold int `json:"low_stock_threshold,optional"` + LookaheadDays int `json:"lookahead_days,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + SourceRefs []PublishSourceRefData `json:"source_refs,optional"` } -type CreateCrmContactReq struct { - BrandID string `json:"brand_id,omitempty"` - AuthorID string `json:"author_id" validate:"required"` - AuthorName string `json:"author_name" validate:"required"` +type UpsertStylePresetHandlerReq struct { + StylePresetPath + UpsertStylePresetReq } -type UpdateCrmContactReq struct { - CrmContactIDPath - Notes *string `json:"notes,optional"` - Tags *[]string `json:"tags,optional"` - Status *string `json:"status,optional"` -} - -type PublishAnalyticsData struct { - ID string `json:"id"` - MediaID string `json:"media_id"` - Permalink string `json:"permalink,omitempty"` - PublishedAt int64 `json:"published_at"` - Checkpoint string `json:"checkpoint"` - LikeCount int `json:"like_count,omitempty"` - ReplyCount int `json:"reply_count,omitempty"` - RepostCount int `json:"repost_count,omitempty"` - QuoteCount int `json:"quote_count,omitempty"` - ImpressionCount int `json:"impression_count,omitempty"` - ReachCount int `json:"reach_count,omitempty"` - CheckedAt int64 `json:"checked_at"` - CreateAt int64 `json:"create_at"` -} - -type PublishAnalyticsListData struct { - List []PublishAnalyticsData `json:"list"` -} - -type LookalikeReq struct { - SourcePostID string `json:"source_post_id" validate:"required"` - BrandID string `json:"brand_id,optional"` - Limit int `json:"limit,optional"` -} - -type LookalikeItemData struct { - PostID string `json:"post_id"` - AuthorID string `json:"author_id"` - AuthorName string `json:"author_name"` - AuthorAvatar string `json:"author_avatar,omitempty"` - Permalink string `json:"permalink"` - Text string `json:"text"` - PlacementScore int `json:"placement_score"` - SemanticScore int `json:"semantic_score"` - EngagementPredicted int `json:"engagement_predicted"` - AudienceQuality int `json:"audience_quality"` - Similarity float64 `json:"similarity"` -} - -type LookalikeData struct { - List []LookalikeItemData `json:"list"` +type UpsertStylePresetReq struct { + Name string `json:"name" validate:"required"` + Tone string `json:"tone,optional"` + CTA []string `json:"cta,optional"` + BannedWords []string `json:"banned_words,optional"` + Notes string `json:"notes,optional"` + Apply bool `json:"apply,optional"` } type ViralScanPostData struct { diff --git a/backend/internal/worker/job/analyze_copy_mission.go b/backend/internal/worker/job/analyze_copy_mission.go deleted file mode 100644 index cf94d4b..0000000 --- a/backend/internal/worker/job/analyze_copy_mission.go +++ /dev/null @@ -1,390 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/placement" - libviral "haixun-backend/internal/library/viral" - "haixun-backend/internal/library/websearch" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - placementusecase "haixun-backend/internal/model/placement/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" -) - -type AnalyzeCopyMissionDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase - Placement placementusecase.UseCase - AI aiusecase.UseCase -} - -func RegisterAnalyzeCopyMissionHandler(runner *Runner, deps AnalyzeCopyMissionDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("copy_mission_map", func(ctx context.Context, step StepContext) error { - return runAnalyzeCopyMission(ctx, step, deps) - }) -} - -func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCopyMissionDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := stringField(payload, "copy_mission_id") - if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { - return fmt.Errorf("analyze-copy-mission payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") - } - - mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) - if err != nil { - return err - } - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "copy_mission_map", - Summary: summary, - Percentage: percentage, - }) - } - - updateProgress("產生拷貝任務研究地圖…", 15) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - - systemPrompt := libviral.BuildMissionResearchMapSystemPrompt() - userPrompt := libviral.BuildMissionResearchMapUserPrompt(libviral.CopyResearchMapInput{ - Label: mission.Label, - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - Persona: persona.Persona, - StyleBenchmark: persona.StyleBenchmark, - PersonaAudienceSummary: persona.CopyResearchMap.AudienceSummary, - PersonaContentGoal: persona.CopyResearchMap.ContentGoal, - PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...), - PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), - }) - // ResearchGenerateRequest raises max_tokens so the full research map JSON is - // not truncated mid-object (truncation is the main cause of parse failures). - result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - })) - if err != nil { - return err - } - - parsed, err := libviral.ParseMissionResearchMapOutput(result.Text) - if err != nil { - // Model sometimes replies with reasoning/prose and no JSON object. - // Retry once, explicitly demanding a raw JSON object. - jsonOnlyResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - {Role: "assistant", Content: result.Text}, - {Role: "user", Content: libviral.MissionResearchMapJSONOnlyRetryPrompt()}, - }, - })) - if retryErr != nil { - return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error()) - } - parsed, err = libviral.ParseMissionResearchMapOutput(jsonOnlyResult.Text) - if err != nil { - return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error()) - } - } - - entityTags := make([]missionentity.SuggestedTag, 0, len(parsed.SuggestedTags)) - for _, tag := range parsed.SuggestedTags { - entityTags = append(entityTags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - researchMap := missionentity.ResearchMap{ - AudienceSummary: parsed.AudienceSummary, - ContentGoal: parsed.ContentGoal, - Questions: parsed.Questions, - Pillars: parsed.Pillars, - Exclusions: parsed.Exclusions, - KnowledgeItems: baseKnowledgeItems(parsed), - BenchmarkNotes: parsed.BenchmarkNotes, - SimilarAccounts: similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts), - } - researchMap.SuggestedTags = entityTags - updateProgress("補充相似帳號與延伸知識…", 70) - enrichMissionResearchMap(ctx, deps, tenantID, ownerUID, mission, &researchMap) - if len(researchMap.SelectedKnowledgeItems) == 0 { - researchMap.SelectedKnowledgeItems = defaultSelectedKnowledgeItems(researchMap.KnowledgeItems) - } - selected := libviral.PickDefaultSelectedTags(parsed.SuggestedTags) - selected = ensureSeedQuerySelected(mission.SeedQuery, selected) - - mapped := missionentity.StatusMapped - updateProgress("儲存研究地圖與預設標籤…", 85) - _, err = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - ResearchMap: &researchMap, - SelectedTagsSet: true, - SelectedTags: selected, - Status: &mapped, - }, - }) - if err != nil { - return err - } - - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "summary": fmt.Sprintf("研究地圖就緒,已預選 %d 個搜尋標籤", len(selected)), - "next_route": fmt.Sprintf("/matrix/missions/%s", missionID), - } - - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "tag_count": len(entityTags), - "account_count": 0, - "selected_count": len(selected), - "handoff": handoff, - }, - }) - return err -} - -func enrichMissionResearchMap( - ctx context.Context, - deps AnalyzeCopyMissionDeps, - tenantID, ownerUID string, - mission *missiondomain.MissionSummary, - researchMap *missionentity.ResearchMap, -) { - if mission == nil || researchMap == nil || deps.Placement == nil || deps.ThreadsAccount == nil { - return - } - settings, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) - if err != nil || !placement.WebSearchAvailable(settings) { - return - } - memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, settings) - if err != nil || memberCtx.WebSearchAPIKey() == "" { - return - } - client := websearch.New(memberCtx.WebSearchConfig()) - if !client.Enabled() { - return - } - if len(researchMap.SimilarAccounts) < libviral.MaxSimilarAccounts { - webAccounts, err := libviral.DiscoverSimilarAccounts(ctx, client, libviral.DiscoverAccountsInput{ - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - Pillars: researchMap.Pillars, - }) - if err == nil && len(webAccounts) > 0 { - researchMap.SimilarAccounts = libviral.MergeSimilarAccounts( - researchMap.SimilarAccounts, - similarAccountEntitiesFromWeb(webAccounts), - ) - } - } - if notes := collectMissionKnowledgeNotes(ctx, client, memberCtx, mission, researchMap); len(notes) > 0 { - researchMap.KnowledgeItems = mergeStringLists(researchMap.KnowledgeItems, notes) - } -} - -func similarAccountEntitiesFromWeb(in []libviral.SimilarAccount) []missionentity.SimilarAccount { - out := make([]missionentity.SimilarAccount, 0, len(in)) - for _, item := range in { - out = append(out, missionentity.SimilarAccount{ - Username: item.Username, - Reason: item.Reason, - Source: item.Source, - MatchedSource: item.MatchedSource, - Confidence: item.Confidence, - Status: missionentity.SimilarAccountStatusRecommended, - ProfileURL: item.ProfileURL, - }) - } - return out -} - -func collectMissionKnowledgeNotes( - ctx context.Context, - client websearch.Client, - member placement.MemberContext, - mission *missiondomain.MissionSummary, - researchMap *missionentity.ResearchMap, -) []string { - seed := strings.TrimSpace(mission.SeedQuery) - if seed == "" { - return nil - } - queries := []string{seed + " 重點 懶人包"} - if len(researchMap.Questions) > 0 { - if q := strings.TrimSpace(researchMap.Questions[0]); q != "" && q != seed { - queries = append(queries, q) - } - } - seen := map[string]struct{}{} - lines := []string{} - for _, query := range queries { - if len(lines) >= 5 { - break - } - res, err := client.Search(ctx, websearch.SearchOptions{ - Query: query, - Limit: 4, - Mode: websearch.ModeKnowledgeExpand, - Country: member.BraveCountry, - SearchLang: member.BraveSearchLang, - UserLocation: member.ExaUserLocation, - }) - if err != nil || res.Status != "success" { - continue - } - for _, item := range res.Results { - if len(lines) >= 5 { - break - } - title := strings.TrimSpace(item.Title) - snippet := strings.TrimSpace(item.Snippet) - if title == "" && snippet == "" { - continue - } - key := strings.ToLower(title + "|" + snippet) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - line := title - if snippet != "" { - if len([]rune(snippet)) > 120 { - snippet = string([]rune(snippet)[:120]) - } - line += ":" + snippet - } - lines = append(lines, line) - } - } - if len(lines) == 0 { - return nil - } - return lines -} - -func baseKnowledgeItems(parsed libviral.MissionResearchMap) []string { - items := []string{} - for _, pillar := range parsed.Pillars { - if strings.TrimSpace(pillar) != "" { - items = append(items, "內容支柱:"+strings.TrimSpace(pillar)) - } - } - for _, question := range parsed.Questions { - if strings.TrimSpace(question) != "" { - items = append(items, "受眾問題:"+strings.TrimSpace(question)) - } - } - if strings.TrimSpace(parsed.BenchmarkNotes) != "" { - items = append(items, "研究筆記:"+strings.TrimSpace(parsed.BenchmarkNotes)) - } - return mergeStringLists(nil, items) -} - -func defaultSelectedKnowledgeItems(items []string) []string { - if len(items) == 0 { - return nil - } - if len(items) > 6 { - return append([]string(nil), items[:6]...) - } - return append([]string(nil), items...) -} - -func mergeStringLists(base []string, extra []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(base)+len(extra)) - for _, item := range append(append([]string{}, base...), extra...) { - item = strings.TrimSpace(item) - if item == "" { - continue - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - out = append(out, item) - } - return out -} - -func ensureSeedQuerySelected(seed string, selected []string) []string { - seed = strings.TrimSpace(seed) - if seed == "" { - return selected - } - for _, item := range selected { - if strings.EqualFold(strings.TrimSpace(item), seed) { - return selected - } - } - out := append([]string{seed}, selected...) - if len(out) > libviral.MaxScanTags { - out = out[:libviral.MaxScanTags] - } - return out -} - -func copyMissionIDFromPayload(payload map[string]any) string { - if id := stringField(payload, "copy_mission_id"); id != "" { - return id - } - return strings.TrimSpace(stringField(payload, "mission_id")) -} diff --git a/backend/internal/worker/job/audience_samples.go b/backend/internal/worker/job/audience_samples.go deleted file mode 100644 index 41d2507..0000000 --- a/backend/internal/worker/job/audience_samples.go +++ /dev/null @@ -1,26 +0,0 @@ -package job - -import ( - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" -) - -func audienceSamplesFromSummary(in []missiondomain.AudienceSampleSummary) []missionentity.AudienceSample { - if len(in) == 0 { - return nil - } - out := make([]missionentity.AudienceSample, 0, len(in)) - for _, item := range in { - out = append(out, missionentity.AudienceSample{ - Username: item.Username, - SamplePostID: item.SamplePostID, - SampleText: item.SampleText, - ReplyLikeCount: item.ReplyLikeCount, - Appearances: item.Appearances, - FirstSeenAt: item.FirstSeenAt, - LastSeenAt: item.LastSeenAt, - Status: item.Status, - }) - } - return out -} diff --git a/backend/internal/worker/job/expand_copy_mission_graph.go b/backend/internal/worker/job/expand_copy_mission_graph.go deleted file mode 100644 index 1da4840..0000000 --- a/backend/internal/worker/job/expand_copy_mission_graph.go +++ /dev/null @@ -1,387 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - "haixun-backend/internal/library/clock" - libcopy "haixun-backend/internal/library/copymission" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" - "haixun-backend/internal/library/placement" - libprompt "haixun-backend/internal/library/prompt" - "haixun-backend/internal/library/websearch" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - placementusecase "haixun-backend/internal/model/placement/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" -) - -type ExpandCopyMissionGraphDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - KnowledgeGraph kgusecase.UseCase - ThreadsAccount threadsaccountdomain.UseCase - Placement placementusecase.UseCase - AI aiusecase.UseCase -} - -func RegisterExpandCopyMissionGraphHandler(runner *Runner, deps ExpandCopyMissionGraphDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("expand_copy_knowledge", func(ctx context.Context, step StepContext) error { - return runExpandCopyMissionGraph(ctx, step, deps) - }) -} - -func runExpandCopyMissionGraph(ctx context.Context, step StepContext, deps ExpandCopyMissionGraphDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := copyMissionIDFromPayload(payload) - seed := stringField(payload, "seed_query") - supplemental := boolField(payload, "supplemental") - if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { - return fmt.Errorf("expand-copy-mission-graph payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") - } - if seed == "" { - return fmt.Errorf("expand-copy-mission-graph payload missing seed_query") - } - - mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) - if err != nil { - return err - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識") - } - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - - research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) - if err != nil { - return err - } - expandStrategy := placement.EffectiveExpandStrategy(research) - if reqStrategy := strings.TrimSpace(stringField(payload, "expand_strategy")); reqStrategy != "" { - expandStrategy = libkg.ParseExpandStrategy(reqStrategy) - } - if supplemental && placement.WebSearchAvailable(research) { - expandStrategy = libkg.ExpandStrategyBrave - } - memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research) - if err != nil { - return err - } - webClient := websearch.New(memberCtx.WebSearchConfig()) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - - entityMap := summaryResearchMap(mission.ResearchMap) - missionCtx := libcopy.MissionContext{ - Label: mission.Label, - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - ResearchMap: entityMap, - } - personaCtx := libcopy.PersonaContext{ - DisplayName: persona.DisplayName, - Persona: persona.Persona, - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "expand_copy_knowledge", - Summary: summary, - Percentage: percentage, - }) - } - - var existing *kgusecase.GraphSummary - if supplemental { - existing, _ = deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID) - } - - braveSources := []libkg.BraveSource{} - var systemPrompt string - var userPrompt string - - switch expandStrategy { - case libkg.ExpandStrategyLLM: - updateProgress("整理延伸知識…", 25) - systemPrompt, err = libprompt.KnowledgeGraphLLMSystem() - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed") - } - kgVars := map[string]string{ - "seed": seed, - "product_brief_line": libkg.OptionalPromptLine("任務補充", mission.Brief), - "target_audience_line": libkg.OptionalPromptLine("目標受眾", mission.ResearchMap.AudienceSummary), - "persona_line": libkg.OptionalPromptLine("人設", persona.Persona), - "research_pillars_line": libkg.BulletPromptLine("內容支柱", mission.ResearchMap.Pillars), - "research_questions_line": libkg.BulletPromptLine("受眾提問", mission.ResearchMap.Questions), - } - userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars) - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph llm user prompt load failed") - } - default: - updateProgress("蒐集參考資料…", 10) - l1Labels := []string{} - if existing != nil { - l1Labels = libkg.L1LabelsFromNodes(existing.Nodes) - } - planIn := libcopy.PlanInput(missionCtx, seed, l1Labels, supplemental, expandStrategy) - queries := libkg.PlanQueries(planIn) - updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)), 25) - braveSources, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) { - pct := 25 + ((i + 1) * 30 / max(total, 1)) - updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", i+1, total), pct) - }, func() error { - cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) - if cancelled { - return errJobCancelled - } - return ctx.Err() - }) - if err != nil { - return err - } - updateProgress("整理延伸知識…", 60) - systemPrompt, err = libprompt.KnowledgeGraphSystem() - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph prompt load failed") - } - userPrompt, err = libkg.BuildUserPrompt(libcopy.SynthInput(missionCtx, personaCtx, braveSources)) - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph user prompt load failed") - } - } - - genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - }) - result, err := deps.AI.GenerateText(ctx, genReq) - if err != nil { - return err - } - - synthIn := libkg.SynthInput{ - Seed: seed, - TargetAudience: mission.ResearchMap.AudienceSummary, - } - graph, err := libkg.ParseSynthOutput(result.Text, synthIn, braveSources) - if err != nil { - // Model occasionally replies with reasoning/prose and no JSON object. - // Retry once, explicitly demanding a raw JSON object. - jsonOnlyReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - {Role: "assistant", Content: result.Text}, - {Role: "user", Content: libkg.KnowledgeGraphJSONOnlyRetryPrompt()}, - }, - }) - retryResult, retryErr := deps.AI.GenerateText(ctx, jsonOnlyReq) - if retryErr != nil { - return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) - } - retryGraph, retryParseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources) - if retryParseErr != nil { - return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + retryParseErr.Error()) - } - graph = retryGraph - result = retryResult - } - if libkg.GraphTooThin(graph) { - retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - {Role: "assistant", Content: result.Text}, - {Role: "user", Content: libkg.KnowledgeGraphRetryUserPrompt()}, - }, - }) - if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil { - if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { - graph = retryGraph - } - } - } - - if supplemental && existing != nil { - graph = mergeGraphs(existing, graph, braveSources) - } - if libkg.GraphNeedsBootstrap(graph) { - libkg.SupplementGraphFromResearchMap(&graph, seed, mission.ResearchMap.Pillars, mission.ResearchMap.Questions) - } - preserveSelection := map[string]bool{} - if existing != nil { - for _, node := range existing.Nodes { - preserveSelection[node.ID] = node.SelectedForScan - } - } - libcopy.ApplyDefaultNodeSelectionPreserving(graph.Nodes, preserveSelection) - libkg.DeriveSearchTagsFromGraph(&graph, libcopy.PatrolTagInput(missionCtx)) - - updateProgress("儲存延伸知識…", 90) - graph.BraveSources = braveSources - now := clock.NowUnixNano() - saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - BrandID: personaID, - CopyMissionID: missionID, - Seed: graph.Seed, - Nodes: graph.Nodes, - Edges: graph.Edges, - BraveSources: graph.BraveSources, - ExpandStrategy: expandStrategy.String(), - PainTagCount: graph.PainTagCount, - GeneratedAt: now, - }) - if err != nil { - return err - } - if err := syncCopyMissionKnowledgeFromGraph(ctx, deps, tenantID, ownerUID, personaID, missionID, entityMap, saved.Nodes, braveSources); err != nil { - return err - } - - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "summary": fmt.Sprintf("延伸知識 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount), - "next_route": fmt.Sprintf("/matrix/missions/%s", missionID), - } - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "graph_id": saved.ID, - "seed": saved.Seed, - "pain_tag_count": saved.PainTagCount, - "node_count": len(saved.Nodes), - "handoff": handoff, - }, - }) - return err -} - -func summaryResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap { - tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags)) - for _, tag := range summary.SuggestedTags { - tags = append(tags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts)) - for _, acc := range summary.SimilarAccounts { - accounts = append(accounts, missionentity.SimilarAccount{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileURL: acc.ProfileURL, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples)) - for _, sample := range summary.AudienceSamples { - samples = append(samples, missionentity.AudienceSample{ - Username: sample.Username, - SamplePostID: sample.SamplePostID, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return missionentity.ResearchMap{ - AudienceSummary: summary.AudienceSummary, - ContentGoal: summary.ContentGoal, - Questions: append([]string(nil), summary.Questions...), - Pillars: append([]string(nil), summary.Pillars...), - Exclusions: append([]string(nil), summary.Exclusions...), - KnowledgeItems: append([]string(nil), summary.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: summary.BenchmarkNotes, - } -} - -func syncCopyMissionKnowledgeFromGraph( - ctx context.Context, - deps ExpandCopyMissionGraphDeps, - tenantID, ownerUID, personaID, missionID string, - prev missionentity.ResearchMap, - nodes []libkg.Node, - sources []libkg.BraveSource, -) error { - researchMap := libcopy.BuildResearchMapFromGraph(prev, nodes, sources) - _, err := deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - ResearchMap: &researchMap, - }, - }) - return err -} diff --git a/backend/internal/worker/job/expand_graph.go b/backend/internal/worker/job/expand_graph.go index 4dc6691..31b5138 100644 --- a/backend/internal/worker/job/expand_graph.go +++ b/backend/internal/worker/job/expand_graph.go @@ -175,6 +175,14 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) var existing *kgusecase.GraphSummary if supplemental { + if scope.TopicID != "" { + existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID) + } else { + existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) + } + } else if scope.TopicID != "" { + existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID) + } else { existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) } @@ -379,7 +387,11 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) libkg.SupplementGraphFromResearchMap(&graph, seed, brand.ResearchMap.Pillars, brand.ResearchMap.Questions) } patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) + libkg.RescoreGraphIntentFit(&graph, patrolInput) libkg.DeriveSearchTagsFromGraph(&graph, patrolInput) + if existing != nil && len(existing.Nodes) > 0 { + libkg.PreserveNodeSelectionByLabel(graph.Nodes, existing.Nodes) + } updateProgress("整理海巡關鍵字…", 88) if err := syncAutoPatrolKeywords(ctx, deps, tenantID, ownerUID, scope, graph.Nodes, productBrief); err != nil { @@ -809,13 +821,18 @@ func ensureResearchMap( } } + patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) entityMap := brandentity.ResearchMap{ AudienceSummary: parsed.AudienceSummary, ContentGoal: parsed.ContentGoal, - Questions: parsed.Questions, - Pillars: parsed.Pillars, + Questions: libkg.RankStringsByIntent(parsed.Questions, patrolInput), + Pillars: libkg.RankStringsByIntent(parsed.Pillars, patrolInput), Exclusions: parsed.Exclusions, - PatrolKeywords: libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords), + PatrolKeywords: libkg.FilterPatrolKeywordsByIntent( + libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords), + patrolInput, + 18, + ), } targetAudience := strings.TrimSpace(brand.TargetAudience) if targetAudience == "" { diff --git a/backend/internal/worker/job/generate_copy_draft.go b/backend/internal/worker/job/generate_copy_draft.go deleted file mode 100644 index 29c7ea3..0000000 --- a/backend/internal/worker/job/generate_copy_draft.go +++ /dev/null @@ -1,210 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/style8d" - libviral "haixun-backend/internal/library/viral" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" -) - -type GenerateCopyDraftDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - ScanPost scanpostdomain.UseCase - CopyDraft copydraftdomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase - AI aiusecase.UseCase -} - -func RegisterGenerateCopyDraftHandler(runner *Runner, deps GenerateCopyDraftDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("copy_draft_generate", func(ctx context.Context, step StepContext) error { - return runGenerateCopyDraft(ctx, step, deps) - }) -} - -func runGenerateCopyDraft(ctx context.Context, step StepContext, deps GenerateCopyDraftDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := copyMissionIDFromPayload(payload) - scanPostID := strings.TrimSpace(stringField(payload, "scan_post_id")) - if tenantID == "" || ownerUID == "" || personaID == "" || scanPostID == "" { - return fmt.Errorf("generate-copy-draft payload missing tenant_id, owner_uid, persona_id, or scan_post_id") - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "copy_draft_generate", - Summary: summary, - Percentage: percentage, - }) - } - - updateProgress("讀取爆款原文…", 12) - - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - post, err := deps.ScanPost.GetForPersona(ctx, tenantID, ownerUID, personaID, scanPostID) - if err != nil { - return err - } - if missionID == "" { - missionID = strings.TrimSpace(post.CopyMissionID) - } - - topicLabel := strings.TrimSpace(post.SearchTag) - topicBrief := strings.TrimSpace(persona.Brief) - if missionID != "" { - if mission, missionErr := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID); missionErr == nil { - if label := strings.TrimSpace(mission.Label); label != "" { - topicLabel = label - } - if brief := strings.TrimSpace(mission.Brief); brief != "" { - topicBrief = brief - } - } - } - - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - - updateProgress("分析爆款結構…", 35) - - analysisText := "" - analyzeResult, analyzeErr := deps.AI.GenerateText(ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: libviral.BuildAnalyzeViralSystemPrompt(), - Messages: []domai.Message{ - { - Role: "user", - Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{ - PostText: post.Text, - AuthorName: post.Author, - LikeCount: post.LikeCount, - ReplyCount: post.ReplyCount, - SearchTag: post.SearchTag, - TopicLabel: topicLabel, - TopicBrief: topicBrief, - Persona: personaBlock, - }), - }, - }, - }) - if analyzeErr == nil { - if parsed, parseErr := libviral.ParseAnalyzeViralOutput(analyzeResult.Text); parseErr == nil { - analysisText = libviral.FormatAnalysisForReplicate(parsed) - } - } - - updateProgress("深仿寫產文中…", 65) - - result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: libviral.BuildSystemPrompt(), - Messages: []domai.Message{ - { - Role: "user", - Content: libviral.BuildUserPrompt(libviral.ReplicateInput{ - TopicLabel: topicLabel, - TopicBrief: topicBrief, - Persona: personaBlock, - StyleProfile: "", - OriginalText: post.Text, - AuthorName: post.Author, - StructureAnalysis: analysisText, - }), - }, - }, - }) - if err != nil { - return err - } - - parsed, err := libviral.ParseReplicateOutput(result.Text) - if err != nil { - return app.For(code.AI).SvcThirdParty("仿寫 LLM 回傳無法解析:" + err.Error()) - } - - updateProgress("儲存仿寫草稿…", 90) - - saved, err := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - CopyMissionID: post.CopyMissionID, - ScanPostID: scanPostID, - DraftType: "replicate", - Text: parsed.Text, - Angle: parsed.Angle, - Hook: parsed.Hook, - Rationale: parsed.Rationale, - ReferenceNotes: parsed.StructureNotes, - Sources: []string{post.Permalink}, - }) - if err != nil { - return err - } - - nextRoute := "/matrix" - if missionID != "" { - nextRoute = fmt.Sprintf("/matrix/missions/%s#copy-output", missionID) - } - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "scan_post_id": scanPostID, - "summary": "已產出深仿寫草稿", - "next_route": nextRoute, - } - - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "draft_id": saved.ID, - "handoff": handoff, - }, - }) - return err -} diff --git a/backend/internal/worker/job/generate_copy_matrix.go b/backend/internal/worker/job/generate_copy_matrix.go deleted file mode 100644 index 47ac1a5..0000000 --- a/backend/internal/worker/job/generate_copy_matrix.go +++ /dev/null @@ -1,290 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libcopy "haixun-backend/internal/library/copymission" - libkg "haixun-backend/internal/library/knowledge" - libmatrix "haixun-backend/internal/library/matrix" - libweb "haixun-backend/internal/library/webpage" - libprompt "haixun-backend/internal/library/prompt" - "haixun-backend/internal/library/style8d" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" -) - -type GenerateCopyMatrixDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - ScanPost scanpostdomain.UseCase - CopyDraft copydraftdomain.UseCase - KnowledgeGraph kgusecase.UseCase - ThreadsAccount threadsaccountdomain.UseCase - AI aiusecase.UseCase -} - -func RegisterGenerateCopyMatrixHandler(runner *Runner, deps GenerateCopyMatrixDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("copy_matrix_generate", func(ctx context.Context, step StepContext) error { - return runGenerateCopyMatrix(ctx, step, deps) - }) -} - -func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateCopyMatrixDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := copyMissionIDFromPayload(payload) - if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { - return fmt.Errorf("generate-copy-matrix payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") - } - - mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) - if err != nil { - return err - } - if mission.Status != string(missionentity.StatusMapped) && - mission.Status != string(missionentity.StatusScanned) && - mission.Status != string(missionentity.StatusDrafted) { - return app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return app.For(code.Persona).InputMissingRequired("請先完成研究地圖") - } - - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) - - count := intField(payload, "count") - if count <= 0 { - count = 5 - } - if count > 12 { - count = 12 - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "copy_matrix_generate", - Summary: summary, - Percentage: percentage, - }) - } - - updateProgress("讀取爆款樣本…", 15) - - posts, err := deps.ScanPost.ListForPersona(ctx, scanpostdomain.PersonaListRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - CopyMissionID: missionID, - Limit: 12, - }) - if err != nil { - return err - } - samples := matrixSamplesFromPosts(posts) - graphNodes, graphSources := loadCopyMissionGraph(ctx, deps, tenantID, ownerUID, missionID) - knowledgeItems := libcopy.MatrixKnowledgeItems( - mission.ResearchMap.SelectedKnowledgeItems, - mission.ResearchMap.KnowledgeItems, - graphNodes, - mission.SelectedTags, - ) - researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems) - refURLs := libcopy.MergeReferenceURLs( - libcopy.ReferenceURLsFromSelection(graphNodes, graphSources), - researchSources, - ) - researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources) - var referenceMarkdown string - if len(refURLs) > 0 { - updateProgress(fmt.Sprintf("抓取 %d 個參考網頁並轉 Markdown…", len(refURLs)), 32) - digests := libweb.FetchDigests(ctx, refURLs, libweb.FetchOptions{}) - referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests) - } - researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences( - mission.ResearchMap.AudienceSummary, - mission.ResearchMap.ContentGoal, - mission.ResearchMap.Questions, - mission.ResearchMap.Pillars, - mission.ResearchMap.Exclusions, - knowledgeItems, - researchItemsBlock, - referenceMarkdown, - ) - userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{ - Count: count, - TopicLabel: mission.Label, - TopicBrief: mission.Brief, - ResearchMap: researchBlock, - SelectedTags: mission.SelectedTags, - ViralSamples: samples, - PersonaBlock: personaBlock, - }) - if err != nil { - return app.For(code.AI).SysInternal("matrix user prompt load failed") - } - systemPrompt, err := libprompt.MatrixCopySystem() - if err != nil { - return app.For(code.AI).SysInternal("matrix system prompt load failed") - } - - matrixSummary := "產出內容矩陣草稿…" - if len(knowledgeItems) > 0 { - matrixSummary = fmt.Sprintf("依 %d 項延伸知識產出矩陣…", len(knowledgeItems)) - } - updateProgress(matrixSummary, 45) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - }, count) - if err != nil { - return app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) - } - - updateProgress("儲存矩陣草稿…", 88) - - createReqs := make([]copydraftdomain.CreateRequest, 0, len(parsed.Rows)) - for _, row := range parsed.Rows { - createReqs = append(createReqs, copydraftdomain.CreateRequest{ - CopyMissionID: missionID, - DraftType: copydraftentity.DraftTypeMatrix, - SortOrder: row.SortOrder, - Text: row.Text, - Angle: row.Angle, - Hook: row.Hook, - Rationale: row.Rationale, - ReferenceNotes: row.ReferenceNotes, - Sources: row.SourcePermalinks, - }) - } - saved, err := deps.CopyDraft.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, createReqs) - if err != nil { - return err - } - - drafted := missionentity.StatusDrafted - _, _ = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - Status: &drafted, - }, - }) - - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "summary": fmt.Sprintf("已產出 %d 篇矩陣草稿", len(saved)), - "next_route": fmt.Sprintf("/matrix/missions/%s#copy-output", missionID), - } - - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "draft_count": len(saved), - "handoff": handoff, - }, - }) - return err -} - -func loadCopyMissionGraph( - ctx context.Context, - deps GenerateCopyMatrixDeps, - tenantID, ownerUID, missionID string, -) ([]libkg.Node, []libkg.BraveSource) { - if deps.KnowledgeGraph == nil { - return nil, nil - } - graph, err := deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID) - if err != nil || graph == nil { - return nil, nil - } - return graph.Nodes, graph.BraveSources -} - -func matrixSamplesFromPosts(posts []scanpostdomain.ScanPostSummary) string { - samples := make([]libmatrix.ViralPostSample, 0, len(posts)) - for _, post := range posts { - replies := make([]libmatrix.ViralReplySample, 0, len(post.Replies)) - for _, reply := range post.Replies { - replies = append(replies, libmatrix.ViralReplySample{ - Author: reply.Author, - Text: reply.Text, - }) - } - samples = append(samples, libmatrix.ViralPostSample{ - Author: post.Author, - LikeCount: post.LikeCount, - SearchTag: post.SearchTag, - Text: post.Text, - Replies: replies, - }) - } - return libmatrix.FormatViralSamples(samples) -} - -func intField(payload map[string]any, key string) int { - if payload == nil { - return 0 - } - switch v := payload[key].(type) { - case int: - return v - case int32: - return int(v) - case int64: - return int(v) - case float64: - return int(v) - default: - return 0 - } -} diff --git a/backend/internal/worker/job/generate_formula_draft.go b/backend/internal/worker/job/generate_formula_draft.go new file mode 100644 index 0000000..742b2de --- /dev/null +++ b/backend/internal/worker/job/generate_formula_draft.go @@ -0,0 +1,81 @@ +package job + +import ( + "context" + "fmt" + + libpersonacopy "haixun-backend/internal/library/personacopy" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateFormulaDraftDeps struct { + Jobs jobdom.UseCase + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI aiusecase.UseCase +} + +func RegisterGenerateFormulaDraftHandler(runner *Runner, deps GenerateFormulaDraftDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("formula_draft_generate", func(ctx context.Context, step StepContext) error { + return runGenerateFormulaDraft(ctx, step, deps) + }) +} + +func runGenerateFormulaDraft(ctx context.Context, step StepContext, deps GenerateFormulaDraftDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + personaID := stringField(payload, "persona_id") + accountID := stringField(payload, "account_id") + formulaID := stringField(payload, "formula_id") + topic := stringField(payload, "topic") + if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || formulaID == "" || topic == "" { + return fmt.Errorf("generate-formula-draft payload missing required fields") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "formula_draft_generate", + Summary: summary, + Percentage: percentage, + }) + } + + count, err := libpersonacopy.RunFormulaDraft(ctx, libpersonacopy.FormulaDraftDeps{ + Persona: deps.Persona, + ContentFormula: deps.ContentFormula, + CopyDraft: deps.CopyDraft, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, libpersonacopy.FormulaDraftInput{ + TenantID: tenantID, + OwnerUID: ownerUID, + PersonaID: personaID, + AccountID: accountID, + FormulaID: formulaID, + Topic: topic, + Brief: stringField(payload, "brief"), + UseWebSearch: boolField(payload, "use_web_search"), + DraftCount: intField(payload, "draft_count"), + }, updateProgress) + if err != nil { + return err + } + _ = count + return nil +} diff --git a/backend/internal/worker/job/generate_outreach_draft.go b/backend/internal/worker/job/generate_outreach_draft.go new file mode 100644 index 0000000..64cee8c --- /dev/null +++ b/backend/internal/worker/job/generate_outreach_draft.go @@ -0,0 +1,97 @@ +package job + +import ( + "context" + "fmt" + + liboutreach "haixun-backend/internal/library/outreach" + aiusecase "haixun-backend/internal/model/ai/usecase" + branddomain "haixun-backend/internal/model/brand/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase" + outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateOutreachDraftDeps struct { + Jobs jobdom.UseCase + Brand branddomain.UseCase + Persona personadomain.UseCase + ScanPost scanpostdomain.UseCase + KnowledgeGraph kgdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + AI aiusecase.UseCase + OutreachDraft outreachdraftdomain.UseCase +} + +func RegisterGenerateOutreachDraftHandler(runner *Runner, deps GenerateOutreachDraftDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("outreach_draft_generate", func(ctx context.Context, step StepContext) error { + return runGenerateOutreachDraft(ctx, step, deps) + }) +} + +func runGenerateOutreachDraft(ctx context.Context, step StepContext, deps GenerateOutreachDraftDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + brandID := stringField(payload, "brand_id") + topicID := stringField(payload, "topic_id") + scanPostID := stringField(payload, "scan_post_id") + voicePersonaID := stringField(payload, "voice_persona_id") + if tenantID == "" || ownerUID == "" || brandID == "" || scanPostID == "" || voicePersonaID == "" { + return fmt.Errorf("generate-outreach-draft payload missing tenant_id, owner_uid, brand_id, scan_post_id, or voice_persona_id") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "outreach_draft_generate", + Summary: summary, + Percentage: percentage, + }) + } + + updateProgress("讀取貼文與產品脈絡…", 15) + updateProgress("呼叫 AI 生成獲客草稿…", 45) + + count := intField(payload, "count") + if count <= 0 { + count = 3 + } + + regenerate := false + if v, ok := payload["regenerate"].(bool); ok { + regenerate = v + } + + _, err := liboutreach.GenerateDraft(ctx, liboutreach.GenerateDraftDeps{ + Brand: deps.Brand, + Persona: deps.Persona, + ScanPost: deps.ScanPost, + KnowledgeGraph: deps.KnowledgeGraph, + ThreadsAccount: deps.ThreadsAccount, + AI: deps.AI, + OutreachDraft: deps.OutreachDraft, + }, liboutreach.GenerateDraftRequest{ + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brandID, + TopicID: topicID, + ScanPostID: scanPostID, + Count: count, + VoicePersonaID: voicePersonaID, + ProductID: stringField(payload, "product_id"), + Regenerate: regenerate, + }) + if err != nil { + return err + } + updateProgress("草稿已寫入", 100) + return nil +} diff --git a/backend/internal/worker/job/generate_rewrite_draft.go b/backend/internal/worker/job/generate_rewrite_draft.go new file mode 100644 index 0000000..157cd67 --- /dev/null +++ b/backend/internal/worker/job/generate_rewrite_draft.go @@ -0,0 +1,82 @@ +package job + +import ( + "context" + "fmt" + + libpersonacopy "haixun-backend/internal/library/personacopy" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateRewriteDraftDeps struct { + Jobs jobdom.UseCase + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI aiusecase.UseCase +} + +func RegisterGenerateRewriteDraftHandler(runner *Runner, deps GenerateRewriteDraftDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("rewrite_draft_generate", func(ctx context.Context, step StepContext) error { + return runGenerateRewriteDraft(ctx, step, deps) + }) +} + +func runGenerateRewriteDraft(ctx context.Context, step StepContext, deps GenerateRewriteDraftDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + personaID := stringField(payload, "persona_id") + accountID := stringField(payload, "account_id") + referenceText := stringField(payload, "reference_text") + topic := stringField(payload, "topic") + if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || referenceText == "" || topic == "" { + return fmt.Errorf("generate-rewrite-draft payload missing required fields") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "rewrite_draft_generate", + Summary: summary, + Percentage: percentage, + }) + } + + count, err := libpersonacopy.RunRewriteDraft(ctx, libpersonacopy.RewriteDraftDeps{ + Persona: deps.Persona, + ContentFormula: deps.ContentFormula, + CopyDraft: deps.CopyDraft, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, libpersonacopy.RewriteDraftInput{ + TenantID: tenantID, + OwnerUID: ownerUID, + PersonaID: personaID, + AccountID: accountID, + ReferenceText: referenceText, + Topic: topic, + Brief: stringField(payload, "brief"), + SaveLabel: stringField(payload, "save_label"), + UseWebSearch: boolField(payload, "use_web_search"), + DraftCount: intField(payload, "draft_count"), + }, updateProgress) + if err != nil { + return err + } + _ = count + return nil +} diff --git a/backend/internal/worker/job/generate_topic_matrix.go b/backend/internal/worker/job/generate_topic_matrix.go new file mode 100644 index 0000000..700b416 --- /dev/null +++ b/backend/internal/worker/job/generate_topic_matrix.go @@ -0,0 +1,78 @@ +package job + +import ( + "context" + "fmt" + + libpersonacopy "haixun-backend/internal/library/personacopy" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateTopicMatrixDeps struct { + Jobs jobdom.UseCase + Persona personadomain.UseCase + CopyDraft copydraftdomain.UseCase + ContentOps contentopsdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI aiusecase.UseCase +} + +func RegisterGenerateTopicMatrixHandler(runner *Runner, deps GenerateTopicMatrixDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("topic_matrix_generate", func(ctx context.Context, step StepContext) error { + return runGenerateTopicMatrix(ctx, step, deps) + }) +} + +func runGenerateTopicMatrix(ctx context.Context, step StepContext, deps GenerateTopicMatrixDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + personaID := stringField(payload, "persona_id") + topic := stringField(payload, "topic") + if tenantID == "" || ownerUID == "" || personaID == "" || topic == "" { + return fmt.Errorf("generate-topic-matrix payload missing tenant_id, owner_uid, persona_id, or topic") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "topic_matrix_generate", + Summary: summary, + Percentage: percentage, + }) + } + + created, err := libpersonacopy.RunTopicMatrix(ctx, libpersonacopy.TopicMatrixDeps{ + Persona: deps.Persona, + CopyDraft: deps.CopyDraft, + ContentOps: deps.ContentOps, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, libpersonacopy.TopicMatrixInput{ + TenantID: tenantID, + OwnerUID: ownerUID, + PersonaID: personaID, + ContentPlanID: stringField(payload, "content_plan_id"), + Topic: topic, + Brief: stringField(payload, "brief"), + UseWebSearch: boolField(payload, "use_web_search"), + DraftCount: intField(payload, "draft_count"), + }, updateProgress) + if err != nil { + return err + } + _ = created + return nil +} diff --git a/backend/internal/worker/job/payload_helpers.go b/backend/internal/worker/job/payload_helpers.go new file mode 100644 index 0000000..7524168 --- /dev/null +++ b/backend/internal/worker/job/payload_helpers.go @@ -0,0 +1,89 @@ +package job + +import ( + "fmt" + "strconv" + "strings" + + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" + missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" +) + +func intField(payload map[string]any, key string) int { + if payload == nil { + return 0 + } + switch v := payload[key].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(v)) + return n + default: + return 0 + } +} + +func copyMissionIDFromPayload(payload map[string]any) string { + if id := stringField(payload, "copy_mission_id"); id != "" { + return id + } + return stringField(payload, "mission_id") +} + +func similarAccountsFromSummary(items []missiondomain.SimilarAccountSummary) []missionentity.SimilarAccount { + out := make([]missionentity.SimilarAccount, 0, len(items)) + for _, item := range items { + username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@")) + if username == "" { + continue + } + out = append(out, missionentity.SimilarAccount{ + Username: username, + Reason: item.Reason, + Source: item.Source, + MatchedSource: append([]string(nil), item.MatchedSource...), + Confidence: item.Confidence, + Status: item.Status, + TopicRelevance: item.TopicRelevance, + LastSeenAt: item.LastSeenAt, + ProfileURL: item.ProfileURL, + AuthorVerified: item.AuthorVerified, + FollowerCount: item.FollowerCount, + EngagementScore: item.EngagementScore, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + PostCount: item.PostCount, + }) + } + return out +} + +func audienceSamplesFromSummary(items []missiondomain.AudienceSampleSummary) []missionentity.AudienceSample { + out := make([]missionentity.AudienceSample, 0, len(items)) + for _, item := range items { + username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@")) + if username == "" { + continue + } + out = append(out, missionentity.AudienceSample{ + Username: username, + SamplePostID: item.SamplePostID, + SampleText: item.SampleText, + ReplyLikeCount: item.ReplyLikeCount, + Appearances: item.Appearances, + FirstSeenAt: item.FirstSeenAt, + LastSeenAt: item.LastSeenAt, + Status: item.Status, + }) + } + return out +} + +func debugPayloadValue(value any) string { + return fmt.Sprintf("%v", value) +} diff --git a/backend/internal/worker/job/publish_analytics.go b/backend/internal/worker/job/publish_analytics.go index 8520bf6..6067352 100644 --- a/backend/internal/worker/job/publish_analytics.go +++ b/backend/internal/worker/job/publish_analytics.go @@ -37,15 +37,19 @@ func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps Publis return fmt.Errorf("publish-analytics payload missing media_id or checkpoint") } - cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID) - if err != nil { - return fmt.Errorf("resolve publish credential: %w", err) + accountID := strings.TrimSpace(stringField(payload, "account_id")) + if accountID == "" { + accountID = strings.TrimSpace(step.Run.ScopeID) } - if cred == nil || cred.AccessToken == "" { + token, err := deps.ThreadsAccount.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return fmt.Errorf("resolve api token: %w", err) + } + if token == "" { return fmt.Errorf("no access token found") } - stats, err := libthreads.GetMediaStats(ctx, cred.AccessToken, mediaID) + stats, err := libthreads.GetMediaStats(ctx, token, mediaID) if err != nil { return fmt.Errorf("get media stats: %w", err) } @@ -59,6 +63,8 @@ func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps Publis ReplyCount: stats.ReplyCount, RepostCount: stats.RepostCount, QuoteCount: stats.QuoteCount, + ViewCount: stats.Views, + ShareCount: stats.Shares, }) if err != nil { return fmt.Errorf("record checkpoint: %w", err) @@ -74,6 +80,8 @@ func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps Publis "reply_count": saved.ReplyCount, "repost_count": saved.RepostCount, "quote_count": saved.QuoteCount, + "view_count": saved.ViewCount, + "share_count": saved.ShareCount, "analytics_id": saved.ID, }, }) diff --git a/backend/internal/worker/job/publish_queue_sweeper.go b/backend/internal/worker/job/publish_queue_sweeper.go new file mode 100644 index 0000000..746ccbb --- /dev/null +++ b/backend/internal/worker/job/publish_queue_sweeper.go @@ -0,0 +1,56 @@ +package job + +import ( + "context" + "log" + "time" + + publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase" +) + +type PublishQueueSweeper struct { + queue publishqueuedomain.UseCase + batchSize int +} + +func NewPublishQueueSweeper(queue publishqueuedomain.UseCase, batchSize int) *PublishQueueSweeper { + if batchSize <= 0 { + batchSize = 20 + } + return &PublishQueueSweeper{queue: queue, batchSize: batchSize} +} + +func (s *PublishQueueSweeper) Start(ctx context.Context, interval time.Duration) { + if s == nil || s.queue == nil { + return + } + if interval <= 0 { + interval = time.Minute + } + log.Printf("publish queue sweeper started: interval=%s batch=%d", interval, s.batchSize) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Printf("publish queue sweeper stopped") + return + case <-ticker.C: + missed, err := s.queue.MarkMissed(ctx, int64(10*time.Minute), s.batchSize) + if err != nil { + log.Printf("publish queue missed sweep error: %v", err) + } else if missed > 0 { + log.Printf("publish queue marked %d missed item(s)", missed) + } + dispatched, err := s.queue.DispatchDue(ctx, s.batchSize) + if err != nil { + log.Printf("publish queue sweeper tick error: %v", err) + continue + } + if dispatched > 0 { + log.Printf("publish queue sweeper dispatched %d item(s)", dispatched) + } + } + } +} diff --git a/backend/internal/worker/job/refresh_threads_token.go b/backend/internal/worker/job/refresh_threads_token.go index d33668d..500f74f 100644 --- a/backend/internal/worker/job/refresh_threads_token.go +++ b/backend/internal/worker/job/refresh_threads_token.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - libthreads "haixun-backend/internal/library/threadsapi" jobdom "haixun-backend/internal/model/job/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) @@ -32,31 +31,17 @@ func runRefreshThreadsToken(ctx context.Context, step StepContext, deps RefreshT return fmt.Errorf("refresh-threads-token payload missing tenant_id, owner_uid, or account_id") } - cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID) + saved, err := deps.ThreadsAccount.RefreshAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) if err != nil { - return fmt.Errorf("resolve publish credential: %w", err) - } - if cred == nil || cred.AccessToken == "" { - return fmt.Errorf("no access token found for account %s", accountID) - } - - result, err := libthreads.RefreshAccessToken(ctx, cred.AccessToken) - if err != nil { - return fmt.Errorf("refresh token: %w", err) - } - - expiresAt := libthreads.NsFromNow(result.ExpiresIn) - if _, err := deps.ThreadsAccount.SaveAPIAccessToken(ctx, accountID, result.AccessToken, expiresAt); err != nil { - return fmt.Errorf("save refreshed token: %w", err) + return fmt.Errorf("refresh account token: %w", err) } _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ JobID: step.JobID, WorkerID: step.WorkerID, Result: map[string]any{ - "account_id": accountID, - "expires_at": expiresAt, - "token_short": result.AccessToken[:min(len(result.AccessToken), 8)] + "...", + "account_id": accountID, + "expires_at": saved.APITokenExpiresAt, }, }) return err diff --git a/backend/internal/worker/job/scan_placement.go b/backend/internal/worker/job/scan_placement.go index 12d9fab..3500db5 100644 --- a/backend/internal/worker/job/scan_placement.go +++ b/backend/internal/worker/job/scan_placement.go @@ -50,6 +50,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD } patrolMode := boolField(payload, "patrol_mode") + testPatrol := boolField(payload, "test_patrol") brand, brandErr := deps.Brand.Get(ctx, tenantID, ownerUID, brandID) if brandErr != nil { @@ -105,14 +106,22 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD if graphSummary != nil { patrolNodes = graphSummary.Nodes } + patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) + patrolInput = libkg.OverlayResearchMap( + patrolInput, + researchMap.AudienceSummary, + researchMap.Questions, + researchMap.Pillars, + researchMap.PatrolKeywords, + ) patrolKeywords = libkg.ResolveScanPatrolKeywords( stringSliceField(payload, "patrol_keywords"), researchMap.PatrolKeywords, - libkg.PatrolTagInputFromBrand(brand, productBrief), - patrolNodes, + patrolInput, + libkg.NodesForPatrolKeywordDerivation(patrolNodes), ) if len(patrolKeywords) == 0 { - return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字") + return fmt.Errorf("無法從產品與研究地圖產出足夠相關的搜尋關鍵字,請補充研究地圖或勾選圖譜節點") } } @@ -142,21 +151,25 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD if err != nil { return err } - if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { - return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定") - } - if placement.MemberNeedsWebSearchKey(memberCtx) && strings.TrimSpace(memberCtx.WebSearchAPIKey()) == "" { - return fmt.Errorf("%s", placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{ - WebSearchProvider: memberCtx.WebSearchProvider, - BraveAPIKey: memberCtx.BraveAPIKey, - ExaAPIKey: memberCtx.ExaAPIKey, - })) - } - if memberCtx.DevMode && !memberCtx.BrowserConnected { - return fmt.Errorf("開發模式需先同步 Chrome Session") - } - if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { - return fmt.Errorf("正式模式需先完成 Threads API 連線") + if testPatrol { + if !memberCtx.BrowserConnected { + return fmt.Errorf("測試海巡需先同步 Chrome Extension Session") + } + memberCtx = placement.MemberContextForCrawlerOnly(memberCtx) + } else { + if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { + return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定") + } + if placement.MemberNeedsWebSearchKey(memberCtx) && strings.TrimSpace(memberCtx.WebSearchAPIKey()) == "" { + return fmt.Errorf("%s", placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{ + WebSearchProvider: memberCtx.WebSearchProvider, + BraveAPIKey: memberCtx.BraveAPIKey, + ExaAPIKey: memberCtx.ExaAPIKey, + })) + } + if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return fmt.Errorf("請先完成 Threads API 連線後再開始海巡") + } } updateProgress := func(summary string, percentage int) { @@ -171,17 +184,27 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD } exclusions := append([]string{}, researchMap.Exclusions...) + patrolInput := libkg.PatrolTagInputFromBrand(brand, strings.TrimSpace(brand.ProductBrief)) + if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" { + patrolInput.ProductFeatures = formatted + } + patrolInput = libkg.OverlayResearchMap( + patrolInput, + researchMap.AudienceSummary, + researchMap.Questions, + researchMap.Pillars, + researchMap.PatrolKeywords, + ) + scanContext := placement.PostScanContextFromPatrolInput(patrolInput, exclusions) - if len(patrolKeywords) > 0 { + if testPatrol { + updateProgress(fmt.Sprintf("測試海巡(爬蟲):依 %d 組關鍵字準備搜尋…", len(patrolKeywords)), 5) + } else if len(patrolKeywords) > 0 { updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5) } else { updateProgress("準備置入海巡…", 5) } - if err := deps.ScanPost.ClearPlacementScan(ctx, tenantID, ownerUID, brandID, topicID); err != nil { - return err - } - webClient := websearch.New(memberCtx.WebSearchConfig()) crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID)) graphNodes := []libkg.Node{} @@ -200,6 +223,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD Nodes: graphNodes, PatrolKeywords: patrolKeywords, Exclusions: exclusions, + PatrolContext: scanContext, Member: memberCtx, WebSearch: webClient, Crawler: crawlerFn, @@ -267,12 +291,22 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD } } + summaryLabel := "雙軌海巡" + discoverNote := fmt.Sprintf( + "Threads API + %s Search API 雙管道搜尋;候選 %d 篇已寫入", + memberCtx.WebSearchProviderLabel(), + count, + ) + if testPatrol { + summaryLabel = "測試海巡(爬蟲)" + discoverNote = fmt.Sprintf("Chrome 爬蟲搜尋;候選 %d 篇已寫入", count) + } handoff := map[string]any{ "flow": "placement", "brand_id": brandID, "summary": fmt.Sprintf( - "雙軌海巡完成:%d 篇(gold %d、近期軌 %d、產品可解 %d)", - count, gold, recent, solved, + "%s完成:%d 篇(gold %d、近期軌 %d、產品可解 %d)", + summaryLabel, count, gold, recent, solved, ), "pain_breakdown": map[string]any{ "posts": count, @@ -281,6 +315,8 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD "solved_by_prod": solved, "replies": replyCount, }, + "discover_note": discoverNote, + "test_patrol": testPatrol, "next_route": "/outreach?brand=" + brandID, "needs_supplemental_expand": false, "search_source_mode": string(memberCtx.SearchSourceMode), diff --git a/backend/internal/worker/job/similar_accounts.go b/backend/internal/worker/job/similar_accounts.go deleted file mode 100644 index 19f9b7a..0000000 --- a/backend/internal/worker/job/similar_accounts.go +++ /dev/null @@ -1,42 +0,0 @@ -package job - -import ( - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" -) - -// similarAccountsFromSummary converts the read-side usecase summary back into a -// write-side entity slice so that worker jobs can preserve previously -// discovered accounts (including MatchedSource provenance) across re-runs -// without losing information. Fields that the summary does not carry are left -// at their zero value. -func similarAccountsFromSummary(in []missiondomain.SimilarAccountSummary) []missionentity.SimilarAccount { - if len(in) == 0 { - return nil - } - out := make([]missionentity.SimilarAccount, 0, len(in)) - for _, item := range in { - matched := item.MatchedSource - if len(matched) == 0 && item.Source != "" { - matched = []string{item.Source} - } - out = append(out, missionentity.SimilarAccount{ - Username: item.Username, - Reason: item.Reason, - Source: item.Source, - MatchedSource: matched, - Confidence: item.Confidence, - Status: item.Status, - TopicRelevance: item.TopicRelevance, - LastSeenAt: item.LastSeenAt, - ProfileURL: item.ProfileURL, - AuthorVerified: item.AuthorVerified, - FollowerCount: item.FollowerCount, - EngagementScore: item.EngagementScore, - LikeCount: item.LikeCount, - ReplyCount: item.ReplyCount, - PostCount: item.PostCount, - }) - } - return out -} diff --git a/backend/internal/worker/job/threads_token_sweeper.go b/backend/internal/worker/job/threads_token_sweeper.go new file mode 100644 index 0000000..6114037 --- /dev/null +++ b/backend/internal/worker/job/threads_token_sweeper.go @@ -0,0 +1,94 @@ +package job + +import ( + "context" + "log" + "time" + + jobdom "haixun-backend/internal/model/job/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +const refreshThreadsTokenTemplateType = "refresh-threads-token" + +type ThreadsTokenSweeper struct { + accounts threadsaccountdomain.UseCase + jobs jobdom.UseCase + leadTime time.Duration +} + +func NewThreadsTokenSweeper( + accounts threadsaccountdomain.UseCase, + jobs jobdom.UseCase, + leadTime time.Duration, +) *ThreadsTokenSweeper { + if leadTime <= 0 { + leadTime = 7 * 24 * time.Hour + } + return &ThreadsTokenSweeper{ + accounts: accounts, + jobs: jobs, + leadTime: leadTime, + } +} + +func (s *ThreadsTokenSweeper) Start(ctx context.Context, interval time.Duration) { + if s == nil || s.accounts == nil || s.jobs == nil { + return + } + if interval <= 0 { + interval = time.Hour + } + log.Printf("threads token sweeper started: interval=%s lead_time=%s", interval, s.leadTime) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Printf("threads token sweeper stopped") + return + case <-ticker.C: + created, err := s.tick(ctx) + if err != nil { + log.Printf("threads token sweeper tick error: %v", err) + continue + } + if created > 0 { + log.Printf("threads token sweeper enqueued %d refresh job(s)", created) + } + } + } +} + +func (s *ThreadsTokenSweeper) tick(ctx context.Context) (int, error) { + candidates, err := s.accounts.ListTokenRefreshCandidates(ctx, s.leadTime) + if err != nil { + return 0, err + } + created := 0 + for _, item := range candidates { + if item.AccountID == "" || item.TenantID == "" || item.OwnerUID == "" { + continue + } + _, err := s.jobs.CreateRun(ctx, jobdom.CreateRunRequest{ + TemplateType: refreshThreadsTokenTemplateType, + Scope: "threads_account", + ScopeID: item.AccountID, + TenantID: item.TenantID, + OwnerUID: item.OwnerUID, + Payload: map[string]any{ + "tenant_id": item.TenantID, + "owner_uid": item.OwnerUID, + "account_id": item.AccountID, + "expires_at": item.ExpiresAt, + }, + }) + if err != nil { + log.Printf("threads token sweeper skip account=%s: %v", item.AccountID, err) + continue + } + created++ + } + return created, nil +} diff --git a/backend/web/Dockerfile b/backend/web/Dockerfile new file mode 100644 index 0000000..12ace45 --- /dev/null +++ b/backend/web/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build + +WORKDIR /src +COPY backend/web/package*.json ./ +RUN npm ci +COPY backend/web/ ./ +RUN npm run build + +FROM nginx:1.27-alpine + +COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/backend/web/index.html b/backend/web/index.html new file mode 100644 index 0000000..212d61b --- /dev/null +++ b/backend/web/index.html @@ -0,0 +1,26 @@ + + + + + + 巡樓 Console + + + + + + +
            + + + diff --git a/backend/web/package-lock.json b/backend/web/package-lock.json new file mode 100644 index 0000000..1d5090e --- /dev/null +++ b/backend/web/package-lock.json @@ -0,0 +1,1718 @@ +{ + "name": "haixun-console-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "haixun-console-web", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "^4.3.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "taipei-sans-tc": "^0.1.1", + "typescript": "^5.8.2", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/taipei-sans-tc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/taipei-sans-tc/-/taipei-sans-tc-0.1.1.tgz", + "integrity": "sha512-GCvdO+SATITfm128bT+fFmbpVW8+o0zLMVlNskSOk3CBghNzfqj4tvwTbIQ9vmvftd31s5UNypLA3aIw/IG6RQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/backend/web/package.json b/backend/web/package.json new file mode 100644 index 0000000..686eb95 --- /dev/null +++ b/backend/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "haixun-console-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.2.0", + "typescript": "^5.8.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "taipei-sans-tc": "^0.1.1" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4" + } +} diff --git a/backend/web/src/App.tsx b/backend/web/src/App.tsx new file mode 100644 index 0000000..f47d478 --- /dev/null +++ b/backend/web/src/App.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from "react"; +import { api } from "./api/haixun"; +import { useAuth } from "./auth/AuthContext"; +import { Layout } from "./components/Layout"; +import { Button, Card, PageTitle } from "./components/ui"; +import { AuthPage } from "./pages/AuthPage"; +import { AccountsPage } from "./pages/AccountsPage"; +import { DashboardPage } from "./pages/DashboardPage"; +import { GapsPage } from "./pages/GapsPage"; +import { JobsPage } from "./pages/JobsPage"; +import { MissionsPage } from "./pages/MissionsPage"; +import { PatrolPage } from "./pages/PatrolPage"; +import { PersonasPage } from "./pages/PersonasPage"; +import { PublishPage } from "./pages/PublishPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { BrandsPage } from "./pages/BrandsPage"; +import { clearOAuthPending, parseOAuthReturn, stashOAuthReturnMessage, stripOAuthReturnFromUrl } from "./lib/threadsOAuth"; +import { persistActiveThreadsAccountId } from "./lib/activeAccount"; + +const validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]); +const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]); + +export function App() { + const { member, loading } = useAuth(); + const [page, setPage] = useState(() => normalizePage(location.hash.replace("#", ""))); + const [accountVersion, setAccountVersion] = useState(0); + const [accountCount, setAccountCount] = useState(null); + + useEffect(() => { + const onHash = () => setPage(normalizePage(location.hash.replace("#", ""))); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + + useEffect(() => { + const onAccountChanged = () => setAccountVersion((value) => value + 1); + window.addEventListener("haixun:active-account-changed", onAccountChanged); + return () => window.removeEventListener("haixun:active-account-changed", onAccountChanged); + }, []); + + useEffect(() => { + if (!member) return; + const result = parseOAuthReturn(window.location.search); + if (!result) return; + + clearOAuthPending(); + stashOAuthReturnMessage(result.message); + if (result.accountId) persistActiveThreadsAccountId(result.accountId); + stripOAuthReturnFromUrl(); + window.location.hash = "accounts"; + setPage("accounts"); + setAccountVersion((value) => value + 1); + window.dispatchEvent(new CustomEvent("haixun:active-account-changed")); + }, [member]); + + useEffect(() => { + if (!member) { + setAccountCount(null); + return; + } + let alive = true; + async function loadAccounts() { + try { + const data = await api.threadsAccounts(); + if (alive) setAccountCount(data.list?.length || 0); + } catch { + if (alive) setAccountCount(0); + } + } + void loadAccounts(); + return () => { + alive = false; + }; + }, [member, accountVersion]); + + function navigate(next: string) { + const normalized = normalizePage(next); + window.location.hash = normalized; + setPage(normalized); + } + + if (loading) { + return
            載入中...
            ; + } + + if (!member) { + return ; + } + + return ( + +
            {renderPage(page, navigate, accountCount)}
            +
            + ); +} + +function normalizePage(raw: string) { + return validPages.has(raw) ? raw : "dashboard"; +} + +function renderPage(page: string, navigate: (key: string) => void, accountCount: number | null) { + if (accountRequiredPages.has(page)) { + if (accountCount === null) { + return ( + +

            正在確認 Threads 帳號狀態...

            +
            + ); + } + if (accountCount === 0) { + return ; + } + } + switch (page) { + case "accounts": + return ; + case "brands": + return ; + case "publish": + return ; + case "personas": + return ; + case "missions": + return ; + case "patrol": + return ; + case "jobs": + return ; + case "settings": + return ; + case "gaps": + return ; + default: + return ; + } +} + +function AccountRequiredPage({ navigate }: { navigate: (key: string) => void }) { + return ( +
            + + +

            + 請先到 Threads 帳號頁建立帳號,完成 OAuth 或 API 連線後,巡樓 Console 才能開始發文、分析與跑任務。 +

            + +
            +
            + ); +} diff --git a/backend/web/src/api/client.ts b/backend/web/src/api/client.ts new file mode 100644 index 0000000..e11570d --- /dev/null +++ b/backend/web/src/api/client.ts @@ -0,0 +1,196 @@ +import { safeGetItem, safeRemoveItem, safeSetItem } from "../lib/safeStorage"; + +export type ApiEnvelope = { + code: number; + message: string; + data?: T; + error?: { + biz_code?: string; + scope?: number; + category?: number; + detail?: number; + }; +}; + +export class ApiError extends Error { + status: number; + code?: number; + bizCode?: string; + + constructor(message: string, status: number, code?: number, bizCode?: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.bizCode = bizCode; + } +} + +const accessTokenKey = "haixun.access_token"; +const refreshTokenKey = "haixun.refresh_token"; + +let onUnauthorized: (() => void) | undefined; +let refreshPromise: Promise | undefined; + +export function setUnauthorizedHandler(handler: (() => void) | undefined) { + onUnauthorized = handler; +} + +export function getAccessToken() { + return safeGetItem(accessTokenKey); +} + +export function getRefreshToken() { + return safeGetItem(refreshTokenKey); +} + +export function setTokens(accessToken: string, refreshToken: string) { + safeSetItem(accessTokenKey, accessToken); + safeSetItem(refreshTokenKey, refreshToken); +} + +export function clearTokens() { + safeRemoveItem(accessTokenKey); + safeRemoveItem(refreshTokenKey); +} + +type RequestOptions = { + method?: string; + body?: unknown; + auth?: boolean; + memberAuth?: boolean; + providerToken?: string; + headers?: Record; + retryOnUnauthorized?: boolean; +}; + +function isTransientGatewayError(status: number) { + return status === 502 || status === 503 || status === 504; +} + +function gatewayRetryDelayMs(attempt: number) { + return 400 + attempt * 600; +} + +async function sleep(ms: number) { + await new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function readApiEnvelope(response: Response): Promise | null> { + return (await response.json().catch(() => null)) as ApiEnvelope | null; +} + +function isProxyUnavailable(response: Response, envelope: ApiEnvelope | null) { + if (!isTransientGatewayError(response.status)) { + return false; + } + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + return true; + } + return envelope == null; +} + +export async function apiRequest(path: string, options: RequestOptions = {}): Promise { + const maxAttempts = 4; + let response: Response | null = null; + let envelope: ApiEnvelope | null = null; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + response = await fetch(path, buildRequestInit(options)); + envelope = await readApiEnvelope(response); + if (!isProxyUnavailable(response, envelope) || attempt === maxAttempts - 1) { + break; + } + await sleep(gatewayRetryDelayMs(attempt)); + } + + if (!response) { + throw new ApiError("後端服務暫時無法連線,請稍後再試", 502); + } + + if (response.status === 401 && options.auth && options.retryOnUnauthorized !== false) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + return apiRequest(path, { ...options, retryOnUnauthorized: false }); + } + onUnauthorized?.(); + } + + if (!response.ok || !envelope || envelope.code !== 102000) { + const message = envelope?.message?.trim(); + const fallback = isProxyUnavailable(response, envelope) + ? "後端服務暫時無法連線,請確認 API gateway 與反向代理狀態。" + : `API request failed (${response.status})`; + const detail = + message || + (response.status === 502 + ? isProxyUnavailable(response, envelope) + ? "請求逾時或閘道無回應(HTTP 502)。請稍後重試;若持續失敗請確認 API gateway 與 worker 狀態。" + : "Threads 或外部 API 回傳失敗(HTTP 502)。請到帳號頁確認 Threads API 連線與 threads_keyword_search 權限。" + : fallback); + throw new ApiError(detail, response.status, envelope?.code, envelope?.error?.biz_code); + } + return envelope.data as T; +} + +function buildRequestInit(options: RequestOptions): RequestInit { + const headers: Record = { + Accept: "application/json", + ...options.headers + }; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (options.auth) { + const token = getAccessToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + } + if (options.memberAuth) { + const token = getAccessToken(); + if (token) { + headers["X-Member-Authorization"] = `Bearer ${token}`; + } + } + if (options.providerToken) { + headers.Authorization = `Bearer ${options.providerToken}`; + } + return { + method: options.method || (options.body === undefined ? "GET" : "POST"), + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body) + }; +} + +async function refreshAccessToken(): Promise { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = doRefreshAccessToken().finally(() => { + refreshPromise = undefined; + }); + return refreshPromise; +} + +async function doRefreshAccessToken(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + clearTokens(); + return false; + } + try { + const data = await apiRequest<{ access_token: string; refresh_token: string }>("/api/v1/auth/refresh", { + method: "POST", + body: { refresh_token: refreshToken }, + retryOnUnauthorized: false + }); + setTokens(data.access_token, data.refresh_token); + return true; + } catch { + clearTokens(); + return false; + } +} diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts new file mode 100644 index 0000000..59c0cae --- /dev/null +++ b/backend/web/src/api/haixun.ts @@ -0,0 +1,1172 @@ +import { apiRequest } from "./client"; + +export type Pagination = { + total: number; + page: number; + pageSize: number; + totalPages: number; +}; + +export type CapabilityData = { + discover_ready: boolean; + ai_ready: boolean; + publish_ready: boolean; + discover_hint?: string; + ai_hint?: string; + publish_hint?: string; + active_threads_account_id?: string; +}; + +export type ThreadsAccount = { + id: string; + display_name?: string; + username?: string; + threads_user_id?: string; + persona_id?: string; + browser_connected: boolean; + api_connected: boolean; + api_token_expires_at?: number; + status: string; +}; + +export type ThreadsAccountList = { + list: ThreadsAccount[]; + active_account_id?: string; +}; + +export type DeleteThreadsAccountResult = { + deleted_id: string; + active_account_id?: string; + message: string; +}; + +export type ThreadsConnection = { + account_id: string; + account_name: string; + username?: string; + browser_connected: boolean; + api_connected: boolean; + prefs: Record; +}; + +export type ThreadsAiSettings = { + account_id: string; + provider: string; + model: string; + research_provider?: string; + research_model?: string; + api_keys?: Record; + api_keys_configured?: Record; +}; + +export type ThreadsAccountAiProviderModels = { + id: string; + label: string; + models: string[]; + streams: boolean; + error?: string; +}; + +export type PublishQueueItem = { + id: string; + account_id: string; + persona_id?: string; + copy_mission_id?: string; + copy_draft_id?: string; + text: string; + topic_tag?: string; + scheduled_at: number; + status: string; + media_id?: string; + permalink?: string; + published_at?: number; + error_message?: string; + retry_count?: number; + last_attempt_at?: number; + next_attempt_at?: number; + missed_at?: number; + paused_reason?: string; + create_at: number; + update_at: number; +}; + +export type PublishQueueList = { + pagination: Pagination; + list: PublishQueueItem[]; +}; + +export type PublishHealth = { + summary: { + pending_scheduled: number; + failed_count: number; + published_7d: number; + total_likes_7d: number; + total_replies_7d: number; + }; + pagination: Pagination; + list: Array<{ + media_id: string; + permalink?: string; + text?: string; + published_at: number; + like_count: number; + reply_count: number; + repost_count?: number; + quote_count?: number; + views?: number; + shares?: number; + checkpoints?: Array<{ checkpoint: string; like_count: number; reply_count: number; checked_at: number }>; + }>; +}; + +export type OwnPostPerformanceItem = { + media_id: string; + text?: string; + permalink?: string; + timestamp?: string; + media_type?: string; + media_url?: string; + thumbnail_url?: string; + topic_tag?: string; + shortcode?: string; + like_count: number; + reply_count: number; + repost_count?: number; + quote_count?: number; + views?: number; + shares?: number; + insights_status?: string; + insights_message?: string; +}; + +export type PostPerformance = { + account_id: string; + username?: string; + threads_user_id?: string; + fetched_at: number; + account_insights?: { + status?: string; + message?: string; + metrics?: Array<{ name: string; value: number }>; + }; + posts: OwnPostPerformanceItem[]; +}; + +export type OwnPostFormulaReview = { + media_id?: string; + reviewed_at?: number; + from_cache?: boolean; + summary: string; + wins?: string[]; + improvements?: string[]; + formula?: string; + post_template?: string; + hook_pattern?: string; + structure?: string; + replication_tips?: string[]; + avoid?: string[]; +}; + +export type Persona = { + id: string; + display_name?: string; + persona?: string; + brief?: string; + style_profile?: string; + style_benchmark?: string; + create_at: number; + update_at: number; +}; + +export type CopyDraft = { + id: string; + persona_id: string; + content_plan_id?: string; + copy_mission_id?: string; + scan_post_id?: string; + formula_id?: string; + draft_type: string; + text: string; + topic_tag?: string; + angle?: string; + hook?: string; + rationale?: string; + ai_score?: number; + formula_score?: number; + brand_fit_score?: number; + risk_score?: number; + similarity_score?: number; + engagement_potential?: number; + freshness_score?: number; + review_suggestion?: string; + status?: string; + publish_queue_id?: string; + published_at?: number; + published_permalink?: string; + create_at?: number; +}; + +export type CopyMissionInspiration = { + topic_candidate_id?: string; + content_plan_id?: string; + label: string; + seed_query: string; + brief: string; + trend_reason?: string; + trend_keywords?: string[]; + angles?: string[]; + mission?: string; + target_audience?: string; + opening_type?: string; + body_type?: string; + emotion?: string; + cta_type?: string; + risk_level?: string; + avoid?: string[]; + sources?: Array<{ query?: string; title?: string; snippet?: string; url?: string }>; + web_search_used: boolean; + message: string; +}; + +export type TopicCandidate = { + id: string; + persona_id: string; + name: string; + source?: string; + category?: string; + seed_query?: string; + trend_reason?: string; + trend_keywords?: string[]; + heat_score?: number; + fit_score?: number; + interaction_score?: number; + extend_score?: number; + risk_score?: number; + final_score?: number; + recommended_missions?: string[]; + status?: string; + create_at: number; + update_at: number; +}; + +export type ContentPlan = { + id: string; + persona_id: string; + topic_candidate_id?: string; + topic: string; + mission: string; + target_audience?: string; + angle?: string; + opening_type?: string; + body_type?: string; + emotion?: string; + ending_type?: string; + cta_type?: string; + risk_level?: string; + requires_human_review?: boolean; + avoid?: string[]; + selected_knowledge?: string[]; + status?: string; + create_at: number; + update_at: number; +}; + +export type FeedbackEvent = { + id: string; + persona_id: string; + content_plan_id?: string; + draft_id?: string; + decision: string; + note?: string; + snapshot?: string; + create_at: number; +}; + +export type KnowledgeSource = { id: string; persona_id: string; source_type: string; title?: string; filename?: string; parsed_status?: string; chunk_count?: number; create_at: number }; +export type KnowledgeChunk = { id: string; persona_id: string; source_id: string; content: string; topics?: string[]; style_tags?: string[]; risk_level?: string; create_at: number }; +export type FormulaPool = { id: string; persona_id: string; type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number; create_at: number; update_at: number }; + +export type ContentInboxItem = CopyDraft & { + scheduled_at?: number; + lifecycle: "draft" | "scheduled" | "published" | string; + group_date: number; + formula_label?: string; +}; + +export type ContentFormula = { + id: string; + account_id: string; + label: string; + source_type: string; + source_ref?: string; + source_post_text?: string; + source_author?: string; + source_permalink?: string; + summary?: string; + wins?: string[]; + improvements?: string[]; + formula: string; + post_template?: string; + hook_pattern?: string; + structure?: string; + replication_tips?: string[]; + avoid?: string[]; + tags?: string[]; + create_at: number; + update_at: number; +}; + +export type ViralScanPost = { + id: string; + search_tag: string; + permalink: string; + author: string; + text: string; + like_count: number; + reply_count: number; + engagement_score: number; + source: string; + create_at: number; +}; + +export type ContentFormulaSearchPost = { + text: string; + author: string; + permalink?: string; + media_id?: string; + like_count: number; + reply_count: number; + engagement_score: number; +}; + +export type PublishSlot = { + weekday: number; + time: string; +}; + +export type PublishSourceRef = { + type: string; + persona_id?: string; + copy_mission_id?: string; + scan_post_id?: string; + manual_seed?: string; +}; + +export type PublishInventoryPolicy = { + account_id: string; + enabled: boolean; + target_daily_count: number; + low_stock_threshold: number; + lookahead_days: number; + timezone: string; + slots: PublishSlot[]; + source_refs: PublishSourceRef[]; + update_at: number; +}; + +export type PublishGuardPolicy = { + account_id: string; + enabled: boolean; + max_daily_posts: number; + min_interval_minutes: number; + consecutive_failure_pause_limit: number; + paused: boolean; + paused_reason?: string; + update_at: number; +}; + +export type PublishSlotInsights = { + account_id: string; + timezone: string; + slots: Array; +}; + +export type PublishQueueEvent = { + id: string; + queue_id: string; + event_type: string; + from_status?: string; + to_status?: string; + message?: string; + create_at: number; +}; + +export type PublishAlert = { + type: string; + severity: string; + message: string; + queue_id?: string; + account_id: string; + create_at: number; +}; + +export type PublishDashboardSummary = { + list: Array<{ + account_id: string; + account_name: string; + pending_scheduled: number; + failed_count: number; + published_7d: number; + total_likes_7d: number; + total_replies_7d: number; + paused: boolean; + best_slot?: string; + low_slot?: string; + }>; + total_pending: number; + total_failed: number; + total_published_7d: number; + total_likes_7d: number; + total_replies_7d: number; +}; + +export type ThreadsDiagnostics = { + account_id: string; + checked_at: number; + api_connected: boolean; + token_expires_at?: number; + scopes: string[]; + items: Array<{ scope: string; name: string; status: string; message: string; count: number }>; + message: string; +}; + +export type ThreadsPlaygroundResult = { + action: string; + ok: boolean; + message: string; + data: string; +}; + +export type MentionInboxItem = { + media_id: string; + author_username?: string; + text?: string; + permalink?: string; + shortcode?: string; + timestamp?: string; + media_type?: string; + is_reply: boolean; + is_quote_post: boolean; + has_replies: boolean; + root_post_id?: string; + parent_id?: string; + thread_post_text?: string; + parent_reply_text?: string; + reply_ready: boolean; + reply_ready_msg?: string; + synced_at: number; +}; + +export type SyncMentionInboxResult = { + synced: number; + ready: number; + total: number; +}; + +export type MentionInboxList = { + list: MentionInboxItem[]; + pagination: Pagination; + ready_total?: number; + newest_synced_at?: number; +}; + +export type ThreadsReply = { + id: string; + text?: string; + username?: string; + permalink?: string; + timestamp?: string; + like_count?: number; + parent_id?: string; + replied_to_id?: string; + root_post_id?: string; + is_reply?: boolean; + is_reply_owned_by_me?: boolean; + has_replies?: boolean; + media_type?: string; + media_url?: string; + thumbnail_url?: string; + gif_url?: string; + media_children?: Array<{ + id?: string; + media_type?: string; + media_url?: string; + thumbnail_url?: string; + }>; +}; + +export type ThreadsInspirationPost = { + id: string; + text?: string; + username?: string; + permalink?: string; + timestamp?: string; + like_count?: number; + reply_count?: number; +}; + +export type StylePreset = { + id: string; + persona_id: string; + name: string; + tone?: string; + cta?: string[]; + banned_words?: string[]; + notes?: string; + create_at: number; + update_at: number; +}; + +// ==================== Brand (full contract per backend) ==================== +export type ResearchItemData = { + title?: string; + url?: string; + snippet?: string; + query?: string; +}; + +export type ResearchMapData = { + audience_summary?: string; + content_goal?: string; + questions?: string[]; + pillars?: string[]; + exclusions?: string[]; + research_items?: ResearchItemData[]; + expand_strategy?: string; + patrol_keywords?: string[]; +}; + +export type BrandProductData = { + id: string; + label: string; + product_context: string; + match_tags?: string[]; + placement_url?: string; // 置入網址 + create_at: number; + update_at: number; +}; + +export type BrandData = { + id: string; + display_name?: string; + topic_name?: string; + seed_query?: string; + brief?: string; + product_brief?: string; + product_context?: string; + product_id?: string; + products?: BrandProductData[]; + target_audience?: string; + goals?: string; + research_map?: ResearchMapData; + create_at: number; + update_at: number; +}; + +export type ListBrandsData = { list: BrandData[] }; + +export type PlacementTopicData = { + id: string; + brand_id: string; + brand_display_name?: string; + topic_name?: string; + seed_query?: string; + brief?: string; + product_id?: string; + research_map?: ResearchMapData; + create_at: number; + update_at: number; +}; + +export type ListPlacementTopicsData = { list: PlacementTopicData[] }; + +export type KnowledgeGraphEvidenceData = { + url?: string; + snippet?: string; + query?: string; +}; + +export type BraveSourceData = { + query?: string; + title?: string; + url?: string; + snippet?: string; +}; + +export type KnowledgeGraphNodeData = { + id: string; + label: string; + node_kind: string; + type: string; + layer: number; + relation?: string; + placement_value?: string; + product_fit_score: number; + selected_for_scan: boolean; + relevance_tags: string[]; + recency_tags: string[]; + evidence?: KnowledgeGraphEvidenceData[]; +}; + +export type KnowledgeGraphEdgeData = { + from: string; + to: string; + relation: string; +}; + +export type KnowledgeGraphData = { + id: string; + brand_id: string; + seed: string; + nodes: KnowledgeGraphNodeData[]; + edges: KnowledgeGraphEdgeData[]; + brave_sources?: BraveSourceData[]; + expand_strategy?: string; + pain_tag_count: number; + generated_at: number; + create_at: number; + update_at: number; +}; + +export type ExpandKnowledgeGraphData = { + job_id: string; + status: string; + message: string; +}; + +export type ScanReplyData = { + external_id?: string; + author?: string; + text: string; + permalink?: string; + like_count?: number; + posted_at?: string; +}; + +export type GenerateOutreachDraftsData = { + id: string; + scan_post_id: string; + relevance: number; + reason: string; + drafts: Array<{ text: string; angle: string; rationale: string }>; + create_at: number; +}; + +export type PublishOutreachDraftData = { + scan_post_id: string; + reply_id?: string; + permalink?: string; + outreach_status: string; + published_permalink?: string; + message: string; +}; + +export type ScanPostData = { + id: string; + graph_node_id: string; + search_tag: string; + query_dimension: string; + recency_days?: number; + external_id: string; + permalink?: string; + author: string; + text: string; + priority: string; + placement_score: number; + product_fit_score: number; + solved_by_product: boolean; + source: string; + scan_job_id: string; + outreach_status?: string; + published_reply_id?: string; + published_permalink?: string; + outreach_update_at?: number; + posted_at?: string; + replies?: ScanReplyData[]; + latest_draft?: GenerateOutreachDraftsData; + create_at: number; +}; + +export type ListBrandScanPostsData = { + list: ScanPostData[]; + total: number; +}; + +export type ContentMatrixRowData = { + sort_order: number; + search_tag: string; + angle: string; + hook: string; + text: string; + reference_notes: string; + source_permalinks: string[]; + rationale: string; +}; + +export type ContentMatrixData = { + id?: string; + brand_id: string; + rows: ContentMatrixRowData[]; + generated_at: number; + create_at?: number; + update_at?: number; +}; + +export type BrandScanScheduleData = { + id?: string; + brand_id: string; + cron: string; + timezone: string; + enabled: boolean; + next_run_at?: number; + last_run_at?: number; +}; + +// Legacy aliases for existing PatrolPage usage (keep minimal surface) +export type Brand = { + id: string; + display_name?: string; + topic_name?: string; + seed_query?: string; + brief?: string; + product_context?: string; + target_audience?: string; + goals?: string; +}; + +export type ScanPost = { + id: string; + author: string; + text: string; + permalink?: string; + priority?: string; + placement_score?: number; + product_fit_score?: number; + outreach_status?: string; +}; + +export type JobStepProgress = { + id: string; + status: string; + message?: string; + started_at?: number; + ended_at?: number; +}; + +export type JobRun = { + id: string; + template_type: string; + status: string; + phase?: string; + worker_type?: string; + progress?: { percentage?: number; summary?: string; steps?: JobStepProgress[] }; + error?: string; + attempt?: number; + max_attempts?: number; + create_at: number; + update_at: number; +}; + +export type JobEvent = { + id: string; + job_id: string; + type: string; + from?: string; + to?: string; + message: string; + metadata?: Record; + create_at: number; +}; + +export type JobList = { + pagination: Pagination; + list: JobRun[]; +}; + +export type JobSchedule = { + id: string; + template_type: string; + cron: string; + timezone: string; + enabled: boolean; + next_run_at?: number; + last_run_at?: number; +}; + +export type SettingValue = { + scope: string; + scope_id: string; + key: string; + value: unknown; + version?: number; +}; + +export const api = { + capabilities: () => apiRequest("/api/v1/members/me/capabilities", { auth: true }), + memberMe: () => apiRequest("/api/v1/members/me", { auth: true }), + placementSettings: () => apiRequest("/api/v1/members/me/placement-settings", { auth: true }), + updatePlacementSettings: (body: Record) => + apiRequest("/api/v1/members/me/placement-settings", { method: "PATCH", body, auth: true }), + + threadsAccounts: () => apiRequest("/api/v1/threads-accounts/", { auth: true }), + createThreadsAccount: (body: { display_name?: string; activate?: boolean }) => + apiRequest("/api/v1/threads-accounts/", { method: "POST", body, auth: true }), + deleteThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}`, { method: "DELETE", auth: true }), + activateThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/activate`, { method: "POST", auth: true }), + getThreadsConnection: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/connection`, { auth: true }), + updateThreadsConnection: (id: string, body: Record) => + apiRequest(`/api/v1/threads-accounts/${id}/connection`, { method: "PATCH", body, auth: true }), + importThreadsAccountSession: (id: string, storageState: string) => + apiRequest<{ success: boolean; valid: boolean; synced: boolean; message: string; account_id: string; username?: string }>( + `/api/v1/threads-accounts/${id}/session/import`, + { method: "POST", body: { storageState }, auth: true } + ), + threadsAiSettings: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }), + updateThreadsAiSettings: (id: string, body: Record) => + apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }), + threadsAccountProviderModels: (id: string, provider: string, apiKey?: string) => + apiRequest(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, { + method: "POST", + body: apiKey ? { api_key: apiKey } : {}, + auth: true + }), + oauthConfig: () => apiRequest<{ enabled: boolean; redirect_uri: string; success_redirect: string; requires_https: boolean }>("/api/v1/threads-accounts/oauth/config", { auth: true }), + oauthLogs: () => apiRequest<{ list: Array<{ at: number; level: string; stage: string; message: string }> }>("/api/v1/threads-accounts/oauth/logs", { auth: true }), + startOauth: (accountId?: string) => + apiRequest<{ authorize_url: string; account_id?: string; state?: string }>( + `/api/v1/threads-accounts/oauth/start${accountId ? `?account_id=${encodeURIComponent(accountId)}` : ""}`, + { auth: true } + ), + smokeTest: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/api/smoke-test`, { method: "POST", auth: true }), + threadsDiagnostics: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/diagnostics`, { auth: true }), + publishDashboardSummary: () => apiRequest("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }), + threadsPlayground: (id: string, body: Record) => + apiRequest(`/api/v1/threads-accounts/${id}/api/playground`, { method: "POST", body, auth: true }), + syncMentionInbox: (accountId: string, limit = 25, maxPages = 4) => + apiRequest(`/api/v1/threads-accounts/${accountId}/mention-inbox/sync`, { + method: "POST", + body: { limit, max_pages: maxPages }, + auth: true + }), + listMentionInbox: (accountId: string, page = 1, pageSize = 8) => + apiRequest( + `/api/v1/threads-accounts/${accountId}/mention-inbox?page=${page}&pageSize=${pageSize}`, + { auth: true } + ), + publishMentionReply: (accountId: string, mediaId: string, text: string) => + apiRequest<{ media_id?: string; permalink?: string }>( + `/api/v1/threads-accounts/${accountId}/mention-inbox/${encodeURIComponent(mediaId)}/reply`, + { method: "POST", body: { text }, auth: true } + ), + + publishQueue: (accountId: string, status = "", page = 1) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { auth: true }), + createPublishQueue: (accountId: string, body: { text: string; topic_tag?: string; scheduled_at?: number }) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue`, { method: "POST", body, auth: true }), + patchPublishQueue: (accountId: string, qid: string, body: { text?: string; topic_tag?: string; scheduled_at?: number }) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "PATCH", body, auth: true }), + cancelPublishQueue: (accountId: string, qid: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/cancel`, { method: "POST", auth: true }), + deletePublishQueue: (accountId: string, qid: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "DELETE", auth: true }), + retryPublishQueue: (accountId: string, qid: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/retry`, { method: "POST", auth: true }), + publishQueueEvents: (accountId: string, qid: string) => + apiRequest<{ list: PublishQueueEvent[] }>(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/events`, { auth: true }), + publishAlerts: (accountId: string) => apiRequest<{ list: PublishAlert[] }>(`/api/v1/threads-accounts/${accountId}/publish-alerts`, { auth: true }), + publishCalendar: (accountId: string, startAt: number, endAt: number, status = "") => + apiRequest( + `/api/v1/threads-accounts/${accountId}/publish-calendar?startAt=${startAt}&endAt=${endAt}${status ? `&status=${status}` : ""}`, + { auth: true } + ), + publishInventoryPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { auth: true }), + updatePublishInventoryPolicy: (accountId: string, body: Partial) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { method: "PUT", body, auth: true }), + startPublishInventoryRefill: (accountId: string, count?: number) => + apiRequest<{ job_id: string; status: string; message: string }>(`/api/v1/threads-accounts/${accountId}/publish-inventory/refill-jobs`, { method: "POST", body: { count }, auth: true }), + publishSlotInsights: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-slot-insights`, { auth: true }), + publishGuardPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { auth: true }), + updatePublishGuardPolicy: (accountId: string, body: Partial) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { method: "PUT", body, auth: true }), + resumePublishGuard: (accountId: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard/resume`, { method: "POST", auth: true }), + publishHealth: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-health?page=1&pageSize=10`, { auth: true }), + postPerformance: (accountId: string, limit = 30) => + apiRequest(`/api/v1/threads-accounts/${accountId}/post-performance?limit=${limit}`, { auth: true }), + generateOwnPostReplyDraft: ( + accountId: string, + body: { + media_id: string; + persona_id?: string; + reply_to_id?: string; + post_text?: string; + reply_text?: string; + thread_post_text?: string; + parent_reply_text?: string; + } + ) => + apiRequest<{ text: string }>(`/api/v1/threads-accounts/${accountId}/own-post-reply-draft`, { + method: "POST", + body, + auth: true + }), + generateOwnPostFormula: ( + accountId: string, + body: { + media_id: string; + persona_id?: string; + post_text?: string; + topic_tag?: string; + media_type?: string; + timestamp?: string; + like_count?: number; + reply_count?: number; + views?: number; + repost_count?: number; + quote_count?: number; + shares?: number; + force?: boolean; + } + ) => + apiRequest(`/api/v1/threads-accounts/${accountId}/own-post-formula`, { + method: "POST", + body, + auth: true + }), + listOwnPostFormulas: (accountId: string) => + apiRequest<{ list: OwnPostFormulaReview[] }>(`/api/v1/threads-accounts/${accountId}/own-post-formulas`, { auth: true }), + + personas: () => apiRequest<{ list: Persona[] }>("/api/v1/personas/", { auth: true }), + createPersona: (body: { display_name?: string }) => apiRequest("/api/v1/personas/", { method: "POST", body, auth: true }), + updatePersona: (id: string, body: Record) => apiRequest(`/api/v1/personas/${id}`, { method: "PATCH", body, auth: true }), + deletePersona: (id: string) => apiRequest(`/api/v1/personas/${id}`, { method: "DELETE", auth: true }), + startStyleAnalysis: (id: string, benchmark_username: string) => + apiRequest<{ job_id: string; status: string }>(`/api/v1/personas/${id}/style-analysis`, { method: "POST", body: { benchmark_username }, auth: true }), + startStyleAnalysisFromText: ( + id: string, + body: { reference_texts?: string[]; raw_text?: string; source_label?: string } + ) => + apiRequest<{ persona: Persona; post_count: number; message: string }>( + `/api/v1/personas/${id}/style-analysis-from-text`, + { method: "POST", body, auth: true } + ), + viralScanPosts: (personaId: string, limit = 30) => + apiRequest<{ list: ViralScanPost[]; total: number }>(`/api/v1/personas/${personaId}/viral-scan-posts?limit=${limit}`, { auth: true }), + personaDrafts: (id: string) => apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${id}/copy-drafts`, { auth: true }), + updateDraft: (personaId: string, draftId: string, body: Record) => + apiRequest(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "PATCH", body, auth: true }), + pruneCopyDrafts: (personaId: string, body?: { keep?: number }) => + apiRequest<{ deleted: number; kept: number; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/prune`, { method: "POST", body: body ?? {}, auth: true }), + deleteDraft: (personaId: string, draftId: string) => + apiRequest<{ draft_id: string; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "DELETE", auth: true }), + schedulePersonaDrafts: (personaId: string, body: { account_id: string; draft_ids: string[]; start_at?: number; timezone?: string; slots?: PublishSlot[]; mode?: string }) => + apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/schedule`, { method: "POST", body, auth: true }), + contentInbox: (personaId: string, params?: { page?: number; pageSize?: number; status?: string; rangeStart?: number; rangeEnd?: number }) => { + const q = new URLSearchParams(); + if (params?.page) q.set("page", String(params.page)); + if (params?.pageSize) q.set("pageSize", String(params.pageSize)); + if (params?.status) q.set("status", params.status); + if (params?.rangeStart) q.set("rangeStart", String(params.rangeStart)); + if (params?.rangeEnd) q.set("rangeEnd", String(params.rangeEnd)); + const suffix = q.toString() ? `?${q.toString()}` : ""; + return apiRequest<{ pagination: Pagination; list: ContentInboxItem[] }>(`/api/v1/personas/${personaId}/content-inbox${suffix}`, { auth: true }); + }, + scheduleCopyDraft: (personaId: string, draftId: string, body: { account_id: string; topic_tag?: string; scheduled_at?: number }) => + apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}/schedule`, { method: "POST", body, auth: true }), + generateFromFormula: (personaId: string, formulaId: string, body: { account_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/content-formulas/${formulaId}/generate`, { method: "POST", body, auth: true }), + contentFormulas: (accountId: string, params?: { page?: number; pageSize?: number; source_type?: string; tag?: string }) => { + const q = new URLSearchParams(); + if (params?.page) q.set("page", String(params.page)); + if (params?.pageSize) q.set("pageSize", String(params.pageSize)); + if (params?.source_type) q.set("source_type", params.source_type); + if (params?.tag) q.set("tag", params.tag); + const suffix = q.toString() ? `?${q.toString()}` : ""; + return apiRequest<{ pagination: Pagination; list: ContentFormula[] }>(`/api/v1/threads-accounts/${accountId}/content-formulas${suffix}`, { auth: true }); + }, + analyzeContentFormula: (accountId: string, body: Record) => + apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/analyze`, { method: "POST", body, auth: true }), + searchContentFormulaPosts: (accountId: string, body: { keyword: string; search_type?: string; limit?: number }) => + apiRequest<{ list: ContentFormulaSearchPost[]; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/search-posts`, { method: "POST", body, auth: true }), + importOwnPostToFormula: (accountId: string, body: { media_id: string; label?: string }) => + apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/import-own-post`, { method: "POST", body, auth: true }), + deleteContentFormula: (accountId: string, formulaId: string) => + apiRequest<{ formula_id: string; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/${formulaId}`, { method: "DELETE", auth: true }), + stylePresets: (personaId: string) => apiRequest<{ list: StylePreset[] }>(`/api/v1/personas/${personaId}/style-presets`, { auth: true }), + upsertStylePreset: (personaId: string, presetId: string, body: { name: string; tone?: string; cta?: string[]; banned_words?: string[]; notes?: string; apply?: boolean }) => + apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "PUT", body, auth: true }), + deleteStylePreset: (personaId: string, presetId: string) => + apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "DELETE", auth: true }), + + generateTopicMatrix: (personaId: string, body: { topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/generate`, { method: "POST", body, auth: true }), + startTopicMatrixJob: (personaId: string, body: { topic: string; content_plan_id?: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/jobs`, { method: "POST", body, auth: true }), + startFormulaDraftJob: (personaId: string, body: { account_id: string; formula_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/formula-draft/jobs`, { method: "POST", body, auth: true }), + startRewriteDraftJob: (personaId: string, body: { account_id: string; reference_text: string; topic: string; brief?: string; save_label?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/rewrite-draft/jobs`, { method: "POST", body, auth: true }), + inspireCopyMission: (personaId: string, body: { keyword?: string; content_direction?: string; use_web_search?: boolean; avoid_topics?: string[] }) => + apiRequest(`/api/v1/personas/${personaId}/copy-mission-inspiration`, { method: "POST", body, auth: true }), + topicCandidates: (personaId: string) => + apiRequest<{ list: TopicCandidate[] }>(`/api/v1/personas/${personaId}/topic-candidates`, { auth: true }), + contentPlans: (personaId: string) => + apiRequest<{ list: ContentPlan[] }>(`/api/v1/personas/${personaId}/content-plans`, { auth: true }), + updateContentPlan: (personaId: string, contentPlanId: string, body: Record) => + apiRequest(`/api/v1/personas/${personaId}/content-plans/${contentPlanId}`, { method: "PATCH", body, auth: true }), + createFeedbackEvent: (personaId: string, body: { content_plan_id?: string; draft_id?: string; decision: string; note?: string; snapshot?: string }) => + apiRequest(`/api/v1/personas/${personaId}/feedback-events`, { method: "POST", body, auth: true }), + createKnowledgeSource: (personaId: string, body: { source_type: string; title?: string; filename?: string; content?: string; content_base64?: string }) => + apiRequest(`/api/v1/personas/${personaId}/knowledge-sources`, { method: "POST", body, auth: true }), + knowledgeSources: (personaId: string) => + apiRequest<{ list: KnowledgeSource[] }>(`/api/v1/personas/${personaId}/knowledge-sources`, { auth: true }), + knowledgeChunks: (personaId: string) => + apiRequest<{ list: KnowledgeChunk[] }>(`/api/v1/personas/${personaId}/knowledge-chunks`, { auth: true }), + formulaPools: (personaId: string) => + apiRequest<{ list: FormulaPool[] }>(`/api/v1/personas/${personaId}/formula-pools`, { auth: true }), + createFormulaPool: (personaId: string, body: { type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number }) => + apiRequest(`/api/v1/personas/${personaId}/formula-pools`, { method: "POST", body, auth: true }), + + // Brand core + brands: () => apiRequest("/api/v1/brands/", { auth: true }), + createBrand: (body: { display_name?: string }) => apiRequest("/api/v1/brands/", { method: "POST", body, auth: true }), + getBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { auth: true }), + updateBrand: (id: string, body: Record) => apiRequest(`/api/v1/brands/${id}`, { method: "PATCH", body, auth: true }), + deleteBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { method: "DELETE", auth: true }), + + // Brand Products + listBrandProducts: (id: string) => apiRequest<{ list: BrandProductData[] }>(`/api/v1/brands/${id}/products`, { auth: true }), + createBrandProduct: (id: string, body: { label: string; product_context: string; match_tags?: string[]; placement_url?: string }) => + apiRequest(`/api/v1/brands/${id}/products`, { method: "POST", body, auth: true }), + updateBrandProduct: (id: string, productId: string, body: Record) => + apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "PATCH", body, auth: true }), + deleteBrandProduct: (id: string, productId: string) => + apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "DELETE", auth: true }), + + // Knowledge Graph + expandKnowledgeGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) => + apiRequest(`/api/v1/brands/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }), + getKnowledgeGraph: (id: string) => apiRequest(`/api/v1/brands/${id}/knowledge-graph`, { auth: true }), + patchKnowledgeGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) => + apiRequest(`/api/v1/brands/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }), + + // Scan jobs & posts + startBrandScan: (id: string, body: Record) => + apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/brands/${id}/scan-jobs`, { method: "POST", body, auth: true }), + listBrandScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => { + const qs = new URLSearchParams(); + if (params?.priority) qs.set("priority", params.priority); + if (params?.recent_7d) qs.set("recent_7d", "true"); + if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min)); + if (params?.limit) qs.set("limit", String(params.limit)); + const q = qs.toString() ? `?${qs.toString()}` : ""; + return apiRequest(`/api/v1/brands/${id}/scan-posts${q}`, { auth: true }); + }, + + // Outreach + generateOutreachDrafts: (id: string, body: { scan_post_id: string; topic_id?: string; count?: number; voice_persona_id?: string; product_id?: string }) => + apiRequest(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }), + // keep old simple name for PatrolPage (returns any for now) + generateOutreachDraftsLegacy: (id: string, body: Record) => + apiRequest>(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }), + publishOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) => + apiRequest(`/api/v1/brands/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }), + patchScanPostOutreach: (id: string, postId: string, body: { outreach_status?: string }) => + apiRequest(`/api/v1/brands/${id}/scan-posts/${postId}`, { method: "PATCH", body, auth: true }), + + // Content Matrix + getBrandContentMatrix: (id: string) => apiRequest(`/api/v1/brands/${id}/content-matrix`, { auth: true }), + generateBrandContentMatrix: (id: string, body?: { count?: number }) => + apiRequest(`/api/v1/brands/${id}/content-matrix/generate`, { method: "POST", body: body || {}, auth: true }), + + // Scan Schedule + getBrandScanSchedule: (id: string) => apiRequest(`/api/v1/brands/${id}/scan-schedule`, { auth: true }), + upsertBrandScanSchedule: (id: string, body: { cron?: string; timezone?: string; enabled: boolean }) => + apiRequest(`/api/v1/brands/${id}/scan-schedule`, { method: "PUT", body, auth: true }), + + // Legacy simple (PatrolPage still uses these names) + brandScanPosts: (id: string) => apiRequest<{ list: ScanPost[]; total: number }>(`/api/v1/brands/${id}/scan-posts?limit=20`, { auth: true }), + + // Placement Topics (海巡主題) - 主要用於海巡頁:新增主題、爬文、知識圖譜 + placementTopics: () => apiRequest("/api/v1/placement/topics", { auth: true }), + createPlacementTopic: (body: { brand_id: string; topic_name: string; seed_query: string; brief: string; product_id?: string }) => + apiRequest("/api/v1/placement/topics", { method: "POST", body, auth: true }), + getPlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { auth: true }), + updatePlacementTopic: (id: string, body: Record) => + apiRequest(`/api/v1/placement/topics/${id}`, { method: "PATCH", body, auth: true }), + deletePlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { method: "DELETE", auth: true }), + + // Placement Topic Knowledge Graph & Scan & Outreach + expandPlacementTopicGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) => + apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }), + getPlacementTopicGraph: (id: string) => apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph`, { auth: true }), + patchPlacementTopicGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) => + apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }), + + startPlacementTopicScan: ( + id: string, + body: { + graph_id?: string; + node_ids?: string[]; + dual_track?: boolean; + patrol_mode?: boolean; + test_patrol?: boolean; + patrol_keywords?: string[]; + } + ) => + apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/placement/topics/${id}/scan-jobs`, { method: "POST", body, auth: true }), + listPlacementTopicScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => { + const qs = new URLSearchParams(); + if (params?.priority) qs.set("priority", params.priority); + if (params?.recent_7d) qs.set("recent_7d", "true"); + if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min)); + if (params?.limit) qs.set("limit", String(params.limit)); + const q = qs.toString() ? `?${qs.toString()}` : ""; + return apiRequest(`/api/v1/placement/topics/${id}/scan-posts${q}`, { auth: true }); + }, + + generatePlacementTopicOutreachDrafts: (id: string, body: { scan_post_id: string; count?: number; voice_persona_id?: string; product_id?: string }) => + apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }), + startPlacementTopicOutreachDraftJobs: ( + id: string, + body: { + scan_post_id?: string; + scan_post_ids?: string[]; + count?: number; + voice_persona_id: string; + product_id?: string; + regenerate?: boolean; + }, + ) => + apiRequest<{ jobs: Array<{ job_id: string; status: string; message: string }>; message: string }>( + `/api/v1/placement/topics/${id}/outreach-draft-jobs`, + { method: "POST", body, auth: true }, + ), + publishPlacementTopicOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) => + apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }), + updatePlacementTopicOutreachDraft: (topicId: string, draftId: string, body: { draft_index: number; text: string }) => + apiRequest(`/api/v1/placement/topics/${topicId}/outreach-drafts/${draftId}`, { method: "PATCH", body, auth: true }), + deletePlacementTopicScanPost: (topicId: string, postId: string) => + apiRequest(`/api/v1/placement/topics/${topicId}/scan-posts/${postId}`, { method: "DELETE", auth: true }), + batchDeletePlacementTopicScanPosts: (topicId: string, postIds: string[]) => + apiRequest<{ deleted_count: number }>(`/api/v1/placement/topics/${topicId}/scan-posts/batch-delete`, { method: "POST", body: { post_ids: postIds }, auth: true }), + + jobs: (page = 1, pageSize = 30) => apiRequest(`/api/v1/jobs?page=${page}&pageSize=${pageSize}`, { auth: true }), + getJob: (id: string) => apiRequest(`/api/v1/jobs/${id}`, { auth: true }), + jobEvents: (jobId: string, limit = 40) => + apiRequest<{ list: JobEvent[] }>(`/api/v1/jobs/${jobId}/events?limit=${limit}`, { auth: true }), + cancelJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/cancel`, { method: "POST", auth: true }), + retryJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/retry`, { method: "POST", auth: true }), + jobSchedules: () => apiRequest<{ list: JobSchedule[]; pagination: Pagination }>("/api/v1/job/schedules?page=1&pageSize=50", { auth: true }), + settings: (scope: string, scopeId: string) => apiRequest<{ list: SettingValue[]; pagination: Pagination }>(`/api/v1/settings/${scope}/${scopeId}`, { auth: true }), + permissions: () => apiRequest("/api/v1/permissions/me?include_tree=true", { auth: true }) +}; diff --git a/backend/web/src/auth/AuthContext.tsx b/backend/web/src/auth/AuthContext.tsx new file mode 100644 index 0000000..b755f5e --- /dev/null +++ b/backend/web/src/auth/AuthContext.tsx @@ -0,0 +1,108 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { apiRequest, clearTokens, getAccessToken, setTokens, setUnauthorizedHandler } from "../api/client"; + +export type MemberMe = { + tenant_id: string; + uid: string; + email: string; + display_name?: string; + status: string; + roles?: string[]; +}; + +type AuthContextValue = { + member: MemberMe | null; + loading: boolean; + login: (tenantId: string, email: string, password: string) => Promise; + register: (tenantId: string, email: string, password: string, displayName?: string) => Promise; + logout: () => Promise; + reloadMember: () => Promise; +}; + +const AuthContext = createContext(null); + +type TokenPair = { + access_token: string; + refresh_token: string; +}; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [member, setMember] = useState(null); + const [loading, setLoading] = useState(true); + + const reloadMember = useCallback(async () => { + if (!getAccessToken()) { + setMember(null); + setLoading(false); + return; + } + setLoading(true); + try { + const data = await apiRequest("/api/v1/members/me", { auth: true }); + setMember(data); + } catch { + clearTokens(); + setMember(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + setUnauthorizedHandler(() => { + clearTokens(); + setMember(null); + }); + void reloadMember(); + return () => setUnauthorizedHandler(undefined); + }, [reloadMember]); + + const login = useCallback( + async (tenantId: string, email: string, password: string) => { + const data = await apiRequest("/api/v1/auth/login", { + method: "POST", + body: { tenant_id: tenantId, email, password } + }); + setTokens(data.access_token, data.refresh_token); + await reloadMember(); + }, + [reloadMember] + ); + + const register = useCallback( + async (tenantId: string, email: string, password: string, displayName?: string) => { + const data = await apiRequest("/api/v1/auth/register", { + method: "POST", + body: { tenant_id: tenantId, email, password, display_name: displayName } + }); + setTokens(data.access_token, data.refresh_token); + await reloadMember(); + }, + [reloadMember] + ); + + const logout = useCallback(async () => { + try { + await apiRequest("/api/v1/auth/logout", { method: "POST", auth: true }); + } catch { + // Local logout still needs to complete if the server token is already invalid. + } + clearTokens(); + setMember(null); + }, []); + + const value = useMemo( + () => ({ member, loading, login, register, logout, reloadMember }), + [member, loading, login, register, logout, reloadMember] + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +} diff --git a/backend/web/src/components/AcIcon.tsx b/backend/web/src/components/AcIcon.tsx new file mode 100644 index 0000000..f7bb8aa --- /dev/null +++ b/backend/web/src/components/AcIcon.tsx @@ -0,0 +1,34 @@ +type IconName = + | "home" + | "account" + | "publish" + | "persona" + | "mission" + | "patrol" + | "job" + | "settings" + | "spark" + | "more" + | "brand"; + +const paths: Record = { + home: "M4 11.5 12 4l8 7.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-8.5Z", + account: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0H5Z", + publish: "M4 19V5l16 7-16 7Zm3-4.4 7.9-2.6L7 9.4v3.1l5 1.5-5 .6Z", + persona: "M7 5h10v4H7V5Zm-2 7h14v7H5v-7Zm3 2v3h2v-3H8Zm6 0v3h2v-3h-2Z", + mission: "M5 5h14v14H5V5Zm3 4h8V7H8v2Zm0 4h8v-2H8v2Zm0 4h5v-2H8v2Z", + patrol: "M12 3 4 7v6c0 4 3.3 7 8 8 4.7-1 8-4 8-8V7l-8-4Zm0 4 4 2v4c0 2.2-1.5 4-4 5-2.5-1-4-2.8-4-5V9l4-2Z", + job: "M6 4h12v4H6V4Zm0 6h12v4H6v-4Zm0 6h12v4H6v-4Z", + settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8.5 5.5v-3l-2.2-.4a7.7 7.7 0 0 0-.7-1.6l1.3-1.9-2.1-2.1-1.9 1.3c-.5-.3-1.1-.5-1.7-.7L12.8 3h-3l-.4 2.1c-.6.2-1.1.4-1.7.7L5.9 4.5 3.8 6.6l1.3 1.9c-.3.5-.5 1.1-.7 1.6l-2.2.4v3l2.2.4c.2.6.4 1.1.7 1.6l-1.3 1.9 2.1 2.1 1.9-1.3c.5.3 1.1.5 1.7.7l.4 2.1h3l.4-2.1c.6-.2 1.1-.4 1.7-.7l1.9 1.3 2.1-2.1-1.3-1.9c.3-.5.5-1.1.7-1.6l2.1-.4Z", + spark: "M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8L12 2Zm6 13 1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z", + more: "M5 12a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Z", + brand: "M4 7h16v2l-2 2v7a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-7L4 9V7Zm2 4 1 1v6h2v-4h6v4h2v-6l1-1V9H6v2Z" +}; + +export function AcIcon({ name, size = 22 }: { name: IconName; size?: number }) { + return ( + + ); +} diff --git a/backend/web/src/components/AuthDecor.tsx b/backend/web/src/components/AuthDecor.tsx new file mode 100644 index 0000000..e5f2eff --- /dev/null +++ b/backend/web/src/components/AuthDecor.tsx @@ -0,0 +1,22 @@ +export function SceneDecor() { + return ( +