From eb73fbba4638227541bc48208d83a15d844cd618 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 4 May 2026 00:15:13 +0800 Subject: [PATCH] feat(chat): migration 0004 + errs codes for chat/llm/storage --- internal/infra/errs/errs.go | 40 ++++++++++ internal/infra/errs/errs_test.go | 19 +++++ migrations/0004_chat.down.sql | 7 ++ migrations/0004_chat.up.sql | 123 +++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 migrations/0004_chat.down.sql create mode 100644 migrations/0004_chat.up.sql diff --git a/internal/infra/errs/errs.go b/internal/infra/errs/errs.go index 4570466..20dbe55 100644 --- a/internal/infra/errs/errs.go +++ b/internal/infra/errs/errs.go @@ -44,6 +44,28 @@ const ( CodeWorktreePathInvalid Code = "worktree_path_invalid" CodeGitCredentialInvalid Code = "git_credential_invalid" CodeGitCmdFailed Code = "git_cmd_failed" + + // Chat 模块 + CodeChatConversationNotFound Code = "chat.conversation_not_found" + CodeChatMessageNotFound Code = "chat.message_not_found" + CodeChatMessageNotPending Code = "chat.message_not_pending" + CodeChatConcurrentMessage Code = "chat.concurrent_message" + CodeChatAttachmentTooLarge Code = "chat.attachment_too_large" + CodeChatAttachmentUnsupportedMime Code = "chat.attachment_unsupported_mime" + CodeChatModelCapabilityMismatch Code = "chat.model_capability_mismatch" + CodeChatTemplateNotFound Code = "chat.template_not_found" + CodeChatEndpointNotFound Code = "chat.endpoint_not_found" + CodeChatModelNotFound Code = "chat.model_not_found" + + // LLM 域 + CodeLLMUpstream Code = "llm.upstream_error" + CodeLLMTimeout Code = "llm.timeout" + CodeLLMRateLimited Code = "llm.rate_limited" + CodeLLMContextTooLong Code = "llm.context_too_long" + + // Storage 域 + CodeStoragePutFailed Code = "storage.put_failed" + CodeStorageGetFailed Code = "storage.get_failed" ) // AppError is the application's structured error type. It carries a stable @@ -131,6 +153,24 @@ func HTTPStatus(code Code) int { return http.StatusBadRequest case CodeWorktreeNotHeldByCaller: return http.StatusForbidden + case CodeChatConversationNotFound, CodeChatMessageNotFound, + CodeChatTemplateNotFound, CodeChatEndpointNotFound, CodeChatModelNotFound: + return http.StatusNotFound + case CodeChatMessageNotPending, CodeChatConcurrentMessage, + CodeChatModelCapabilityMismatch: + return http.StatusConflict + case CodeChatAttachmentTooLarge: + return http.StatusRequestEntityTooLarge + case CodeChatAttachmentUnsupportedMime: + return http.StatusUnsupportedMediaType + case CodeLLMRateLimited: + return http.StatusTooManyRequests + case CodeLLMTimeout: + return http.StatusGatewayTimeout + case CodeLLMUpstream, CodeStoragePutFailed, CodeStorageGetFailed: + return http.StatusBadGateway + case CodeLLMContextTooLong: + return http.StatusBadRequest default: return http.StatusInternalServerError } diff --git a/internal/infra/errs/errs_test.go b/internal/infra/errs/errs_test.go index e6ca8ec..6468698 100644 --- a/internal/infra/errs/errs_test.go +++ b/internal/infra/errs/errs_test.go @@ -51,6 +51,25 @@ func TestHTTPStatus(t *testing.T) { CodeWorktreePathInvalid: http.StatusBadRequest, CodeGitCredentialInvalid: http.StatusBadRequest, CodeWorktreeNotHeldByCaller: http.StatusForbidden, + // Chat codes + CodeChatConversationNotFound: http.StatusNotFound, + CodeChatMessageNotFound: http.StatusNotFound, + CodeChatMessageNotPending: http.StatusConflict, + CodeChatConcurrentMessage: http.StatusConflict, + CodeChatAttachmentTooLarge: http.StatusRequestEntityTooLarge, + CodeChatAttachmentUnsupportedMime: http.StatusUnsupportedMediaType, + CodeChatModelCapabilityMismatch: http.StatusConflict, + CodeChatTemplateNotFound: http.StatusNotFound, + CodeChatEndpointNotFound: http.StatusNotFound, + CodeChatModelNotFound: http.StatusNotFound, + // LLM codes + CodeLLMUpstream: http.StatusBadGateway, + CodeLLMTimeout: http.StatusGatewayTimeout, + CodeLLMRateLimited: http.StatusTooManyRequests, + CodeLLMContextTooLong: http.StatusBadRequest, + // Storage codes + CodeStoragePutFailed: http.StatusBadGateway, + CodeStorageGetFailed: http.StatusBadGateway, } for code, want := range cases { require.Equal(t, want, HTTPStatus(code), "code=%s", code) diff --git a/migrations/0004_chat.down.sql b/migrations/0004_chat.down.sql new file mode 100644 index 0000000..ce6d241 --- /dev/null +++ b/migrations/0004_chat.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS llm_usage; +DROP TABLE IF EXISTS message_attachments; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS conversations; +DROP TABLE IF EXISTS prompt_templates; +DROP TABLE IF EXISTS llm_models; +DROP TABLE IF EXISTS llm_endpoints; diff --git a/migrations/0004_chat.up.sql b/migrations/0004_chat.up.sql new file mode 100644 index 0000000..c7cf1ff --- /dev/null +++ b/migrations/0004_chat.up.sql @@ -0,0 +1,123 @@ +-- ============ LLM Endpoints ============ +CREATE TABLE llm_endpoints ( + id UUID PRIMARY KEY, + provider TEXT NOT NULL CHECK (provider IN ('anthropic','openai','gemini')), + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + api_key_encrypted BYTEA NOT NULL, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX llm_endpoints_provider_idx ON llm_endpoints(provider); + +-- ============ LLM Models ============ +CREATE TABLE llm_models ( + id UUID PRIMARY KEY, + endpoint_id UUID NOT NULL REFERENCES llm_endpoints(id) ON DELETE RESTRICT, + model_id TEXT NOT NULL, + display_name TEXT NOT NULL, + capabilities JSONB NOT NULL DEFAULT '{}'::jsonb, + context_window INT NOT NULL, + max_output_tokens INT NOT NULL, + prompt_price_per_million_usd NUMERIC(10,6) NOT NULL DEFAULT 0, + completion_price_per_million_usd NUMERIC(10,6) NOT NULL DEFAULT 0, + thinking_price_per_million_usd NUMERIC(10,6) NOT NULL DEFAULT 0, + is_title_generator BOOLEAN NOT NULL DEFAULT false, + enabled BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (endpoint_id, model_id) +); +CREATE UNIQUE INDEX llm_models_one_title_generator + ON llm_models ((1)) WHERE is_title_generator; +CREATE INDEX llm_models_enabled_sort_idx ON llm_models(enabled, sort_order); + +-- ============ Prompt Templates ============ +CREATE TABLE prompt_templates ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + content TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('system','user')), + owner_id UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK ((scope='user' AND owner_id IS NOT NULL) OR (scope='system' AND owner_id IS NULL)) +); +CREATE INDEX prompt_templates_scope_owner_idx ON prompt_templates(scope, owner_id); + +-- ============ Conversations ============ +CREATE TABLE conversations ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + project_id UUID REFERENCES projects(id), + workspace_id UUID REFERENCES workspaces(id), + issue_id UUID REFERENCES issues(id), + model_id UUID NOT NULL REFERENCES llm_models(id) ON DELETE RESTRICT, + system_prompt TEXT NOT NULL DEFAULT '', + title TEXT, + title_generated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX conversations_user_list_idx ON conversations(user_id, deleted_at, updated_at DESC); +CREATE INDEX conversations_project_idx ON conversations(project_id) WHERE project_id IS NOT NULL; +CREATE INDEX conversations_workspace_idx ON conversations(workspace_id) WHERE workspace_id IS NOT NULL; +CREATE INDEX conversations_issue_idx ON conversations(issue_id) WHERE issue_id IS NOT NULL; + +-- ============ Messages ============ +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user','assistant')), + content TEXT NOT NULL DEFAULT '', + thinking TEXT, + tool_calls JSONB, + status TEXT NOT NULL CHECK (status IN ('pending','ok','error','cancelled')), + error_message TEXT, + prompt_tokens INT, + completion_tokens INT, + thinking_tokens INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX messages_conv_id_idx ON messages(conversation_id, id); +CREATE UNIQUE INDEX messages_one_pending_per_conv + ON messages(conversation_id) WHERE status='pending'; + +-- ============ Message Attachments ============ +CREATE TABLE message_attachments ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + sha256 TEXT NOT NULL, + storage_path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX message_attachments_orphan_idx + ON message_attachments(user_id, created_at) WHERE message_id IS NULL; +CREATE INDEX message_attachments_msg_idx + ON message_attachments(message_id) WHERE message_id IS NOT NULL; + +-- ============ LLM Usage ============ +CREATE TABLE llm_usage ( + id BIGSERIAL PRIMARY KEY, + conversation_id UUID NOT NULL, + message_id BIGINT NOT NULL, + user_id UUID NOT NULL, + endpoint_id UUID NOT NULL, + model_id UUID NOT NULL, + prompt_tokens INT NOT NULL, + completion_tokens INT NOT NULL, + thinking_tokens INT NOT NULL DEFAULT 0, + cost_usd NUMERIC(10,6) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX llm_usage_user_time_idx ON llm_usage(user_id, created_at); +CREATE INDEX llm_usage_model_time_idx ON llm_usage(model_id, created_at); +CREATE INDEX llm_usage_time_idx ON llm_usage(created_at);