feat: add Stow prototype iOS app and Gin backend

This commit is contained in:
2026-10-02 21:23:31 +08:00
commit fe72c1ebd2
30 changed files with 3252 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Stow Go API(开发中)
当前实现匿名会话、Item 创建/列表/详情/编辑/软删除,以及关键词搜索。使用 PostgreSQL 和 `pg_trgm` 建表;向量搜索需要服务器安装 `pgvector` 后再增加相应迁移。AI Worker、图片上传和云同步仍在开发。
数据库业务表统一使用 `stow_` 前缀,字段使用 `snake_case` 命名;业务表的主键 `id` 使用 `VARCHAR(36)` 存储 UUID 字符串,并由数据库检查格式。Go 结构体类型名不加 `Stow` 前缀,导出字段使用 Go 惯用的大驼峰写法。HTTP JSON 字段保持现有 `snake_case` 接口约定。
HTTP 层使用 Gin。所有响应统一为 `{"code":"ok","message":"success","data":...}`;失败时 `code` 为错误标识、`message` 为可读提示、`data` 为 `null`,同时返回对应的 HTTP 状态码。删除成功返回 HTTP 200 和同样的响应结构。
从 `backend/` 目录启动本地依赖与 API:
```sh
docker compose up -d
export DATABASE_URL='postgres://stow:stow_dev@localhost:5432/stow?sslmode=disable'
go run ./cmd/migrate
go run ./cmd/api
```
验证:
```sh
curl http://localhost:8080/v1/health
curl -X POST http://localhost:8080/v1/session/anonymous
```
所有 `/v1/items` 与 `/v1/search` 请求需要 `Authorization: Bearer <access_token>`。开发环境数据库密码只用于本地 Compose;生产部署需要单独配置私有数据库、TLS、对象存储和密钥管理。
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"stow/backend/internal/api"
"stow/backend/internal/store"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
logger.Error("DATABASE_URL is required")
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
dataStore, err := store.Open(ctx, databaseURL)
if err != nil {
logger.Error("database unavailable", "error", err)
os.Exit(1)
}
defer dataStore.Close()
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &http.Server{Addr: ":" + port, Handler: api.New(dataStore), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
logger.Info("stow api listening", "port", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"database/sql"
"log/slog"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
logger.Error("DATABASE_URL is required")
os.Exit(1)
}
directory := os.Getenv("MIGRATIONS_DIR")
if directory == "" {
directory = "migrations"
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := sql.Open("pgx", databaseURL)
if err != nil {
logger.Error("database open failed", "error", err)
os.Exit(1)
}
defer db.Close()
if err := migrate(ctx, db, directory, logger); err != nil {
logger.Error("migration failed", "error", err)
os.Exit(1)
}
}
func migrate(ctx context.Context, db *sql.DB, directory string, logger *slog.Logger) error {
entries, err := os.ReadDir(directory)
if err != nil {
return err
}
var names []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
names = append(names, entry.Name())
}
}
sort.Strings(names)
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS stow_schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
return err
}
for _, name := range names {
var applied bool
if err := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM stow_schema_migrations WHERE name=$1)`, name).Scan(&applied); err != nil {
return err
}
if applied {
continue
}
content, err := os.ReadFile(filepath.Join(directory, name))
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(735160)`); err != nil {
tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, string(content)); err != nil {
tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO stow_schema_migrations (name) VALUES ($1)`, name); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
logger.Info("applied migration", "name", name)
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: stow
POSTGRES_USER: stow
POSTGRES_PASSWORD: stow_dev
ports:
- "5432:5432"
volumes:
- stow_postgres:/var/lib/postgresql/data
volumes:
stow_postgres:
+44
View File
@@ -0,0 +1,44 @@
module stow/backend
go 1.26.2
require (
github.com/gin-gonic/gin v1.12.0
github.com/jackc/pgx/v5 v5.11.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+100
View File
@@ -0,0 +1,100 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+17
View File
@@ -0,0 +1,17 @@
package api
import "github.com/gin-gonic/gin"
type Response struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
func success(c *gin.Context, status int, data any) {
c.JSON(status, Response{Code: "ok", Message: "success", Data: data})
}
func failure(c *gin.Context, status int, code, message string) {
c.JSON(status, Response{Code: code, Message: message, Data: nil})
}
+292
View File
@@ -0,0 +1,292 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"stow/backend/internal/store"
)
type DataStore interface {
CreateAnonymousSession(context.Context) (string, string, error)
Authenticate(context.Context, string) (string, error)
CreateItem(context.Context, string, store.NewItem) (store.Item, error)
GetItem(context.Context, string, string) (store.Item, error)
ListItems(context.Context, string, int) ([]store.Item, error)
SearchItems(context.Context, string, string, int) ([]store.Item, error)
UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error)
DeleteItem(context.Context, string, string) error
}
type Server struct{ store DataStore }
func New(dataStore DataStore) http.Handler {
server := &Server{store: dataStore}
router := gin.New()
router.HandleMethodNotAllowed = true
router.Use(gin.CustomRecovery(func(c *gin.Context, _ any) {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
}))
_ = router.SetTrustedProxies(nil)
router.NoRoute(func(c *gin.Context) { failure(c, http.StatusNotFound, "not_found", "接口不存在") })
router.NoMethod(func(c *gin.Context) {
failure(c, http.StatusMethodNotAllowed, "method_not_allowed", "请求方法不支持")
})
v1 := router.Group("/v1")
v1.GET("/health", func(c *gin.Context) { success(c, http.StatusOK, gin.H{"status": "ok"}) })
v1.POST("/session/anonymous", server.anonymousSession)
authorized := v1.Group("")
authorized.Use(server.requireAuth())
authorized.POST("/items", server.createItem)
authorized.GET("/items", server.listItems)
authorized.GET("/items/:id", server.getItem)
authorized.PATCH("/items/:id", server.updateItem)
authorized.DELETE("/items/:id", server.deleteItem)
authorized.POST("/search", server.search)
return router
}
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") || strings.TrimSpace(header[7:]) == "" {
failure(c, http.StatusUnauthorized, "missing_token", "请先创建匿名会话")
c.Abort()
return
}
userID, err := s.store.Authenticate(c.Request.Context(), strings.TrimSpace(header[7:]))
if errors.Is(err, sql.ErrNoRows) {
failure(c, http.StatusUnauthorized, "invalid_token", "会话无效或已过期")
c.Abort()
return
}
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
c.Abort()
return
}
c.Set("userID", userID)
c.Next()
}
}
func (s *Server) anonymousSession(c *gin.Context) {
userID, token, err := s.store.CreateAnonymousSession(c.Request.Context())
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "创建会话失败")
return
}
success(c, http.StatusCreated, gin.H{"user_id": userID, "access_token": token, "token_type": "Bearer"})
}
func (s *Server) createItem(c *gin.Context) {
var input store.NewItem
if !readJSON(c, &input) {
return
}
if message := validateNewItem(&input); message != "" {
failure(c, http.StatusBadRequest, "invalid_item", message)
return
}
item, err := s.store.CreateItem(c.Request.Context(), c.GetString("userID"), input)
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "保存失败")
return
}
success(c, http.StatusCreated, item)
}
func (s *Server) listItems(c *gin.Context) {
items, err := s.store.ListItems(c.Request.Context(), c.GetString("userID"), parseLimit(c.Query("limit")))
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "读取失败")
return
}
success(c, http.StatusOK, gin.H{"items": items})
}
func (s *Server) getItem(c *gin.Context) {
item, err := s.store.GetItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))
if !handleItemError(c, err) {
return
}
success(c, http.StatusOK, item)
}
func (s *Server) updateItem(c *gin.Context) {
var patch store.ItemPatch
if !readJSON(c, &patch) {
return
}
if message := validatePatch(patch); message != "" {
failure(c, http.StatusBadRequest, "invalid_item", message)
return
}
item, err := s.store.UpdateItem(c.Request.Context(), c.GetString("userID"), c.Param("id"), patch)
if !handleItemError(c, err) {
return
}
success(c, http.StatusOK, item)
}
func (s *Server) deleteItem(c *gin.Context) {
if !handleItemError(c, s.store.DeleteItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))) {
return
}
success(c, http.StatusOK, nil)
}
func (s *Server) search(c *gin.Context) {
var request struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if !readJSON(c, &request) {
return
}
request.Query = strings.TrimSpace(request.Query)
if request.Query == "" || len([]rune(request.Query)) > 200 {
failure(c, http.StatusBadRequest, "invalid_query", "搜索内容需为 1 到 200 字")
return
}
if request.Limit <= 0 {
request.Limit = 20
}
if request.Limit > 100 {
request.Limit = 100
}
items, err := s.store.SearchItems(c.Request.Context(), c.GetString("userID"), request.Query, request.Limit)
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "搜索失败")
return
}
success(c, http.StatusOK, gin.H{"items": items, "query": request.Query})
}
func handleItemError(c *gin.Context, err error) bool {
if err == nil {
return true
}
if errors.Is(err, store.ErrNotFound) {
failure(c, http.StatusNotFound, "not_found", "收藏不存在")
} else if errors.Is(err, store.ErrConflict) {
failure(c, http.StatusConflict, "conflict", "收藏已更新,请重试")
} else {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
}
return false
}
func readJSON(c *gin.Context, destination any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
failure(c, http.StatusBadRequest, "invalid_json", "请求内容不是有效 JSON")
return false
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
failure(c, http.StatusBadRequest, "invalid_json", "请求只能包含一个 JSON 对象")
return false
}
return true
}
func parseLimit(raw string) int {
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
return 20
}
if limit > 100 {
return 100
}
return limit
}
func validateNewItem(input *store.NewItem) string {
input.Title = strings.TrimSpace(input.Title)
input.Summary = strings.TrimSpace(input.Summary)
if input.Title == "" || len([]rune(input.Title)) > 160 {
return "标题需为 1 到 160 字"
}
if len([]rune(input.Summary)) > 2000 {
return "摘要不能超过 2000 字"
}
if input.PrimaryType == "" {
input.PrimaryType = "other"
}
if !validType(input.PrimaryType) {
return "无效的分类"
}
if !validSource(input.SourceType) {
return "无效的来源类型"
}
if input.SourceURL != nil {
parsed, err := url.Parse(*input.SourceURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "无效的来源链接"
}
}
if len(input.Tags) > 20 {
return "标签不能超过 20 个"
}
for _, tag := range input.Tags {
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
return "标签需为 1 到 40 字"
}
}
if input.CapturedAt != nil && input.CapturedAt.After(time.Now().Add(24*time.Hour)) {
return "收藏时间不能晚于明天"
}
return ""
}
func validatePatch(patch store.ItemPatch) string {
if patch.Title != nil && (strings.TrimSpace(*patch.Title) == "" || len([]rune(*patch.Title)) > 160) {
return "标题需为 1 到 160 字"
}
if patch.Summary != nil && len([]rune(*patch.Summary)) > 2000 {
return "摘要不能超过 2000 字"
}
if patch.PrimaryType != nil && !validType(*patch.PrimaryType) {
return "无效的分类"
}
if patch.Tags != nil {
if len(*patch.Tags) > 20 {
return "标签不能超过 20 个"
}
for _, tag := range *patch.Tags {
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
return "标签需为 1 到 40 字"
}
}
}
return ""
}
func validType(value string) bool {
switch value {
case "place", "product", "content", "event", "task", "document", "conversation", "other":
return true
}
return false
}
func validSource(value string) bool {
switch value {
case "screenshot", "manual_photo", "share_image", "share_url", "share_text", "manual_url", "manual_text":
return true
}
return false
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"stow/backend/internal/store"
)
type fakeStore struct {
created bool
lastUser string
lastItem store.NewItem
}
func (f *fakeStore) CreateAnonymousSession(context.Context) (string, string, error) {
return "user-1", "token-1", nil
}
func (f *fakeStore) Authenticate(_ context.Context, token string) (string, error) {
if token != "token-1" {
return "", sql.ErrNoRows
}
return "user-1", nil
}
func (f *fakeStore) CreateItem(_ context.Context, userID string, item store.NewItem) (store.Item, error) {
f.created, f.lastUser, f.lastItem = true, userID, item
return store.Item{ID: "item-1", SourceType: item.SourceType, PrimaryType: item.PrimaryType, Title: item.Title, Summary: item.Summary, Status: "ready", Tags: item.Tags, CreatedAt: time.Now()}, nil
}
func (f *fakeStore) GetItem(context.Context, string, string) (store.Item, error) {
return store.Item{}, store.ErrNotFound
}
func (f *fakeStore) ListItems(context.Context, string, int) ([]store.Item, error) {
return []store.Item{}, nil
}
func (f *fakeStore) SearchItems(context.Context, string, string, int) ([]store.Item, error) {
return []store.Item{}, nil
}
func (f *fakeStore) UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error) {
return store.Item{}, store.ErrNotFound
}
func (f *fakeStore) DeleteItem(context.Context, string, string) error { return store.ErrNotFound }
func TestItemsRequireSession(t *testing.T) {
fake := &fakeStore{}
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(`{"source_type":"manual_text","title":"咖啡店"}`))
response := httptest.NewRecorder()
New(fake).ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.Code)
}
if fake.created {
t.Fatal("item was created without authentication")
}
}
func TestCreateItemValidatesAndUsesAuthenticatedUser(t *testing.T) {
fake := &fakeStore{}
handler := New(fake)
for _, test := range []struct {
name, body string
want int
}{
{"invalid source", `{"source_type":"unknown","title":"咖啡店"}`, http.StatusBadRequest},
{"valid item", `{"source_type":"manual_text","title":"咖啡店","tags":["上海"]}`, http.StatusCreated},
} {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(test.body))
request.Header.Set("Authorization", "Bearer token-1")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != test.want {
t.Fatalf("status = %d, want %d: %s", response.Code, test.want, response.Body.String())
}
})
}
if !fake.created || fake.lastUser != "user-1" || fake.lastItem.Title != "咖啡店" {
t.Fatalf("wrong authenticated create: %+v", fake)
}
}
func TestAnonymousSessionReturnsToken(t *testing.T) {
response := httptest.NewRecorder()
New(&fakeStore{}).ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/session/anonymous", nil))
if response.Code != http.StatusCreated {
t.Fatalf("status = %d", response.Code)
}
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != "ok" || body.Data["access_token"] != "token-1" || body.Data["token_type"] != "Bearer" {
t.Fatalf("unexpected response: %v", body)
}
}
func TestAllResponsesUseEnvelope(t *testing.T) {
handler := New(&fakeStore{})
for _, test := range []struct {
method string
path string
want int
}{
{http.MethodGet, "/v1/health", http.StatusOK},
{http.MethodGet, "/v1/items", http.StatusUnauthorized},
{http.MethodGet, "/missing", http.StatusNotFound},
{http.MethodPut, "/v1/health", http.StatusMethodNotAllowed},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.want {
t.Fatalf("%s %s: status %d, want %d", test.method, test.path, response.Code, test.want)
}
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatalf("%s %s: %v", test.method, test.path, err)
}
if body.Code == "" || body.Message == "" || body.Data == nil {
t.Fatalf("%s %s: invalid envelope: %s", test.method, test.path, response.Body.String())
}
}
}
+259
View File
@@ -0,0 +1,259 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
var ErrNotFound = errors.New("item not found")
var ErrConflict = errors.New("item changed during update")
type Store struct{ db *sql.DB }
func Open(ctx context.Context, databaseURL string) (*Store, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(12)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
type Item struct {
ID string `json:"id"`
SourceType string `json:"source_type"`
SourceURL *string `json:"source_url,omitempty"`
PrimaryType string `json:"primary_type"`
Title string `json:"title"`
Summary string `json:"summary"`
Status string `json:"status"`
Tags []string `json:"tags"`
CapturedAt *time.Time `json:"captured_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
}
type NewItem struct {
SourceType string `json:"source_type"`
SourceURL *string `json:"source_url"`
PrimaryType string `json:"primary_type"`
Title string `json:"title"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
CapturedAt *time.Time `json:"captured_at"`
}
type ItemPatch struct {
Title *string `json:"title"`
Summary *string `json:"summary"`
PrimaryType *string `json:"primary_type"`
Tags *[]string `json:"tags"`
}
func NewUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
s := hex.EncodeToString(b[:])
return fmt.Sprintf("%s-%s-%s-%s-%s", s[:8], s[8:12], s[12:16], s[16:20], s[20:]), nil
}
func (s *Store) CreateAnonymousSession(ctx context.Context) (userID, token string, err error) {
userID, err = NewUUID()
if err != nil {
return "", "", err
}
sessionID, err := NewUUID()
if err != nil {
return "", "", err
}
var secret [32]byte
if _, err = rand.Read(secret[:]); err != nil {
return "", "", err
}
token = base64.RawURLEncoding.EncodeToString(secret[:])
hash := sha256.Sum256([]byte(token))
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", "", err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO stow_users (id) VALUES ($1)`, userID); err != nil {
return "", "", err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO stow_sessions (id, user_id, token_hash, expires_at) VALUES ($1,$2,$3,$4)`, sessionID, userID, hash[:], time.Now().Add(365*24*time.Hour)); err != nil {
return "", "", err
}
if err = tx.Commit(); err != nil {
return "", "", err
}
return userID, token, nil
}
func (s *Store) Authenticate(ctx context.Context, token string) (string, error) {
hash := sha256.Sum256([]byte(token))
var userID string
err := s.db.QueryRowContext(ctx, `SELECT user_id FROM stow_sessions WHERE token_hash=$1 AND expires_at>now()`, hash[:]).Scan(&userID)
return userID, err
}
const itemColumns = `id, source_type, source_url, primary_type, title, summary, status, tags, captured_at, created_at, updated_at, version`
func scanItem(scanner interface{ Scan(...any) error }) (Item, error) {
var item Item
var sourceURL sql.NullString
var capturedAt sql.NullTime
var tagsJSON []byte
err := scanner.Scan(&item.ID, &item.SourceType, &sourceURL, &item.PrimaryType, &item.Title, &item.Summary, &item.Status, &tagsJSON, &capturedAt, &item.CreatedAt, &item.UpdatedAt, &item.Version)
if err != nil {
return Item{}, err
}
if sourceURL.Valid {
item.SourceURL = &sourceURL.String
}
if capturedAt.Valid {
item.CapturedAt = &capturedAt.Time
}
if err := json.Unmarshal(tagsJSON, &item.Tags); err != nil {
return Item{}, err
}
if item.Tags == nil {
item.Tags = []string{}
}
return item, nil
}
func searchText(item NewItem) string {
return strings.Join(append([]string{item.Title, item.Summary}, item.Tags...), " ")
}
func (s *Store) CreateItem(ctx context.Context, userID string, input NewItem) (Item, error) {
id, err := NewUUID()
if err != nil {
return Item{}, err
}
tags, err := json.Marshal(input.Tags)
if err != nil {
return Item{}, err
}
return scanItem(s.db.QueryRowContext(ctx, `
INSERT INTO stow_items (id,user_id,source_type,source_url,primary_type,title,summary,status,tags,search_text,captured_at,ready_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,'ready',$8,$9,$10,now())
RETURNING `+itemColumns,
id, userID, input.SourceType, input.SourceURL, input.PrimaryType, input.Title, input.Summary, tags, searchText(input), input.CapturedAt))
}
func (s *Store) GetItem(ctx context.Context, userID, itemID string) (Item, error) {
item, err := scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM stow_items WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, itemID, userID))
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrNotFound
}
return item, err
}
func (s *Store) ListItems(ctx context.Context, userID string, limit int) ([]Item, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM stow_items WHERE user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC,id DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Item, 0)
for rows.Next() {
item, err := scanItem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) SearchItems(ctx context.Context, userID, query string, limit int) ([]Item, error) {
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query)
pattern := "%" + escaped + "%"
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM stow_items
WHERE user_id=$1 AND deleted_at IS NULL AND (search_text ILIKE $2 ESCAPE '\' OR title ILIKE $2 ESCAPE '\')
ORDER BY GREATEST(similarity(title,$3),similarity(search_text,$3)) DESC,created_at DESC LIMIT $4`, userID, pattern, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Item, 0)
for rows.Next() {
item, err := scanItem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) UpdateItem(ctx context.Context, userID, itemID string, patch ItemPatch) (Item, error) {
current, err := s.GetItem(ctx, userID, itemID)
if err != nil {
return Item{}, err
}
if patch.Title != nil {
current.Title = *patch.Title
}
if patch.Summary != nil {
current.Summary = *patch.Summary
}
if patch.PrimaryType != nil {
current.PrimaryType = *patch.PrimaryType
}
if patch.Tags != nil {
current.Tags = *patch.Tags
}
tags, err := json.Marshal(current.Tags)
if err != nil {
return Item{}, err
}
newText := searchText(NewItem{Title: current.Title, Summary: current.Summary, Tags: current.Tags})
updated, err := scanItem(s.db.QueryRowContext(ctx, `UPDATE stow_items SET title=$3,summary=$4,primary_type=$5,tags=$6,search_text=$7,updated_at=now(),version=version+1
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL AND version=$8 RETURNING `+itemColumns,
itemID, userID, current.Title, current.Summary, current.PrimaryType, tags, newText, current.Version))
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrConflict
}
return updated, err
}
func (s *Store) DeleteItem(ctx context.Context, userID, itemID string) error {
result, err := s.db.ExecContext(ctx, `UPDATE stow_items SET deleted_at=now(),updated_at=now(),version=version+1 WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, itemID, userID)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE stow_users (
id VARCHAR(36) PRIMARY KEY,
"isAnonymous" BOOLEAN NOT NULL DEFAULT TRUE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE stow_sessions (
id VARCHAR(36) PRIMARY KEY,
"userId" VARCHAR(36) NOT NULL REFERENCES stow_users(id) ON DELETE CASCADE,
"tokenHash" BYTEA NOT NULL UNIQUE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"expiresAt" TIMESTAMPTZ NOT NULL
);
CREATE INDEX stow_sessions_userId_idx ON stow_sessions ("userId");
CREATE TABLE stow_items (
id VARCHAR(36) PRIMARY KEY,
"userId" VARCHAR(36) NOT NULL REFERENCES stow_users(id) ON DELETE CASCADE,
"sourceType" VARCHAR(32) NOT NULL,
"sourceUrl" TEXT,
"primaryType" VARCHAR(32) NOT NULL DEFAULT 'other',
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'ready',
"ocrText" TEXT,
"searchText" TEXT NOT NULL DEFAULT '',
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
"capturedAt" TIMESTAMPTZ,
"readyAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMPTZ,
version BIGINT NOT NULL DEFAULT 1
);
CREATE INDEX stow_items_userId_createdAt_idx ON stow_items ("userId", "createdAt" DESC) WHERE "deletedAt" IS NULL;
CREATE INDEX stow_items_userId_updatedAt_idx ON stow_items ("userId", "updatedAt", id);
CREATE INDEX stow_items_searchText_trgm_idx ON stow_items USING gin ("searchText" gin_trgm_ops) WHERE "deletedAt" IS NULL;
CREATE TABLE stow_assets (
id VARCHAR(36) PRIMARY KEY,
"itemId" VARCHAR(36) NOT NULL REFERENCES stow_items(id) ON DELETE CASCADE,
"objectKey" TEXT,
"thumbnailKey" TEXT,
"mimeType" VARCHAR(128),
width INTEGER,
height INTEGER,
"sizeBytes" BIGINT,
sha256 VARCHAR(64),
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX stow_assets_itemId_sha256_idx ON stow_assets ("itemId", sha256) WHERE sha256 IS NOT NULL;
CREATE TABLE stow_item_analyses (
id VARCHAR(36) PRIMARY KEY,
"itemId" VARCHAR(36) NOT NULL REFERENCES stow_items(id) ON DELETE CASCADE,
"pipelineVersion" VARCHAR(64) NOT NULL,
"promptVersion" VARCHAR(64),
"modelProvider" VARCHAR(64),
"modelName" VARCHAR(128),
"resultJson" JSONB NOT NULL,
confidence REAL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX stow_item_analyses_itemId_createdAt_idx ON stow_item_analyses ("itemId", "createdAt" DESC);
@@ -0,0 +1,10 @@
ALTER TABLE stow_users ADD CONSTRAINT stow_users_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_sessions ADD CONSTRAINT stow_sessions_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_items ADD CONSTRAINT stow_items_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_assets ADD CONSTRAINT stow_assets_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_item_analyses ADD CONSTRAINT stow_item_analyses_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
@@ -0,0 +1,37 @@
ALTER TABLE stow_users RENAME COLUMN "isAnonymous" TO is_anonymous;
ALTER TABLE stow_users RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_users RENAME COLUMN "updatedAt" TO updated_at;
ALTER TABLE stow_sessions RENAME COLUMN "userId" TO user_id;
ALTER TABLE stow_sessions RENAME COLUMN "tokenHash" TO token_hash;
ALTER TABLE stow_sessions RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_sessions RENAME COLUMN "expiresAt" TO expires_at;
ALTER TABLE stow_items RENAME COLUMN "userId" TO user_id;
ALTER TABLE stow_items RENAME COLUMN "sourceType" TO source_type;
ALTER TABLE stow_items RENAME COLUMN "sourceUrl" TO source_url;
ALTER TABLE stow_items RENAME COLUMN "primaryType" TO primary_type;
ALTER TABLE stow_items RENAME COLUMN "ocrText" TO ocr_text;
ALTER TABLE stow_items RENAME COLUMN "searchText" TO search_text;
ALTER TABLE stow_items RENAME COLUMN "capturedAt" TO captured_at;
ALTER TABLE stow_items RENAME COLUMN "readyAt" TO ready_at;
ALTER TABLE stow_items RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_items RENAME COLUMN "updatedAt" TO updated_at;
ALTER TABLE stow_items RENAME COLUMN "deletedAt" TO deleted_at;
ALTER TABLE stow_assets RENAME COLUMN "itemId" TO item_id;
ALTER TABLE stow_assets RENAME COLUMN "objectKey" TO object_key;
ALTER TABLE stow_assets RENAME COLUMN "thumbnailKey" TO thumbnail_key;
ALTER TABLE stow_assets RENAME COLUMN "mimeType" TO mime_type;
ALTER TABLE stow_assets RENAME COLUMN "sizeBytes" TO size_bytes;
ALTER TABLE stow_assets RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_item_analyses RENAME COLUMN "itemId" TO item_id;
ALTER TABLE stow_item_analyses RENAME COLUMN "pipelineVersion" TO pipeline_version;
ALTER TABLE stow_item_analyses RENAME COLUMN "promptVersion" TO prompt_version;
ALTER TABLE stow_item_analyses RENAME COLUMN "modelProvider" TO model_provider;
ALTER TABLE stow_item_analyses RENAME COLUMN "modelName" TO model_name;
ALTER TABLE stow_item_analyses RENAME COLUMN "resultJson" TO result_json;
ALTER TABLE stow_item_analyses RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_schema_migrations RENAME COLUMN "appliedAt" TO applied_at;