新增语言学习后台服务

- 分类管理:支持分类的增删改查
- 单词管理:支持单词的增删改查,包含音标、释义、例句等字段
- 更新 README 项目说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 22:38:09 +08:00
parent ea1b2569b5
commit 71a6bc4aa4
30 changed files with 1577 additions and 1 deletions
+22 -1
View File
@@ -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)
+14
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
APP_ENV=development
HTTP_ADDR=:8080
DATABASE_URL=postgres://language:language123@localhost:5432/language?sslmode=disable
+4
View File
@@ -0,0 +1,4 @@
.env
*.log
tmp/
.DS_Store
+32
View File
@@ -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
+50
View File
@@ -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` 并按需修改。
+47
View File
@@ -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")
}
+39
View File
@@ -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:
+19
View File
@@ -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"]
+18
View File
@@ -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
)
+32
View File
@@ -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=
+68
View File
@@ -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)
}
+23
View File
@@ -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
}
@@ -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
}
+40
View File
@@ -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"`
}
+136
View File
@@ -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)
}
+87
View File
@@ -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"`
}
+21
View File
@@ -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
}
@@ -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
}
+32
View File
@@ -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,
}
}
@@ -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"`
}
@@ -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)
@@ -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
}
@@ -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,
}
}
+155
View File
@@ -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
}
@@ -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;
@@ -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);
+25
View File
@@ -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;
+50
View File
@@ -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;
+20
View File
@@ -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"