diff --git a/README.md b/README.md index 1d3b636..29c376e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,23 @@ -# language +# Language Learning Tool +一个从英语背单词入门的语言学习工具。 + +## 目录结构 + +``` +language/ +├── backend/ Go + PostgreSQL 后台服务 +├── frontend/ 未来前端(Web) +├── client/ 未来客户端(App / Desktop) +└── README.md +``` + +## 当前阶段 + +- 类别管理:如「农场动物」「颜色」 +- 单词维护:英文单词、中文释义、音标、例句等 + +## 技术栈 + +- 后台:Go 1.25.8 + PostgreSQL 18.3 + Docker +- 详见 [backend/README.md](backend/README.md) diff --git a/backend/.air.toml b/backend/.air.toml new file mode 100644 index 0000000..b46d78d --- /dev/null +++ b/backend/.air.toml @@ -0,0 +1,14 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ./cmd/server" +bin = "./tmp/main" +full_bin = "./tmp/main" +include_ext = ["go", "mod", "sum"] +exclude_dir = ["tmp", "vendor", "migrations"] +delay = 1000 +stop_on_error = true + +[misc] +clean_on_exit = true diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d86c8b6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,3 @@ +APP_ENV=development +HTTP_ADDR=:8080 +DATABASE_URL=postgres://language:language123@localhost:5432/language?sslmode=disable diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..a41a50f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +.env +*.log +tmp/ +.DS_Store diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..fd74769 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,32 @@ +.PHONY: dev up down build logs migrate migrate-down sqlc test clean + +dev: + docker compose up --build + +up: + docker compose up -d + +down: + docker compose down + +build: + docker compose build + +logs: + docker compose logs -f api db + +migrate: + migrate -path migrations -database "$(DATABASE_URL)" up + +migrate-down: + migrate -path migrations -database "$(DATABASE_URL)" down 1 + +sqlc: + sqlc generate + +test: + go test ./... + +clean: + docker compose down -v + rm -rf tmp diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f1ffe3a --- /dev/null +++ b/backend/README.md @@ -0,0 +1,50 @@ +# Language Backend + +Go + PostgreSQL 实现的语言学习后台服务。 + +## 快速开始 + +```bash +cd backend + +# 启动开发环境(热重载) +make dev + +# 数据库迁移 +make migrate + +# 重新生成 sqlc 代码 +make sqlc + +# 运行测试 +make test +``` + +## 开发端口 + +- API: http://localhost:8080 +- PostgreSQL: localhost:5432 + +## API 文档 + +接口以 `/api/v1` 为前缀。 + +### Categories + +- `GET /api/v1/categories` +- `POST /api/v1/categories` +- `GET /api/v1/categories/:id` +- `PUT /api/v1/categories/:id` +- `DELETE /api/v1/categories/:id` + +### Words + +- `GET /api/v1/words` +- `POST /api/v1/words` +- `GET /api/v1/words/:id` +- `PUT /api/v1/words/:id` +- `DELETE /api/v1/words/:id` + +## 环境变量 + +复制 `.env.example` 为 `.env` 并按需修改。 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..a02501b --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/fish/language/backend/internal/app" + "github.com/fish/language/backend/internal/config" + "github.com/fish/language/backend/internal/repository" +) + +func main() { + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + slog.Error("failed to load config", "error", err) + os.Exit(1) + } + + pool, err := repository.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer pool.Close() + + application := app.New(cfg, pool) + + go func() { + if err := application.Run(); err != nil { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + slog.Info("server started", "addr", cfg.HTTPAddr) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + slog.Info("shutting down server") +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..5522c98 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:18.3-alpine3.23 + container_name: language-db + environment: + POSTGRES_USER: language + POSTGRES_PASSWORD: language123 + POSTGRES_DB: language + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U language -d language"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: + context: . + dockerfile: docker/Dockerfile + target: dev + container_name: language-api + environment: + APP_ENV: development + DATABASE_URL: postgres://language:language123@db:5432/language?sslmode=disable + HTTP_ADDR: :8080 + ports: + - "8080:8080" + volumes: + - .:/app + depends_on: + db: + condition: service_healthy + command: ["air", "-c", ".air.toml"] + +volumes: + postgres_data: diff --git a/backend/docker/Dockerfile b/backend/docker/Dockerfile new file mode 100644 index 0000000..7776a33 --- /dev/null +++ b/backend/docker/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.25.8-alpine3.23 AS base +WORKDIR /app +RUN apk add --no-cache git make + +FROM base AS dev +RUN go install github.com/air-verse/air@latest +COPY go.mod go.sum ./ +RUN go mod download +CMD ["air", "-c", ".air.toml"] + +FROM base AS builder +COPY . . +RUN go build -o /bin/server ./cmd/server + +FROM alpine:3.23 AS prod +RUN apk add --no-cache ca-certificates +COPY --from=builder /bin/server /bin/server +EXPOSE 8080 +CMD ["/bin/server"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..3ad1535 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,18 @@ +module github.com/fish/language/backend + +go 1.25 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/kelseyhightower/envconfig v1.4.0 +) + +require ( + 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 + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..5a0500b --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,32 @@ +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/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +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.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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= diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go new file mode 100644 index 0000000..7d79159 --- /dev/null +++ b/backend/internal/app/app.go @@ -0,0 +1,68 @@ +package app + +import ( + "net/http" + + "github.com/fish/language/backend/internal/config" + "github.com/fish/language/backend/internal/handlers" + "github.com/fish/language/backend/internal/repository/queries" + "github.com/fish/language/backend/internal/services" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/jackc/pgx/v5/pgxpool" +) + +// App wires all dependencies and exposes the HTTP server. +type App struct { + cfg *config.Config + router *chi.Mux + pool *pgxpool.Pool +} + +// New creates and initializes a new App. +func New(cfg *config.Config, pool *pgxpool.Pool) *App { + app := &App{ + cfg: cfg, + pool: pool, + } + app.setupRouter() + return app +} + +func (a *App) setupRouter() { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.SetHeader("Content-Type", "application/json")) + + q := queries.New(a.pool) + categorySvc := services.NewCategoryService(q) + wordSvc := services.NewWordService(q) + + categoryHandler := handlers.NewCategoryHandler(categorySvc) + wordHandler := handlers.NewWordHandler(wordSvc) + + r.Route("/api/v1", func(api chi.Router) { + categoryHandler.Register(api) + wordHandler.Register(api) + }) + + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + a.router = r +} + +// Router returns the underlying chi router for testing. +func (a *App) Router() *chi.Mux { + return a.router +} + +// Run starts the HTTP server. +func (a *App) Run() error { + return http.ListenAndServe(a.cfg.HTTPAddr, a.router) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..60d9ca7 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,23 @@ +package config + +import ( + "fmt" + + "github.com/kelseyhightower/envconfig" +) + +// Config holds all application configuration loaded from environment variables. +type Config struct { + AppEnv string `envconfig:"APP_ENV" default:"development"` + HTTPAddr string `envconfig:"HTTP_ADDR" default:":8080"` + DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` +} + +// Load parses environment variables into Config. +func Load() (*Config, error) { + var cfg Config + if err := envconfig.Process("", &cfg); err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return &cfg, nil +} diff --git a/backend/internal/handlers/category_handler.go b/backend/internal/handlers/category_handler.go new file mode 100644 index 0000000..90e4b08 --- /dev/null +++ b/backend/internal/handlers/category_handler.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/fish/language/backend/internal/models" + "github.com/fish/language/backend/internal/services" + "github.com/go-chi/chi/v5" +) + +// CategoryHandler handles HTTP requests for categories. +type CategoryHandler struct { + svc *services.CategoryService +} + +// NewCategoryHandler creates a new CategoryHandler. +func NewCategoryHandler(svc *services.CategoryService) *CategoryHandler { + return &CategoryHandler{svc: svc} +} + +// Register mounts category routes. +func (h *CategoryHandler) Register(r chi.Router) { + r.Get("/categories", h.List) + r.Post("/categories", h.Create) + r.Get("/categories/{id}", h.Get) + r.Put("/categories/{id}", h.Update) + r.Delete("/categories/{id}", h.Delete) +} + +// List returns all categories. +func (h *CategoryHandler) List(w http.ResponseWriter, r *http.Request) { + items, err := h.svc.List(r.Context()) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, items) +} + +// Get returns a category by id. +func (h *CategoryHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + RespondError(w, http.StatusBadRequest, 10002, "invalid id") + return + } + item, err := h.svc.Get(r.Context(), id) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, item) +} + +// Create creates a new category. +func (h *CategoryHandler) Create(w http.ResponseWriter, r *http.Request) { + var req models.CreateCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + RespondError(w, http.StatusBadRequest, 10003, "invalid request body") + return + } + if req.Name == "" { + RespondError(w, http.StatusBadRequest, 10004, "name is required") + return + } + item, err := h.svc.Create(r.Context(), req) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondJSON(w, http.StatusCreated, modelsAPIResponse{Code: 0, Message: "created", Data: item}) +} + +// Update updates a category. +func (h *CategoryHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + RespondError(w, http.StatusBadRequest, 10002, "invalid id") + return + } + var req models.UpdateCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + RespondError(w, http.StatusBadRequest, 10003, "invalid request body") + return + } + if req.Name == "" { + RespondError(w, http.StatusBadRequest, 10004, "name is required") + return + } + item, err := h.svc.Update(r.Context(), id, req) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, item) +} + +// Delete removes a category. +func (h *CategoryHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + RespondError(w, http.StatusBadRequest, 10002, "invalid id") + return + } + if err := h.svc.Delete(r.Context(), id); err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, nil) +} + +func parseID(r *http.Request) (int32, error) { + idStr := chi.URLParam(r, "id") + id64, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + return 0, err + } + return int32(id64), nil +} diff --git a/backend/internal/handlers/response.go b/backend/internal/handlers/response.go new file mode 100644 index 0000000..03459b2 --- /dev/null +++ b/backend/internal/handlers/response.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/jackc/pgx/v5" +) + +// RespondJSON writes a JSON response with the given status code. +func RespondJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +// RespondSuccess returns the common success envelope. +func RespondSuccess(w http.ResponseWriter, data any) { + RespondJSON(w, http.StatusOK, modelsAPIResponse{Code: 0, Message: "ok", Data: data}) +} + +// RespondError returns the common error envelope. +func RespondError(w http.ResponseWriter, status int, code int, message string) { + RespondJSON(w, status, modelsAPIResponse{Code: code, Message: message, Data: nil}) +} + +// MapError maps repository/ service errors to HTTP status and code. +func MapError(err error) (status int, code int, message string) { + if errors.Is(err, pgx.ErrNoRows) { + return http.StatusNotFound, 10001, "resource not found" + } + return http.StatusInternalServerError, 10000, "internal server error" +} + +type modelsAPIResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` +} diff --git a/backend/internal/handlers/word_handler.go b/backend/internal/handlers/word_handler.go new file mode 100644 index 0000000..1307233 --- /dev/null +++ b/backend/internal/handlers/word_handler.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/fish/language/backend/internal/models" + "github.com/fish/language/backend/internal/services" + "github.com/go-chi/chi/v5" +) + +// WordHandler handles HTTP requests for words. +type WordHandler struct { + svc *services.WordService +} + +// NewWordHandler creates a new WordHandler. +func NewWordHandler(svc *services.WordService) *WordHandler { + return &WordHandler{svc: svc} +} + +// Register mounts word routes. +func (h *WordHandler) Register(r chi.Router) { + r.Get("/words", h.List) + r.Post("/words", h.Create) + r.Get("/words/{id}", h.Get) + r.Put("/words/{id}", h.Update) + r.Delete("/words/{id}", h.Delete) +} + +// List returns a paginated list of words. +func (h *WordHandler) List(w http.ResponseWriter, r *http.Request) { + req := models.ListWordsRequest{} + if v := r.URL.Query().Get("category_id"); v != "" { + if id64, err := strconv.ParseInt(v, 10, 32); err == nil { + id := int32(id64) + req.CategoryID = &id + } + } + req.Keyword = r.URL.Query().Get("keyword") + if v := r.URL.Query().Get("page"); v != "" { + if page, err := strconv.ParseInt(v, 10, 32); err == nil { + req.Page = int32(page) + } + } + if v := r.URL.Query().Get("page_size"); v != "" { + if ps, err := strconv.ParseInt(v, 10, 32); err == nil { + req.PageSize = int32(ps) + } + } + + resp, err := h.svc.List(r.Context(), req) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, resp) +} + +// Get returns a word by id. +func (h *WordHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + RespondError(w, http.StatusBadRequest, 10002, "invalid id") + return + } + item, err := h.svc.Get(r.Context(), id) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, item) +} + +// Create creates a new word. +func (h *WordHandler) Create(w http.ResponseWriter, r *http.Request) { + var req models.CreateWordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + RespondError(w, http.StatusBadRequest, 10003, "invalid request body") + return + } + if req.Word == "" || req.Translation == "" || req.CategoryID == 0 { + RespondError(w, http.StatusBadRequest, 10004, "word, translation and category_id are required") + return + } + item, err := h.svc.Create(r.Context(), req) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondJSON(w, http.StatusCreated, modelsAPIResponse{Code: 0, Message: "created", Data: item}) +} + +// Update updates a word. +func (h *WordHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + RespondError(w, http.StatusBadRequest, 10002, "invalid id") + return + } + var req models.UpdateWordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + RespondError(w, http.StatusBadRequest, 10003, "invalid request body") + return + } + if req.Word == "" || req.Translation == "" || req.CategoryID == 0 { + RespondError(w, http.StatusBadRequest, 10004, "word, translation and category_id are required") + return + } + item, err := h.svc.Update(r.Context(), id, req) + if err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, item) +} + +// Delete removes a word. +func (h *WordHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + RespondError(w, http.StatusBadRequest, 10002, "invalid id") + return + } + if err := h.svc.Delete(r.Context(), id); err != nil { + status, code, msg := MapError(err) + RespondError(w, status, code, msg) + return + } + RespondSuccess(w, nil) +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..ea387c6 --- /dev/null +++ b/backend/internal/models/models.go @@ -0,0 +1,87 @@ +package models + +import "time" + +// Category represents a word category, such as "农场动物" or "颜色". +type Category struct { + ID int32 `json:"id"` + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateCategoryRequest is the payload for creating a category. +type CreateCategoryRequest struct { + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` +} + +// UpdateCategoryRequest is the payload for updating a category. +type UpdateCategoryRequest struct { + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` +} + +// Word represents a vocabulary word. +type Word struct { + ID int32 `json:"id"` + CategoryID int32 `json:"category_id"` + Word string `json:"word"` + Translation string `json:"translation"` + Phonetic *string `json:"phonetic,omitempty"` + ExampleSentence *string `json:"example_sentence,omitempty"` + AudioURL *string `json:"audio_url,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + SortOrder int32 `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateWordRequest is the payload for creating a word. +type CreateWordRequest struct { + CategoryID int32 `json:"category_id"` + Word string `json:"word"` + Translation string `json:"translation"` + Phonetic *string `json:"phonetic,omitempty"` + ExampleSentence *string `json:"example_sentence,omitempty"` + AudioURL *string `json:"audio_url,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + SortOrder int32 `json:"sort_order"` +} + +// UpdateWordRequest is the payload for updating a word. +type UpdateWordRequest struct { + CategoryID int32 `json:"category_id"` + Word string `json:"word"` + Translation string `json:"translation"` + Phonetic *string `json:"phonetic,omitempty"` + ExampleSentence *string `json:"example_sentence,omitempty"` + AudioURL *string `json:"audio_url,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + SortOrder int32 `json:"sort_order"` +} + +// ListWordsRequest holds query parameters for listing words. +type ListWordsRequest struct { + CategoryID *int32 `json:"category_id,omitempty"` + Keyword string `json:"keyword,omitempty"` + Page int32 `json:"page,omitempty"` + PageSize int32 `json:"page_size,omitempty"` +} + +// ListWordsResponse is the paginated response for word listing. +type ListWordsResponse struct { + Words []Word `json:"words"` + Total int64 `json:"total"` + Page int32 `json:"page"` + PageSize int32 `json:"page_size"` + TotalPages int32 `json:"total_pages"` +} + +// APIResponse is the common JSON envelope for all API responses. +type APIResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` +} diff --git a/backend/internal/repository/db.go b/backend/internal/repository/db.go new file mode 100644 index 0000000..efaffa8 --- /dev/null +++ b/backend/internal/repository/db.go @@ -0,0 +1,21 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// NewPool creates a new pgx connection pool. +func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + return pool, nil +} diff --git a/backend/internal/repository/queries/category.sql.go b/backend/internal/repository/queries/category.sql.go new file mode 100644 index 0000000..5c6b34a --- /dev/null +++ b/backend/internal/repository/queries/category.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: category.sql + +package queries + +import ( + "context" +) + +const createCategory = `-- name: CreateCategory :one +INSERT INTO categories (name, sort_order) +VALUES ($1, $2) +RETURNING id, name, sort_order, created_at, updated_at +` + +type CreateCategoryParams struct { + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` +} + +func (q *Queries) CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error) { + row := q.db.QueryRow(ctx, createCategory, arg.Name, arg.SortOrder) + var i Category + err := row.Scan( + &i.ID, + &i.Name, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteCategory = `-- name: DeleteCategory :exec +DELETE FROM categories WHERE id = $1 +` + +func (q *Queries) DeleteCategory(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteCategory, id) + return err +} + +const getCategory = `-- name: GetCategory :one +SELECT id, name, sort_order, created_at, updated_at +FROM categories +WHERE id = $1 +` + +func (q *Queries) GetCategory(ctx context.Context, id int32) (Category, error) { + row := q.db.QueryRow(ctx, getCategory, id) + var i Category + err := row.Scan( + &i.ID, + &i.Name, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listCategories = `-- name: ListCategories :many +SELECT id, name, sort_order, created_at, updated_at +FROM categories +ORDER BY sort_order ASC, id ASC +` + +func (q *Queries) ListCategories(ctx context.Context) ([]Category, error) { + rows, err := q.db.Query(ctx, listCategories) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Category{} + for rows.Next() { + var i Category + if err := rows.Scan( + &i.ID, + &i.Name, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateCategory = `-- name: UpdateCategory :one +UPDATE categories +SET name = $2, + sort_order = $3, + updated_at = NOW() +WHERE id = $1 +RETURNING id, name, sort_order, created_at, updated_at +` + +type UpdateCategoryParams struct { + ID int32 `json:"id"` + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` +} + +func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error) { + row := q.db.QueryRow(ctx, updateCategory, arg.ID, arg.Name, arg.SortOrder) + var i Category + err := row.Scan( + &i.ID, + &i.Name, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/repository/queries/db.go b/backend/internal/repository/queries/db.go new file mode 100644 index 0000000..c69f0c5 --- /dev/null +++ b/backend/internal/repository/queries/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package queries + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/backend/internal/repository/queries/models.go b/backend/internal/repository/queries/models.go new file mode 100644 index 0000000..9761d33 --- /dev/null +++ b/backend/internal/repository/queries/models.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package queries + +import ( + "time" +) + +type Category struct { + ID int32 `json:"id"` + Name string `json:"name"` + SortOrder int32 `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Word struct { + ID int32 `json:"id"` + CategoryID int32 `json:"category_id"` + Word string `json:"word"` + Translation string `json:"translation"` + Phonetic *string `json:"phonetic"` + ExampleSentence *string `json:"example_sentence"` + AudioUrl *string `json:"audio_url"` + ImageUrl *string `json:"image_url"` + SortOrder int32 `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/repository/queries/querier.go b/backend/internal/repository/queries/querier.go new file mode 100644 index 0000000..ee4bf3d --- /dev/null +++ b/backend/internal/repository/queries/querier.go @@ -0,0 +1,25 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package queries + +import ( + "context" +) + +type Querier interface { + CountWords(ctx context.Context, arg CountWordsParams) (int64, error) + CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error) + CreateWord(ctx context.Context, arg CreateWordParams) (Word, error) + DeleteCategory(ctx context.Context, id int32) error + DeleteWord(ctx context.Context, id int32) error + GetCategory(ctx context.Context, id int32) (Category, error) + GetWord(ctx context.Context, id int32) (Word, error) + ListCategories(ctx context.Context) ([]Category, error) + ListWords(ctx context.Context, arg ListWordsParams) ([]Word, error) + UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error) + UpdateWord(ctx context.Context, arg UpdateWordParams) (Word, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/backend/internal/repository/queries/word.sql.go b/backend/internal/repository/queries/word.sql.go new file mode 100644 index 0000000..84cb24b --- /dev/null +++ b/backend/internal/repository/queries/word.sql.go @@ -0,0 +1,227 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: word.sql + +package queries + +import ( + "context" +) + +const countWords = `-- name: CountWords :one +SELECT COUNT(*) AS total +FROM words w +WHERE + ($1::int = 0 OR w.category_id = $1) + AND ($2::text = '' OR w.word ILIKE '%' || $2 || '%' OR w.translation ILIKE '%' || $2 || '%') +` + +type CountWordsParams struct { + CategoryID int32 `json:"category_id"` + Keyword string `json:"keyword"` +} + +func (q *Queries) CountWords(ctx context.Context, arg CountWordsParams) (int64, error) { + row := q.db.QueryRow(ctx, countWords, arg.CategoryID, arg.Keyword) + var total int64 + err := row.Scan(&total) + return total, err +} + +const createWord = `-- name: CreateWord :one +INSERT INTO words (category_id, word, translation, phonetic, example_sentence, audio_url, image_url, sort_order) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, category_id, word, translation, phonetic, + example_sentence, audio_url, image_url, sort_order, + created_at, updated_at +` + +type CreateWordParams struct { + CategoryID int32 `json:"category_id"` + Word string `json:"word"` + Translation string `json:"translation"` + Phonetic *string `json:"phonetic"` + ExampleSentence *string `json:"example_sentence"` + AudioUrl *string `json:"audio_url"` + ImageUrl *string `json:"image_url"` + SortOrder int32 `json:"sort_order"` +} + +func (q *Queries) CreateWord(ctx context.Context, arg CreateWordParams) (Word, error) { + row := q.db.QueryRow(ctx, createWord, + arg.CategoryID, + arg.Word, + arg.Translation, + arg.Phonetic, + arg.ExampleSentence, + arg.AudioUrl, + arg.ImageUrl, + arg.SortOrder, + ) + var i Word + err := row.Scan( + &i.ID, + &i.CategoryID, + &i.Word, + &i.Translation, + &i.Phonetic, + &i.ExampleSentence, + &i.AudioUrl, + &i.ImageUrl, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteWord = `-- name: DeleteWord :exec +DELETE FROM words WHERE id = $1 +` + +func (q *Queries) DeleteWord(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteWord, id) + return err +} + +const getWord = `-- name: GetWord :one +SELECT id, category_id, word, translation, phonetic, + example_sentence, audio_url, image_url, sort_order, + created_at, updated_at +FROM words +WHERE id = $1 +` + +func (q *Queries) GetWord(ctx context.Context, id int32) (Word, error) { + row := q.db.QueryRow(ctx, getWord, id) + var i Word + err := row.Scan( + &i.ID, + &i.CategoryID, + &i.Word, + &i.Translation, + &i.Phonetic, + &i.ExampleSentence, + &i.AudioUrl, + &i.ImageUrl, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listWords = `-- name: ListWords :many +SELECT w.id, w.category_id, w.word, w.translation, w.phonetic, + w.example_sentence, w.audio_url, w.image_url, w.sort_order, + w.created_at, w.updated_at +FROM words w +WHERE + ($3::int = 0 OR w.category_id = $3) + AND ($4::text = '' OR w.word ILIKE '%' || $4 || '%' OR w.translation ILIKE '%' || $4 || '%') +ORDER BY w.sort_order ASC, w.id ASC +LIMIT $1 OFFSET $2 +` + +type ListWordsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + CategoryID int32 `json:"category_id"` + Keyword string `json:"keyword"` +} + +func (q *Queries) ListWords(ctx context.Context, arg ListWordsParams) ([]Word, error) { + rows, err := q.db.Query(ctx, listWords, + arg.Limit, + arg.Offset, + arg.CategoryID, + arg.Keyword, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Word{} + for rows.Next() { + var i Word + if err := rows.Scan( + &i.ID, + &i.CategoryID, + &i.Word, + &i.Translation, + &i.Phonetic, + &i.ExampleSentence, + &i.AudioUrl, + &i.ImageUrl, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateWord = `-- name: UpdateWord :one +UPDATE words +SET category_id = $2, + word = $3, + translation = $4, + phonetic = $5, + example_sentence = $6, + audio_url = $7, + image_url = $8, + sort_order = $9, + updated_at = NOW() +WHERE id = $1 +RETURNING id, category_id, word, translation, phonetic, + example_sentence, audio_url, image_url, sort_order, + created_at, updated_at +` + +type UpdateWordParams struct { + ID int32 `json:"id"` + CategoryID int32 `json:"category_id"` + Word string `json:"word"` + Translation string `json:"translation"` + Phonetic *string `json:"phonetic"` + ExampleSentence *string `json:"example_sentence"` + AudioUrl *string `json:"audio_url"` + ImageUrl *string `json:"image_url"` + SortOrder int32 `json:"sort_order"` +} + +func (q *Queries) UpdateWord(ctx context.Context, arg UpdateWordParams) (Word, error) { + row := q.db.QueryRow(ctx, updateWord, + arg.ID, + arg.CategoryID, + arg.Word, + arg.Translation, + arg.Phonetic, + arg.ExampleSentence, + arg.AudioUrl, + arg.ImageUrl, + arg.SortOrder, + ) + var i Word + err := row.Scan( + &i.ID, + &i.CategoryID, + &i.Word, + &i.Translation, + &i.Phonetic, + &i.ExampleSentence, + &i.AudioUrl, + &i.ImageUrl, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/backend/internal/services/category_service.go b/backend/internal/services/category_service.go new file mode 100644 index 0000000..22d5dbb --- /dev/null +++ b/backend/internal/services/category_service.go @@ -0,0 +1,80 @@ +package services + +import ( + "context" + + "github.com/fish/language/backend/internal/models" + "github.com/fish/language/backend/internal/repository/queries" +) + +// CategoryService handles business logic for categories. +type CategoryService struct { + q *queries.Queries +} + +// NewCategoryService creates a new CategoryService. +func NewCategoryService(q *queries.Queries) *CategoryService { + return &CategoryService{q: q} +} + +// List returns all categories ordered by sort_order. +func (s *CategoryService) List(ctx context.Context) ([]models.Category, error) { + rows, err := s.q.ListCategories(ctx) + if err != nil { + return nil, err + } + out := make([]models.Category, len(rows)) + for i, r := range rows { + out[i] = toCategory(r) + } + return out, nil +} + +// Get returns a single category by id. +func (s *CategoryService) Get(ctx context.Context, id int32) (models.Category, error) { + row, err := s.q.GetCategory(ctx, id) + if err != nil { + return models.Category{}, err + } + return toCategory(row), nil +} + +// Create creates a new category. +func (s *CategoryService) Create(ctx context.Context, req models.CreateCategoryRequest) (models.Category, error) { + row, err := s.q.CreateCategory(ctx, queries.CreateCategoryParams{ + Name: req.Name, + SortOrder: req.SortOrder, + }) + if err != nil { + return models.Category{}, err + } + return toCategory(row), nil +} + +// Update updates an existing category. +func (s *CategoryService) Update(ctx context.Context, id int32, req models.UpdateCategoryRequest) (models.Category, error) { + row, err := s.q.UpdateCategory(ctx, queries.UpdateCategoryParams{ + ID: id, + Name: req.Name, + SortOrder: req.SortOrder, + }) + if err != nil { + return models.Category{}, err + } + return toCategory(row), nil +} + +// Delete removes a category by id. +func (s *CategoryService) Delete(ctx context.Context, id int32) error { + return s.q.DeleteCategory(ctx, id) +} + +func toCategory(c queries.Category) models.Category { + return models.Category{ + ID: c.ID, + Name: c.Name, + SortOrder: c.SortOrder, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} diff --git a/backend/internal/services/word_service.go b/backend/internal/services/word_service.go new file mode 100644 index 0000000..a25a4c8 --- /dev/null +++ b/backend/internal/services/word_service.go @@ -0,0 +1,155 @@ +package services + +import ( + "context" + "math" + + "github.com/fish/language/backend/internal/models" + "github.com/fish/language/backend/internal/repository/queries" +) + +const defaultPageSize = 20 +const maxPageSize = 100 + +// WordService handles business logic for words. +type WordService struct { + q *queries.Queries +} + +// NewWordService creates a new WordService. +func NewWordService(q *queries.Queries) *WordService { + return &WordService{q: q} +} + +// List returns a paginated list of words with optional filters. +func (s *WordService) List(ctx context.Context, req models.ListWordsRequest) (models.ListWordsResponse, error) { + page := req.Page + if page < 1 { + page = 1 + } + pageSize := req.PageSize + if pageSize < 1 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + offset := (page - 1) * pageSize + + listParams := queries.ListWordsParams{ + Limit: pageSize, + Offset: offset, + CategoryID: 0, + Keyword: "", + } + countParams := queries.CountWordsParams{ + CategoryID: 0, + Keyword: "", + } + if req.CategoryID != nil && *req.CategoryID > 0 { + listParams.CategoryID = *req.CategoryID + countParams.CategoryID = *req.CategoryID + } + if req.Keyword != "" { + listParams.Keyword = req.Keyword + countParams.Keyword = req.Keyword + } + + rows, err := s.q.ListWords(ctx, listParams) + if err != nil { + return models.ListWordsResponse{}, err + } + total, err := s.q.CountWords(ctx, countParams) + if err != nil { + return models.ListWordsResponse{}, err + } + + words := make([]models.Word, len(rows)) + for i, r := range rows { + words[i] = toWord(r) + } + + totalPages := int32(math.Ceil(float64(total) / float64(pageSize))) + return models.ListWordsResponse{ + Words: words, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +// Get returns a single word by id. +func (s *WordService) Get(ctx context.Context, id int32) (models.Word, error) { + row, err := s.q.GetWord(ctx, id) + if err != nil { + return models.Word{}, err + } + return toWord(row), nil +} + +// Create creates a new word. +func (s *WordService) Create(ctx context.Context, req models.CreateWordRequest) (models.Word, error) { + row, err := s.q.CreateWord(ctx, queries.CreateWordParams{ + CategoryID: req.CategoryID, + Word: req.Word, + Translation: req.Translation, + Phonetic: req.Phonetic, + ExampleSentence: req.ExampleSentence, + AudioUrl: req.AudioURL, + ImageUrl: req.ImageURL, + SortOrder: req.SortOrder, + }) + if err != nil { + return models.Word{}, err + } + return toWord(row), nil +} + +// Update updates an existing word. +func (s *WordService) Update(ctx context.Context, id int32, req models.UpdateWordRequest) (models.Word, error) { + row, err := s.q.UpdateWord(ctx, queries.UpdateWordParams{ + ID: id, + CategoryID: req.CategoryID, + Word: req.Word, + Translation: req.Translation, + Phonetic: req.Phonetic, + ExampleSentence: req.ExampleSentence, + AudioUrl: req.AudioURL, + ImageUrl: req.ImageURL, + SortOrder: req.SortOrder, + }) + if err != nil { + return models.Word{}, err + } + return toWord(row), nil +} + +// Delete removes a word by id. +func (s *WordService) Delete(ctx context.Context, id int32) error { + return s.q.DeleteWord(ctx, id) +} + +func toWord(w queries.Word) models.Word { + return models.Word{ + ID: w.ID, + CategoryID: w.CategoryID, + Word: w.Word, + Translation: w.Translation, + Phonetic: nullString(w.Phonetic), + ExampleSentence: nullString(w.ExampleSentence), + AudioURL: nullString(w.AudioUrl), + ImageURL: nullString(w.ImageUrl), + SortOrder: w.SortOrder, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + } +} + +func nullString(s *string) *string { + if s == nil { + return nil + } + v := *s + return &v +} diff --git a/backend/migrations/000001_init_schema.down.sql b/backend/migrations/000001_init_schema.down.sql new file mode 100644 index 0000000..82ddcfd --- /dev/null +++ b/backend/migrations/000001_init_schema.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_words_word; +DROP INDEX IF EXISTS idx_words_category_id; +DROP TABLE IF EXISTS words; +DROP TABLE IF EXISTS categories; diff --git a/backend/migrations/000001_init_schema.up.sql b/backend/migrations/000001_init_schema.up.sql new file mode 100644 index 0000000..a3bed21 --- /dev/null +++ b/backend/migrations/000001_init_schema.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (name) +); + +CREATE TABLE IF NOT EXISTS words ( + id SERIAL PRIMARY KEY, + category_id INT NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + word VARCHAR(255) NOT NULL, + translation VARCHAR(255) NOT NULL, + phonetic VARCHAR(255), + example_sentence TEXT, + audio_url VARCHAR(500), + image_url VARCHAR(500), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_words_category_id ON words(category_id); +CREATE INDEX IF NOT EXISTS idx_words_word ON words(word); diff --git a/backend/sql/category.sql b/backend/sql/category.sql new file mode 100644 index 0000000..24d3c87 --- /dev/null +++ b/backend/sql/category.sql @@ -0,0 +1,25 @@ +-- name: ListCategories :many +SELECT id, name, sort_order, created_at, updated_at +FROM categories +ORDER BY sort_order ASC, id ASC; + +-- name: GetCategory :one +SELECT id, name, sort_order, created_at, updated_at +FROM categories +WHERE id = $1; + +-- name: CreateCategory :one +INSERT INTO categories (name, sort_order) +VALUES ($1, $2) +RETURNING id, name, sort_order, created_at, updated_at; + +-- name: UpdateCategory :one +UPDATE categories +SET name = $2, + sort_order = $3, + updated_at = NOW() +WHERE id = $1 +RETURNING id, name, sort_order, created_at, updated_at; + +-- name: DeleteCategory :exec +DELETE FROM categories WHERE id = $1; diff --git a/backend/sql/word.sql b/backend/sql/word.sql new file mode 100644 index 0000000..cc007a3 --- /dev/null +++ b/backend/sql/word.sql @@ -0,0 +1,50 @@ +-- name: ListWords :many +SELECT w.id, w.category_id, w.word, w.translation, w.phonetic, + w.example_sentence, w.audio_url, w.image_url, w.sort_order, + w.created_at, w.updated_at +FROM words w +WHERE + (@category_id::int = 0 OR w.category_id = @category_id) + AND (@keyword::text = '' OR w.word ILIKE '%' || @keyword || '%' OR w.translation ILIKE '%' || @keyword || '%') +ORDER BY w.sort_order ASC, w.id ASC +LIMIT $1 OFFSET $2; + +-- name: CountWords :one +SELECT COUNT(*) AS total +FROM words w +WHERE + (@category_id::int = 0 OR w.category_id = @category_id) + AND (@keyword::text = '' OR w.word ILIKE '%' || @keyword || '%' OR w.translation ILIKE '%' || @keyword || '%'); + +-- name: GetWord :one +SELECT id, category_id, word, translation, phonetic, + example_sentence, audio_url, image_url, sort_order, + created_at, updated_at +FROM words +WHERE id = $1; + +-- name: CreateWord :one +INSERT INTO words (category_id, word, translation, phonetic, example_sentence, audio_url, image_url, sort_order) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, category_id, word, translation, phonetic, + example_sentence, audio_url, image_url, sort_order, + created_at, updated_at; + +-- name: UpdateWord :one +UPDATE words +SET category_id = $2, + word = $3, + translation = $4, + phonetic = $5, + example_sentence = $6, + audio_url = $7, + image_url = $8, + sort_order = $9, + updated_at = NOW() +WHERE id = $1 +RETURNING id, category_id, word, translation, phonetic, + example_sentence, audio_url, image_url, sort_order, + created_at, updated_at; + +-- name: DeleteWord :exec +DELETE FROM words WHERE id = $1; diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml new file mode 100644 index 0000000..8a9dc54 --- /dev/null +++ b/backend/sqlc.yaml @@ -0,0 +1,20 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "sql/" + schema: "migrations/" + gen: + go: + package: "queries" + out: "internal/repository/queries" + sql_package: "pgx/v5" + emit_json_tags: true + json_tags_case_style: "snake" + emit_interface: true + emit_empty_slices: true + emit_pointers_for_null_types: true + overrides: + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time"