feat: 支持 UserPromptSubmit 事件,将 prompt 存入 MySQL

- 新增 db.go:MySQL 连接管理、自动建表、兼容旧表 ALTER
- handler.go:处理 UserPromptSubmit,从 cwd 提取项目名称
- main.go:可选初始化 MySQL,优雅关闭释放连接
- config.go:新增 MYSQL_DSN 配置项
- 表结构:session_id + project_name + prompt + created_at
This commit is contained in:
2026-06-25 23:52:42 +08:00
parent ea7f57d984
commit 717d2c70be
8 changed files with 229 additions and 10 deletions
+4
View File
@@ -6,3 +6,7 @@ GOTIFY_TOKEN=your-gotify-token
# 服务监听端口
PORT=:8082
# MySQL 连接串(可选,留空则不记录 prompt)
# 格式: user:password@tcp(host:port)/dbname?parseTime=true
MYSQL_DSN=
+44 -3
View File
@@ -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
+2
View File
@@ -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", ""),
}
}
+86
View File
@@ -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
}
+6 -1
View File
@@ -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
)
+4
View File
@@ -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=
+33 -2
View File
@@ -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))
}
+50 -4
View File
@@ -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")
}