diff --git a/.env.example b/.env.example index a554dae2..095f4e88 100644 --- a/.env.example +++ b/.env.example @@ -27,11 +27,16 @@ APP_API_PREFIX=/api APP_SESSION_COOKIE_NAME=wavelet_session_id APP_SESSION_SECRET=change-me-to-a-random-string-in-production # APP_SESSION_DOMAIN= -# APP_SESSION_AGE=86400 +APP_SESSION_AGE=86400 APP_SESSION_HTTP_ONLY=true +# HTTPS 部署时设为 true,HTTP 环境必须为 false APP_SESSION_SECURE=true # ─── 数据库 ──────────────────────────────────────────────────────────────────── +# 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制 +# DB_ENABLED=false 时使用 SQLite 作为后备数据库 +DB_ENABLED=true +# SQLITE_PATH=./data/wavelet.db DB_HOST=postgres DB_PORT=5432 DB_USERNAME=postgres @@ -44,6 +49,8 @@ DB_TIMEZONE=Asia/Shanghai # DB_MAX_OPEN_CONN=128 # ─── Redis ───────────────────────────────────────────────────────────────────── +# 设置 REDIS_ADDR 后自动启用,也可通过 REDIS_ENABLED 显式控制 +REDIS_ENABLED=true REDIS_ADDR=redis:6379 # REDIS_USERNAME= # REDIS_PASSWORD= @@ -51,7 +58,9 @@ REDIS_ADDR=redis:6379 REDIS_KEY_PREFIX=wavelet: # REDIS_POOL_SIZE=100 -# ─── ClickHouse(可选)─────────────────────────────────────────────────────── +# ─── ClickHouse(可选,默认关闭)────────────────────────────────────────── +# 设置 CLICKHOUSE_HOST 后自动启用,也可显式控制 +# CLICKHOUSE_ENABLED=false # CLICKHOUSE_HOST=clickhouse:9000 # CLICKHOUSE_USERNAME=default # CLICKHOUSE_PASSWORD= @@ -66,7 +75,8 @@ LOG_OUTPUT=stdout # 设为 0 关闭 tracing(无 Collector 时推荐) OTEL_SAMPLING_RATE=0.0 -# ─── S3 兼容存储(可选)─────────────────────────────────────────────────────── +# ─── S3 兼容存储(可选,默认关闭)────────────────────────────────────────── +# S3_ENABLED=false # S3_ENDPOINT=https://.r2.cloudflarestorage.com # S3_REGION=auto # S3_BUCKET= @@ -77,6 +87,8 @@ OTEL_SAMPLING_RATE=0.0 # ─── Worker ──────────────────────────────────────────────────────────────────── # WORKER_CONCURRENCY=20 +# WORKER_STRICT_PRIORITY=false # ─── Scheduler ───────────────────────────────────────────────────────────────── +# 未设置时默认为 @daily # SCHEDULER_CLEANUP_CRON=0 */2 * * * diff --git a/.gitignore b/.gitignore index 8487b5e1..9dbf81b3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,12 @@ config.yaml .env +# sqlite +*.db +*.db-journal +*.db-shm +*.db-wal + # frontend frontend/build frontend/disk diff --git a/Agents.md b/Agents.md index 677fccaa..c4e4bbdd 100644 --- a/Agents.md +++ b/Agents.md @@ -511,3 +511,116 @@ apps/admin// | POST | `/api/v1/admin/tasks/executions/:id/retry` | 重试失败任务(校验 Retryable && RetryCount < MaxRetry) | --- + + +## 代码规范 + +### 后端 + +**基础检查** + +需要通过 CodeQL 扫描,较长的代码建议增加 Copilot 检查。 + +**API 文档** + +所有接口需要写 Swagger 文档,提交前通过 make swagger 更新文档后再提交。 + +**响应格式** + +```json +# 响应数据最外层有两个字段,error_msg 和 data +{ + "error_msg": "", + "data": null +} + +# 如果是非列表数据 +{ + "error_msg": "", + "data": {} +} + +# 如果是分页数据 +{ + "error_msg": "", + "data": { + "total": 0, + "results": [] + } +} +``` + +**数据库** + +- 禁止使用外键,但需要保留对应字段的索引; +- 字段如有默认值,需要与 struct 默认值相同,如 nil,0,false,空字符串等,避免初始化时未填写或漏填写导致的数据异常。 + +### 前端 + +**基础检查** + +代码需要通过 ESLint 检查和 CodeQL 扫描。 + +**类型安全** + +- 禁止使用 `any` 类型,`any` 类型绕过了 TypeScript 的类型检查系统,会导致潜在的运行时错误; +- `unknown` 是类型安全的 `any`,但必须立即进行类型断言或类型收窄; +- `never` 类型表示永远不会发生的值类型,必须谨慎使用,并提供清晰的注释说明。 + +**组件规范** + +- 组件应按功能分类 +- 公共组件放在 `components/common` 目录 +- ShadcnUI 组件放在 `components/ui` 目录 +- 自定义图标应放置在 `/components/icons/` 目录下以命名导出形式管理,对于常规的图标,我们使用 Lucide 库 + +**服务层** + +服务层架构是前端与API交互的统一入口,基于以下原则: +1. 关注点分离 - 每个服务负责一个业务领域 +2. 统一入口 - 通过services对象导出所有服务 +3. 类型安全 - 所有请求和响应有明确类型定义 + + +**如何新建接口服务** + +1. **创建目录结构**: + ``` + /services/新服务名/ + - types.ts // 类型定义 + - 服务名.service.ts // 服务实现 + - index.ts // 导出服务 + ``` + +2. **实现服务类**: + ```typescript + // 新服务名/服务名.service.ts + import {BaseService} from '../core/base.service'; + + export class 新服务类 extends BaseService { + protected static readonly basePath = '/api/v1/路径'; + + static async 方法名(参数): Promise<返回类型> { + return this.get<返回类型>('/endpoint'); + } + } + ``` + +3. **在services/index.ts注册**: + ```typescript + import {新服务类} from './新服务名'; + + const services = { + auth: AuthService, + 新服务名: 新服务类 + }; + ``` + +**使用方法** + +```typescript +import services from '@/lib/services'; + +// 调用服务方法 +const 结果 = await services.新服务名.方法名(参数); +``` \ No newline at end of file diff --git a/CLA.md b/CLA.md deleted file mode 100644 index eed7d466..00000000 --- a/CLA.md +++ /dev/null @@ -1,55 +0,0 @@ -# 贡献者许可协议 (Contributor License Agreement) - -## 中文版 - -### 什么是贡献者许可协议 (CLA)? - -贡献者许可协议 (CLA) 是一份法律文件,明确了您对开源项目贡献代码时的权利和义务。通过签署 CLA,您确认: - -1. **您拥有贡献内容的版权**:您确认您提交的代码、文档或其他内容是您原创的,或者您有权将其贡献给本项目。 - -2. **您同意许可条款**:您同意将您的贡献按照本项目的 MIT 许可证进行许可。 - -3. **您授予项目维护者权利**:您授予项目维护者和 linux.do 社区永久、全球、免版税、不可撤销的权利来使用、修改、分发您的贡献。 - -### 您的贡献将如何使用? - -- 您的贡献将根据 [MIT 许可证](LICENSE) 进行许可 -- 您的贡献可能被修改、整合到项目中或重新分发 -- 您将在项目的贡献者名单中被适当地标识 - -### 如何表示同意? - -在创建 Pull Request 时,请在 PR 描述中勾选 CLA 同意选项,表示您已阅读并同意本贡献者许可协议的条款。 - ---- - -## English Version - -### What is a Contributor License Agreement (CLA)? - -A Contributor License Agreement (CLA) is a legal document that clarifies your rights and obligations when contributing code to an open source project. By agreeing to the CLA, you confirm that: - -1. **You own the copyright to your contributions**: You confirm that the code, documentation, or other content you submit is your original work or you have the right to contribute it to this project. - -2. **You agree to the license terms**: You agree to license your contributions under this project's MIT License. - -3. **You grant rights to project maintainers**: You grant the project maintainers and linux.do community a perpetual, worldwide, royalty-free, irrevocable right to use, modify, and distribute your contributions. - -### How will your contributions be used? - -- Your contributions will be licensed under the [MIT License](LICENSE) -- Your contributions may be modified, integrated into the project, or redistributed -- You will be appropriately credited in the project's contributor list - -### How to indicate agreement? - -When creating a Pull Request, please check the CLA agreement option in the PR description to indicate that you have read and agree to the terms of this Contributor License Agreement. - ---- - -## 许可证信息 (License Information) - -本项目使用 MIT 许可证。详情请查看 [LICENSE](LICENSE) 文件。 - -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 852593d4..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,98 +0,0 @@ - -# 贡献者公约 - -## 我们的承诺 - -身为社区成员、贡献者和领袖,我们承诺使社区参与者不受骚扰,无论其年龄、体型、可见或不可见的缺陷、族裔、性征、性别认同和表达、经验水平、教育程度、社会与经济地位、国籍、相貌、种族、种姓、肤色、宗教信仰、性倾向或性取向如何。 - -我们承诺以有助于建立开放、友善、多样化、包容、健康社区的方式行事和互动。 - -## 我们的准则 - -有助于为我们的社区创造积极环境的行为例子包括但不限于: - -* 表现出对他人的同情和善意 -* 尊重不同的主张、观点和感受 -* 提出和大方接受建设性意见 -* 承担责任并向受我们错误影响的人道歉 -* 注重社区共同诉求,而非个人得失 - -不当行为例子包括: - -* 使用情色化的语言或图像,及性引诱或挑逗 -* 嘲弄、侮辱或诋毁性评论,以及人身或政治攻击 -* 公开或私下的骚扰行为 -* 未经他人明确许可,公布他人的私人信息,如物理或电子邮件地址 -* 其他有理由认定为违反职业操守的不当行为 - -## 代码质量与性能准则 - -为确保项目的持续改进和稳定性,我们制定以下代码质量与性能准则: - -* **性能标准**:所有代码变更不得导致项目整体性能下降 -* **性能测试**:提交的 Pull Request 应确保不会引入性能回归 -* **拒绝合并**:如果 Pull Request 导致性能下降,将被拒绝合并,直到性能问题得到解决 -* **优化鼓励**:我们鼓励并欢迎提升系统性能的优化改进 - -## 责任和权力 - -社区领袖有责任解释和落实我们所认可的行为准则,并妥善公正地对他们认为不当、威胁、冒犯或有害的任何行为采取纠正措施。 - -社区领导有权力和责任删除、编辑或拒绝或拒绝与本行为准则不相符的评论(comment)、提交(commits)、代码、维基(wiki)编辑、议题(issues)或其他贡献,并在适当时机知采取措施的理由。 - -## 适用范围 - -本行为准则适用于所有社区场合,也适用于在公共场所代表社区时的个人。 - -代表社区的情形包括使用官方电子邮件地址、通过官方社交媒体帐户发帖或在线上或线下活动中担任指定代表。 - -## 监督 - -辱骂、骚扰或其他不可接受的行为可通过 [插入联系方式] 向负责监督的社区领袖报告。 -所有投诉都将得到及时和公平的审查和调查。 - -所有社区领袖都有义务尊重任何事件报告者的隐私和安全。 - -## 处理方针 - -社区领袖将遵循下列社区处理方针来明确他们所认定违反本行为准则的行为的处理方式: - -### 1. 纠正 - -**社区影响**:使用不恰当的语言或其他在社区中被认定为不符合职业道德或不受欢迎的行为。 - -**处理意见**:由社区领袖发出非公开的书面警告,明确说明违规行为的性质,并解释举止如何不妥。或将要求公开道歉。 - -### 2. 警告 - -**社区影响**:单个或一系列违规行为。 - -**处理意见**:警告并对连续性行为进行处理。在指定时间内,不得与相关人员互动,包括主动与行为准则执行者互动。这包括避免在社区场所和外部渠道中的互动。违反这些条款可能会导致临时或永久封禁。 - -### 3. 临时封禁 - -**社区影响**: 严重违反社区准则,包括持续的不当行为。 - -**处理意见**: 在指定时间内,暂时禁止与社区进行任何形式的互动或公开交流。在此期间,不得与相关人员进行公开或私下互动,包括主动与行为准则执行者互动。违反这些条款可能会导致永久封禁。 - -### 4. 永久封禁 - -**社区影响**:行为模式表现出违反社区准则,包括持续的不当行为、骚扰个人或攻击或贬低某个类别的个体。 - -**处理意见**:永久禁止在社区内进行任何形式的公开互动。 - -## 参见 - -本行为准则改编自 [Contributor Covenant][homepage] 2.1 版, 参见 [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]。 - -社区处理方针灵感来源于 [Mozilla's code of conduct enforcement ladder][Mozilla CoC]。 - -有关本行为准则的常见问题的答案,参见 [https://www.contributor-covenant.org/faq][FAQ]。 -其他语言翻译参见 [https://www.contributor-covenant.org/translations][translations]。 - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3cde4e9..edcb6683 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,16 +14,11 @@ - 仅修正无影响力的拼写错误(typo)或代码注释,不提升可读性或准确性; - 重构已稳定工作的逻辑而不带来可维护性或功能上的实质提升; - 未经讨论的接口或 API 命名改动; -- 为了获取社区徽章而提交的大量无实际意义的更改。 **请注意:判断标准不是改动大小,而是改动是否有实际作用。** 为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。 -## 在编写代码前 - -1. 请仔细阅读[贡献者许可协议](/CLA.md),您提交 PR 默认您已经阅读并同意该协议。 -2. 请预先构想您的更改,并确保它不在**我们不接受的更改**列表中。 ## 贡献步骤 @@ -36,10 +31,6 @@ ## 代码规范 -### 通用 - -所有 PR ,需至少有一位具有写权限的协作者 Approve 后再合并。 - ### 后端 **基础检查** diff --git a/config.docker.yaml b/config.docker.yaml deleted file mode 100644 index 34aa69e6..00000000 --- a/config.docker.yaml +++ /dev/null @@ -1,120 +0,0 @@ -# wavelet — Docker Compose 专用配置 -# 此文件供 docker-compose 挂载使用,主机名指向 Docker 服务名 -# 如需自定义,请复制此文件并修改,然后在 docker-compose.yml 中更新挂载路径 - -# ─── Application ──────────────────────────────────────────────────────────────── -app: - app_name: "wavelet" - env: "production" - addr: ":8000" - node_id: 1 - graceful_shutdown_timeout: 30 - session_cookie_name: "wavelet_session_id" - session_secret: "change-me-to-a-random-string-in-production" - session_domain: "" - session_age: 86400 - session_secure: true - session_http_only: true - api_prefix: "/api" - -# ─── PostgreSQL ───────────────────────────────────────────────────────────────── -database: - enabled: true - host: "postgres" # Docker 服务名 - port: 5432 - username: "postgres" - password: "postgres" - database: "wavelet" - max_idle_conn: 16 - max_open_conn: 128 - conn_max_lifetime: 1800 - conn_max_idle_time: 600 - log_level: "info" - ssl_mode: "disable" - time_zone: "Asia/Shanghai" - application_name: "wavelet-server" - prefer_simple_protocol: false - search_path: "public" - statement_cache_capacity: 256 - default_query_exec_mode: "cache_statement" - slow_threshold: 200ms - -# ─── Redis ────────────────────────────────────────────────────────────────────── -redis: - enabled: true - addrs: - - "redis:6379" # Docker 服务名 - username: "" - password: "" - db: 0 - cluster_mode: false - master_name: "" - key_prefix: "wavelet:" - pool_size: 100 - min_idle_conn: 10 - dial_timeout: 5 - read_timeout: 3 - write_timeout: 3 - max_retries: 3 - pool_timeout: 4 - conn_max_idle_time: 300 - -# ─── ClickHouse (optional) ────────────────────────────────────────────────────── -clickhouse: - enabled: false - hosts: - - "clickhouse:9000" - username: "default" - password: "" - database: "wavelet" - max_idle_conn: 10 - max_open_conn: 100 - conn_max_lifetime: 3600 - dial_timeout: 5 - block_buffer_size: 10 - -# ─── Logging ──────────────────────────────────────────────────────────────────── -log: - level: "info" - format: "json" - output: "stdout" - file_path: "" - max_size: 100 - max_age: 30 - max_backups: 10 - compress: true - -# ─── Task Scheduler (Cron) ────────────────────────────────────────────────────── -scheduler: - cleanup_unused_uploads_task_cron: "0 */2 * * *" - -# ─── Async Task Worker ────────────────────────────────────────────────────────── -worker: - concurrency: 20 - strict_priority: false - queues: - - name: webhook - priority: 10 - - name: whitelist_only - priority: 5 - - name: default - priority: 3 - -# ─── OpenTelemetry Tracing ────────────────────────────────────────────────────── -otel: - sampling_rate: 0.0 # Docker 环境默认关闭 tracing,避免无 Collector 时报错 - -# ─── S3-Compatible File Storage ───────────────────────────────────────────────── -s3: - enabled: false - endpoint: "" - region: "auto" - bucket: "" - access_key_id: "" - secret_access_key: "" - path_style: false - key_prefix: "" - cdn_url: "" - local_cache: - enabled: false - cache_dir: "./s3_cache" diff --git a/config.example.yaml b/config.example.yaml index 194b9eee..b5486dac 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -21,6 +21,7 @@ app: # Supports Standalone and Primary-Replica (read/write split) modes. database: enabled: true + sqlite_path: "wavelet.db" # PostgreSQL 禁用时使用此 SQLite 文件路径 host: "127.0.0.1" port: 5432 username: "postgres" diff --git a/docker-compose.yml b/docker-compose.yml index bcf2ad3b..caff4de3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: ports: - "${APP_PORT:-8000}:8000" volumes: - - ./config.docker.yaml:/app/config.yaml:ro - ./uploads:/app/uploads + - ./data/sqlite:/app/data depends_on: postgres: condition: service_healthy diff --git a/frontend/public/icon.png b/frontend/public/icon.png index df85a1c1..0c607ce1 100644 Binary files a/frontend/public/icon.png and b/frontend/public/icon.png differ diff --git a/frontend/public/red-envelope.svg b/frontend/public/red-envelope.svg deleted file mode 100644 index 530737b7..00000000 --- a/frontend/public/red-envelope.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/internal/config/config.go b/internal/config/config.go index 9c7756de..e87d523a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,9 +70,19 @@ func init() { viper.SetConfigFile(configPath) viper.AutomaticEnv() - // 读取配置文件 + // 读取配置文件(可选:找不到文件时使用空默认值 + 环境变量) if err := viper.ReadInConfig(); err != nil { - log.Fatalf("[Config] read config failed: %v\n", err) + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + // 文件存在但读取/解析失败 + if _, statErr := os.Stat(configPath); statErr == nil { + log.Fatalf("[Config] read config failed: %v\n", err) + } + } + log.Println("[Config] no config file found, using environment variables only") + viper.SetConfigType("yaml") + if err := viper.ReadConfig(strings.NewReader("")); err != nil { + log.Fatalf("[Config] failed to init empty config: %v\n", err) + } } // 解析配置到结构体 @@ -81,8 +91,11 @@ func init() { log.Fatalf("[Config] parse config failed: %v\n", err) } + applyDefaults(&c) + // 环境变量覆盖(优先级高于 config.yaml) applyEnvOverrides(&c) + applyDefaults(&c) // Disable standard DB/Redis initializations during tests to prevent connection attempts. if isTest() { @@ -98,6 +111,12 @@ func init() { printConfig(&c) } +func applyDefaults(c *configModel) { + if c.App.SessionAge <= 0 { + c.App.SessionAge = 86400 + } +} + // ─── 环境变量覆盖层 ──────────────────────────────────────────────────────────── // 环境变量优先级高于 config.yaml,未设置则保留 yaml 中的值。 @@ -171,11 +190,19 @@ func applyEnvOverrides(c *configModel) { c.Database.LogLevel = envStr("DB_LOG_LEVEL", c.Database.LogLevel) c.Database.MaxIdleConn = envInt("DB_MAX_IDLE_CONN", c.Database.MaxIdleConn) c.Database.MaxOpenConn = envInt("DB_MAX_OPEN_CONN", c.Database.MaxOpenConn) + // 当 DB_HOST 环境变量已设置时自动启用数据库 + if _, ok := os.LookupEnv("DB_HOST"); ok { + c.Database.Enabled = true + } + c.Database.Enabled = envBool("DB_ENABLED", c.Database.Enabled) + c.Database.SQLitePath = envStr("SQLITE_PATH", c.Database.SQLitePath) // ─── Redis ─── if v, ok := os.LookupEnv("REDIS_ADDR"); ok { c.Redis.Addrs = []string{v} + c.Redis.Enabled = true // 当 REDIS_ADDR 已设置时自动启用 } + c.Redis.Enabled = envBool("REDIS_ENABLED", c.Redis.Enabled) c.Redis.Username = envStr("REDIS_USERNAME", c.Redis.Username) c.Redis.Password = envStr("REDIS_PASSWORD", c.Redis.Password) c.Redis.DB = envInt("REDIS_DB", c.Redis.DB) @@ -185,7 +212,9 @@ func applyEnvOverrides(c *configModel) { // ─── ClickHouse ─── if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok { c.ClickHouse.Hosts = []string{v} + c.ClickHouse.Enabled = true } + c.ClickHouse.Enabled = envBool("CLICKHOUSE_ENABLED", c.ClickHouse.Enabled) c.ClickHouse.Username = envStr("CLICKHOUSE_USERNAME", c.ClickHouse.Username) c.ClickHouse.Password = envStr("CLICKHOUSE_PASSWORD", c.ClickHouse.Password) c.ClickHouse.Database = envStr("CLICKHOUSE_NAME", c.ClickHouse.Database) @@ -206,14 +235,28 @@ func applyEnvOverrides(c *configModel) { c.S3.SecretAccessKey = envStr("S3_SECRET_ACCESS_KEY", c.S3.SecretAccessKey) c.S3.CdnURL = envStr("S3_CDN_URL", c.S3.CdnURL) c.S3.PathStyle = envBool("S3_PATH_STYLE", c.S3.PathStyle) + c.S3.Enabled = envBool("S3_ENABLED", c.S3.Enabled) // ─── Worker ─── c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency) + c.Worker.StrictPriority = envBool("WORKER_STRICT_PRIORITY", c.Worker.StrictPriority) + + // 无 yaml 且无环境变量时,使用代码级默认队列 + if len(c.Worker.Queues) == 0 { + c.Worker.Queues = []QueueConfig{ + {Name: "webhook", Priority: 10}, + {Name: "whitelist_only", Priority: 5}, + {Name: "default", Priority: 3}, + } + } // ─── Scheduler ─── c.Scheduler.CleanupUnusedUploadsTaskCron = envStr( "SCHEDULER_CLEANUP_CRON", c.Scheduler.CleanupUnusedUploadsTaskCron, ) + if c.Scheduler.CleanupUnusedUploadsTaskCron == "" { + c.Scheduler.CleanupUnusedUploadsTaskCron = "@daily" + } } // printConfig 打印配置内容 diff --git a/internal/config/model.go b/internal/config/model.go index 09c14f46..2c15e68e 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -55,6 +55,7 @@ func (a *appConfig) IsProduction() bool { // databaseConfig 数据库配置 type databaseConfig struct { Enabled bool `mapstructure:"enabled"` + SQLitePath string `mapstructure:"sqlite_path"` // PostgreSQL 禁用时的 SQLite 文件路径 Host string `mapstructure:"host"` Port int `mapstructure:"port"` Username string `mapstructure:"username"` diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 6bce8ff5..cca14bc2 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -29,11 +29,15 @@ import ( "github.com/Rain-kl/Wavelet/internal/db/idgen" ) -func Migrate() { +// dbType 返回当前数据库类型名称(用于日志输出) +func dbType() string { if !config.Config.Database.Enabled { - return + return "SQLite" } + return "PostgreSQL" +} +func Migrate() { if err := db.DB(context.Background()).AutoMigrate( &model.User{}, &model.AuthSource{}, @@ -44,9 +48,9 @@ func Migrate() { &model.TaskExecution{}, &model.Template{}, ); err != nil { - log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err) + log.Fatalf("[%s] auto migrate failed: %v\n", dbType(), err) } - log.Printf("[PostgreSQL] auto migrate success\n") + log.Printf("[%s] auto migrate success\n", dbType()) // 初始化系统配置数据 initSystemConfigs() @@ -68,9 +72,9 @@ func ensureConfigKeyExists(key, value, configType, description string) { Description: description, } if err := tx.Create(&newConfig).Error; err != nil { - log.Printf("[PostgreSQL] failed to create system config key %s: %v\n", key, err) + log.Printf("[%s] failed to create system config key %s: %v\n", dbType(), key, err) } else { - log.Printf("[PostgreSQL] initialized system config key %s\n", key) + log.Printf("[%s] initialized system config key %s\n", dbType(), key) } } } @@ -81,7 +85,7 @@ func initSystemConfigs() { var count int64 if err := tx.Model(&model.SystemConfig{}).Count(&count).Error; err != nil { - log.Printf("[PostgreSQL] failed to check system_config table: %v\n", err) + log.Printf("[%s] failed to check system_config table: %v\n", dbType(), err) return } @@ -240,9 +244,9 @@ func initSystemConfigs() { } if err := tx.Create(&defaultConfigs).Error; err != nil { - log.Printf("[PostgreSQL] failed to create default system configs: %v\n", err) + log.Printf("[%s] failed to create default system configs: %v\n", dbType(), err) } else { - log.Printf("[PostgreSQL] initialized %d default system configs\n", len(defaultConfigs)) + log.Printf("[%s] initialized %d default system configs\n", dbType(), len(defaultConfigs)) } } @@ -252,7 +256,7 @@ func initDefaultAdmin() { var count int64 if err := tx.Model(&model.User{}).Where("username = ?", "admin").Count(&count).Error; err != nil { - log.Printf("[PostgreSQL] failed to check default admin user: %v\n", err) + log.Printf("[%s] failed to check default admin user: %v\n", dbType(), err) return } @@ -272,9 +276,9 @@ func initDefaultAdmin() { } if err := tx.Create(&adminUser).Error; err != nil { - log.Printf("[PostgreSQL] failed to create default admin user: %v\n", err) + log.Printf("[%s] failed to create default admin user: %v\n", dbType(), err) } else { - log.Printf("[PostgreSQL] default admin user created successfully (username: admin, password: 12345678)\n") + log.Printf("[%s] default admin user created successfully (username: admin, password: 12345678)\n", dbType()) } } @@ -283,7 +287,7 @@ func initTemplates() { tx := db.DB(context.Background()) var count int64 if err := tx.Model(&model.Template{}).Count(&count).Error; err != nil { - log.Printf("[PostgreSQL] failed to check templates table: %v\n", err) + log.Printf("[%s] failed to check templates table: %v\n", dbType(), err) return } @@ -314,9 +318,9 @@ func initTemplates() { var t model.Template if err := tx.Where("key = ?", dt.Key).First(&t).Error; err != nil { if err := tx.Create(&dt).Error; err != nil { - log.Printf("[PostgreSQL] failed to create template key %s: %v\n", dt.Key, err) + log.Printf("[%s] failed to create template key %s: %v\n", dbType(), dt.Key, err) } else { - log.Printf("[PostgreSQL] initialized template key %s\n", dt.Key) + log.Printf("[%s] initialized template key %s\n", dbType(), dt.Key) } } } @@ -324,8 +328,8 @@ func initTemplates() { } if err := tx.Create(&defaultTemplates).Error; err != nil { - log.Printf("[PostgreSQL] failed to create default templates: %v\n", err) + log.Printf("[%s] failed to create default templates: %v\n", dbType(), err) } else { - log.Printf("[PostgreSQL] initialized %d default templates\n", len(defaultTemplates)) + log.Printf("[%s] initialized %d default templates\n", dbType(), len(defaultTemplates)) } } diff --git a/internal/db/postgres.go b/internal/db/postgres.go index 958b3d5c..3860c306 100644 --- a/internal/db/postgres.go +++ b/internal/db/postgres.go @@ -26,6 +26,7 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/config" + "github.com/glebarez/sqlite" "go.opentelemetry.io/otel/attribute" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -39,10 +40,39 @@ var ( func init() { if !config.Config.Database.Enabled { - log.Println("[PostgreSQL] is disabled, skipping initialization") + // PostgreSQL 禁用,使用 SQLite + initSQLite() return } + initPostgres() +} + +// initSQLite 初始化 SQLite 数据库(PostgreSQL 禁用时的后备方案) +func initSQLite() { + sqlitePath := config.Config.Database.SQLitePath + if sqlitePath == "" { + sqlitePath = "./data/wavelet.db" + } + + var err error + db, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + Logger: &gormZapLogger{ + logLevel: parseLogLevel(config.Config.Database.LogLevel), + slowThreshold: config.Config.Database.SlowThreshold, + ignoreRecordNotFoundError: config.Config.App.IsProduction(), + }, + }) + if err != nil { + log.Fatalf("[SQLite] init connection failed: %v\n", err) + } + + log.Printf("[SQLite] initialized (path: %s)\n", sqlitePath) +} + +// initPostgres 初始化 PostgreSQL 数据库 +func initPostgres() { var err error dbConfig := config.Config.Database