docs: add backend onboarding guide
This commit is contained in:
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Arkie Library Backend environment example
|
||||||
|
# Copy to .env for your own reference, but note: the Go app does not auto-load .env.
|
||||||
|
# Export these values in your shell, Docker, process manager, or deployment system.
|
||||||
|
|
||||||
|
# HTTP
|
||||||
|
HTTP_ADDR=:8080
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
DATABASE_URL=postgres://ark:ark@localhost:5432/arkdb?sslmode=disable
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
# Change this in production. Use a long random string.
|
||||||
|
JWT_SECRET=dev-insecure-change-me
|
||||||
|
|
||||||
|
# Wallet auth schema DDL at startup.
|
||||||
|
# true is convenient for dev; false is safer for public/read-only DB users after migrations are applied.
|
||||||
|
RUN_WALLET_AUTH_SCHEMA=true
|
||||||
|
|
||||||
|
# Local uploads
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
|
UPLOAD_MULTIPART_MEM_MB=64
|
||||||
|
|
||||||
|
# Optional first admin seed. Only creates an admin when the admins table is empty.
|
||||||
|
SEED_ADMIN=false
|
||||||
|
ADMIN_EMAIL=admin@ark.local
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# Optional S3 uploads. Leave S3_BUCKET empty to keep uploads local.
|
||||||
|
S3_BUCKET=
|
||||||
|
AWS_REGION=ap-southeast-1
|
||||||
|
S3_UPLOAD_PREFIX=uploads
|
||||||
|
# Optional CDN/website base URL, e.g. https://cdn.example.com
|
||||||
|
S3_PUBLIC_BASE_URL=
|
||||||
81
AGENTS.md
Normal file
81
AGENTS.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# AI Agent Instructions
|
||||||
|
|
||||||
|
This file is first-stop context for AI coding agents working in `Arkie-Library-Backend`.
|
||||||
|
|
||||||
|
## Repository identity
|
||||||
|
|
||||||
|
- Project: Arkie Library Backend / ARK database API.
|
||||||
|
- Go module: `github.com/arkie/ark-database`.
|
||||||
|
- Stack: Go, chi router, PostgreSQL/pgx, JWT, bcrypt, optional AWS S3 uploads, Ethereum wallet signature verification.
|
||||||
|
- Frontend repo expects backend API at `/api` and uploaded assets under `/uploads`.
|
||||||
|
|
||||||
|
## Branch and safety rules
|
||||||
|
|
||||||
|
- Current branch observed during initialization: `terry-staging`.
|
||||||
|
- Do not commit or push unless Terry explicitly asks.
|
||||||
|
- Before branch-changing, pulling, rebasing, or destructive operations, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short --branch
|
||||||
|
```
|
||||||
|
|
||||||
|
- Do not commit secrets. `.env.example` is safe; real `.env` files should stay local/deployment-only.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
Before proposing deploy/push, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w .
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional extra check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture quick map
|
||||||
|
|
||||||
|
- `cmd/server/main.go`: starts everything. Loads config, connects DB, ensures upload dir, optionally seeds admin, sets up S3, registers routes.
|
||||||
|
- `internal/config/config.go`: all environment variables and defaults.
|
||||||
|
- `internal/db/db.go`: creates and pings a `pgxpool.Pool`.
|
||||||
|
- `internal/auth/`: JWT helpers. Admin tokens use issuer `ark-admin`; wallet user tokens use issuer `ark-user`.
|
||||||
|
- `internal/handlers/public.go`: public category/resource/search/metric endpoints.
|
||||||
|
- `internal/handlers/admin.go`: admin login, dashboard, resource CRUD, tags.
|
||||||
|
- `internal/handlers/wallet_auth.go`: wallet nonce, signature verification, wallet profile.
|
||||||
|
- `internal/handlers/upload.go`: admin multipart upload to local disk or S3.
|
||||||
|
- `migrations/`: SQL schema files; there is no Go migration runner yet.
|
||||||
|
|
||||||
|
## Important implementation patterns
|
||||||
|
|
||||||
|
- HTTP handlers are plain `net/http` functions using chi route params.
|
||||||
|
- DB access comes from request context via `handlers.WithPool` and `poolFrom(r)`.
|
||||||
|
- JSON responses use `writeJSON` in `internal/handlers/util.go`.
|
||||||
|
- JSON request bodies use `jsonDecode`, limited to 1 MiB.
|
||||||
|
- Resource IDs are UUIDs (`github.com/google/uuid`).
|
||||||
|
- Tags are stored in `tags` + `resource_tags`; new tag slugs are deterministic hashes from `tagSlug`.
|
||||||
|
- Public resource listing only returns `resources.status = 'published'` and `is_public = TRUE`.
|
||||||
|
- Admin routes require `Authorization: Bearer <admin-jwt>` from `/api/admin/login`.
|
||||||
|
- Wallet routes produce normal user JWTs, not admin JWTs.
|
||||||
|
|
||||||
|
## Database/migration notes
|
||||||
|
|
||||||
|
- `migrations/001_init.sql` is designed for a fresh database and is not fully idempotent.
|
||||||
|
- `migrations/002_wallet_auth.sql` is idempotent.
|
||||||
|
- `RUN_WALLET_AUTH_SCHEMA` defaults to true and lets startup create the wallet nonce table. In production with read-only/public DB users, apply migration separately and set it to false.
|
||||||
|
|
||||||
|
## Deployment/runtime notes
|
||||||
|
|
||||||
|
- Dockerfile builds a static Go binary in a `golang:1.24-alpine` builder and runs it on Alpine.
|
||||||
|
- `UPLOAD_DIR` defaults to `./uploads`; Docker sets it to `/app/uploads`.
|
||||||
|
- When `S3_BUCKET` is set and AWS config loads, uploads go to S3. Otherwise uploads stay local.
|
||||||
|
- `JWT_SECRET` default is insecure and must be changed outside development.
|
||||||
|
|
||||||
|
## Agent behavior preferences
|
||||||
|
|
||||||
|
- Answer Terry concisely in Chinese unless code/docs require English.
|
||||||
|
- Prefer small direct changes over broad rewrites.
|
||||||
|
- Keep this README/AGENTS/docs updated when discovering non-obvious backend behavior.
|
||||||
|
- Search project memory before making decisions about workflow, deployment, or conventions.
|
||||||
131
README.md
Normal file
131
README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Arkie Library Backend
|
||||||
|
|
||||||
|
Go backend for the ARK Library / ARK Database website.
|
||||||
|
|
||||||
|
If Go is new for you: start with **[docs/GO_FOR_BEGINNERS.md](docs/GO_FOR_BEGINNERS.md)**, then read **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**.
|
||||||
|
|
||||||
|
## What this service does
|
||||||
|
|
||||||
|
- Serves public library data under `/api`.
|
||||||
|
- Serves uploaded files under `/uploads`.
|
||||||
|
- Provides admin login and admin CRUD APIs for resources.
|
||||||
|
- Supports wallet sign-in with Ethereum personal-sign messages.
|
||||||
|
- Stores data in PostgreSQL.
|
||||||
|
- Stores uploads locally by default, or in S3 when configured.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- Go module: `github.com/arkie/ark-database`
|
||||||
|
- HTTP router: `github.com/go-chi/chi/v5`
|
||||||
|
- Database: PostgreSQL via `github.com/jackc/pgx/v5/pgxpool`
|
||||||
|
- Auth: JWT via `github.com/golang-jwt/jwt/v5`
|
||||||
|
- Password hashing: bcrypt
|
||||||
|
- Wallet signature verification: `github.com/ethereum/go-ethereum`
|
||||||
|
- Optional uploads: AWS S3 SDK v2
|
||||||
|
|
||||||
|
## Folder map
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/server/main.go # application entry point: config, DB, router, endpoints
|
||||||
|
internal/config/config.go # environment variables and default values
|
||||||
|
internal/db/db.go # PostgreSQL connection pool
|
||||||
|
internal/auth/ # JWT signing/parsing for admin and wallet users
|
||||||
|
internal/handlers/ # HTTP handlers: public, admin, upload, wallet auth
|
||||||
|
internal/seed/seed.go # optional initial admin user
|
||||||
|
migrations/ # SQL schema files, apply manually
|
||||||
|
static/ # bundled static assets copied into uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick local run
|
||||||
|
|
||||||
|
### 1) Start PostgreSQL
|
||||||
|
|
||||||
|
Example with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name ark-postgres \
|
||||||
|
-e POSTGRES_USER=ark \
|
||||||
|
-e POSTGRES_PASSWORD=ark \
|
||||||
|
-e POSTGRES_DB=arkdb \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-d postgres:16
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) Apply schema once
|
||||||
|
|
||||||
|
`migrations/001_init.sql` is for a fresh empty database. Do not run it twice on the same DB unless you reset the DB first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Arkie-Library-Backend
|
||||||
|
export DATABASE_URL='postgres://ark:ark@localhost:5432/arkdb?sslmode=disable'
|
||||||
|
psql "$DATABASE_URL" -f migrations/001_init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Wallet nonce schema is normally auto-created at app startup because `RUN_WALLET_AUTH_SCHEMA` defaults to true. If you want to apply it manually and disable startup DDL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f migrations/002_wallet_auth.sql
|
||||||
|
export RUN_WALLET_AUTH_SCHEMA=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Configure env
|
||||||
|
|
||||||
|
Copy the example and edit values as needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
This project does not auto-load `.env`; either export variables in your shell or run with a dotenv helper.
|
||||||
|
|
||||||
|
### 4) Run the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
Check health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format all Go files
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
# Compile and run all package tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run the backend
|
||||||
|
go run ./cmd/server
|
||||||
|
|
||||||
|
# Build a local binary
|
||||||
|
go build -o ./bin/server ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
See `.env.example` for all variables. Important ones:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HTTP_ADDR` | `:8080` | Listen address |
|
||||||
|
| `DATABASE_URL` | `postgres://ark:ark@localhost:5432/arkdb?sslmode=disable` | PostgreSQL connection |
|
||||||
|
| `JWT_SECRET` | `dev-insecure-change-me` | JWT signing key; change in production |
|
||||||
|
| `UPLOAD_DIR` | `./uploads` | Local upload directory |
|
||||||
|
| `CORS_ORIGINS` | empty | Comma-separated allowed browser origins |
|
||||||
|
| `SEED_ADMIN` | false | Set `true`/`1` to create first admin if none exists |
|
||||||
|
| `ADMIN_EMAIL` | `admin@ark.local` | Seed admin email |
|
||||||
|
| `ADMIN_PASSWORD` | `admin123` | Seed admin password |
|
||||||
|
| `S3_BUCKET` | empty | Enables S3 uploads when set |
|
||||||
|
|
||||||
|
## API docs
|
||||||
|
|
||||||
|
See **[docs/API.md](docs/API.md)**.
|
||||||
|
|
||||||
|
## Agent notes
|
||||||
|
|
||||||
|
AI/coding agent instructions are in **[AGENTS.md](AGENTS.md)**.
|
||||||
360
docs/API.md
Normal file
360
docs/API.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
Base URL in local development: `http://localhost:8080`.
|
||||||
|
|
||||||
|
Frontend usually calls paths like `/api/resources`; uploaded files are available under `/uploads/...`.
|
||||||
|
|
||||||
|
## Response style
|
||||||
|
|
||||||
|
Most responses are JSON. Errors use plain text via `http.Error`, for example `unauthorized` or `bad json`.
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
### `GET /healthz`
|
||||||
|
|
||||||
|
Returns plain text:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Same as `/healthz`.
|
||||||
|
|
||||||
|
## Public categories/resources
|
||||||
|
|
||||||
|
### `GET /api/categories`
|
||||||
|
|
||||||
|
Query/header language behavior:
|
||||||
|
|
||||||
|
- `?lang=zh-CN` returns Simplified Chinese name when available.
|
||||||
|
- `?lang=en` returns English name when available.
|
||||||
|
- otherwise Traditional Chinese is preferred.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"slug": "project-ppt",
|
||||||
|
"name": "項目資料(PPT)",
|
||||||
|
"description": "ARK 項目介紹、簡報與對外展示資料",
|
||||||
|
"iconKey": "folder",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"updatedAt": "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/resources`
|
||||||
|
|
||||||
|
Returns paginated public resources.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
| Param | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `page` | page number, default 1 |
|
||||||
|
| `limit` | page size, default 20, max 100 |
|
||||||
|
| `q` | search title/description/tag name |
|
||||||
|
| `type` | resource type; `all` means no filter |
|
||||||
|
| `language` | exact resource language filter |
|
||||||
|
| `category` | category slug |
|
||||||
|
| `tag` | tag slug or tag name, case-insensitive |
|
||||||
|
| `sort` | `latest` default, `published`, `recommended`, `popular` |
|
||||||
|
| `lang` | category display language, e.g. `en`, `zh-CN` |
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "ARK 項目介紹簡報(示例)",
|
||||||
|
"description": "適合線下宣講與新人培訓。",
|
||||||
|
"type": "ppt",
|
||||||
|
"language": "zh-TW",
|
||||||
|
"categoryId": 1,
|
||||||
|
"categorySlug": "project-ppt",
|
||||||
|
"categoryName": "項目資料(PPT)",
|
||||||
|
"coverImage": "/uploads/placeholder-cover.svg",
|
||||||
|
"fileUrl": "/uploads/placeholder-cover.svg",
|
||||||
|
"isDownloadable": true,
|
||||||
|
"isRecommended": true,
|
||||||
|
"publishedAt": "2026-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2026-01-01T00:00:00Z",
|
||||||
|
"tags": ["官方推薦"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/resources/recommended?limit=12`
|
||||||
|
|
||||||
|
Returns recommended resources:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "items": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Max limit is 50.
|
||||||
|
|
||||||
|
### `GET /api/resources/latest?limit=12`
|
||||||
|
|
||||||
|
Returns latest resources:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "items": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/resources/popular?limit=12`
|
||||||
|
|
||||||
|
Returns resources ordered by download + favorite + share count:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "items": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/resources/{id}`
|
||||||
|
|
||||||
|
Returns one published public resource. `{id}` must be a UUID.
|
||||||
|
|
||||||
|
### `GET /api/resources/{id}/related`
|
||||||
|
|
||||||
|
Returns up to 8 resources from the same category:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "items": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public activity endpoints
|
||||||
|
|
||||||
|
### `POST /api/search-log`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "query": "ark" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/resources/{id}/view`
|
||||||
|
|
||||||
|
Increments view count.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/resources/{id}/download`
|
||||||
|
|
||||||
|
Increments download count.
|
||||||
|
|
||||||
|
### `POST /api/resources/{id}/share`
|
||||||
|
|
||||||
|
Increments share count.
|
||||||
|
|
||||||
|
### `POST /api/resources/{id}/favorite`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "add": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `add: true` increments favorite count.
|
||||||
|
- `add: false` decrements favorite count but not below zero.
|
||||||
|
|
||||||
|
## Wallet auth
|
||||||
|
|
||||||
|
Wallet auth is for normal users, not admin users.
|
||||||
|
|
||||||
|
### `POST /api/auth/wallet/nonce`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "address": "0x0000000000000000000000000000000000000000" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nonce": "hex-code",
|
||||||
|
"message": "ARK Database — wallet sign-in\n\nWallet: 0x...\nOne-time code: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend asks the wallet to sign `message`.
|
||||||
|
|
||||||
|
### `POST /api/auth/wallet/verify`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"address": "0x0000000000000000000000000000000000000000",
|
||||||
|
"message": "same message from nonce endpoint",
|
||||||
|
"signature": "0x..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "jwt",
|
||||||
|
"wallet": "0xChecksumAddress"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/auth/wallet/me`
|
||||||
|
|
||||||
|
Requires header:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <wallet-jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "wallet": "0xChecksumAddress", "role": "user" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin auth
|
||||||
|
|
||||||
|
### `POST /api/admin/login`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "admin@ark.local", "password": "admin123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "token": "admin-jwt" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this token for admin endpoints:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <admin-jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin endpoints
|
||||||
|
|
||||||
|
All endpoints below require admin JWT.
|
||||||
|
|
||||||
|
### `GET /api/admin/dashboard`
|
||||||
|
|
||||||
|
Response includes counts and hot resources:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"totalResources": 10,
|
||||||
|
"published": 8,
|
||||||
|
"todayNew": 1,
|
||||||
|
"totalViews": 100,
|
||||||
|
"totalDownloads": 20,
|
||||||
|
"totalFavorites": 5,
|
||||||
|
"totalShares": 3,
|
||||||
|
"hotResources": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/admin/search-logs?limit=200`
|
||||||
|
|
||||||
|
Max limit is 500.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "items": [{ "id": 1, "query": "ark", "createdAt": "2026-01-01T00:00:00Z" }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/admin/resources?page=1&limit=20`
|
||||||
|
|
||||||
|
Lists all resources, including drafts/private resources.
|
||||||
|
|
||||||
|
### `GET /api/admin/resources/{id}`
|
||||||
|
|
||||||
|
Gets one resource by UUID.
|
||||||
|
|
||||||
|
### `POST /api/admin/resources`
|
||||||
|
|
||||||
|
Creates a resource.
|
||||||
|
|
||||||
|
Example request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Example",
|
||||||
|
"description": "Short text",
|
||||||
|
"type": "ppt",
|
||||||
|
"language": "zh-TW",
|
||||||
|
"categoryId": 1,
|
||||||
|
"coverImage": "/uploads/example.svg",
|
||||||
|
"fileUrl": "/uploads/example.pdf",
|
||||||
|
"previewUrl": "",
|
||||||
|
"externalUrl": "",
|
||||||
|
"bodyText": "",
|
||||||
|
"badgeLabel": "新人必看",
|
||||||
|
"isPublic": true,
|
||||||
|
"isDownloadable": true,
|
||||||
|
"isRecommended": false,
|
||||||
|
"sortOrder": 0,
|
||||||
|
"status": "draft",
|
||||||
|
"tags": ["教程"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/admin/resources/{id}`
|
||||||
|
|
||||||
|
Updates a resource. Body shape is the same as create.
|
||||||
|
|
||||||
|
### `DELETE /api/admin/resources/{id}`
|
||||||
|
|
||||||
|
Deletes a resource.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/admin/upload`
|
||||||
|
|
||||||
|
Multipart upload with field name `file`.
|
||||||
|
|
||||||
|
Response for local storage:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "url": "/uploads/generated-name.ext", "filename": "generated-name.ext", "storage": "local" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response for S3:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "url": "https://...", "filename": "generated-name.ext", "storage": "s3" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/admin/categories`
|
||||||
|
|
||||||
|
Same handler as public categories; returns visible categories.
|
||||||
180
docs/ARCHITECTURE.md
Normal file
180
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Backend Architecture
|
||||||
|
|
||||||
|
This document explains the backend in beginner-friendly language.
|
||||||
|
|
||||||
|
## Big picture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser / Frontend
|
||||||
|
|
|
||||||
|
| HTTP JSON requests
|
||||||
|
v
|
||||||
|
cmd/server/main.go
|
||||||
|
|
|
||||||
|
+-- config.Load() reads environment variables
|
||||||
|
+-- db.Connect() creates PostgreSQL pool
|
||||||
|
+-- chi router maps URLs to handler functions
|
||||||
|
+-- handlers read/write DB and return JSON
|
||||||
|
|
|
||||||
|
v
|
||||||
|
PostgreSQL + local uploads or S3
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend is one Go HTTP server. There is no framework magic: `main.go` wires everything manually.
|
||||||
|
|
||||||
|
## Request flow example
|
||||||
|
|
||||||
|
Example: frontend calls `GET /api/resources?limit=20`.
|
||||||
|
|
||||||
|
1. `main.go` has this route:
|
||||||
|
```go
|
||||||
|
r.Get("/resources", handlers.ListResources)
|
||||||
|
```
|
||||||
|
2. The `/api` router has middleware:
|
||||||
|
```go
|
||||||
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
|
return handlers.WithPool(next, pool)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
This places the PostgreSQL pool into the request context.
|
||||||
|
3. `handlers.ListResources` calls `poolFrom(r)` to get the DB pool.
|
||||||
|
4. It reads query string filters (`q`, `type`, `language`, `category`, `sort`, `tag`).
|
||||||
|
5. It builds SQL, queries PostgreSQL, scans rows into `ResourceDTO`.
|
||||||
|
6. It returns JSON using `writeJSON`.
|
||||||
|
|
||||||
|
## Why `internal/`?
|
||||||
|
|
||||||
|
In Go, a folder named `internal` can only be imported by code inside the same module tree. This prevents other modules from depending on private implementation packages.
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/config private config package
|
||||||
|
internal/db private DB package
|
||||||
|
internal/auth private JWT package
|
||||||
|
internal/handlers private HTTP handler package
|
||||||
|
internal/seed private seed package
|
||||||
|
```
|
||||||
|
|
||||||
|
## Main packages
|
||||||
|
|
||||||
|
### `cmd/server`
|
||||||
|
|
||||||
|
`cmd/server/main.go` is the executable entry point.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Load environment config.
|
||||||
|
- Connect to PostgreSQL.
|
||||||
|
- Ensure wallet nonce table when enabled.
|
||||||
|
- Create upload directory and placeholder file.
|
||||||
|
- Optionally seed the first admin.
|
||||||
|
- Configure optional S3 upload client.
|
||||||
|
- Register all HTTP middleware and routes.
|
||||||
|
- Start `http.ListenAndServe`.
|
||||||
|
|
||||||
|
### `internal/config`
|
||||||
|
|
||||||
|
`config.Load()` reads env vars and fills defaults. It returns a `Config` struct.
|
||||||
|
|
||||||
|
Important pattern:
|
||||||
|
|
||||||
|
```go
|
||||||
|
addr := os.Getenv("HTTP_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8080"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This means env vars are optional in local development.
|
||||||
|
|
||||||
|
### `internal/db`
|
||||||
|
|
||||||
|
`db.Connect(ctx, databaseURL)` parses `DATABASE_URL`, creates a pgx connection pool, then pings the DB.
|
||||||
|
|
||||||
|
The app shares this pool for all requests.
|
||||||
|
|
||||||
|
### `internal/auth`
|
||||||
|
|
||||||
|
Two JWT token types exist:
|
||||||
|
|
||||||
|
- Admin token: issuer `ark-admin`, includes `admin_id` and email.
|
||||||
|
- Wallet user token: issuer `ark-user`, includes wallet address and role `user`.
|
||||||
|
|
||||||
|
Do not mix these. Admin middleware only accepts admin tokens.
|
||||||
|
|
||||||
|
### `internal/handlers`
|
||||||
|
|
||||||
|
Handlers are grouped by topic:
|
||||||
|
|
||||||
|
- `public.go`: public category/resource list/detail, search logs, counters.
|
||||||
|
- `admin.go`: admin login/dashboard/resource CRUD/tag replacement.
|
||||||
|
- `wallet_auth.go`: wallet nonce and signature verification.
|
||||||
|
- `upload.go`: admin file upload.
|
||||||
|
- `middleware.go`: admin auth middleware.
|
||||||
|
- `context.go`: stores DB pool in request context.
|
||||||
|
- `util.go`: shared JSON helpers.
|
||||||
|
|
||||||
|
## Database model summary
|
||||||
|
|
||||||
|
Main tables from `migrations/001_init.sql`:
|
||||||
|
|
||||||
|
- `admins`: admin email + bcrypt password hash.
|
||||||
|
- `categories`: resource categories with multilingual names.
|
||||||
|
- `resources`: library items; can be ppt/image/video/link/etc.
|
||||||
|
- `tags`: reusable tag names.
|
||||||
|
- `resource_tags`: many-to-many join table.
|
||||||
|
- `search_logs`: saved public search terms.
|
||||||
|
|
||||||
|
Wallet auth table from migration/startup DDL:
|
||||||
|
|
||||||
|
- `wallet_auth_nonces`: one temporary nonce per wallet address.
|
||||||
|
|
||||||
|
## Route groups
|
||||||
|
|
||||||
|
### Public routes
|
||||||
|
|
||||||
|
These do not require auth:
|
||||||
|
|
||||||
|
- health: `/healthz`, `/health`
|
||||||
|
- categories/resources under `/api`
|
||||||
|
- wallet auth nonce/verify/me (me requires wallet JWT)
|
||||||
|
- resource view/download/share/favorite counters
|
||||||
|
|
||||||
|
### Admin routes
|
||||||
|
|
||||||
|
`POST /api/admin/login` is public because it returns the admin token.
|
||||||
|
|
||||||
|
Everything in this group requires admin JWT:
|
||||||
|
|
||||||
|
- dashboard
|
||||||
|
- search logs
|
||||||
|
- resource list/get/create/update/delete
|
||||||
|
- upload
|
||||||
|
- admin categories list
|
||||||
|
|
||||||
|
## Upload behavior
|
||||||
|
|
||||||
|
Admin upload endpoint: `POST /api/admin/upload` with multipart field `file`.
|
||||||
|
|
||||||
|
If S3 is configured:
|
||||||
|
|
||||||
|
```text
|
||||||
|
S3_BUCKET non-empty + AWS config loads => upload to S3
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise:
|
||||||
|
|
||||||
|
```text
|
||||||
|
save file to UPLOAD_DIR and return /uploads/<filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
The max upload size is 512 MiB (`uploadMaxBytes`).
|
||||||
|
|
||||||
|
## Things to be careful with
|
||||||
|
|
||||||
|
- `migrations/001_init.sql` is not safe to run repeatedly on the same DB.
|
||||||
|
- `JWT_SECRET` default is for dev only.
|
||||||
|
- `RUN_WALLET_AUTH_SCHEMA=true` runs DDL at startup. Use false if the runtime DB user should not create tables.
|
||||||
|
- The app does not auto-load `.env`.
|
||||||
|
- Public resources are filtered to published + public; admin list sees all resources.
|
||||||
281
docs/GO_FOR_BEGINNERS.md
Normal file
281
docs/GO_FOR_BEGINNERS.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Go Beginner Guide for This Backend
|
||||||
|
|
||||||
|
This is not a full Go course. It explains only the Go ideas you need to read this project.
|
||||||
|
|
||||||
|
## 1. How a Go project starts
|
||||||
|
|
||||||
|
This repo has a `go.mod` file:
|
||||||
|
|
||||||
|
```go
|
||||||
|
module github.com/arkie/ark-database
|
||||||
|
```
|
||||||
|
|
||||||
|
That module name is used in imports:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/arkie/ark-database/internal/config"
|
||||||
|
```
|
||||||
|
|
||||||
|
The executable starts at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
In Go, a runnable program has:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// program starts here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Packages
|
||||||
|
|
||||||
|
Every `.go` file begins with a package name:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
Files in the same folder usually share the same package and can call each other directly.
|
||||||
|
|
||||||
|
Example: files in `internal/handlers/` all say `package handlers`, so `public.go` can call `writeJSON` from `util.go`.
|
||||||
|
|
||||||
|
## 3. Imports
|
||||||
|
|
||||||
|
Go imports are explicit:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If an import is unused, Go compilation fails. Run `gofmt -w .` after edits.
|
||||||
|
|
||||||
|
## 4. Structs
|
||||||
|
|
||||||
|
A struct is an object/data shape.
|
||||||
|
|
||||||
|
Example from `internal/config/config.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DatabaseURL string
|
||||||
|
JWTSecret string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON field names are controlled by tags:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ResourceDTO struct {
|
||||||
|
CategoryID int `json:"categoryId"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Go field is `CategoryID`, but JSON output is `categoryId`.
|
||||||
|
|
||||||
|
## 5. Functions with errors
|
||||||
|
|
||||||
|
Go commonly returns `(value, error)`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Read this as:
|
||||||
|
|
||||||
|
1. Try connecting to DB.
|
||||||
|
2. If `err` is not nil, stop.
|
||||||
|
3. Otherwise use `pool`.
|
||||||
|
|
||||||
|
## 6. Short variable declaration `:=`
|
||||||
|
|
||||||
|
This creates a new variable:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg := config.Load()
|
||||||
|
```
|
||||||
|
|
||||||
|
This assigns to an existing variable:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg = config.Load()
|
||||||
|
```
|
||||||
|
|
||||||
|
Most code here uses `:=` inside functions.
|
||||||
|
|
||||||
|
## 7. HTTP handlers
|
||||||
|
|
||||||
|
A Go HTTP handler usually looks like:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// w writes response
|
||||||
|
// r reads request
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `w` = response writer.
|
||||||
|
- `r` = request.
|
||||||
|
|
||||||
|
Route registration in `main.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.Get("/categories", handlers.ListCategories)
|
||||||
|
```
|
||||||
|
|
||||||
|
Because this is inside `r.Route("/api", ...)`, the full URL is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Middleware
|
||||||
|
|
||||||
|
Middleware wraps a request before it reaches the final handler.
|
||||||
|
|
||||||
|
Example in `main.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
```
|
||||||
|
|
||||||
|
This logs requests.
|
||||||
|
|
||||||
|
Admin auth is also middleware:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.Use(handlers.AdminAuth(cfg.JWTSecret))
|
||||||
|
```
|
||||||
|
|
||||||
|
If token is bad, it returns `401 unauthorized` before reaching the admin handler.
|
||||||
|
|
||||||
|
## 9. Context
|
||||||
|
|
||||||
|
Context carries request-scoped values and cancellation.
|
||||||
|
|
||||||
|
This project stores the DB pool in request context:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
|
return handlers.WithPool(next, pool)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Handlers retrieve it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
pool := poolFrom(r)
|
||||||
|
```
|
||||||
|
|
||||||
|
So most handlers do not receive the DB pool as a direct function parameter.
|
||||||
|
|
||||||
|
## 10. Database queries
|
||||||
|
|
||||||
|
This project uses pgx.
|
||||||
|
|
||||||
|
One row:
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := pool.QueryRow(ctx, `SELECT id FROM admins WHERE email = $1`, email).Scan(&id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Many rows:
|
||||||
|
|
||||||
|
```go
|
||||||
|
rows, err := pool.Query(ctx, `SELECT id, title FROM resources`)
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
rows.Scan(&id, &title)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL parameters use `$1`, `$2`, etc. This protects from SQL injection when used correctly.
|
||||||
|
|
||||||
|
## 11. Pointers and nil
|
||||||
|
|
||||||
|
You will see `*string` and `*time.Time`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var pubAt *time.Time
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows SQL `NULL` to become Go `nil`.
|
||||||
|
|
||||||
|
Code checks before using it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if pubAt != nil {
|
||||||
|
s := pubAt.UTC().Format(time.RFC3339)
|
||||||
|
dto.PublishedAt = &s
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. `defer`
|
||||||
|
|
||||||
|
`defer` runs later when the current function returns.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```go
|
||||||
|
defer pool.Close()
|
||||||
|
defer rows.Close()
|
||||||
|
defer r.Body.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it for cleanup.
|
||||||
|
|
||||||
|
## 13. JSON helpers in this project
|
||||||
|
|
||||||
|
Write JSON response:
|
||||||
|
|
||||||
|
```go
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
```
|
||||||
|
|
||||||
|
Read JSON request body:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var req loginReq
|
||||||
|
if err := jsonDecode(r, &req); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Common edit workflow
|
||||||
|
|
||||||
|
After changing `.go` files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w .
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`go test ./...` also compiles all packages, even if there are no test files.
|
||||||
|
|
||||||
|
## 15. Recommended reading order
|
||||||
|
|
||||||
|
1. `README.md`
|
||||||
|
2. `cmd/server/main.go`
|
||||||
|
3. `internal/config/config.go`
|
||||||
|
4. `internal/handlers/public.go`
|
||||||
|
5. `internal/handlers/admin.go`
|
||||||
|
6. `internal/handlers/wallet_auth.go`
|
||||||
|
7. `migrations/001_init.sql`
|
||||||
|
|
||||||
|
When you see a function you do not understand, search for its definition:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg "func FunctionName" .
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user