diff --git a/.env.example b/.env.example index 8bd6248..30322fb 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ GOTIFY_TOKEN=your-gotify-token # 服务监听端口 PORT=:8082 + +# MySQL 连接串(可选,留空则不记录 prompt) +# 格式: user:password@tcp(host:port)/dbname?parseTime=true +MYSQL_DSN= diff --git a/README.md b/README.md index d1cc1f3..463073d 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,16 @@ docker-compose up -d } ] } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "http", + "url": "http://47.121.181.112:8082/hooks" + } + ] + } ] } } @@ -123,6 +133,32 @@ docker-compose up -d | `AskUserQuestion` | 等待回答 | 6 | Claude 向你提问,等待回答 | | 其他工具 | 工具调用 | 4 | Claude 正在使用工具 | +### UserPromptSubmit 事件 + +| 行为 | 说明 | +|------|------| +| 记录 Prompt | 将用户提交的 prompt 存入 MySQL 数据库,便于复盘提示词质量 | + +> ⚠️ 需要配置 `MYSQL_DSN` 环境变量才会启用数据库记录,否则仅打印日志。 + +#### 数据库表结构 + +```sql +CREATE TABLE prompts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(128) NOT NULL, -- Claude Code 会话 ID + project_name VARCHAR(255) NOT NULL, -- 项目名称(从 cwd 自动提取) + prompt TEXT NOT NULL, -- 用户提交的 prompt + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_id (session_id), + INDEX idx_project_name (project_name), + INDEX idx_created_at (created_at) +); +``` + +- `session_id`:用于关联同一会话的多条 prompt +- `project_name`:从工作目录自动提取(如 `/home/user/my-app` → `my-app`),便于按项目筛选 + ## 配置说明 | 环境变量 | 必填 | 说明 | @@ -130,6 +166,7 @@ docker-compose up -d | `GOTIFY_URL` | 是 | Gotify 服务器地址 | | `GOTIFY_TOKEN` | 是 | Gotify 应用 Token | | `PORT` | 否 | 服务监听端口(默认 `:8082`) | +| `MYSQL_DSN` | 否 | MySQL 连接串,留空则不记录 prompt(格式: `user:password@tcp(host:port)/dbname?parseTime=true`) | ## API 端点 @@ -196,6 +233,11 @@ curl -X POST http://47.121.181.112:8082/hooks \ curl -X POST http://47.121.181.112:8082/hooks \ -H 'Content-Type: application/json' \ -d '{"session_id":"test","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"question":"你想使用哪种数据库?"}}' + +# 模拟 UserPromptSubmit 事件(记录 prompt 到 MySQL) +curl -X POST http://47.121.181.112:8082/hooks \ + -H 'Content-Type: application/json' \ + -d '{"session_id":"test","cwd":"/tmp","hook_event_name":"UserPromptSubmit","tool_input":{"prompt":"帮我写一个 Hello World 程序"}}' ``` ## 项目结构 @@ -205,6 +247,7 @@ cc-hook/ ├── .env.example # 环境变量示例 ├── .gitignore # Git 忽略规则 ├── config.go # 配置管理(环境变量读取) +├── db.go # MySQL 连接与 prompt 存储 ├── gotify.go # Gotify HTTP 客户端 ├── handler.go # Hook 事件处理器 ├── main.go # HTTP 服务入口 @@ -215,10 +258,8 @@ cc-hook/ ## 扩展计划 -- [ ] 数据库记录:将 Hook 事件写入数据库,便于复盘和分析 +- [x] 数据库记录:将 UserPromptSubmit 的 prompt 写入 MySQL,便于复盘提示词质量 - [x] 更多事件支持:PreToolUse(AskUserQuestion 提问通知) -- [ ] 消息模板:自定义通知消息格式 -- [ ] 多 Gotify 支持:同时推送到多个 Gotify 服务器 ## License diff --git a/config.go b/config.go index 2899dc6..af82cb2 100644 --- a/config.go +++ b/config.go @@ -15,6 +15,7 @@ type Config struct { GotifyURL string GotifyToken string Port string + MySQLDSN string // MySQL 连接串,留空则不启用数据库记录 } func LoadConfig() Config { @@ -22,6 +23,7 @@ func LoadConfig() Config { GotifyURL: getEnv("GOTIFY_URL", ""), GotifyToken: getEnv("GOTIFY_TOKEN", ""), Port: getEnv("PORT", ":8082"), + MySQLDSN: getEnv("MYSQL_DSN", ""), } } diff --git a/db.go b/db.go new file mode 100644 index 0000000..bbe7335 --- /dev/null +++ b/db.go @@ -0,0 +1,86 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +type PromptDB struct { + db *sql.DB +} + +// NewPromptDB 创建 MySQL 连接并自动建表 +// dsn 格式: user:password@tcp(host:port)/dbname?parseTime=true +func NewPromptDB(dsn string) (*PromptDB, error) { + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open mysql: %w", err) + } + + // 连接池配置 + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + + // 等待连接就绪 + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping mysql: %w", err) + } + + // 自动建表 + if err := migrate(db); err != nil { + return nil, fmt.Errorf("failed to migrate: %w", err) + } + + log.Println("mysql connected and migrated") + return &PromptDB{db: db}, nil +} + +func migrate(db *sql.DB) error { + query := ` + CREATE TABLE IF NOT EXISTS prompts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(128) NOT NULL, + project_name VARCHAR(255) NOT NULL DEFAULT '', + prompt TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_id (session_id), + INDEX idx_project_name (project_name), + INDEX idx_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;` + + _, err := db.Exec(query) + if err != nil { + return err + } + + // 兼容旧表:如果已存在但缺少 project_name 列,则补充 + _, err = db.Exec("ALTER TABLE prompts ADD COLUMN project_name VARCHAR(255) NOT NULL DEFAULT '' AFTER session_id") + if err != nil { + // 忽略 "Duplicate column" 错误,说明列已存在 + log.Printf("alter table (expected if column exists): %v", err) + } + + return nil +} + +// SavePrompt 将一条 prompt 写入数据库 +func (p *PromptDB) SavePrompt(sessionID, projectName, prompt string) error { + _, err := p.db.Exec( + "INSERT INTO prompts (session_id, project_name, prompt) VALUES (?, ?, ?)", + sessionID, projectName, prompt, + ) + return err +} + +// Close 关闭数据库连接 +func (p *PromptDB) Close() error { + if p.db != nil { + return p.db.Close() + } + return nil +} diff --git a/go.mod b/go.mod index 270311c..fc30b48 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,10 @@ module cc-hook -go 1.22.2 +go 1.24.0 require github.com/joho/godotenv v1.5.1 + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect +) diff --git a/go.sum b/go.sum index d61b19e..c2cfc2f 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/handler.go b/handler.go index 042a1ef..8522ab2 100644 --- a/handler.go +++ b/handler.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "path/filepath" ) // ClaudeCodeHookEvent represents the JSON payload from Claude Code hooks @@ -20,10 +21,11 @@ type ClaudeCodeHookEvent struct { type HookHandler struct { config Config + db *PromptDB // 可能为 nil,未配置 MySQL 时跳过数据库写入 } -func NewHookHandler(config Config) *HookHandler { - return &HookHandler{config: config} +func NewHookHandler(config Config, db *PromptDB) *HookHandler { + return &HookHandler{config: config, db: db} } func (h *HookHandler) HandleHook(w http.ResponseWriter, r *http.Request) { @@ -51,6 +53,11 @@ func (h *HookHandler) HandleHook(w http.ResponseWriter, r *http.Request) { title, message, priority = h.handleStop(event) case "PreToolUse": title, message, priority = h.handlePreToolUse(event) + case "UserPromptSubmit": + h.handleUserPromptSubmit(event) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"ok"}`) + return default: log.Printf("ignored event: %s", event.HookEventName) w.WriteHeader(http.StatusOK) @@ -124,3 +131,27 @@ func (h *HookHandler) handleStop(event ClaudeCodeHookEvent) (title, message stri return title, message, priority } + +// handleUserPromptSubmit 将用户提交的 prompt 写入数据库 +func (h *HookHandler) handleUserPromptSubmit(event ClaudeCodeHookEvent) { + if h.db == nil { + log.Printf("mysql not configured, skip saving prompt for session %s", event.SessionID) + return + } + + prompt, _ := event.ToolInput["prompt"].(string) + if prompt == "" { + log.Printf("empty prompt in UserPromptSubmit, session=%s", event.SessionID) + return + } + + // 从 cwd 提取项目名称(最后一级目录) + projectName := filepath.Base(event.CWD) + + if err := h.db.SavePrompt(event.SessionID, projectName, prompt); err != nil { + log.Printf("failed to save prompt: %v", err) + return + } + + log.Printf("prompt saved: session=%s, project=%s, len=%d", event.SessionID, projectName, len(prompt)) +} diff --git a/main.go b/main.go index 5ea5bf3..cd736f3 100644 --- a/main.go +++ b/main.go @@ -1,25 +1,71 @@ package main import ( + "context" "log" "net/http" + "os" + "os/signal" + "syscall" + "time" ) func main() { config := LoadConfig() - handler := NewHookHandler(config) + // 可选初始化 MySQL + var db *PromptDB + if config.MySQLDSN != "" { + var err error + db, err = NewPromptDB(config.MySQLDSN) + if err != nil { + log.Fatalf("failed to init mysql: %v", err) + } + log.Println("mysql prompt recording enabled") + } else { + log.Println("mysql not configured, prompt recording disabled") + } - http.HandleFunc("/hooks", handler.HandleHook) - http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + handler := NewHookHandler(config, db) + + mux := http.NewServeMux() + mux.HandleFunc("/hooks", handler.HandleHook) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) }) + srv := &http.Server{ + Addr: config.Port, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // 优雅关闭 + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + log.Println("shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Printf("server shutdown error: %v", err) + } + if db != nil { + db.Close() + } + }() + log.Printf("cc-hook service starting on %s", config.Port) log.Printf("gotify endpoint: %s", config.GotifyURL) - if err := http.ListenAndServe(config.Port, nil); err != nil { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("server failed: %v", err) } + log.Println("server stopped") }