重置项目

This commit is contained in:
2026-07-17 22:52:37 +08:00
parent 50190afab0
commit 368971330d
50 changed files with 1 additions and 6821 deletions
-44
View File
@@ -1,45 +1 @@
# Language Learning Tool # Language Learning Tool
一个从英语背单词入门的语言学习工具。
## 目录结构
```
language/
├── backend/ Go + PostgreSQL 后台服务
├── frontend/
│ └── admin/ 管理后台(Refine + Ant Design
├── client/ 未来客户端(App / Desktop
└── README.md
```
## 当前阶段
- 类别管理:如「农场动物」「颜色」
- 单词维护:英文单词、中文释义、音标、例句等
- 管理后台:分类与单词的增删改查
## 技术栈
- 后台:Go 1.25.8 + PostgreSQL 18.3 + Docker,详见 [backend/README.md](backend/README.md)
- 管理后台:Vite + React 19 + Refine + Ant Design 5
## 开发
```bash
# 启动后端(热重载,API 在 :8080)
cd backend && make dev
# 启动管理后台(:5173,/api 自动代理到 :8080
cd frontend/admin && npm install && npm run dev
```
## Docker 部署
根目录一条命令拉起整套(PostgreSQL + 迁移 + API + 管理后台):
```bash
docker compose up -d --build
```
浏览器访问 http://localhost 即可使用管理后台。
-14
View File
@@ -1,14 +0,0 @@
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
@@ -1,3 +0,0 @@
APP_ENV=development
HTTP_ADDR=:8080
DATABASE_URL=postgres://language:language123@localhost:5432/language?sslmode=disable
-4
View File
@@ -1,4 +0,0 @@
.env
*.log
tmp/
.DS_Store
-32
View File
@@ -1,32 +0,0 @@
.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
@@ -1,50 +0,0 @@
# 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`(分类下仍有单词时返回 409,code 10005
### 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
@@ -1,47 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,32 +0,0 @@
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
@@ -1,68 +0,0 @@
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
@@ -1,23 +0,0 @@
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
}
@@ -1,126 +0,0 @@
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
}
-44
View File
@@ -1,44 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/fish/language/backend/internal/services"
"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"
}
if errors.Is(err, services.ErrCategoryNotEmpty) {
return http.StatusConflict, 10005, "category has words and cannot be deleted"
}
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
@@ -1,136 +0,0 @@
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
@@ -1,87 +0,0 @@
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
@@ -1,21 +0,0 @@
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
}
@@ -1,122 +0,0 @@
// 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
@@ -1,32 +0,0 @@
// 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,
}
}
@@ -1,31 +0,0 @@
// 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"`
}
@@ -1,25 +0,0 @@
// 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)
@@ -1,227 +0,0 @@
// 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
}
@@ -1,91 +0,0 @@
package services
import (
"context"
"errors"
"github.com/fish/language/backend/internal/models"
"github.com/fish/language/backend/internal/repository/queries"
)
// ErrCategoryNotEmpty is returned when deleting a category that still has words.
var ErrCategoryNotEmpty = errors.New("category has words")
// 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. It refuses to delete a category that still has words.
func (s *CategoryService) Delete(ctx context.Context, id int32) error {
count, err := s.q.CountWords(ctx, queries.CountWordsParams{CategoryID: id})
if err != nil {
return err
}
if count > 0 {
return ErrCategoryNotEmpty
}
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
@@ -1,155 +0,0 @@
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
}
@@ -1,4 +0,0 @@
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;
@@ -1,25 +0,0 @@
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
@@ -1,25 +0,0 @@
-- 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
@@ -1,50 +0,0 @@
-- 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
@@ -1,20 +0,0 @@
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"
-51
View File
@@ -1,51 +0,0 @@
services:
db:
image: postgres:18.3-alpine3.23
environment:
POSTGRES_USER: language
POSTGRES_PASSWORD: language123
POSTGRES_DB: language
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U language -d language"]
interval: 5s
timeout: 5s
retries: 5
migrate:
image: migrate/migrate:v4.18.3
volumes:
- ./backend/migrations:/migrations
command:
- -path
- /migrations
- -database
- postgres://language:language123@db:5432/language?sslmode=disable
- up
depends_on:
db:
condition: service_healthy
api:
build:
context: ./backend
dockerfile: docker/Dockerfile
target: prod
environment:
APP_ENV: production
DATABASE_URL: postgres://language:language123@db:5432/language?sslmode=disable
HTTP_ADDR: :8080
depends_on:
migrate:
condition: service_completed_successfully
admin:
build: ./frontend/admin
ports:
- "80:80"
depends_on:
- api
volumes:
postgres_data:
-3
View File
@@ -1,3 +0,0 @@
node_modules
dist
.git
-3
View File
@@ -1,3 +0,0 @@
node_modules
dist
*.local
-12
View File
@@ -1,12 +0,0 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29.0-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>语言学习管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-17
View File
@@ -1,17 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
}
-4534
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
{
"name": "language-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@refinedev/antd": "^6.0.3",
"@refinedev/core": "^5.0.12",
"@refinedev/react-router": "^2.0.4",
"antd": "^5.23.0",
"axios": "^1.13.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^7.13.1"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"typescript": "~5.9.3",
"vite": "^7.3.4"
}
}
-74
View File
@@ -1,74 +0,0 @@
import { Refine } from "@refinedev/core";
import routerProvider, {
DocumentTitleHandler,
NavigateToResource,
UnsavedChangesNotifier,
} from "@refinedev/react-router";
import {
ErrorComponent,
ThemedLayout,
ThemedTitle,
useNotificationProvider,
} from "@refinedev/antd";
import { App as AntdApp, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import { BrowserRouter, Outlet, Route, Routes } from "react-router";
import { dataProvider } from "./providers/dataProvider";
import { CategoryList } from "./pages/categories/list";
import { WordCreate } from "./pages/words/create";
import { WordEdit } from "./pages/words/edit";
import { WordList } from "./pages/words/list";
export default function App() {
return (
<BrowserRouter>
<ConfigProvider locale={zhCN}>
<AntdApp>
<Refine
dataProvider={dataProvider}
routerProvider={routerProvider}
notificationProvider={useNotificationProvider}
resources={[
{
name: "words",
list: "/words",
create: "/words/create",
edit: "/words/edit/:id",
meta: { label: "单词管理" },
},
{
name: "categories",
list: "/categories",
meta: { label: "分类管理" },
},
]}
options={{ syncWithLocation: true, warnWhenUnsavedChanges: true }}
>
<Routes>
<Route
element={
<ThemedLayout
Title={({ collapsed }) => (
<ThemedTitle collapsed={collapsed} text="语言学习管理后台" />
)}
>
<Outlet />
</ThemedLayout>
}
>
<Route index element={<NavigateToResource resource="words" />} />
<Route path="/words" element={<WordList />} />
<Route path="/words/create" element={<WordCreate />} />
<Route path="/words/edit/:id" element={<WordEdit />} />
<Route path="/categories" element={<CategoryList />} />
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
</BrowserRouter>
);
}
-9
View File
@@ -1,9 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -1,93 +0,0 @@
import { DeleteButton, List, useModalForm, useTable } from "@refinedev/antd";
import { Button, Form, Input, InputNumber, Modal, Space, Table } from "antd";
import type { Category } from "../../types";
function CategoryFormFields() {
return (
<>
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: "请输入分类名称" }]}
>
<Input placeholder="如:农场动物、颜色" maxLength={100} />
</Form.Item>
<Form.Item label="排序" name="sort_order" initialValue={0}>
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</>
);
}
export function CategoryList() {
const { tableProps } = useTable<Category>({
resource: "categories",
pagination: { mode: "off" },
});
const {
formProps: createFormProps,
modalProps: createModalProps,
show: showCreate,
} = useModalForm<Category>({ resource: "categories", action: "create" });
const {
formProps: editFormProps,
modalProps: editModalProps,
show: showEdit,
} = useModalForm<Category>({ resource: "categories", action: "edit" });
return (
<>
<List
title="分类管理"
headerButtons={
<Button type="primary" onClick={() => showCreate()}>
</Button>
}
>
<Table {...tableProps} rowKey="id" pagination={false}>
<Table.Column dataIndex="name" title="名称" />
<Table.Column dataIndex="sort_order" title="排序" width={100} />
<Table.Column
dataIndex="created_at"
title="创建时间"
width={180}
render={(v: string) => new Date(v).toLocaleString("zh-CN")}
/>
<Table.Column
title="操作"
width={160}
render={(_, record: Category) => (
<Space>
<Button size="small" onClick={() => showEdit(record.id)}>
</Button>
<DeleteButton
size="small"
recordItemId={record.id}
confirmTitle="确定删除该分类?仅当分类下没有单词时才能删除。"
confirmOkText="删除"
confirmCancelText="取消"
/>
</Space>
)}
/>
</Table>
</List>
<Modal {...createModalProps} title="新建分类">
<Form {...createFormProps} layout="vertical">
<CategoryFormFields />
</Form>
</Modal>
<Modal {...editModalProps} title="编辑分类">
<Form {...editFormProps} layout="vertical">
<CategoryFormFields />
</Form>
</Modal>
</>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { Create, useForm } from "@refinedev/antd";
import { Form } from "antd";
import type { Word } from "../../types";
import { normalizeWordValues, WordFormFields } from "./form";
export function WordCreate() {
const { formProps, saveButtonProps } = useForm<Word>({
resource: "words",
action: "create",
});
return (
<Create title="新建单词" saveButtonProps={saveButtonProps}>
<Form
{...formProps}
layout="vertical"
onFinish={(values) => formProps.onFinish?.(normalizeWordValues(values))}
>
<WordFormFields form={formProps.form!} />
</Form>
</Create>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { Edit, useForm } from "@refinedev/antd";
import { Form } from "antd";
import type { Word } from "../../types";
import { normalizeWordValues, WordFormFields } from "./form";
export function WordEdit() {
const { formProps, saveButtonProps } = useForm<Word>({
resource: "words",
action: "edit",
});
return (
<Edit title="编辑单词" saveButtonProps={saveButtonProps}>
<Form
{...formProps}
layout="vertical"
onFinish={(values) => formProps.onFinish?.(normalizeWordValues(values))}
>
<WordFormFields form={formProps.form!} />
</Form>
</Edit>
);
}
-85
View File
@@ -1,85 +0,0 @@
import { useList } from "@refinedev/core";
import { Form, Image, Input, InputNumber, Select } from "antd";
import type { FormInstance } from "antd";
import type { Category } from "../../types";
export function useCategoryOptions() {
const { result } = useList<Category>({
resource: "categories",
pagination: { mode: "off" },
});
const categories = result?.data ?? [];
return {
categories,
options: categories.map((c) => ({ label: c.name, value: c.id })),
};
}
// 可选字段的空字符串转为 undefined,避免后端存空串而不是 NULL
export function normalizeWordValues<T extends object>(values: T): T {
const optionalKeys = ["phonetic", "example_sentence", "audio_url", "image_url"];
const out = { ...values } as Record<string, unknown>;
for (const key of optionalKeys) {
if (out[key] === "" || out[key] === undefined) {
out[key] = undefined;
}
}
return out as T;
}
export function WordFormFields({ form }: { form: FormInstance }) {
const { options } = useCategoryOptions();
const audioUrl = Form.useWatch("audio_url", form);
const imageUrl = Form.useWatch("image_url", form);
return (
<>
<Form.Item
label="所属分类"
name="category_id"
rules={[{ required: true, message: "请选择分类" }]}
>
<Select options={options} placeholder="选择分类" showSearch optionFilterProp="label" />
</Form.Item>
<Form.Item
label="单词"
name="word"
rules={[{ required: true, message: "请输入单词" }]}
>
<Input placeholder="如:apple" maxLength={255} />
</Form.Item>
<Form.Item
label="中文释义"
name="translation"
rules={[{ required: true, message: "请输入中文释义" }]}
>
<Input placeholder="如:苹果" maxLength={255} />
</Form.Item>
<Form.Item label="音标" name="phonetic">
<Input placeholder="如:/ˈæpəl/" maxLength={255} />
</Form.Item>
<Form.Item label="例句" name="example_sentence">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
<Form.Item label="音频 URL" name="audio_url">
<Input placeholder="可选,填写后可试听" maxLength={500} />
</Form.Item>
{audioUrl ? (
<Form.Item label="音频预览">
<audio controls src={audioUrl} style={{ width: "100%" }} />
</Form.Item>
) : null}
<Form.Item label="图片 URL" name="image_url">
<Input placeholder="可选,填写后可预览" maxLength={500} />
</Form.Item>
{imageUrl ? (
<Form.Item label="图片预览">
<Image src={imageUrl} width={160} fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='90'%3E%3Crect width='100%25' height='100%25' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' fill='%23999' font-size='12' text-anchor='middle'%3E加载失败%3C/text%3E%3C/svg%3E" />
</Form.Item>
) : null}
<Form.Item label="排序" name="sort_order" initialValue={0}>
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</>
);
}
-82
View File
@@ -1,82 +0,0 @@
import type { CrudFilters, HttpError } from "@refinedev/core";
import { DeleteButton, EditButton, List, useTable } from "@refinedev/antd";
import { Button, Form, Input, Select, Space, Table } from "antd";
import type { Word } from "../../types";
import { useCategoryOptions } from "./form";
interface SearchForm {
category_id?: number;
keyword?: string;
}
export function WordList() {
const { categories, options } = useCategoryOptions();
const categoryName = (id: number) =>
categories.find((c) => c.id === id)?.name ?? `#${id}`;
const { tableProps, searchFormProps } = useTable<Word, HttpError, SearchForm>({
resource: "words",
pagination: { pageSize: 20 },
onSearch: (values): CrudFilters => [
{ field: "category_id", operator: "eq", value: values.category_id },
{ field: "keyword", operator: "eq", value: values.keyword },
],
});
return (
<List title="单词管理">
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="category_id" label="分类">
<Select
options={options}
placeholder="全部分类"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item name="keyword" label="关键词">
<Input placeholder="单词或释义" allowClear style={{ width: 200 }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="word" title="单词" />
<Table.Column dataIndex="phonetic" title="音标" width={140} />
<Table.Column dataIndex="translation" title="中文释义" />
<Table.Column
dataIndex="category_id"
title="分类"
width={140}
render={(id: number) => categoryName(id)}
/>
<Table.Column dataIndex="sort_order" title="排序" width={80} />
<Table.Column
title="操作"
width={160}
render={(_, record: Word) => (
<Space>
<EditButton size="small" recordItemId={record.id} hideText>
</EditButton>
<DeleteButton
size="small"
recordItemId={record.id}
confirmTitle="确定删除该单词?"
confirmOkText="删除"
confirmCancelText="取消"
hideText
/>
</Space>
)}
/>
</Table>
</List>
);
}
@@ -1,86 +0,0 @@
import type { CrudFilters, DataProvider } from "@refinedev/core";
import axios, { AxiosError } from "axios";
import type { ApiEnvelope, ListWordsData } from "../types";
const http = axios.create({ baseURL: "/api/v1" });
const ERROR_MESSAGES: Record<number, string> = {
10001: "资源不存在",
10005: "该分类下还有单词,无法删除",
};
class ApiError extends Error {
constructor(
public code: number,
message: string,
public status?: number,
) {
super(ERROR_MESSAGES[code] ?? message);
this.name = "ApiError";
}
}
async function unwrap<T>(promise: Promise<{ data: ApiEnvelope<T> }>): Promise<T> {
try {
const res = await promise;
if (res.data.code !== 0) {
throw new ApiError(res.data.code, res.data.message);
}
return res.data.data;
} catch (err) {
if (err instanceof ApiError) throw err;
const axErr = err as AxiosError<ApiEnvelope<unknown>>;
const envelope = axErr.response?.data;
if (envelope) {
throw new ApiError(envelope.code, envelope.message, axErr.response?.status);
}
throw err;
}
}
function filtersToParams(filters: CrudFilters = []) {
const params: Record<string, unknown> = {};
for (const f of filters) {
if ("field" in f && f.operator === "eq" && f.value !== undefined && f.value !== "" && f.value !== null) {
params[f.field] = f.value;
}
}
return params;
}
export const dataProvider: DataProvider = {
getApiUrl: () => "/api/v1",
getList: async ({ resource, pagination, filters }) => {
const params: Record<string, unknown> = { ...filtersToParams(filters) };
if (pagination && pagination.mode !== "off") {
params.page = pagination.currentPage ?? 1;
params.page_size = pagination.pageSize ?? 20;
}
const data = await unwrap<ListWordsData | unknown[]>(http.get(`/${resource}`, { params }));
if (Array.isArray(data)) {
return { data: data as never[], total: data.length };
}
return { data: (data.words ?? []) as never[], total: Number(data.total) };
},
getOne: async ({ resource, id }) => {
const data = await unwrap(http.get(`/${resource}/${id}`));
return { data: data as never };
},
create: async ({ resource, variables }) => {
const data = await unwrap(http.post(`/${resource}`, variables));
return { data: data as never };
},
update: async ({ resource, id, variables }) => {
const data = await unwrap(http.put(`/${resource}/${id}`, variables));
return { data: data as never };
},
deleteOne: async ({ resource, id }) => {
const data = await unwrap(http.delete(`/${resource}/${id}`));
return { data: (data ?? {}) as never };
},
};
-35
View File
@@ -1,35 +0,0 @@
export interface Category {
id: number;
name: string;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface Word {
id: number;
category_id: number;
word: string;
translation: string;
phonetic?: string;
example_sentence?: string;
audio_url?: string;
image_url?: string;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
}
export interface ListWordsData {
words: Word[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});