Merge branch 'dev-mysql-support' into master
- 新增 MySQL 数据库驱动支持,支持跨设备数据同步 - 新增 MySQL 专项测试能力(并发、约束、迁移) - 重构迁移目录结构:migrations/sqlite 和 migrations/mysql - 修复 statsRepo 并发竞态条件,使用 upsert 保证原子性 - Makefile 合并:保留完整命令体系 + 新增 MySQL 测试命令
This commit is contained in:
@@ -77,6 +77,7 @@
|
||||
- **THEN** SHALL 支持 `required` 规则
|
||||
- **THEN** SHALL 支持 `min`、`max` 规则
|
||||
- **THEN** SHALL 支持 `oneof` 规则
|
||||
- **THEN** SHALL 支持 `required_if` 条件验证规则
|
||||
|
||||
#### Scenario: 验证执行
|
||||
|
||||
@@ -85,6 +86,17 @@
|
||||
- **THEN** SHALL 返回验证错误
|
||||
- **THEN** SHALL NOT 启动应用(如果验证失败)
|
||||
|
||||
#### Scenario: 数据库驱动条件验证
|
||||
|
||||
- **WHEN** `database.driver` 为 `sqlite`
|
||||
- **THEN** SHALL 验证 `database.path` 必填
|
||||
- **THEN** SHALL NOT 要求 MySQL 字段(host/port/user/password/dbname)
|
||||
- **WHEN** `database.driver` 为 `mysql`
|
||||
- **THEN** SHALL 验证 `database.host` 必填
|
||||
- **THEN** SHALL 验证 `database.user` 必填
|
||||
- **THEN** SHALL 验证 `database.dbname` 必填
|
||||
- **THEN** SHALL NOT 要求 `database.path`
|
||||
|
||||
### Requirement: 配置结构定义
|
||||
|
||||
系统 SHALL 定义清晰的配置结构。
|
||||
@@ -98,7 +110,14 @@
|
||||
#### Scenario: Database 配置
|
||||
|
||||
- **WHEN** 加载 database 配置
|
||||
- **THEN** SHALL 包含 path、max_idle_conns、max_open_conns、conn_max_lifetime 字段
|
||||
- **THEN** SHALL 包含 driver 字段(值为 `sqlite` 或 `mysql`,默认 `sqlite`)
|
||||
- **THEN** SHALL 包含 path 字段(SQLite 模式下的数据库文件路径)
|
||||
- **THEN** SHALL 包含 host 字段(MySQL 主机地址)
|
||||
- **THEN** SHALL 包含 port 字段(MySQL 端口,默认 3306)
|
||||
- **THEN** SHALL 包含 user 字段(MySQL 用户名)
|
||||
- **THEN** SHALL 包含 password 字段(MySQL 密码,选填)
|
||||
- **THEN** SHALL 包含 dbname 字段(MySQL 数据库名)
|
||||
- **THEN** SHALL 包含 max_idle_conns、max_open_conns、conn_max_lifetime 字段
|
||||
- **THEN** SHALL 使用合理的默认值
|
||||
|
||||
#### Scenario: Log 配置
|
||||
@@ -121,7 +140,13 @@
|
||||
#### Scenario: Database 默认值
|
||||
|
||||
- **WHEN** 使用默认配置
|
||||
- **THEN** database.driver SHALL 为 `sqlite`
|
||||
- **THEN** database.path SHALL 为 `~/.nex/config.db`
|
||||
- **THEN** database.host SHALL 为空字符串
|
||||
- **THEN** database.port SHALL 为 3306
|
||||
- **THEN** database.user SHALL 为空字符串
|
||||
- **THEN** database.password SHALL 为空字符串
|
||||
- **THEN** database.dbname SHALL 为 `nex`
|
||||
- **THEN** database.max_idle_conns SHALL 为 10
|
||||
- **THEN** database.max_open_conns SHALL 为 100
|
||||
- **THEN** database.conn_max_lifetime SHALL 为 1h
|
||||
@@ -248,18 +273,38 @@
|
||||
- **THEN** SHALL 在日志中记录覆盖信息
|
||||
- **THEN** SHALL 显示被覆盖的配置项名称
|
||||
|
||||
### Requirement: 配置文件安全
|
||||
|
||||
系统 SHALL 使用安全的文件权限保存配置文件。
|
||||
|
||||
#### Scenario: 配置文件权限
|
||||
|
||||
- **WHEN** 保存配置文件(`SaveConfig`)
|
||||
- **THEN** SHALL 使用 `0600` 权限写入文件(仅 owner 可读写)
|
||||
- **THEN** SHALL 防止其他用户读取配置中的 MySQL 密码等敏感信息
|
||||
|
||||
### Requirement: 配置摘要输出
|
||||
|
||||
系统 SHALL 在启动时输出配置摘要。
|
||||
|
||||
#### Scenario: 摘要内容
|
||||
#### Scenario: SQLite 模式摘要
|
||||
|
||||
- **WHEN** 配置加载完成
|
||||
- **WHEN** `database.driver` 为 `sqlite`
|
||||
- **THEN** SHALL 打印关键配置项(端口、数据库路径、日志级别等)
|
||||
- **THEN** SHALL 打印配置文件路径
|
||||
- **THEN** SHALL 打印环境变量数量
|
||||
- **THEN** SHALL 打印 CLI 参数数量
|
||||
|
||||
#### Scenario: MySQL 模式摘要
|
||||
|
||||
- **WHEN** `database.driver` 为 `mysql`
|
||||
- **THEN** SHALL 打印关键配置项(端口、数据库类型、数据库地址、日志级别等)
|
||||
- **THEN** SHALL 打印数据库地址格式为 `{host}:{port}/{dbname}`
|
||||
- **THEN** SHALL 不打印密码
|
||||
- **THEN** SHALL 打印配置文件路径
|
||||
- **THEN** SHALL 打印环境变量数量
|
||||
- **THEN** SHALL 打印 CLI 参数数量
|
||||
|
||||
#### Scenario: 摘要格式
|
||||
|
||||
- **WHEN** 打印配置摘要
|
||||
@@ -297,7 +342,7 @@
|
||||
- **WHEN** 使用服务器相关参数
|
||||
- **THEN** SHALL 支持 `--server-port`、`--server-read-timeout`、`--server-write-timeout`
|
||||
- **WHEN** 使用数据库相关参数
|
||||
- **THEN** SHALL 支持 `--database-path`、`--database-max-idle-conns`、`--database-max-open-conns`、`--database-conn-max-lifetime`
|
||||
- **THEN** SHALL 支持 `--database-driver`、`--database-path`、`--database-host`、`--database-port`、`--database-user`、`--database-password`、`--database-dbname`、`--database-max-idle-conns`、`--database-max-open-conns`、`--database-conn-max-lifetime`
|
||||
- **WHEN** 使用日志相关参数
|
||||
- **THEN** SHALL 支持 `--log-level`、`--log-path`、`--log-max-size`、`--log-max-backups`、`--log-max-age`、`--log-compress`
|
||||
|
||||
@@ -348,7 +393,7 @@
|
||||
- **WHEN** 设置服务器相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_SERVER_PORT`、`NEX_SERVER_READ_TIMEOUT`、`NEX_SERVER_WRITE_TIMEOUT`
|
||||
- **WHEN** 设置数据库相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_DATABASE_PATH`、`NEX_DATABASE_MAX_IDLE_CONNS`、`NEX_DATABASE_MAX_OPEN_CONNS`、`NEX_DATABASE_CONN_MAX_LIFETIME`
|
||||
- **THEN** SHALL 支持 `NEX_DATABASE_DRIVER`、`NEX_DATABASE_PATH`、`NEX_DATABASE_HOST`、`NEX_DATABASE_PORT`、`NEX_DATABASE_USER`、`NEX_DATABASE_PASSWORD`、`NEX_DATABASE_DBNAME`、`NEX_DATABASE_MAX_IDLE_CONNS`、`NEX_DATABASE_MAX_OPEN_CONNS`、`NEX_DATABASE_CONN_MAX_LIFETIME`
|
||||
- **WHEN** 设置日志相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_LOG_LEVEL`、`NEX_LOG_PATH`、`NEX_LOG_MAX_SIZE`、`NEX_LOG_MAX_BACKUPS`、`NEX_LOG_MAX_AGE`、`NEX_LOG_COMPRESS`
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
- **THEN** SHALL 删除所有表和索引
|
||||
- **THEN** SHALL 按正确顺序删除(避免外键约束错误)
|
||||
|
||||
#### Scenario: 按数据库方言拆分迁移目录
|
||||
|
||||
- **WHEN** 组织迁移文件
|
||||
- **THEN** SHALL 将 SQLite 方言迁移文件存储在 `migrations/sqlite/` 目录
|
||||
- **THEN** SHALL 将 MySQL 方言迁移文件存储在 `migrations/mysql/` 目录
|
||||
- **THEN** SHALL 两个目录维护独立的版本号序列
|
||||
- **THEN** SHALL 两个目录的迁移文件内容在逻辑上一致(相同的表结构和约束),但使用各自方言的 DDL
|
||||
|
||||
### Requirement: models 表 schema 变更
|
||||
|
||||
系统 SHALL 在初始迁移脚本中直接创建新的 models 表结构(服务未上线,无需考虑数据迁移,迁移脚本已合并为单个初始迁移文件)。
|
||||
@@ -63,28 +71,37 @@
|
||||
|
||||
#### Scenario: 迁移 up 命令
|
||||
|
||||
- **WHEN** 执行 `make migrate-up`
|
||||
- **WHEN** 执行 `make backend-migrate-up`
|
||||
- **THEN** SHALL 执行所有待执行的迁移
|
||||
- **THEN** SHALL 使用 `DB_DRIVER` 变量选择方言目录(默认 `sqlite3`)
|
||||
- **THEN** SHALL 使用 `DB_DSN` 变量作为数据库连接串
|
||||
- **THEN** SHALL 显示迁移进度
|
||||
|
||||
#### Scenario: 迁移 down 命令
|
||||
|
||||
- **WHEN** 执行 `make migrate-down`
|
||||
- **WHEN** 执行 `make backend-migrate-down`
|
||||
- **THEN** SHALL 回滚最后一个迁移
|
||||
- **THEN** SHALL 使用 `DB_DRIVER` 和 `DB_DSN` 变量
|
||||
- **THEN** SHALL 显示回滚进度
|
||||
|
||||
#### Scenario: 迁移状态命令
|
||||
|
||||
- **WHEN** 执行 `make migrate-status`
|
||||
- **WHEN** 执行 `make backend-migrate-status`
|
||||
- **THEN** SHALL 显示当前迁移状态
|
||||
- **THEN** SHALL 显示已执行和待执行的迁移
|
||||
|
||||
#### Scenario: 创建迁移命令
|
||||
|
||||
- **WHEN** 执行 `make migrate-create name=<name>`
|
||||
- **THEN** SHALL 创建新的迁移文件模板
|
||||
- **WHEN** 执行 `make backend-migrate-create`
|
||||
- **THEN** SHALL 同时在 `migrations/sqlite/` 和 `migrations/mysql/` 两个目录创建新的迁移文件模板
|
||||
- **THEN** SHALL 使用递增的版本号
|
||||
|
||||
#### Scenario: MySQL 迁移命令使用
|
||||
|
||||
- **WHEN** 使用 MySQL 驱动执行迁移
|
||||
- **THEN** SHALL 设置 `DB_DRIVER=mysql`
|
||||
- **THEN** SHALL 设置 `DB_DSN` 为 MySQL 连接串(如 `user:pass@tcp(localhost:3306)/nex`)
|
||||
|
||||
### Requirement: 应用启动时迁移
|
||||
|
||||
应用 SHALL 在启动时执行迁移。
|
||||
@@ -92,6 +109,9 @@
|
||||
#### Scenario: 自动迁移
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
||||
- **THEN** SHALL 自动执行待执行的迁移
|
||||
- **THEN** SHALL 在迁移失败时拒绝启动
|
||||
- **THEN** SHALL 记录迁移日志
|
||||
@@ -149,5 +169,5 @@
|
||||
#### Scenario: 迁移文件存储
|
||||
|
||||
- **WHEN** 创建迁移文件
|
||||
- **THEN** SHALL 存储在 migrations/ 目录
|
||||
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/` 或 `migrations/mysql/`)
|
||||
- **THEN** SHALL 提交到版本控制系统
|
||||
|
||||
107
openspec/specs/mysql-driver/spec.md
Normal file
107
openspec/specs/mysql-driver/spec.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# MySQL Driver
|
||||
|
||||
## Purpose
|
||||
|
||||
支持 MySQL 作为可选数据库后端,通过配置选择 sqlite 或 mysql 驱动,提供 MySQL 连接管理、初始化和方言迁移文件。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: MySQL 数据库驱动支持
|
||||
|
||||
系统 SHALL 支持通过配置项 `database.driver` 选择 `sqlite` 或 `mysql` 数据库驱动,默认值为 `sqlite`。
|
||||
|
||||
#### Scenario: 默认使用 SQLite 驱动
|
||||
|
||||
- **WHEN** 配置中未指定 `database.driver`
|
||||
- **THEN** SHALL 使用 `sqlite` 作为数据库驱动
|
||||
- **THEN** SHALL 行为与现有逻辑完全一致
|
||||
|
||||
#### Scenario: 配置 MySQL 驱动
|
||||
|
||||
- **WHEN** 配置 `database.driver` 设置为 `mysql`
|
||||
- **THEN** SHALL 使用 MySQL 驱动连接远程数据库
|
||||
- **THEN** SHALL 使用 `gorm.io/driver/mysql` 打开连接
|
||||
- **THEN** SHALL 构建 DSN 格式为 `{user}:{password}@tcp({host}:{port})/{dbname}?charset=utf8mb4&parseTime=true&loc=Local`
|
||||
|
||||
#### Scenario: driver 值不合法
|
||||
|
||||
- **WHEN** 配置 `database.driver` 不是 `sqlite` 或 `mysql`
|
||||
- **THEN** SHALL 配置验证失败,拒绝启动
|
||||
|
||||
### Requirement: MySQL 连接配置
|
||||
|
||||
系统 SHALL 在 `DatabaseConfig` 中支持 MySQL 连接参数。
|
||||
|
||||
#### Scenario: MySQL 连接参数字段
|
||||
|
||||
- **WHEN** `database.driver` 为 `mysql`
|
||||
- **THEN** SHALL 读取 `host`(MySQL 主机地址,必填)
|
||||
- **THEN** SHALL 读取 `port`(MySQL 端口,默认 3306)
|
||||
- **THEN** SHALL 读取 `user`(MySQL 用户名,必填)
|
||||
- **THEN** SHALL 读取 `password`(MySQL 密码,选填)
|
||||
- **THEN** SHALL 读取 `dbname`(数据库名,必填)
|
||||
|
||||
#### Scenario: SQLite 模式忽略 MySQL 参数
|
||||
|
||||
- **WHEN** `database.driver` 为 `sqlite`
|
||||
- **THEN** SHALL 忽略 MySQL 相关配置字段(host/port/user/password/dbname)
|
||||
- **THEN** SHALL 仅使用 `path` 字段作为数据库文件路径
|
||||
|
||||
#### Scenario: MySQL 模式忽略 SQLite 参数
|
||||
|
||||
- **WHEN** `database.driver` 为 `mysql`
|
||||
- **THEN** SHALL 忽略 `path` 字段
|
||||
|
||||
### Requirement: 数据库初始化公共包
|
||||
|
||||
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server` 和 `cmd/desktop` 共同调用。
|
||||
|
||||
#### Scenario: 公共包 Init 函数
|
||||
|
||||
- **WHEN** 调用 `database.Init(cfg, logger)`
|
||||
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
|
||||
- **THEN** SHALL 执行对应方言的 goose 迁移
|
||||
- **THEN** SHALL 配置连接池参数
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
|
||||
- **THEN** SHALL 返回 `*gorm.DB` 实例
|
||||
|
||||
#### Scenario: 公共包 Close 函数
|
||||
|
||||
- **WHEN** 调用 `database.Close(db)`
|
||||
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
|
||||
|
||||
#### Scenario: 迁移目录选择
|
||||
|
||||
- **WHEN** 执行迁移
|
||||
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录,goose dialect 为 `sqlite3`
|
||||
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录,goose dialect 为 `mysql`
|
||||
|
||||
### Requirement: MySQL 方言迁移文件
|
||||
|
||||
系统 SHALL 提供 MySQL 方言的初始迁移文件 `migrations/mysql/20260421000001_initial_schema.sql`。
|
||||
|
||||
#### Scenario: providers 表
|
||||
|
||||
- **WHEN** 执行 MySQL 初始迁移
|
||||
- **THEN** SHALL 创建 `providers` 表,字段:`id VARCHAR(36) PRIMARY KEY`、`name VARCHAR(255) NOT NULL`、`api_key VARCHAR(255) NOT NULL`、`base_url VARCHAR(255) NOT NULL`、`protocol VARCHAR(50) DEFAULT 'openai'`、`enabled BOOLEAN DEFAULT TRUE`、`created_at DATETIME(3)`、`updated_at DATETIME(3)`
|
||||
|
||||
#### Scenario: models 表
|
||||
|
||||
- **WHEN** 执行 MySQL 初始迁移
|
||||
- **THEN** SHALL 创建 `models` 表,字段:`id VARCHAR(36) PRIMARY KEY`、`provider_id VARCHAR(36) NOT NULL`、`model_name VARCHAR(255) NOT NULL`、`enabled BOOLEAN DEFAULT TRUE`、`created_at DATETIME(3)`
|
||||
- **THEN** SHALL 创建 `UNIQUE(provider_id, model_name)` 约束
|
||||
- **THEN** SHALL 创建 `FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE` 约束
|
||||
- **THEN** SHALL 创建 `idx_models_provider_id` 和 `idx_models_model_name` 索引
|
||||
|
||||
#### Scenario: usage_stats 表
|
||||
|
||||
- **WHEN** 执行 MySQL 初始迁移
|
||||
- **THEN** SHALL 创建 `usage_stats` 表,字段:`id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY`、`provider_id VARCHAR(36) NOT NULL`、`model_name VARCHAR(255) NOT NULL`、`request_count INT DEFAULT 0`、`date DATE NOT NULL`
|
||||
- **THEN** SHALL 创建 `UNIQUE(provider_id, model_name, date)` 约束
|
||||
- **THEN** SHALL 创建 `idx_usage_stats_provider_model_date` 复合索引
|
||||
|
||||
#### Scenario: Down 迁移
|
||||
|
||||
- **WHEN** 执行 MySQL down 迁移
|
||||
- **THEN** SHALL 按正确顺序删除索引和表(usage_stats → models → providers)
|
||||
104
openspec/specs/mysql-testing/spec.md
Normal file
104
openspec/specs/mysql-testing/spec.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# MySQL Testing
|
||||
|
||||
## Purpose
|
||||
|
||||
提供 MySQL 数据库专项测试能力,验证迁移正确性、外键约束、并发写入等数据库特定行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: MySQL 测试环境可启动
|
||||
|
||||
系统 SHALL 提供 Docker Compose 配置以启动 MySQL 8.0 测试环境。
|
||||
|
||||
#### Scenario: 启动 MySQL 测试容器
|
||||
- **WHEN** 执行 `make test-mysql-up`
|
||||
- **THEN** 启动 MySQL 8.0 容器,端口 13306
|
||||
- **AND** 创建数据库 `nex_test`
|
||||
- **AND** 容器数据存储在内存盘(tmpfs)
|
||||
|
||||
#### Scenario: 销毁 MySQL 测试容器
|
||||
- **WHEN** 执行 `make test-mysql-down`
|
||||
- **THEN** 停止并删除容器
|
||||
- **AND** 所有数据被销毁
|
||||
|
||||
### Requirement: MySQL 测试可通过 build tag 控制
|
||||
|
||||
MySQL 测试 SHALL 使用 `// +build mysql` build tag,默认不运行。
|
||||
|
||||
#### Scenario: 默认测试不包含 MySQL 测试
|
||||
- **WHEN** 执行 `go test ./...`
|
||||
- **THEN** 不运行 `tests/mysql/` 下的测试
|
||||
|
||||
#### Scenario: 启用 MySQL 测试
|
||||
- **WHEN** 执行 `go test -tags=mysql ./tests/mysql/...`
|
||||
- **THEN** 运行所有 MySQL 测试
|
||||
|
||||
### Requirement: MySQL 迁移正确执行
|
||||
|
||||
MySQL 测试 SHALL 验证迁移脚本在 MySQL 环境下正确执行。
|
||||
|
||||
#### Scenario: 迁移创建所有表
|
||||
- **WHEN** 运行 MySQL 迁移
|
||||
- **THEN** 创建 `providers`、`models`、`usage_stats` 表
|
||||
- **AND** 字段类型符合 MySQL 迁移文件定义(VARCHAR、DATETIME(3)、BOOLEAN 等)
|
||||
- **AND** 索引 `idx_models_provider_id`、`idx_models_model_name`、`idx_usage_stats_provider_model_date` 创建成功
|
||||
|
||||
#### Scenario: 迁移可重复执行
|
||||
- **WHEN** 在已迁移的数据库上再次运行迁移
|
||||
- **THEN** 不报错,数据库状态不变
|
||||
|
||||
### Requirement: MySQL 外键约束生效
|
||||
|
||||
MySQL 测试 SHALL 验证外键约束行为符合预期。
|
||||
|
||||
#### Scenario: 外键约束阻止无效引用
|
||||
- **WHEN** 创建 model 时 `provider_id` 不存在
|
||||
- **THEN** 操作失败,返回外键约束错误
|
||||
|
||||
#### Scenario: 级联删除生效
|
||||
- **WHEN** 删除 provider
|
||||
- **THEN** 该 provider 的所有 models 被级联删除
|
||||
|
||||
### Requirement: MySQL UNIQUE 约束生效
|
||||
|
||||
MySQL 测试 SHALL 验证 UNIQUE 约束行为符合预期。
|
||||
|
||||
#### Scenario: models 表 UNIQUE 约束
|
||||
- **WHEN** 尝试创建相同 `(provider_id, model_name)` 组合的 model
|
||||
- **THEN** 操作失败,返回唯一约束错误
|
||||
|
||||
#### Scenario: usage_stats 表 UNIQUE 约束
|
||||
- **WHEN** 尝试创建相同 `(provider_id, model_name, date)` 组合的 usage_stats
|
||||
- **THEN** 操作失败,返回唯一约束错误
|
||||
|
||||
### Requirement: MySQL 并发写入正确
|
||||
|
||||
MySQL 测试 SHALL 验证并发写入不丢失数据。
|
||||
|
||||
#### Scenario: 并发记录 usage_stats
|
||||
- **WHEN** 10 个 goroutine 并发调用 `statsRepo.Record(providerID, modelName)`
|
||||
- **THEN** 最终 `request_count` 等于 10
|
||||
- **AND** 无数据丢失或重复
|
||||
|
||||
#### Scenario: 并发创建相同 provider
|
||||
- **WHEN** 10 个 goroutine 并发创建相同 ID 的 provider
|
||||
- **THEN** 仅 1 个成功,其他 9 个失败
|
||||
|
||||
#### Scenario: 并发创建相同 model
|
||||
- **WHEN** 10 个 goroutine 并发创建相同 `(provider_id, model_name)` 的 model
|
||||
- **THEN** 仅 1 个成功,其他 9 个失败
|
||||
|
||||
### Requirement: MySQL 测试命令完整
|
||||
|
||||
Makefile SHALL 提供完整的 MySQL 测试命令。
|
||||
|
||||
#### Scenario: 完整测试流程
|
||||
- **WHEN** 执行 `make test-mysql`
|
||||
- **THEN** 启动 Docker MySQL
|
||||
- **AND** 等待 MySQL 就绪
|
||||
- **AND** 运行所有 MySQL 测试
|
||||
- **AND** 销毁容器
|
||||
|
||||
#### Scenario: 快速测试(容器已运行)
|
||||
- **WHEN** 执行 `make test-mysql-quick`
|
||||
- **THEN** 直接运行测试,不管理容器生命周期
|
||||
@@ -93,7 +93,21 @@
|
||||
- **WHEN** 同时处理多个并发请求
|
||||
- **THEN** 网关 SHALL 使用原子操作正确增加每个请求的计数
|
||||
- **THEN** 不 SHALL 因并发写入而丢失统计
|
||||
- **THEN** SHALL 使用 StatsBuffer 的内存计数器
|
||||
- **THEN** SHALL 使用 upsert 操作保证原子性
|
||||
|
||||
#### Scenario: 并发调用 Record 方法
|
||||
|
||||
- **WHEN** 多个 goroutine 并发调用 StatsRepository.Record
|
||||
- **THEN** SHALL 使用 INSERT ... ON DUPLICATE KEY UPDATE (MySQL) 或 INSERT ... ON CONFLICT DO UPDATE (SQLite)
|
||||
- **THEN** SHALL 保证所有并发调用的计数都被正确累加
|
||||
- **THEN** 不 SHALL 因 UNIQUE 约束冲突而丢失数据
|
||||
|
||||
#### Scenario: 并发调用 BatchUpdate 方法
|
||||
|
||||
- **WHEN** 多个 goroutine 并发调用 StatsRepository.BatchUpdate
|
||||
- **THEN** SHALL 使用 upsert 操作保证原子性
|
||||
- **THEN** SHALL 正确累加所有 delta 值
|
||||
- **THEN** 不 SHALL 因并发写入而丢失统计
|
||||
|
||||
### Requirement: 使用 service 层处理业务逻辑
|
||||
|
||||
@@ -125,14 +139,14 @@ Service SHALL 通过 StatsRepository 访问数据。
|
||||
|
||||
- **WHEN** StatsBuffer 刷新统计
|
||||
- **THEN** SHALL 调用 StatsRepository.BatchUpdate
|
||||
- **THEN** SHALL 使用事务更新或创建统计记录
|
||||
- **THEN** SHALL 使用 upsert 操作更新或创建统计记录
|
||||
- **THEN** SHALL 支持增量更新(request_count + delta)
|
||||
|
||||
#### Scenario: 事务处理
|
||||
#### Scenario: upsert 操作
|
||||
|
||||
- **WHEN** 记录统计
|
||||
- **THEN** SHALL 在 repository 层使用数据库事务
|
||||
- **THEN** SHALL 确保并发安全
|
||||
- **THEN** SHALL 在 repository 层使用 upsert 操作
|
||||
- **THEN** SHALL 保证原子性和并发安全
|
||||
|
||||
### Requirement: 统计查询优化
|
||||
|
||||
@@ -168,11 +182,18 @@ StatsRepository SHALL 新增 BatchUpdate 方法支持批量增量更新。
|
||||
#### Scenario: BatchUpdate 更新现有记录
|
||||
|
||||
- **WHEN** 调用 BatchUpdate 且当日记录存在
|
||||
- **THEN** SHALL 使用事务更新 request_count = request_count + delta
|
||||
- **THEN** SHALL 使用 upsert 操作更新 request_count = request_count + delta
|
||||
- **THEN** SHALL 保证原子性,无竞态条件
|
||||
- **THEN** SHALL 不创建新记录
|
||||
|
||||
#### Scenario: BatchUpdate 创建新记录
|
||||
|
||||
- **WHEN** 调用 BatchUpdate 且当日记录不存在
|
||||
- **THEN** SHALL 创建新记录,request_count = delta
|
||||
- **THEN** SHALL 使用事务保证原子性
|
||||
- **THEN** SHALL 使用 upsert 操作保证原子性
|
||||
|
||||
#### Scenario: BatchUpdate 并发安全
|
||||
|
||||
- **WHEN** 多个 BatchUpdate 调用同时执行
|
||||
- **THEN** SHALL 保证所有 delta 都被正确累加
|
||||
- **THEN** SHALL 不因并发冲突而丢失数据
|
||||
|
||||
Reference in New Issue
Block a user