From 4cd221af5e104ffe737adaedb97ae66d2580fd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Tue, 23 Jun 2026 17:54:27 +0800 Subject: [PATCH] fix dockerfile unhealth problem --- haixun-backend/.idea/.gitignore | 10 + haixun-backend/.idea/go.imports.xml | 10 + haixun-backend/.idea/haixun-backend.iml | 9 + .../.idea/material_theme_project_new.xml | 10 + haixun-backend/.idea/modules.xml | 8 + haixun-backend/.idea/vcs.xml | 6 + haixun-backend/AGENTS.md | 335 +++ haixun-backend/Makefile | 52 + haixun-backend/README.md | 365 +++ haixun-backend/cmd/tool/main.go | 87 + haixun-backend/deploy/README.md | 64 + haixun-backend/deploy/docker-compose.yml | 37 + .../deploy/mongo/init/01-gateway-indexes.js | 31 + haixun-backend/docs/job-system-plan.md | 381 +++ haixun-backend/etc/gateway.dev.example.yaml | 20 + haixun-backend/etc/gateway.yaml | 32 + haixun-backend/gateway.go | 34 + haixun-backend/generate/api/ai.api | 67 + haixun-backend/generate/api/auth.api | 62 + haixun-backend/generate/api/common.api | 15 + haixun-backend/generate/api/gateway.api | 24 + haixun-backend/generate/api/job.api | 245 ++ haixun-backend/generate/api/member.api | 46 + haixun-backend/generate/api/normal.api | 16 + haixun-backend/generate/api/permission.api | 52 + haixun-backend/generate/api/setting.api | 68 + haixun-backend/generate/goctl/api/handler.tpl | 31 + haixun-backend/go.mod | 69 + haixun-backend/go.sum | 196 ++ haixun-backend/internal/bootstrap/admin.go | 72 + .../internal/bootstrap/admin_test.go | 116 + haixun-backend/internal/bootstrap/init.go | 104 + haixun-backend/internal/config/config.go | 48 + .../internal/handler/ai/chat_handler.go | 36 + .../handler/ai/chat_stream_handler.go | 72 + .../ai/list_ai_provider_models_handler.go | 36 + .../handler/ai/list_ai_providers_handler.go | 17 + .../internal/handler/auth/login_handler.go | 29 + .../internal/handler/auth/logout_handler.go | 17 + .../internal/handler/auth/refresh_handler.go | 29 + .../internal/handler/auth/register_handler.go | 29 + .../handler/job/cancel_job_handler.go | 33 + .../handler/job/create_job_handler.go | 33 + .../job/create_job_schedule_handler.go | 33 + .../job/disable_job_schedule_handler.go | 33 + .../job/enable_job_schedule_handler.go | 33 + .../internal/handler/job/get_job_handler.go | 33 + .../handler/job/get_job_template_handler.go | 33 + .../handler/job/list_job_events_handler.go | 33 + .../handler/job/list_job_schedules_handler.go | 33 + .../handler/job/list_job_templates_handler.go | 20 + .../internal/handler/job/list_jobs_handler.go | 33 + .../internal/handler/job/retry_job_handler.go | 33 + .../job/update_job_schedule_handler.go | 33 + .../job/upsert_job_template_handler.go | 33 + .../handler/member/get_member_me_handler.go | 20 + .../member/update_member_me_handler.go | 33 + .../internal/handler/normal/health_handler.go | 17 + .../permission/get_me_permissions_handler.go | 33 + .../get_permission_catalog_handler.go | 29 + haixun-backend/internal/handler/routes.go | 248 ++ .../handler/setting/delete_setting_handler.go | 29 + .../handler/setting/get_setting_handler.go | 29 + .../handler/setting/list_settings_handler.go | 29 + .../handler/setting/upsert_setting_handler.go | 29 + .../internal/library/authctx/context.go | 20 + .../internal/library/clock/clock.go | 36 + .../internal/library/clock/clock_test.go | 24 + .../internal/library/errors/code/types.go | 39 + .../internal/library/errors/errors.go | 169 ++ .../internal/library/mongo/client.go | 51 + .../internal/library/redact/redact.go | 17 + .../internal/library/redact/redact_test.go | 12 + .../internal/library/redis/client.go | 17 + .../internal/library/validate/validate.go | 18 + .../internal/logic/ai/chat_logic.go | 26 + .../internal/logic/ai/chat_stream_logic.go | 22 + .../internal/logic/ai/credential.go | 27 + .../logic/ai/list_ai_provider_models_logic.go | 30 + .../logic/ai/list_ai_providers_logic.go | 30 + haixun-backend/internal/logic/ai/mapper.go | 28 + .../internal/logic/auth/login_logic.go | 27 + .../internal/logic/auth/logout_logic.go | 29 + haixun-backend/internal/logic/auth/mapper.go | 19 + .../internal/logic/auth/refresh_logic.go | 31 + .../internal/logic/auth/register_logic.go | 29 + .../internal/logic/job/cancel_job_logic.go | 30 + .../internal/logic/job/create_job_logic.go | 32 + .../logic/job/create_job_schedule_logic.go | 35 + .../logic/job/disable_job_schedule_logic.go | 26 + .../logic/job/enable_job_schedule_logic.go | 26 + .../internal/logic/job/get_job_logic.go | 26 + .../logic/job/get_job_template_logic.go | 26 + .../logic/job/list_job_events_logic.go | 33 + .../logic/job/list_job_schedules_logic.go | 37 + .../logic/job/list_job_templates_logic.go | 29 + .../internal/logic/job/list_jobs_logic.go | 37 + haixun-backend/internal/logic/job/mapper.go | 123 + .../internal/logic/job/retry_job_logic.go | 26 + .../logic/job/update_job_schedule_logic.go | 33 + .../logic/job/upsert_job_template_logic.go | 48 + .../logic/member/get_member_me_logic.go | 32 + .../internal/logic/member/mapper.go | 31 + .../logic/member/update_member_me_logic.go | 48 + .../internal/logic/normal/health_logic.go | 21 + .../permission/get_me_permissions_logic.go | 42 + .../get_permission_catalog_logic.go | 33 + .../internal/logic/permission/mapper.go | 33 + .../logic/setting/delete_setting_logic.go | 21 + .../logic/setting/get_setting_logic.go | 26 + .../logic/setting/list_settings_logic.go | 41 + .../internal/logic/setting/mapper.go | 19 + .../logic/setting/upsert_setting_logic.go | 33 + haixun-backend/internal/middleware/auth.go | 97 + .../internal/middleware/auth_test.go | 46 + .../internal/middleware/authjwt_middleware.go | 23 + .../middleware/memberauth_middleware.go | 24 + .../internal/model/ai/domain/enum/provider.go | 8 + .../internal/model/ai/domain/usecase/ai.go | 66 + .../model/ai/provider/openai_compatible.go | 356 +++ .../ai/provider/openai_compatible_test.go | 178 ++ .../internal/model/ai/usecase/usecase.go | 118 + .../auth/domain/repository/token_revoke.go | 14 + .../model/auth/domain/usecase/token.go | 42 + .../auth/repository/token_revoke_redis.go | 98 + .../internal/model/auth/usecase/token.go | 233 ++ .../internal/model/job/cron/next_run.go | 31 + .../internal/model/job/cron/next_run_test.go | 27 + .../internal/model/job/domain/entity/event.go | 16 + .../internal/model/job/domain/entity/run.go | 50 + .../model/job/domain/entity/schedule.go | 20 + .../model/job/domain/entity/template.go | 42 + .../internal/model/job/domain/enum/status.go | 58 + .../model/job/domain/repository/event.go | 13 + .../model/job/domain/repository/queue.go | 19 + .../model/job/domain/repository/run.go | 29 + .../model/job/domain/repository/schedule.go | 21 + .../model/job/domain/repository/template.go | 14 + .../internal/model/job/domain/usecase/job.go | 122 + .../model/job/repository/mongo_event.go | 71 + .../model/job/repository/mongo_run.go | 317 ++ .../model/job/repository/mongo_schedule.go | 145 + .../model/job/repository/mongo_template.go | 90 + .../model/job/repository/redis_queue.go | 220 ++ .../model/job/repository/redis_queue_test.go | 71 + .../internal/model/job/resume/resume.go | 69 + .../internal/model/job/resume/resume_test.go | 46 + .../internal/model/job/usecase/cancel_test.go | 58 + .../internal/model/job/usecase/dedupe_test.go | 85 + .../internal/model/job/usecase/helpers.go | 146 + .../internal/model/job/usecase/maintenance.go | 76 + .../model/job/usecase/maintenance_test.go | 90 + .../model/job/usecase/retry_resume_test.go | 85 + .../internal/model/job/usecase/retry_test.go | 52 + .../internal/model/job/usecase/schedule.go | 171 ++ .../model/job/usecase/schedule_payload.go | 25 + .../job/usecase/schedule_payload_test.go | 40 + .../model/job/usecase/schedule_test.go | 75 + .../internal/model/job/usecase/step_resume.go | 18 + .../internal/model/job/usecase/template.go | 68 + .../model/job/usecase/template_test.go | 35 + .../internal/model/job/usecase/test_mocks.go | 430 +++ .../internal/model/job/usecase/usecase.go | 562 ++++ .../model/member/domain/entity/member.go | 41 + .../model/member/domain/repository/member.go | 24 + .../model/member/domain/usecase/member.go | 46 + .../internal/model/member/repository/mongo.go | 147 + .../internal/model/member/usecase/usecase.go | 120 + .../permission/domain/entity/permission.go | 42 + .../domain/repository/permission.go | 25 + .../permission/domain/usecase/permission.go | 43 + .../permission/repository/mongo_permission.go | 112 + .../repository/mongo_role_permission.go | 89 + .../model/permission/repository/object_id.go | 14 + .../model/permission/usecase/usecase.go | 209 ++ .../model/setting/domain/entity/setting.go | 16 + .../setting/domain/repository/setting.go | 15 + .../model/setting/domain/usecase/setting.go | 22 + .../model/setting/repository/mongo.go | 135 + .../internal/model/setting/usecase/usecase.go | 88 + haixun-backend/internal/response/request.go | 14 + haixun-backend/internal/response/response.go | 53 + .../internal/svc/service_context.go | 194 ++ haixun-backend/internal/types/types.go | 390 +++ haixun-backend/internal/worker/job/reaper.go | 44 + haixun-backend/internal/worker/job/runner.go | 221 ++ .../internal/worker/job/runner_test.go | 52 + .../internal/worker/job/scheduler.go | 44 + haixun-backend/scripts/debug-opencode-raw.sh | 71 + haixun-backend/scripts/test-job-cancel.sh | 76 + .../scripts/test-job-concurrency.sh | 87 + haixun-backend/web/.gitignore | 4 + haixun-backend/web/index.html | 25 + haixun-backend/web/package-lock.json | 2558 +++++++++++++++++ haixun-backend/web/package.json | 26 + haixun-backend/web/src/App.tsx | 45 + haixun-backend/web/src/api/client.ts | 167 ++ haixun-backend/web/src/auth/AuthContext.tsx | 132 + .../web/src/components/AuthShell.tsx | 28 + haixun-backend/web/src/components/Layout.tsx | 110 + .../web/src/components/MobileBottomNav.tsx | 187 ++ .../web/src/components/ProtectedRoute.tsx | 15 + .../web/src/components/ThemeToggle.tsx | 28 + haixun-backend/web/src/components/ui.tsx | 178 ++ haixun-backend/web/src/index.css | 181 ++ haixun-backend/web/src/lib/jobStatus.ts | 52 + haixun-backend/web/src/lib/storage.ts | 25 + haixun-backend/web/src/main.tsx | 10 + haixun-backend/web/src/pages/AiPage.tsx | 148 + .../web/src/pages/DashboardPage.tsx | 85 + .../web/src/pages/JobDetailPage.tsx | 90 + .../web/src/pages/JobSchedulesPage.tsx | 125 + .../web/src/pages/JobTemplatesPage.tsx | 43 + haixun-backend/web/src/pages/JobsPage.tsx | 183 ++ haixun-backend/web/src/pages/LoginPage.tsx | 66 + .../web/src/pages/PermissionsPage.tsx | 86 + haixun-backend/web/src/pages/ProfilePage.tsx | 90 + haixun-backend/web/src/pages/RegisterPage.tsx | 71 + haixun-backend/web/src/pages/SettingsPage.tsx | 124 + haixun-backend/web/src/theme/ThemeContext.tsx | 48 + haixun-backend/web/src/types/api.ts | 138 + haixun-backend/web/src/vite-env.d.ts | 1 + haixun-backend/web/tsconfig.app.json | 22 + haixun-backend/web/tsconfig.json | 7 + haixun-backend/web/tsconfig.node.json | 20 + haixun-backend/web/vite.config.ts | 16 + template-monorepo | 1 + 227 files changed, 17959 insertions(+) create mode 100644 haixun-backend/.idea/.gitignore create mode 100644 haixun-backend/.idea/go.imports.xml create mode 100644 haixun-backend/.idea/haixun-backend.iml create mode 100644 haixun-backend/.idea/material_theme_project_new.xml create mode 100644 haixun-backend/.idea/modules.xml create mode 100644 haixun-backend/.idea/vcs.xml create mode 100644 haixun-backend/AGENTS.md create mode 100644 haixun-backend/Makefile create mode 100644 haixun-backend/README.md create mode 100644 haixun-backend/cmd/tool/main.go create mode 100644 haixun-backend/deploy/README.md create mode 100644 haixun-backend/deploy/docker-compose.yml create mode 100644 haixun-backend/deploy/mongo/init/01-gateway-indexes.js create mode 100644 haixun-backend/docs/job-system-plan.md create mode 100644 haixun-backend/etc/gateway.dev.example.yaml create mode 100644 haixun-backend/etc/gateway.yaml create mode 100644 haixun-backend/gateway.go create mode 100644 haixun-backend/generate/api/ai.api create mode 100644 haixun-backend/generate/api/auth.api create mode 100644 haixun-backend/generate/api/common.api create mode 100644 haixun-backend/generate/api/gateway.api create mode 100644 haixun-backend/generate/api/job.api create mode 100644 haixun-backend/generate/api/member.api create mode 100644 haixun-backend/generate/api/normal.api create mode 100644 haixun-backend/generate/api/permission.api create mode 100644 haixun-backend/generate/api/setting.api create mode 100644 haixun-backend/generate/goctl/api/handler.tpl create mode 100644 haixun-backend/go.mod create mode 100644 haixun-backend/go.sum create mode 100644 haixun-backend/internal/bootstrap/admin.go create mode 100644 haixun-backend/internal/bootstrap/admin_test.go create mode 100644 haixun-backend/internal/bootstrap/init.go create mode 100644 haixun-backend/internal/config/config.go create mode 100644 haixun-backend/internal/handler/ai/chat_handler.go create mode 100644 haixun-backend/internal/handler/ai/chat_stream_handler.go create mode 100644 haixun-backend/internal/handler/ai/list_ai_provider_models_handler.go create mode 100644 haixun-backend/internal/handler/ai/list_ai_providers_handler.go create mode 100644 haixun-backend/internal/handler/auth/login_handler.go create mode 100644 haixun-backend/internal/handler/auth/logout_handler.go create mode 100644 haixun-backend/internal/handler/auth/refresh_handler.go create mode 100644 haixun-backend/internal/handler/auth/register_handler.go create mode 100644 haixun-backend/internal/handler/job/cancel_job_handler.go create mode 100644 haixun-backend/internal/handler/job/create_job_handler.go create mode 100644 haixun-backend/internal/handler/job/create_job_schedule_handler.go create mode 100644 haixun-backend/internal/handler/job/disable_job_schedule_handler.go create mode 100644 haixun-backend/internal/handler/job/enable_job_schedule_handler.go create mode 100644 haixun-backend/internal/handler/job/get_job_handler.go create mode 100644 haixun-backend/internal/handler/job/get_job_template_handler.go create mode 100644 haixun-backend/internal/handler/job/list_job_events_handler.go create mode 100644 haixun-backend/internal/handler/job/list_job_schedules_handler.go create mode 100644 haixun-backend/internal/handler/job/list_job_templates_handler.go create mode 100644 haixun-backend/internal/handler/job/list_jobs_handler.go create mode 100644 haixun-backend/internal/handler/job/retry_job_handler.go create mode 100644 haixun-backend/internal/handler/job/update_job_schedule_handler.go create mode 100644 haixun-backend/internal/handler/job/upsert_job_template_handler.go create mode 100644 haixun-backend/internal/handler/member/get_member_me_handler.go create mode 100644 haixun-backend/internal/handler/member/update_member_me_handler.go create mode 100644 haixun-backend/internal/handler/normal/health_handler.go create mode 100644 haixun-backend/internal/handler/permission/get_me_permissions_handler.go create mode 100644 haixun-backend/internal/handler/permission/get_permission_catalog_handler.go create mode 100644 haixun-backend/internal/handler/routes.go create mode 100644 haixun-backend/internal/handler/setting/delete_setting_handler.go create mode 100644 haixun-backend/internal/handler/setting/get_setting_handler.go create mode 100644 haixun-backend/internal/handler/setting/list_settings_handler.go create mode 100644 haixun-backend/internal/handler/setting/upsert_setting_handler.go create mode 100644 haixun-backend/internal/library/authctx/context.go create mode 100644 haixun-backend/internal/library/clock/clock.go create mode 100644 haixun-backend/internal/library/clock/clock_test.go create mode 100644 haixun-backend/internal/library/errors/code/types.go create mode 100644 haixun-backend/internal/library/errors/errors.go create mode 100644 haixun-backend/internal/library/mongo/client.go create mode 100644 haixun-backend/internal/library/redact/redact.go create mode 100644 haixun-backend/internal/library/redact/redact_test.go create mode 100644 haixun-backend/internal/library/redis/client.go create mode 100644 haixun-backend/internal/library/validate/validate.go create mode 100644 haixun-backend/internal/logic/ai/chat_logic.go create mode 100644 haixun-backend/internal/logic/ai/chat_stream_logic.go create mode 100644 haixun-backend/internal/logic/ai/credential.go create mode 100644 haixun-backend/internal/logic/ai/list_ai_provider_models_logic.go create mode 100644 haixun-backend/internal/logic/ai/list_ai_providers_logic.go create mode 100644 haixun-backend/internal/logic/ai/mapper.go create mode 100644 haixun-backend/internal/logic/auth/login_logic.go create mode 100644 haixun-backend/internal/logic/auth/logout_logic.go create mode 100644 haixun-backend/internal/logic/auth/mapper.go create mode 100644 haixun-backend/internal/logic/auth/refresh_logic.go create mode 100644 haixun-backend/internal/logic/auth/register_logic.go create mode 100644 haixun-backend/internal/logic/job/cancel_job_logic.go create mode 100644 haixun-backend/internal/logic/job/create_job_logic.go create mode 100644 haixun-backend/internal/logic/job/create_job_schedule_logic.go create mode 100644 haixun-backend/internal/logic/job/disable_job_schedule_logic.go create mode 100644 haixun-backend/internal/logic/job/enable_job_schedule_logic.go create mode 100644 haixun-backend/internal/logic/job/get_job_logic.go create mode 100644 haixun-backend/internal/logic/job/get_job_template_logic.go create mode 100644 haixun-backend/internal/logic/job/list_job_events_logic.go create mode 100644 haixun-backend/internal/logic/job/list_job_schedules_logic.go create mode 100644 haixun-backend/internal/logic/job/list_job_templates_logic.go create mode 100644 haixun-backend/internal/logic/job/list_jobs_logic.go create mode 100644 haixun-backend/internal/logic/job/mapper.go create mode 100644 haixun-backend/internal/logic/job/retry_job_logic.go create mode 100644 haixun-backend/internal/logic/job/update_job_schedule_logic.go create mode 100644 haixun-backend/internal/logic/job/upsert_job_template_logic.go create mode 100644 haixun-backend/internal/logic/member/get_member_me_logic.go create mode 100644 haixun-backend/internal/logic/member/mapper.go create mode 100644 haixun-backend/internal/logic/member/update_member_me_logic.go create mode 100644 haixun-backend/internal/logic/normal/health_logic.go create mode 100644 haixun-backend/internal/logic/permission/get_me_permissions_logic.go create mode 100644 haixun-backend/internal/logic/permission/get_permission_catalog_logic.go create mode 100644 haixun-backend/internal/logic/permission/mapper.go create mode 100644 haixun-backend/internal/logic/setting/delete_setting_logic.go create mode 100644 haixun-backend/internal/logic/setting/get_setting_logic.go create mode 100644 haixun-backend/internal/logic/setting/list_settings_logic.go create mode 100644 haixun-backend/internal/logic/setting/mapper.go create mode 100644 haixun-backend/internal/logic/setting/upsert_setting_logic.go create mode 100644 haixun-backend/internal/middleware/auth.go create mode 100644 haixun-backend/internal/middleware/auth_test.go create mode 100644 haixun-backend/internal/middleware/authjwt_middleware.go create mode 100644 haixun-backend/internal/middleware/memberauth_middleware.go create mode 100644 haixun-backend/internal/model/ai/domain/enum/provider.go create mode 100644 haixun-backend/internal/model/ai/domain/usecase/ai.go create mode 100644 haixun-backend/internal/model/ai/provider/openai_compatible.go create mode 100644 haixun-backend/internal/model/ai/provider/openai_compatible_test.go create mode 100644 haixun-backend/internal/model/ai/usecase/usecase.go create mode 100644 haixun-backend/internal/model/auth/domain/repository/token_revoke.go create mode 100644 haixun-backend/internal/model/auth/domain/usecase/token.go create mode 100644 haixun-backend/internal/model/auth/repository/token_revoke_redis.go create mode 100644 haixun-backend/internal/model/auth/usecase/token.go create mode 100644 haixun-backend/internal/model/job/cron/next_run.go create mode 100644 haixun-backend/internal/model/job/cron/next_run_test.go create mode 100644 haixun-backend/internal/model/job/domain/entity/event.go create mode 100644 haixun-backend/internal/model/job/domain/entity/run.go create mode 100644 haixun-backend/internal/model/job/domain/entity/schedule.go create mode 100644 haixun-backend/internal/model/job/domain/entity/template.go create mode 100644 haixun-backend/internal/model/job/domain/enum/status.go create mode 100644 haixun-backend/internal/model/job/domain/repository/event.go create mode 100644 haixun-backend/internal/model/job/domain/repository/queue.go create mode 100644 haixun-backend/internal/model/job/domain/repository/run.go create mode 100644 haixun-backend/internal/model/job/domain/repository/schedule.go create mode 100644 haixun-backend/internal/model/job/domain/repository/template.go create mode 100644 haixun-backend/internal/model/job/domain/usecase/job.go create mode 100644 haixun-backend/internal/model/job/repository/mongo_event.go create mode 100644 haixun-backend/internal/model/job/repository/mongo_run.go create mode 100644 haixun-backend/internal/model/job/repository/mongo_schedule.go create mode 100644 haixun-backend/internal/model/job/repository/mongo_template.go create mode 100644 haixun-backend/internal/model/job/repository/redis_queue.go create mode 100644 haixun-backend/internal/model/job/repository/redis_queue_test.go create mode 100644 haixun-backend/internal/model/job/resume/resume.go create mode 100644 haixun-backend/internal/model/job/resume/resume_test.go create mode 100644 haixun-backend/internal/model/job/usecase/cancel_test.go create mode 100644 haixun-backend/internal/model/job/usecase/dedupe_test.go create mode 100644 haixun-backend/internal/model/job/usecase/helpers.go create mode 100644 haixun-backend/internal/model/job/usecase/maintenance.go create mode 100644 haixun-backend/internal/model/job/usecase/maintenance_test.go create mode 100644 haixun-backend/internal/model/job/usecase/retry_resume_test.go create mode 100644 haixun-backend/internal/model/job/usecase/retry_test.go create mode 100644 haixun-backend/internal/model/job/usecase/schedule.go create mode 100644 haixun-backend/internal/model/job/usecase/schedule_payload.go create mode 100644 haixun-backend/internal/model/job/usecase/schedule_payload_test.go create mode 100644 haixun-backend/internal/model/job/usecase/schedule_test.go create mode 100644 haixun-backend/internal/model/job/usecase/step_resume.go create mode 100644 haixun-backend/internal/model/job/usecase/template.go create mode 100644 haixun-backend/internal/model/job/usecase/template_test.go create mode 100644 haixun-backend/internal/model/job/usecase/test_mocks.go create mode 100644 haixun-backend/internal/model/job/usecase/usecase.go create mode 100644 haixun-backend/internal/model/member/domain/entity/member.go create mode 100644 haixun-backend/internal/model/member/domain/repository/member.go create mode 100644 haixun-backend/internal/model/member/domain/usecase/member.go create mode 100644 haixun-backend/internal/model/member/repository/mongo.go create mode 100644 haixun-backend/internal/model/member/usecase/usecase.go create mode 100644 haixun-backend/internal/model/permission/domain/entity/permission.go create mode 100644 haixun-backend/internal/model/permission/domain/repository/permission.go create mode 100644 haixun-backend/internal/model/permission/domain/usecase/permission.go create mode 100644 haixun-backend/internal/model/permission/repository/mongo_permission.go create mode 100644 haixun-backend/internal/model/permission/repository/mongo_role_permission.go create mode 100644 haixun-backend/internal/model/permission/repository/object_id.go create mode 100644 haixun-backend/internal/model/permission/usecase/usecase.go create mode 100644 haixun-backend/internal/model/setting/domain/entity/setting.go create mode 100644 haixun-backend/internal/model/setting/domain/repository/setting.go create mode 100644 haixun-backend/internal/model/setting/domain/usecase/setting.go create mode 100644 haixun-backend/internal/model/setting/repository/mongo.go create mode 100644 haixun-backend/internal/model/setting/usecase/usecase.go create mode 100644 haixun-backend/internal/response/request.go create mode 100644 haixun-backend/internal/response/response.go create mode 100644 haixun-backend/internal/svc/service_context.go create mode 100644 haixun-backend/internal/types/types.go create mode 100644 haixun-backend/internal/worker/job/reaper.go create mode 100644 haixun-backend/internal/worker/job/runner.go create mode 100644 haixun-backend/internal/worker/job/runner_test.go create mode 100644 haixun-backend/internal/worker/job/scheduler.go create mode 100755 haixun-backend/scripts/debug-opencode-raw.sh create mode 100755 haixun-backend/scripts/test-job-cancel.sh create mode 100755 haixun-backend/scripts/test-job-concurrency.sh create mode 100644 haixun-backend/web/.gitignore create mode 100644 haixun-backend/web/index.html create mode 100644 haixun-backend/web/package-lock.json create mode 100644 haixun-backend/web/package.json create mode 100644 haixun-backend/web/src/App.tsx create mode 100644 haixun-backend/web/src/api/client.ts create mode 100644 haixun-backend/web/src/auth/AuthContext.tsx create mode 100644 haixun-backend/web/src/components/AuthShell.tsx create mode 100644 haixun-backend/web/src/components/Layout.tsx create mode 100644 haixun-backend/web/src/components/MobileBottomNav.tsx create mode 100644 haixun-backend/web/src/components/ProtectedRoute.tsx create mode 100644 haixun-backend/web/src/components/ThemeToggle.tsx create mode 100644 haixun-backend/web/src/components/ui.tsx create mode 100644 haixun-backend/web/src/index.css create mode 100644 haixun-backend/web/src/lib/jobStatus.ts create mode 100644 haixun-backend/web/src/lib/storage.ts create mode 100644 haixun-backend/web/src/main.tsx create mode 100644 haixun-backend/web/src/pages/AiPage.tsx create mode 100644 haixun-backend/web/src/pages/DashboardPage.tsx create mode 100644 haixun-backend/web/src/pages/JobDetailPage.tsx create mode 100644 haixun-backend/web/src/pages/JobSchedulesPage.tsx create mode 100644 haixun-backend/web/src/pages/JobTemplatesPage.tsx create mode 100644 haixun-backend/web/src/pages/JobsPage.tsx create mode 100644 haixun-backend/web/src/pages/LoginPage.tsx create mode 100644 haixun-backend/web/src/pages/PermissionsPage.tsx create mode 100644 haixun-backend/web/src/pages/ProfilePage.tsx create mode 100644 haixun-backend/web/src/pages/RegisterPage.tsx create mode 100644 haixun-backend/web/src/pages/SettingsPage.tsx create mode 100644 haixun-backend/web/src/theme/ThemeContext.tsx create mode 100644 haixun-backend/web/src/types/api.ts create mode 100644 haixun-backend/web/src/vite-env.d.ts create mode 100644 haixun-backend/web/tsconfig.app.json create mode 100644 haixun-backend/web/tsconfig.json create mode 100644 haixun-backend/web/tsconfig.node.json create mode 100644 haixun-backend/web/vite.config.ts create mode 160000 template-monorepo diff --git a/haixun-backend/.idea/.gitignore b/haixun-backend/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/haixun-backend/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/haixun-backend/.idea/go.imports.xml b/haixun-backend/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/haixun-backend/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/haixun-backend/.idea/haixun-backend.iml b/haixun-backend/.idea/haixun-backend.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/haixun-backend/.idea/haixun-backend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/haixun-backend/.idea/material_theme_project_new.xml b/haixun-backend/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..b401c17 --- /dev/null +++ b/haixun-backend/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/haixun-backend/.idea/modules.xml b/haixun-backend/.idea/modules.xml new file mode 100644 index 0000000..1881042 --- /dev/null +++ b/haixun-backend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/haixun-backend/.idea/vcs.xml b/haixun-backend/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/haixun-backend/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/haixun-backend/AGENTS.md b/haixun-backend/AGENTS.md new file mode 100644 index 0000000..74d581e --- /dev/null +++ b/haixun-backend/AGENTS.md @@ -0,0 +1,335 @@ +# Agent Handoff Notes + +這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。 + +## 核心原則 + +- 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**(`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload,不作為儲存時區。 +- 複製模式,不複製舊業務。 +- `logic` 做 API 編排,`model/usecase` 做可重複使用能力。 +- provider adapter 不讀 setting、不碰 Mongo、不知道 HTTP。 +- setting 是通用 key-value model,不依賴 AI 或其他業務。 +- token / API key 第一版每次 request 帶入,不寫入 config。 +- SSE contract 由本服務 normalize,前端不要讀 provider 原始 chunk。 +- JSON API 必須使用 `code/message/data/error` envelope 與 `SSCCCDDD` 錯誤碼。 +- 列表 API 必須使用 `page/pageSize` query,並在 `data` 回傳 `pagination/list`。 +- Job 狀態轉移必須使用 guarded/conditional update;不要在 API/worker 直接裸 `Update` 覆蓋 job 狀態。 +- Redis job lock 的 value 是 `workerID`;release / refresh 必須檢查 owner,長任務必須 heartbeat。 +- Auth 目前是 native email/password + JWT,不包含 OAuth / OTP / MFA / Zitadel。不要為了相容 template-monorepo 把重依賴搬進來。 +- AI provider token 與會員 JWT 是兩種不同 token;AI token 每次 request header 帶入,會員 JWT 由 `/api/v1/auth/*` 簽發。 + +## 設計文件 + +- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。 + +## 新增 API 流程 + +1. 修改 `generate/api/*.api`。 +2. 優先使用 `make gen-api` 重新產生 handler/logic/types。 +3. 若手寫 handler,仍需遵守 `response.Write` 與 validator 流程。 +4. SSE endpoint 不使用 `response.Write`,直接輸出 `text/event-stream`。 +5. 更新 `README.md` 的 API 與架構說明。 + +## Response / Error Code + +錯誤碼格式是 `SSCCCDDD`: + +```text +SS = scope +CCC = category +DDD = detail +``` + +目前 scope: + +```text +10 = Facade +32 = Setting +33 = AI +34 = Job +35 = Auth +36 = Member +37 = Permission +``` + +建立錯誤時使用: + +```go +errs.For(code.AI).InputMissingRequired("缺少 AI provider token") +errs.For(code.Setting).ResNotFound("找不到設定") +errs.For(code.Job).ResInvalidState("job state changed; update rejected") +errs.For(code.Auth).AuthUnauthorized("missing bearer token") +``` + +不要直接手寫 `33104000` 這種數字,也不要回傳裸 `error` 給 handler 後讓使用者看到內部錯誤。 + +## Pagination + +列表 API 使用: + +```text +?page=1&pageSize=10 +``` + +response: + +```json +{ + "code": 102000, + "message": "SUCCESS", + "data": { + "pagination": { + "total": 100, + "page": 1, + "pageSize": 10, + "totalPages": 10 + }, + "list": [] + } +} +``` + +`page/pageSize` 必須是 server 正規化後的值。不要使用 `offset/limit/items`。 + +## 新增 Model 流程 + +模組放在: + +```text +internal/model// + domain/entity + domain/repository + domain/usecase + repository + usecase +``` + +依賴方向: + +```text +handler -> logic -> model/domain/usecase +model/usecase -> model/domain/repository +model/repository -> Mongo / Redis +``` + +不要讓 `logic` import `model//repository`。 + +## Auth / Permission 擴充 + +目前已接: + +```text +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/refresh +POST /api/v1/auth/logout # requires member JWT +GET /api/v1/members/me +PATCH /api/v1/members/me +GET /api/v1/permissions/catalog +GET /api/v1/permissions/me +``` + +Auth matrix(`internal/handler/routes.go`): + +| 路由 | 需要會員 JWT | +|------|----------------| +| `GET /api/v1/health` | 否 | +| `POST /api/v1/auth/register/login/refresh` | 否 | +| `POST /api/v1/auth/logout` | 是(`Authorization`) | +| `GET /api/v1/ai/providers` | 否 | +| `POST /api/v1/ai/chat/stream/models` | 是(`X-Member-Authorization`)+ provider token(`Authorization`) | +| `/api/v1/members/*`、`/api/v1/permissions/me` | 是(`Authorization`) | +| `/api/v1/permissions/catalog`、`/api/v1/settings/*`、`/api/v1/jobs*`、`/api/v1/job/*` | 是(`Authorization`) | + +規則: + +- 保護路由用 `internal/middleware.Auth`(`Authorization: Bearer `);AI 變更路由用 `middleware.MemberAuth`(`X-Member-Authorization`),因 `Authorization` 保留給 provider API key。 +- logic 從 `authctx.ActorFromContext` 讀 `tenant_id` / `uid`。 +- 不要在 handler 直接 parse JWT;token 驗證集中在 `model/auth/usecase`。 +- 密碼只存 bcrypt hash,不回傳、不寫 log。 +- `members.roles` 第一版是簡化 role key。正式 RBAC 可逐步補 roles collection,但不要破壞 `role_permissions` 的 tenant + role_key contract。 +- `Auth.DevHeaderFallback` 只給本機開發,正式環境應關閉。 + +## AI Provider 擴充 + +新增 provider 時: + +1. 在 `internal/model/ai/domain/enum` 新增 provider id。 +2. 在 `internal/model/ai/provider` 新增 adapter。 +3. 在 `internal/model/ai/usecase` registry 註冊 provider 與 models。 +4. 確保 adapter 回傳統一 `StreamEvent`。 +5. 不要改 `logic/ai` 的 SSE 格式。 + +## Job Worker 擴充 + +新增 job step 時優先註冊 runner handler: + +```go +runner.RegisterStepHandler("analyze_8d", func(ctx context.Context, step job.StepContext) error { + if err := step.Heartbeat(ctx); err != nil { + return err + } + // do work, check cancel via job usecase if needed + return nil +}) +``` + +規則: + +- Handler 不要直接操作 Mongo / Redis,透過 job usecase 更新進度、完成、失敗或取消。 +- 長任務每個 checkpoint 呼叫 `StepContext.Heartbeat` 或 `RefreshRunLock`。 +- 收到 cancel signal 後呼叫 `AcknowledgeCancel(jobId, workerID)`,不要自行把狀態改成 `cancelled`。 +- release lock 時必須帶 `workerID`;不要新增無 owner 的 release helper。 + +## 前端設計規則(`web/`) + +巡樓 Console 前端在 `haixun-backend/web/`,風格參考 [simular.co](https://simular.co/):**明亮、年輕、圓角多、配色克制**。不要把舊 Next.js / `template-monorepo` UI 搬進來,也不要引入重型 UI 框架。 + +### 技術棧與指令 + +```text +web/ + src/ + api/ # API client(envelope、JWT refresh) + auth/ # AuthContext + components/ # Layout、ui、ThemeToggle、AuthShell + theme/ # ThemeContext(淺色 / 深色) + pages/ # 路由頁面 + lib/ # jobStatus 等共用工具 + index.css # 設計 token 唯一來源 +``` + +```bash +make web-dev # dev server :5173,proxy 到 :8890 +make web-build # tsc + vite build +``` + +### 字型 + +| 語言 | 字型 | 載入方式 | +|------|------|----------| +| 繁體中文 | **台北黑體 Taipei Sans TC** | npm `taipei-sans-tc`,在 `index.css` `@import` Light / Regular / Bold | +| 英文 | **Inter**(與 simular.co 相同,Google Fonts 免費) | `web/index.html` link | + +規則: + +- `body` / 中文標題:`Inter` + `Taipei Sans TC` 混排(`--font-sans`)。 +- 純英文裝飾字(導覽副標、Hero 小字):加 class `display-en`,使用 `--font-en`。 +- 中文 `line-height` 維持 **1.7+**;不要用過細字重當標題(標題用 `font-bold` / `font-black`)。 +- 只載入 Taipei Sans TC **Regular + Bold**,不要載入 Light,避免小字過細。 +- 不要改回 Noto Sans TC,也不要手寫 `#333` 這類裸色碼當主色。 + +### 對比度與字級 + +- 內文、表頭、表單 label、卡片說明:優先 `text-ink` / `text-ink-secondary`,**不要**拿 `text-muted` 當主要閱讀文字。 +- `text-muted` 只給次要提示(筆數、hint、placeholder 用 `text-subtle`)。 +- 表單輸入字級 **15px**(`text-[15px]`),輸入框底用 `bg-surface` 白底,確保與背景拉開。 +- 淺色 `muted` 約 `#5a6578`、深色約 `#b8c4d6`;改色時以「小字仍可舒適閱讀」為準,不要回到 `#94a3b8` 那種淡灰。 + +### 主題(淺色 / 深色) + +- `ThemeProvider`(`src/theme/ThemeContext.tsx`)包住 App;偏好存 `localStorage` key:`haixun.theme`(`light` | `dark`)。 +- `index.html` 內嵌 script 在 React 載入前設定 `data-theme`,避免閃爍。 +- 所有顏色必須走 CSS 變數 `--hx-*`,再映射到 Tailwind `@theme`(`bg-canvas`、`text-brand` 等)。 +- 切換按鈕用 `ThemeToggle`;Layout 頂欄與 `AuthShell` 都要有。 +- **禁止**在元件裡寫死 `bg-slate-*`、`text-emerald-*`、`bg-amber-*` 等 Tailwind 預設色;語意狀態用 `text-success` / `text-warning` / `text-danger` 或 `jobStatus.ts` 的 badge class。 + +淺色預設明亮藍白底;深色為深藍黑底。兩套都只允許 **一個主色 brand(靛藍)** + success / warning / danger 語意色,不要再加褐色、墨綠、多種 accent 亂配。 + +### 色彩 token(語意命名) + +開發時只用這些 Tailwind class(值定義在 `web/src/index.css`): + +| Token | 用途 | +|-------|------| +| `canvas` | 全頁背景 | +| `surface` / `surface-muted` | 卡片、輸入框底 | +| `ink` / `ink-secondary` / `muted` | 主文 / 次文 / 輔助 | +| `line` | 邊框 | +| `brand` / `brand-hover` / `brand-soft` | 主 CTA、active 導覽、連結 hover | +| `glow` | 裝飾色塊(`.glow-blob-alt`) | +| `success` / `warning` / `danger`(含 `*-soft`) | 狀態、錯誤、Job badge | + +主按鈕一律 `Button variant="primary"` → `bg-brand`,不要用全黑按鈕。 + +### 圓角與陰影 + +```text +--radius-sm 0.75rem 小元素、code +--radius-md 1.25rem Input / Textarea +--radius-lg 1.75rem Card +--radius-xl 2.25rem Hero、QuickLink、StatCard +--radius-pill 9999px Button、Badge、導覽 pill +``` + +陰影用 utility:`shadow-card`(一般卡片)、`shadow-soft`(主按鈕、Hero)。Hero 背景用 class `hero-panel`;裝飾 blob 用 `glow-blob` / `glow-blob-alt`。 + +### 共用元件(優先復用) + +新頁面必須從 `src/components/ui.tsx` 組裝,不要另寫一套按鈕樣式: + +| 元件 | 用途 | +|------|------| +| `PageTitle` | 頁面標題 + 副標 | +| `Card` | 內容區塊 | +| `Field` + `Input` / `Textarea` | 表單 | +| `Button` | `primary` / `ghost` / `danger` / `soft` | +| `Badge` | 標籤 pill(`brand` / `sky` / `success` / `warning` / `danger` / `neutral`) | +| `StatCard` / `QuickLinkCard` | 總覽統計與快捷入口 | +| `ErrorText` / `CopyableId` | 錯誤與可複製 ID | + +`Button` 必須渲染 `{children}`;文案用**中文動詞**(例:「建立背景任務」「重新載入任務列表」),不要留空白小框。 + +### RWD(手機) + +- `< lg`:隱藏左側欄;**底部固定導覽**最多 **4 格**(總覽 / 任務 / 排程 / **更多**),不要把漢堡或 ⋯ 選單放在左上角。 +- 「更多」以底部 sheet 展開:AI、模板、設定、會員、權限、主題切換、登出。 +- 主內容加 `layout-main` 底部 padding,避開 tab bar + `safe-area-inset-bottom`。 +- 寬表格包 `overflow-x-auto` + `min-w-*`,避免小螢幕擠爆版面。 + +### 版面與導覽 + +- 已登入(桌面):`Layout` = 左側欄 + 頂部 sticky bar(UID + `ThemeToggle`)+ `Outlet`。 +- 已登入(手機):頂欄品牌 + 主題切換;導覽走 `MobileBottomNav`。 +- 側欄分組:**工作區**(總覽、背景任務、排程、AI)、**管理**(模板、設定、會員、權限)。 +- Active 導覽:`bg-brand text-white shadow-soft`;hover:`bg-brand-soft text-brand`。 +- 未登入:`AuthShell` 置中卡片 + 右上主題切換;背景用柔和 blob,不要花俏插圖牆。 +- 語氣:年輕、直接、短句;Hero 可有一句主標 + brand 色強調詞,避免長篇企業八股。 + +### API 與狀態 + +- JSON 一律走 `api/client.ts`(`code/message/data` envelope);需登入加 `{ auth: true }`。 +- AI 路由用 `X-Member-Authorization`;provider token 用 `Authorization`(見後端 Auth matrix)。 +- Job 狀態中文與 badge 色:`src/lib/jobStatus.ts`(`jobStatusLabel` / `jobStatusBadgeClass`),列表有進行中任務時可每 3 秒 refresh。 +- 不要在前端 parse JWT;`uid` / `tenant_id` 從 `AuthContext` 讀。 + +### 新增頁面流程 + +1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下)。 +2. 頁面用 `PageTitle` + `Card` + 既有元件;色票只引用 semantic token。 +3. 若需新語意色,**先**改 `index.css` 的 `--hx-*` 與 `@theme`,再改元件;不要頁面內硬編色碼。 +4. 完成後執行 `make web-build`。 + +### 前端禁忌 + +- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。 +- 不要為單頁新增第三套配色或漸層彩虹按鈕。 +- 不要讓 SSE / AI 直接吃 provider 原始 chunk(後端已 normalize)。 +- 不要用 `offset/limit` 呼叫列表 API;用 `page` / `pageSize`。 + +## 驗證 + +完成變更後至少執行: + +```bash +cd haixun-backend +go mod tidy +make fmt +go test ./... +``` + +有動到前端時另執行: + +```bash +make web-build +``` diff --git a/haixun-backend/Makefile b/haixun-backend/Makefile new file mode 100644 index 0000000..708ed11 --- /dev/null +++ b/haixun-backend/Makefile @@ -0,0 +1,52 @@ +GO ?= go +GOFMT ?= gofmt +GOCTL ?= goctl +GO_ZERO_STYLE := go_zero +API_ENTRY := ./generate/api/gateway.api +GOFILES := $(shell find . -name '*.go') + +.DEFAULT_GOAL := help + +help: ## 顯示可用指令 + @echo "Haixun Backend" + @echo "" + @grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " make %-12s %s\n", $$1, $$2}' + +tools: ## 安裝 goctl / goimports + @command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install github.com/zeromicro/go-zero/tools/goctl@latest) + @command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest) + +gen-api: tools ## 由 .api 生成 handler / logic / types + $(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl + +fmt: ## gofmt + goimports + $(GOFMT) -s -w $(GOFILES) + @command -v goimports >/dev/null 2>&1 && goimports -w . || true + +test: ## 執行測試 + $(GO) test ./... + +run: ## 啟動 API + $(GO) run ./gateway.go -f etc/gateway.yaml + +CONFIG ?= etc/gateway.yaml +INIT_TENANT ?= default +INIT_EMAIL ?= admin@30cm.net +INIT_PASSWORD ?= Fafafa54088! + +tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號 + $(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)' + +tool: ## 執行 cmd/tool(例:make tool ARGS="init -f etc/gateway.yaml") + $(GO) run ./cmd/tool $(ARGS) + +web-install: ## 安裝前端依賴 + cd web && npm install + +web-dev: web-install ## 啟動前端 dev server(proxy 到 :8890) + cd web && npm run dev + +web-build: web-install ## 建置前端靜態檔 + cd web && npm run build + +check: fmt test ## 格式化並測試 diff --git a/haixun-backend/README.md b/haixun-backend/README.md new file mode 100644 index 0000000..84c34ef --- /dev/null +++ b/haixun-backend/README.md @@ -0,0 +1,365 @@ +# Haixun Backend + +新的巡樓後端核心。這個資料夾刻意不直接複製 `template-monorepo` 的產物碼,只沿用它的架構模式、goctl handler template 概念與必要 runtime library,讓後續可以用更乾淨的邊界重建服務。 + +## 目前範圍 + +第一版先放六個核心能力: + +- `setting`:通用設定模型,支援 `scope + scope_id + key` 儲存不同類型設定。 +- `ai`:可替換 AI provider interface,第一版支援 OpenCode Go 與 Grok/xAI,並提供 SSE 串流回應。 +- `job`:通用背景任務系統,支援 template/run/schedule/event、Redis queue/lock、進度、retry 與 cooperative cancel。 +- `auth`:native email/password 登入、JWT access/refresh token、logout revoke。 +- `member`:目前登入會員的 profile 讀寫。 +- `permission`:permission catalog 與目前會員權限查詢。 + +暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。 + +## 快速開始 + +```bash +cd haixun-backend +go mod download +make run +``` + +預設服務: + +```text +http://127.0.0.1:8890 +``` + +健康檢查: + +```bash +curl http://127.0.0.1:8890/api/v1/health +``` + +## 專案結構 + +```text +haixun-backend/ + gateway.go # go-zero server 入口 + Makefile # gen-api / fmt / test / run + etc/ # runtime config + generate/ + api/ # goctl .api 定義 + goctl/api/handler.tpl # 從 template-monorepo 精簡改來的 handler 模板 + internal/ + config/ # config struct + handler/ # HTTP handler,目前手寫;之後可由 goctl 生成 + logic/ # API 編排層 + model/ + setting/ # 通用設定 model + ai/ # AI provider interface + adapter + job/ # Job template/run/schedule/event usecase + repository + auth/ # JWT token issue/refresh/logout + Redis revoke store + member/ # Native member profile + password hash + permission/ # Permission catalog + role permission mapping + worker/ # 常駐背景 worker / scheduler / reaper + library/ # 最小 runtime library + response/ # 統一 JSON response envelope + svc/ # ServiceContext 組裝依賴 + types/ # API request/response types +``` + +## 分層規則 + +## Response 與錯誤碼標準 + +所有一般 JSON API 都必須回傳同一層 envelope: + +```json +{ + "code": 102000, + "message": "SUCCESS", + "data": {} +} +``` + +成功固定: + +```text +HTTP 200 +code = 102000 +message = SUCCESS +``` + +失敗格式: + +```json +{ + "code": 33101000, + "message": "缺少 AI provider token", + "error": { + "biz_code": "33101000", + "scope": 33, + "category": 104, + "detail": 0 + } +} +``` + +錯誤碼採 `SSCCCDDD`: + +```text +SS = scope,服務或模組範圍 +CCC = category,錯誤分類 +DDD = detail,細分錯誤碼,未細分時為 000 +``` + +目前 scope: + +```text +10 = Facade / request parse / validation +32 = Setting +33 = AI +34 = Job +35 = Auth +36 = Member +37 = Permission +``` + +常用 category: + +```text +101 = InputInvalidFormat +104 = InputMissingRequired +204 = DBUnavailable +301 = ResourceNotFound +303 = ResourceConflict +401 = AuthUnauthorized +505 = AuthForbidden +601 = SystemInternal +802 = ServiceThirdParty +``` + +實作規則: + +- Handler 成功/失敗都用 `internal/response.Write`,SSE endpoint 例外。 +- Request parse / validation 錯誤用 `response.WrapRequestError`,會落在 Facade scope。 +- Model/usecase 內建立錯誤時使用 `errors.For(code.)` builder,不要手刻數字。 +- 不要把 provider 原始錯誤完整洩漏到前端;必要時只保留可排查的摘要。 + +### 分頁標準 + +列表型 API 的 query 使用 `page` / `pageSize`: + +```text +GET /api/v1/settings/user/user_123?page=1&pageSize=10 +``` + +回應的分頁資訊放在 `data.pagination`,資料陣列放在 `data.list`: + +```json +{ + "code": 102000, + "message": "SUCCESS", + "data": { + "pagination": { + "total": 42, + "page": 1, + "pageSize": 10, + "totalPages": 5 + }, + "list": [] + } +} +``` + +規則: + +- `page` 從 1 開始。 +- `pageSize <= 0` 時由 server 套用預設值。 +- `pageSize` 超過 server 上限時由 server 截斷。 +- `totalPages = ceil(total / pageSize)`。 +- response 內的 `page/pageSize` 必須回傳 server 正規化後的值。 + +### logic + +`internal/logic/*` 只負責一次 API 請求的流程編排: + +- 轉換 HTTP types 與 usecase DTO +- 呼叫一個或多個 model usecase +- 不直接操作 Mongo / Redis +- 不放 provider HTTP 細節 + +### model + +`internal/model/*` 放可重複使用的業務能力: + +- `domain/entity`:資料結構 +- `domain/repository`:repository interface +- `domain/usecase`:usecase interface 與 DTO +- `repository`:Mongo / Redis 實作 +- `usecase`:業務能力實作 + +### provider + +`internal/model/ai/provider` 只負責外部 AI API adapter: + +- 不讀 setting +- 不碰 HTTP handler +- 不存 token +- token 每次由 request 帶入 + +## Setting Model + +設定使用 typed setting 形式: + +```json +{ + "scope": "user", + "scope_id": "user_123", + "key": "ai.default", + "value": { + "provider": "opencode-go", + "model": "deepseek-v4-pro", + "temperature": 0.7, + "max_tokens": 2000 + }, + "version": 1 +} +``` + +API: + +```text +GET /api/v1/settings/:scope/:scope_id?page=1&pageSize=10 +GET /api/v1/settings/:scope/:scope_id/:key +PUT /api/v1/settings/:scope/:scope_id/:key +DELETE /api/v1/settings/:scope/:scope_id/:key +``` + +`setting` model 不知道 AI、Threads、crawler 等業務含義。各業務 model 自己解讀對應 key 的 value。 + +## Auth / Member / Permission + +這版從 `template-monorepo` 精簡搬入會員、權限與 token 的核心概念,但不搬 OAuth / OTP / MFA / Zitadel 依賴。 + +Auth 採 native email/password: + +```text +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/refresh +POST /api/v1/auth/logout +``` + +`register` / `login` 回傳: + +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_in": 900, + "uid": "user_uid", + "token_type": "Bearer" +} +``` + +保護路由使用: + +```http +Authorization: Bearer +``` + +本機開發可以開啟 `Auth.DevHeaderFallback`,用 header 模擬登入: + +```http +X-Tenant-ID: default +X-UID: user_uid +``` + +Member API: + +```text +GET /api/v1/members/me +PATCH /api/v1/members/me +``` + +Permission API: + +```text +GET /api/v1/permissions/catalog?tree=true +GET /api/v1/permissions/me?include_tree=true +``` + +資料模型: + +- `members`:tenant-scoped profile、email、bcrypt password hash、roles。 +- `permissions`:平台 permission catalog。 +- `role_permissions`:tenant + role_key 對 permission catalog 的綁定。 +- Redis `auth:jwt:*`:access/refresh pair 與 blacklist。Redis 未配置時仍可簽發 token,但 refresh/logout revoke 不會持久化。 + +## AI Provider + +AI token 不存在 config,呼叫時每次帶入,且**只放 HTTP header**,不要放 JSON body(避免 log / 回應洩漏): + +```http +Authorization: Bearer sk-... +Content-Type: application/json + +{ + "provider": "opencode-go", + "model": "deepseek-v4-pro", + "messages": [ + { "role": "user", "content": "請幫我寫一段文案" } + ] +} +``` + +API: + +```text +GET /api/v1/ai/providers +POST /api/v1/ai/providers/:provider/models +POST /api/v1/ai/chat +POST /api/v1/ai/chat/stream +``` + +- `GET /providers`:只回傳 catalog(id、label、streams),不含 models、不含 token。 +- `POST /providers/:provider/models`:向 provider 的 `/models` 動態拉清單,需帶 `Authorization: Bearer `。 +- 回應與錯誤訊息不會 echo token;provider 原始錯誤 body 也不會直接回傳給前端。 + +串流 endpoint 使用 SSE: + +```text +event: delta +data: {"type":"delta","text":"..."} + +event: done +data: {"type":"done","finish_reason":"stop"} +``` + +## Job System + +Job 系統的詳細設計在 `docs/job-system-plan.md`。目前 runtime 原則: + +- MongoDB 的 `job_runs` 是狀態真相來源;claim、cancel、complete、fail、retry 必須使用 conditional update,避免 worker 與 API 互相覆蓋狀態。 +- Redis `jobs:lock:` 的 value 是 `workerID`;release / refresh 必須檢查 owner,只能由持有 lock 的 worker 操作。 +- Worker 執行長任務時要定期呼叫 `RefreshRunLock(jobId, workerID, ttlSeconds)`,避免 reaper 誤判過期。 +- Runner 支援 `RegisterStepHandler(stepID, handler)` 註冊自訂 step handler;未註冊時會走 demo handler。自訂 handler 可用 `StepContext.Heartbeat` 續約 lock。 +- 取消採 cooperative cancellation:API 先寫 `cancel_requested` 與 Redis cancel signal,worker checkpoint 讀取後呼叫 `AcknowledgeCancel(jobId, workerID)`。 + +## OpenCode Go 注意事項 + +第一版 OpenCode Go 先走 OpenAI-compatible `/chat/completions`: + +```text +https://opencode.ai/zen/go/v1/chat/completions +``` + +目前已處理 Kimi 模型 `temperature = 1` 的特殊規則。部分 OpenCode Go 模型官方文件標示為 Anthropic-compatible `/messages`,後續可在 `internal/model/ai/provider` 新增 messages adapter,不需要改 logic 或前端 SSE contract。 + +## 下一步建議 + +1. 用 `goctl` 重新生成 handler / logic / types,確認 `.api` 與手寫版本對齊。 +2. 補 `setting` repository 測試與 Mongo integration 測試。 +3. 補 AI provider mock,讓 `logic/ai` 不需要真的打 provider 也能測。 +4. 新增 credential service 或 Vault/KMS 整合,但不要把 token 放進 provider config。 +5. 新增 worker/job model,讓 Go worker 與 Node Playwright worker 共用同一套 job contract。 + +## 設計文件 + +- [Job 核心系統規劃](docs/job-system-plan.md):通用 job template、run、schedule、事件、取消語意與 worker contract。 diff --git a/haixun-backend/cmd/tool/main.go b/haixun-backend/cmd/tool/main.go new file mode 100644 index 0000000..cb936de --- /dev/null +++ b/haixun-backend/cmd/tool/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + "haixun-backend/internal/bootstrap" + "haixun-backend/internal/config" + + "github.com/zeromicro/go-zero/core/conf" +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + switch os.Args[1] { + case "init": + if err := runInit(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "[tool] error: %v\n", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "[tool] unknown command: %s\n", os.Args[1]) + printUsage() + os.Exit(1) + } +} + +func runInit(args []string) error { + fs := flag.NewFlagSet("init", flag.ExitOnError) + 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@haixun.local"), "bootstrap admin email") + password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Admin-Pass-1!"), "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 + } + + var cfg config.Config + conf.MustLoad(*configFile, &cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + report, err := bootstrap.Init(ctx, cfg, bootstrap.InitOptions{ + TenantID: strings.TrimSpace(*tenantID), + AdminEmail: strings.TrimSpace(*email), + AdminPass: *password, + DisplayName: strings.TrimSpace(*displayName), + }) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "[tool] indexes ensured\n") + fmt.Fprintf(os.Stderr, "[tool] permissions catalog seeded\n") + fmt.Fprintf(os.Stderr, "[tool] role_permissions seeded (admin=all, user=default)\n") + if report.AdminCreated { + fmt.Fprintf(os.Stderr, "[tool] admin created uid=%s email=%s tenant=%s\n", report.AdminUID, *email, *tenantID) + } else { + fmt.Fprintf(os.Stderr, "[tool] admin exists uid=%s email=%s tenant=%s (roles ensured admin)\n", report.AdminUID, *email, *tenantID) + } + fmt.Printf("export INIT_TENANT_ID=%s\n", *tenantID) + fmt.Printf("export INIT_ADMIN_EMAIL=%s\n", *email) + fmt.Printf("export INIT_ADMIN_PASSWORD=%s\n", *password) + fmt.Printf("export INIT_ADMIN_UID=%s\n", report.AdminUID) + return nil +} + +func envOr(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func printUsage() { + fmt.Fprintf(os.Stderr, "usage:\n") + fmt.Fprintf(os.Stderr, " tool init [-f etc/gateway.yaml] [-tenant default] [-email admin@haixun.local] [-password ...]\n") +} diff --git a/haixun-backend/deploy/README.md b/haixun-backend/deploy/README.md new file mode 100644 index 0000000..e0f0a17 --- /dev/null +++ b/haixun-backend/deploy/README.md @@ -0,0 +1,64 @@ +# 本機依賴(Docker Compose) + +Gateway 啟用 **Notification** / **Member OTP** 需要: + +| 服務 | 用途 | 預設埠 | +|------|------|--------| +| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 | +| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 | +| MailHog(選用) | 本機 SMTP 測試 | 1025 / 8025 | +| OpenLDAP(`make ldap-up` / `make k6-up`) | ZITADEL LDAP IdP 本機目錄 | 389 | +| ZITADEL(`make k6-up`) | OIDC / Social / LDAP 登入 | 8080 | + +Mongo **不需要**事先手動建 collection;應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。 + +## 快速開始 + +```bash +# 1. 啟動 Mongo + Redis +make deps-up + +# 2.(選用)含 MailHog +make deps-up-smtp + +# 3. 確認索引(首次 docker volume 通常已由 init 建立;可再跑一次保險) +make mongo-index + +# 4. 啟動 Gateway(使用 etc/gateway.dev.yaml) +make run-dev +``` + +## Mongo collections + +| Collection | 模組 | 說明 | +|------------|------|------| +| `notifications` | notification | 發送紀錄、冪等 | +| `notification_dlq` | notification | 超過 MaxRetry 的死信 | + +索引定義見 [`deploy/mongo/init/01-gateway-indexes.js`](mongo/init/01-gateway-indexes.js),與 Go 的 `Index20260520001UP` 一致。 + +## 常用指令 + +```bash +make deps-up # docker compose up -d mongo redis +make deps-up-smtp # 再加上 mailhog(profile smtp) +make ldap-up # 只起 OpenLDAP(profile ldap) +make k6-up # 全棧含 OpenLDAP + ZITADEL(見 deploy/zitadel、deploy/openldap README) +make ldap-test # 確認 LDAP 測試帳號 alice/bob +make deps-down # 停止並移除容器(保留 volume) +make deps-down-v # 停止並刪除 volume(會清掉 Mongo 資料) +make deps-logs # 查看 log +make mongo-index # 手動建立/補齊索引 +``` + +LDAP 本機測試:[deploy/openldap/README.md](openldap/README.md) + +## 連線設定 + +設定說明:[`etc/README.md`](../etc/README.md) + +| 檔案 | 用途 | +|------|------| +| [`etc/gateway.yaml`](../etc/gateway.yaml) | 預設,無需 Docker | +| [`etc/gateway.dev.example.yaml`](../etc/gateway.dev.example.yaml) | 範例(可提交) | +| `etc/gateway.dev.yaml` | 本機專用(**勿提交**,見 `.gitignore`) | diff --git a/haixun-backend/deploy/docker-compose.yml b/haixun-backend/deploy/docker-compose.yml new file mode 100644 index 0000000..5eaef55 --- /dev/null +++ b/haixun-backend/deploy/docker-compose.yml @@ -0,0 +1,37 @@ +services: + mongo: + image: mongo:7 + container_name: gateway-mongo + restart: unless-stopped + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: gateway + volumes: + - mongo_data:/data/db + - ./mongo/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + redis: + image: redis:7-alpine + container_name: gateway-redis + restart: unless-stopped + ports: + - "6379:6379" + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + mongo_data: + redis_data: diff --git a/haixun-backend/deploy/mongo/init/01-gateway-indexes.js b/haixun-backend/deploy/mongo/init/01-gateway-indexes.js new file mode 100644 index 0000000..82ff7b3 --- /dev/null +++ b/haixun-backend/deploy/mongo/init/01-gateway-indexes.js @@ -0,0 +1,31 @@ +// Gateway MongoDB 初始化(僅在 data volume 首次建立時執行) +// 與 internal/model/notification/repository/* Index20260520001UP 對齊 +// 既有 volume 請執行:make mongo-index + +db = db.getSiblingDB('gateway'); + +print('Creating indexes on notifications...'); + +db.notifications.createIndex( + { tenant_id: 1, kind: 1, idempotency_key: 1 }, + { unique: true, name: 'idx_notifications_tenant_kind_idempotency' } +); + +db.notifications.createIndex( + { tenant_id: 1, uid: 1, occurred_at: -1 }, + { name: 'idx_notifications_tenant_uid_occurred' } +); + +db.notifications.createIndex( + { status: 1, attempts: 1, occurred_at: 1 }, + { name: 'idx_notifications_status_attempts_occurred' } +); + +print('Creating indexes on notification_dlq...'); + +db.notification_dlq.createIndex( + { tenant_id: 1, occurred_at: -1 }, + { name: 'idx_notification_dlq_tenant_occurred' } +); + +print('Gateway Mongo init done.'); diff --git a/haixun-backend/docs/job-system-plan.md b/haixun-backend/docs/job-system-plan.md new file mode 100644 index 0000000..99702db --- /dev/null +++ b/haixun-backend/docs/job-system-plan.md @@ -0,0 +1,381 @@ +# Job 核心系統規劃 + +## 目標 + +建立一套通用 job system,讓任何長任務、流程任務、定時任務未來都能共用。Job 不只是背景任務,而是「有模板、有設定、有狀態、有進度、有取消能力、有重跑策略、有排程能力」的工作單元。 + +## 核心設計 + +採用: + +```text +Mongo = job/template/run/history 的真相來源 +Redis = queue、distributed lock、schedule tick、短期 lease +``` + +```mermaid +flowchart LR + Api[GoAPI] --> Template[JobTemplate] + Api --> JobRun[JobRunMongo] + JobRun --> RedisQueue[RedisQueue] + Scheduler[SchedulerTick] --> RedisQueue + Worker[Worker] --> RedisQueue + Worker --> JobRun + Worker --> Step[JobStep] +``` + +## 核心概念 + +### JobTemplate + +Template 定義「這種 job 要怎麼做」。例如: + +```text +demo_long_task +external_worker_task +scheduled_report +multi_step_pipeline +``` + +Template 要回答: + +- 這個 job 的輸入 payload schema 是什麼 +- 有哪些 steps +- 最終狀態是什麼 +- 可不可以重複執行 +- 是否允許同 account / 同 target 同時跑 +- retry policy 是什麼 +- timeout 是多少 +- 是否可被排程 +- 是否支援取消,以及取消時 worker 要如何收斂 + +### JobRun + +JobRun 是每一次執行實例。它引用 template,保存當次 payload、狀態、進度、結果、錯誤與執行歷史。 + +### JobSchedule + +JobSchedule 是「何時建立 JobRun」。支援: + +- cron +- enabled / disabled +- timezone +- payload template +- target scope,例如 user/account/system +- nextRunAt / lastRunAt + +### JobFlow + +Flow 是多步驟流程。第一版不用做完整 DAG,先支援線性 steps: + +```text +multi_step_pipeline: + 1. prepare + 2. execute + 3. finalize +``` + +之後再擴成 DAG 或 conditional branch。 + +## Mongo Collections + +### `job_templates` + +```json +{ + "_id": "...", + "type": "demo_long_task", + "version": 1, + "name": "示範長任務", + "description": "展示 job template、進度、取消、重跑與排程能力", + "enabled": true, + "repeatable": true, + "concurrencyPolicy": "reject_same_scope", + "dedupeKeys": ["scope_id", "target"], + "timeoutSeconds": 600, + "cancelPolicy": { + "supported": true, + "mode": "cooperative", + "graceSeconds": 30 + }, + "retryPolicy": { + "maxAttempts": 2, + "backoffSeconds": [30, 120] + }, + "steps": [ + { "id": "prepare", "name": "準備資料", "workerType": "go", "timeoutSeconds": 60, "cancelable": true }, + { "id": "execute", "name": "執行任務", "workerType": "go", "timeoutSeconds": 300, "cancelable": true }, + { "id": "finalize", "name": "整理結果", "workerType": "go", "timeoutSeconds": 30, "cancelable": false } + ], + "createAt": 0, + "updateAt": 0 +} +``` + +### `job_runs` + +```json +{ + "_id": "...", + "templateType": "demo_long_task", + "templateVersion": 1, + "scope": "user", + "scopeId": "user_123", + "status": "pending", + "phase": "prepare", + "payload": {}, + "progress": { + "summary": "等待 worker 執行", + "percentage": 20, + "steps": [] + }, + "result": null, + "error": null, + "attempt": 0, + "maxAttempts": 2, + "lockedBy": null, + "lockedUntil": null, + "cancelRequestedAt": null, + "cancelReason": null, + "scheduledAt": null, + "startedAt": null, + "completedAt": null, + "createAt": 0, + "updateAt": 0 +} +``` + +### `job_schedules` + +```json +{ + "_id": "...", + "templateType": "demo_long_task", + "scope": "user", + "scopeId": "user_123", + "enabled": true, + "cron": "0 9 * * *", + "timezone": "Asia/Taipei", + "payloadTemplate": {}, + "lastRunAt": null, + "nextRunAt": 0, + "createAt": 0, + "updateAt": 0 +} +``` + +### `job_events` + +用來觀察與 audit: + +```json +{ + "_id": "...", + "jobId": "...", + "type": "status_changed", + "from": "pending", + "to": "running", + "message": "worker claimed job", + "metadata": {}, + "createAt": 0 +} +``` + +## Status Model + +```text +pending = 已建立,等待 queue +queued = 已推進 Redis queue +running = worker 執行中 +waiting_worker = 等外部 worker 回寫 +cancel_requested = 使用者已要求取消,等待 worker cooperative stop +succeeded = 成功完成 +failed = 最終失敗 +cancelled = 使用者取消 +expired = lock/timeout 過期後無法恢復 +``` + +Step status: + +```text +pending | running | succeeded | failed | skipped | cancelled +``` + +## 取消語意 + +取消是第一版必做能力,採 cooperative cancellation: + +```mermaid +flowchart LR + User[User] --> ApiCancel[CancelAPI] + ApiCancel --> Run[JobRun cancel_requested] + Run --> RedisCancel[RedisCancelSignal] + Worker[Worker] -->|"poll cancel flag"| Run + Worker --> Stop[StopCurrentStep] + Stop --> Final[JobRun cancelled] +``` + +規則: + +- `pending` / `queued`:取消後直接變 `cancelled`,並盡量從 Redis queue 移除;若無法移除,worker claim 時必須檢查狀態並跳過。 +- `running`:狀態改為 `cancel_requested`,寫入 `cancelRequestedAt` / `cancelReason`,worker 必須在 step 間或長任務 checkpoint 檢查取消旗標。 +- `waiting_worker`:狀態改為 `cancel_requested`,同時寫 Redis cancel signal;外部 worker 回寫前要檢查 job 狀態。 +- `succeeded` / `failed` / `cancelled` / `expired`:不可取消,回傳 ResourceInvalidState。 +- worker 收到取消後呼叫 `AcknowledgeCancel(jobId, workerId)`,釋放 lock,寫入 `job_events`,狀態變 `cancelled`。 +- 若 `cancel_requested` 超過 template 的 `cancelPolicy.graceSeconds`,scheduler/reaper 可標記為 `cancelled` 或 `expired`,第一版建議標記 `cancelled` 並記錄 timeout event。 + +## 狀態與 Lock 安全規則 + +第一版已把最容易出問題的 race condition 收斂在 repository / usecase: + +- `ClaimNext` 只能從 `pending` / `queued` conditional update 成 `running`。如果 API 同時取消,Mongo update 會被拒絕。 +- `RequestCancel` 只能從 cancellable 狀態 conditional update;`pending` / `queued` 直接變 `cancelled`,`running` / `waiting_worker` 變 `cancel_requested`。 +- `CompleteRun` / `FailRun` / `UpdateProgress` 必須帶 `workerID`,並且只能更新 `lockedBy == workerID` 的 job。 +- Redis `jobs:lock:` 的 value 是 `workerID`;`ReleaseLock` / `RefreshLock` 使用 owner check,避免舊 worker 誤刪新 worker 的 lock。 +- Worker 長任務要定期 heartbeat,呼叫 `RefreshRunLock(jobId, workerID, ttlSeconds)`。自訂 step handler 可用 `StepContext.Heartbeat`。 + +之後新增狀態轉移時,不要直接使用裸 `Update`;若是生命週期狀態,應新增明確的 guarded repository 方法或使用現有 conditional update。 + +## Redis Keys + +```text +jobs:queue: # list 或 stream,worker 消費 +jobs:lock: # lease lock +jobs:scheduler:lock # scheduler singleton lock +jobs:dedupe: