Browse Source
- Add package.json for frontend dependencies and scripts - Create AdminPanel component for managing short links - Implement App component for shortening URLs and admin access - Add API utility functions for token management and link operations - Set up main entry point for the Svelte application - Configure Vite with proxy settings for API requests - Create systemd service file for the short link server Co-authored-by: Copilot <copilot@github.com>main
commit
0f0f211ac2
21 changed files with 3426 additions and 0 deletions
@ -0,0 +1,31 @@ |
|||
kind: pipeline |
|||
type: exec |
|||
name: deploy |
|||
|
|||
platform: |
|||
os: linux |
|||
arch: amd64 |
|||
|
|||
steps: |
|||
- name: build-backend |
|||
commands: |
|||
- cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o short-link-server . |
|||
|
|||
- name: build-frontend |
|||
commands: |
|||
- cd frontend && npm ci && npm run build |
|||
|
|||
- name: deploy |
|||
commands: |
|||
- cp backend/short-link-server /opt/short-link/short-link-server.new |
|||
- cp -r frontend/dist/* /opt/short-link/frontend/ |
|||
- mv /opt/short-link/short-link-server.new /opt/short-link/short-link-server |
|||
- systemctl restart short-link |
|||
- systemctl reload caddy |
|||
depends_on: |
|||
- build-backend |
|||
- build-frontend |
|||
|
|||
trigger: |
|||
branch: |
|||
- main |
|||
@ -0,0 +1,4 @@ |
|||
node_modules |
|||
dist |
|||
backend/.go |
|||
backend/file::memory:?cache=shared |
|||
@ -0,0 +1,16 @@ |
|||
xieyaxin.top:8899 { |
|||
@api path /api/* |
|||
handle @api { |
|||
reverse_proxy localhost:8080 |
|||
} |
|||
|
|||
@shortcode path_regexp shortcode ^/[a-zA-Z0-9]{7}$ |
|||
handle @shortcode { |
|||
reverse_proxy localhost:8080 |
|||
} |
|||
|
|||
handle { |
|||
root * /opt/short-link/frontend |
|||
file_server |
|||
} |
|||
} |
|||
@ -0,0 +1,184 @@ |
|||
# 短链接生成器 |
|||
|
|||
粘贴长链接,一键变短。极简工具网站。 |
|||
|
|||
## 架构 |
|||
|
|||
``` |
|||
用户请求 → Caddy |
|||
├── /api/* → Go 后端 :8080 |
|||
├── /[a-zA-Z0-9]{7} → Go 后端 :8080 (短码重定向) |
|||
└── 其他 → 静态文件 /opt/short-link/frontend |
|||
``` |
|||
|
|||
``` |
|||
后端: Go 1.22 + net/http(标准库路由) + JSON 文件持久化 |
|||
前端: Svelte 4 + Vite 5 |
|||
网关: Caddy(自动 HTTPS) |
|||
部署: Drone CI → systemd |
|||
``` |
|||
|
|||
## 本地开发 |
|||
|
|||
### 后端 |
|||
|
|||
```bash |
|||
cd backend |
|||
|
|||
# 安装依赖 |
|||
go mod tidy |
|||
|
|||
# 运行 (默认 :8080) |
|||
ADDR=:8080 DOMAIN=http://localhost:8080 DB_PATH=./data.db ADMIN_TOKEN=dev go run . |
|||
|
|||
# 运行测试 |
|||
go test ./... |
|||
``` |
|||
|
|||
### 前端 |
|||
|
|||
```bash |
|||
cd frontend |
|||
|
|||
# 安装依赖 |
|||
npm install |
|||
|
|||
# 开发模式 (默认 :5173, API 请求会由 Vite 转发配置决定) |
|||
npm run dev |
|||
|
|||
# 构建 |
|||
npm run build |
|||
``` |
|||
|
|||
## 部署 |
|||
|
|||
### 环境要求 |
|||
|
|||
- Go 1.22+ |
|||
- Node.js 20+ |
|||
- Caddy 2.x |
|||
- systemd (Linux) |
|||
|
|||
### 手动部署 |
|||
|
|||
```bash |
|||
# 1. 构建 |
|||
cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o short-link-server . |
|||
cd ../frontend && npm install && npm run build |
|||
|
|||
# 2. 部署文件 |
|||
mkdir -p /opt/short-link/data /opt/short-link/frontend |
|||
cp backend/short-link-server /opt/short-link/ |
|||
cp -r frontend/dist/* /opt/short-link/frontend/ |
|||
|
|||
# 3. 生成管理员 Token |
|||
openssl rand -hex 32 # 填入 short-link.service 的 ADMIN_TOKEN |
|||
|
|||
# 4. 注册服务 |
|||
cp short-link.service /etc/systemd/system/ |
|||
systemctl daemon-reload |
|||
systemctl enable --now short-link |
|||
|
|||
# 5. Caddy |
|||
cp Caddyfile /etc/caddy/ |
|||
systemctl reload caddy |
|||
``` |
|||
|
|||
### Drone CI 自动部署 |
|||
|
|||
推送到 `main` 分支自动触发。需在 Drone 配置 `ssh_key` secret(部署机 SSH 私钥)。 |
|||
|
|||
`.drone.yml` 完成了三步流水线: |
|||
|
|||
1. `golang:1.22` — 编译 Go 二进制 |
|||
2. `node:20-alpine` — 构建前端 |
|||
3. `alpine:3` — 上传文件 + `systemctl restart short-link` + `systemctl reload caddy` |
|||
|
|||
## 配置 |
|||
|
|||
| 环境变量 | 默认值 | 说明 | |
|||
|----------|--------|------| |
|||
| `ADDR` | `:8080` | 监听地址 | |
|||
| `DOMAIN` | `http://localhost:8080` | 短链接域名,用于拼接 short_url | |
|||
| `DB_PATH` | `./data.db` | JSON 数据文件路径 | |
|||
| `ADMIN_TOKEN` | (空) | 管理员 Bearer Token,空值时所有管理接口返回 401 | |
|||
|
|||
## API |
|||
|
|||
### POST /api/shorten |
|||
|
|||
``` |
|||
请求: { "url": "https://example.com/very/long/path" } |
|||
响应: { "code": "abc1234", "short_url": "https://xieyaxin.top:8899/abc1234", "original_url": "https://example.com/very/long/path" } |
|||
``` |
|||
|
|||
- 自动补全 `https://` 前缀 |
|||
- 仅支持 http/https 协议 |
|||
- 返回 400(URL 不合法)、500(服务端错误) |
|||
|
|||
### GET /:code |
|||
|
|||
``` |
|||
GET /abc1234 → 302 Found → Location: https://example.com/... |
|||
``` |
|||
|
|||
- 存在:302 临时重定向 |
|||
- 不存在:404 |
|||
|
|||
### 管理接口 |
|||
|
|||
以下接口需在请求头中携带 `Authorization: Bearer <ADMIN_TOKEN>`。 |
|||
|
|||
#### GET /api/admin/links |
|||
|
|||
列出所有短链接,按创建时间倒序。 |
|||
|
|||
``` |
|||
→ 200 [{ "code": "abc1234", "original_url": "https://...", "visit_count": 42, "created_at": "2026-05-06T14:30:00Z" }] |
|||
→ 401 未授权 |
|||
``` |
|||
|
|||
#### PUT /api/admin/links/{code} |
|||
|
|||
更新短链接指向的原始 URL。 |
|||
|
|||
``` |
|||
请求: { "url": "https://new.example.com" } |
|||
→ 200 { "code": "abc1234", "original_url": "https://new.example.com", ... } |
|||
→ 404 短码不存在 |
|||
``` |
|||
|
|||
#### DELETE /api/admin/links/{code} |
|||
|
|||
删除短链接。 |
|||
|
|||
``` |
|||
→ 200 { "ok": true } |
|||
→ 404 短码不存在 |
|||
``` |
|||
|
|||
#### GET /api/admin/export |
|||
|
|||
导出全部数据。 |
|||
|
|||
``` |
|||
GET /api/admin/export → JSON 文件下载 (application/json) |
|||
GET /api/admin/export?format=csv → CSV 文件下载 (text/csv) |
|||
``` |
|||
|
|||
## 短码设计 |
|||
|
|||
- 7 位 base62 字符集 (a-z, A-Z, 0-9) |
|||
- `crypto/rand` 密码学安全随机数 |
|||
- 62^7 ≈ 3.5 万亿组合 |
|||
- 插入前检查唯一约束,碰撞重试最多 3 次 |
|||
|
|||
## 管理后台 |
|||
|
|||
访问 `/#admin` 进入管理界面: |
|||
|
|||
1. 输入 `ADMIN_TOKEN` 登录(浏览器 sessionStorage 保存,关闭标签页自动清除) |
|||
2. 表格展示所有短链接:短码、原始链接、访问次数、创建时间 |
|||
3. 行内编辑:修改原始 URL |
|||
4. 行内删除:确认后删除 |
|||
5. 导出数据:支持 JSON / CSV 格式下载 |
|||
@ -0,0 +1,109 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"encoding/csv" |
|||
"encoding/json" |
|||
"log" |
|||
"net/http" |
|||
"strconv" |
|||
"strings" |
|||
) |
|||
|
|||
// HandleListLinks handles GET /api/admin/links — returns all records.
|
|||
func (h *Handler) HandleListLinks(w http.ResponseWriter, r *http.Request) { |
|||
records, err := h.store.ListAll() |
|||
if err != nil { |
|||
log.Printf("list links error: %v", err) |
|||
jsonError(w, "internal error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
w.Header().Set("Content-Type", "application/json") |
|||
json.NewEncoder(w).Encode(records) |
|||
} |
|||
|
|||
// HandleUpdateLink handles PUT /api/admin/links/{code} — updates a link's URL.
|
|||
func (h *Handler) HandleUpdateLink(w http.ResponseWriter, r *http.Request) { |
|||
code := r.PathValue("code") |
|||
if code == "" { |
|||
jsonError(w, "missing short code", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
var req struct { |
|||
URL string `json:"url"` |
|||
} |
|||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
|||
jsonError(w, "invalid request body", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
req.URL = strings.TrimSpace(req.URL) |
|||
if req.URL == "" { |
|||
jsonError(w, "url is required", http.StatusBadRequest) |
|||
return |
|||
} |
|||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") { |
|||
jsonError(w, "url must start with http:// or https://", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
if err := h.store.Update(code, req.URL); err != nil { |
|||
log.Printf("update link error: %v", err) |
|||
jsonError(w, "short link not found", http.StatusNotFound) |
|||
return |
|||
} |
|||
|
|||
rec, _ := h.store.FindByCode(code) |
|||
w.Header().Set("Content-Type", "application/json") |
|||
json.NewEncoder(w).Encode(rec) |
|||
} |
|||
|
|||
// HandleDeleteLink handles DELETE /api/admin/links/{code} — deletes a link.
|
|||
func (h *Handler) HandleDeleteLink(w http.ResponseWriter, r *http.Request) { |
|||
code := r.PathValue("code") |
|||
if code == "" { |
|||
jsonError(w, "missing short code", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
if err := h.store.Delete(code); err != nil { |
|||
log.Printf("delete link error: %v", err) |
|||
jsonError(w, "short link not found", http.StatusNotFound) |
|||
return |
|||
} |
|||
|
|||
w.Header().Set("Content-Type", "application/json") |
|||
json.NewEncoder(w).Encode(map[string]bool{"ok": true}) |
|||
} |
|||
|
|||
// HandleExport handles GET /api/admin/export?format=json|csv — exports all data.
|
|||
func (h *Handler) HandleExport(w http.ResponseWriter, r *http.Request) { |
|||
records, err := h.store.ListAll() |
|||
if err != nil { |
|||
log.Printf("export error: %v", err) |
|||
jsonError(w, "internal error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
format := r.URL.Query().Get("format") |
|||
if format == "csv" { |
|||
w.Header().Set("Content-Type", "text/csv; charset=utf-8") |
|||
w.Header().Set("Content-Disposition", "attachment; filename=\"shortlinks.csv\"") |
|||
wr := csv.NewWriter(w) |
|||
wr.Write([]string{"code", "original_url", "visit_count", "created_at"}) |
|||
for _, rec := range records { |
|||
wr.Write([]string{ |
|||
rec.Code, |
|||
rec.OriginalURL, |
|||
strconv.FormatInt(rec.VisitCount, 10), |
|||
rec.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), |
|||
}) |
|||
} |
|||
wr.Flush() |
|||
return |
|||
} |
|||
|
|||
w.Header().Set("Content-Type", "application/json") |
|||
w.Header().Set("Content-Disposition", "attachment; filename=\"shortlinks.json\"") |
|||
json.NewEncoder(w).Encode(records) |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
module shortlink |
|||
|
|||
go 1.22 |
|||
@ -0,0 +1,166 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"crypto/rand" |
|||
"encoding/json" |
|||
"log" |
|||
"math/big" |
|||
"net/http" |
|||
"strings" |
|||
) |
|||
|
|||
const ( |
|||
base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" |
|||
codeLength = 7 |
|||
maxRetries = 3 |
|||
) |
|||
|
|||
// Handler holds references for HTTP handlers.
|
|||
type Handler struct { |
|||
store *Store |
|||
baseURL string // optional; falls back to request Host
|
|||
adminToken string |
|||
} |
|||
|
|||
// NewHandler creates a Handler.
|
|||
func NewHandler(store *Store, baseURL, adminToken string) *Handler { |
|||
return &Handler{store: store, baseURL: baseURL, adminToken: adminToken} |
|||
} |
|||
|
|||
// generateCode produces a 7-character base62 code using crypto/rand.
|
|||
func generateCode() (string, error) { |
|||
buf := make([]byte, codeLength) |
|||
for i := range buf { |
|||
n, err := rand.Int(rand.Reader, big.NewInt(62)) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
buf[i] = base62Chars[n.Int64()] |
|||
} |
|||
return string(buf), nil |
|||
} |
|||
|
|||
// requireAdmin wraps a handler with Bearer token authentication.
|
|||
// If adminToken is empty, all requests are rejected (fail-secure).
|
|||
func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc { |
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") |
|||
if h.adminToken == "" || token != h.adminToken { |
|||
jsonError(w, "unauthorized", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
next(w, r) |
|||
} |
|||
} |
|||
|
|||
// --- helpers ---
|
|||
|
|||
func jsonError(w http.ResponseWriter, msg string, status int) { |
|||
w.Header().Set("Content-Type", "application/json") |
|||
w.WriteHeader(status) |
|||
json.NewEncoder(w).Encode(map[string]string{"error": msg}) |
|||
} |
|||
|
|||
// --- handlers ---
|
|||
|
|||
// HandleCreate handles POST /api/shorten.
|
|||
func (h *Handler) HandleCreate(w http.ResponseWriter, r *http.Request) { |
|||
var req struct { |
|||
URL string `json:"url"` |
|||
} |
|||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
|||
jsonError(w, "invalid request body", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
req.URL = strings.TrimSpace(req.URL) |
|||
if req.URL == "" { |
|||
jsonError(w, "url is required", http.StatusBadRequest) |
|||
return |
|||
} |
|||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") { |
|||
jsonError(w, "url must start with http:// or https://", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
var code string |
|||
var err error |
|||
for attempt := 0; attempt < maxRetries; attempt++ { |
|||
code, err = generateCode() |
|||
if err != nil { |
|||
log.Printf("generate code error: %v", err) |
|||
jsonError(w, "failed to generate short code", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
if err = h.store.Create(code, req.URL); err == nil { |
|||
break // success
|
|||
} |
|||
log.Printf("collision on code %q (attempt %d): %v", code, attempt+1, err) |
|||
} |
|||
if err != nil { |
|||
log.Printf("store create error after %d retries: %v", maxRetries, err) |
|||
jsonError(w, "failed to create short link", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
shortURL := h.resolveBaseURL(r) + "/" + code |
|||
|
|||
resp := map[string]string{ |
|||
"code": code, |
|||
"short_url": shortURL, |
|||
"original_url": req.URL, |
|||
} |
|||
|
|||
w.Header().Set("Content-Type", "application/json") |
|||
json.NewEncoder(w).Encode(resp) |
|||
} |
|||
|
|||
// HandleRedirect handles GET /{code}, redirecting to the original URL.
|
|||
func (h *Handler) HandleRedirect(w http.ResponseWriter, r *http.Request) { |
|||
code := r.PathValue("code") |
|||
if code == "" { |
|||
jsonError(w, "missing short code", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
// Skip API-like paths that snuck through the mux
|
|||
if strings.HasPrefix(code, "api/") { |
|||
jsonError(w, "not found", http.StatusNotFound) |
|||
return |
|||
} |
|||
|
|||
url, err := h.store.FindByCode(code) |
|||
if err != nil { |
|||
log.Printf("store find error: %v", err) |
|||
jsonError(w, "internal error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
if url == nil { |
|||
jsonError(w, "short link not found", http.StatusNotFound) |
|||
return |
|||
} |
|||
|
|||
// Fire-and-forget visit counter
|
|||
go func() { |
|||
if err := h.store.IncrementVisit(url.Code); err != nil { |
|||
log.Printf("increment visit error: %v", err) |
|||
} |
|||
}() |
|||
|
|||
// 302 — browsers don't cache it, user can update the link later
|
|||
http.Redirect(w, r, url.OriginalURL, http.StatusFound) |
|||
} |
|||
|
|||
// resolveBaseURL returns the configured base URL or constructs one from the
|
|||
// incoming request's Host header.
|
|||
func (h *Handler) resolveBaseURL(r *http.Request) string { |
|||
if h.baseURL != "" { |
|||
return strings.TrimRight(h.baseURL, "/") |
|||
} |
|||
scheme := "http" |
|||
if r.TLS != nil { |
|||
scheme = "https" |
|||
} |
|||
return scheme + "://" + r.Host |
|||
} |
|||
@ -0,0 +1,239 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"path/filepath" |
|||
"strings" |
|||
"testing" |
|||
) |
|||
|
|||
func newTestHandler(t *testing.T) *Handler { |
|||
t.Helper() |
|||
store, err := NewStore(filepath.Join(t.TempDir(), "test.db")) |
|||
if err != nil { |
|||
t.Fatalf("NewStore() failed: %v", err) |
|||
} |
|||
t.Cleanup(func() { store.Close() }) |
|||
return NewHandler(store, "http://xieyaxin.top:8899", "test-token") |
|||
} |
|||
|
|||
func decodeBody(t *testing.T, body string) map[string]string { |
|||
t.Helper() |
|||
var resp map[string]string |
|||
if err := json.NewDecoder(strings.NewReader(body)).Decode(&resp); err != nil { |
|||
t.Fatalf("failed to decode response: %v", err) |
|||
} |
|||
return resp |
|||
} |
|||
|
|||
func TestHandleCreate_Valid(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
body := `{"url":"https://example.com"}` |
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body)) |
|||
r.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w, r) |
|||
|
|||
if w.Code != http.StatusOK { |
|||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) |
|||
} |
|||
|
|||
resp := decodeBody(t, w.Body.String()) |
|||
if len(resp["code"]) != 7 { |
|||
t.Fatalf("expected code length 7, got %d: %q", len(resp["code"]), resp["code"]) |
|||
} |
|||
if resp["short_url"] != "http://xieyaxin.top:8899/"+resp["code"] { |
|||
t.Fatalf("expected short_url %q, got %q", "http://xieyaxin.top:8899/"+resp["code"], resp["short_url"]) |
|||
} |
|||
if resp["original_url"] != "https://example.com" { |
|||
t.Fatalf("expected original_url %q, got %q", "https://example.com", resp["original_url"]) |
|||
} |
|||
} |
|||
|
|||
func TestHandleCreate_Consecutive(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
body1 := `{"url":"https://example.com/first"}` |
|||
w1 := httptest.NewRecorder() |
|||
r1 := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body1)) |
|||
r1.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w1, r1) |
|||
|
|||
body2 := `{"url":"https://example.com/second"}` |
|||
w2 := httptest.NewRecorder() |
|||
r2 := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body2)) |
|||
r2.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w2, r2) |
|||
|
|||
resp1 := decodeBody(t, w1.Body.String()) |
|||
resp2 := decodeBody(t, w2.Body.String()) |
|||
|
|||
if len(resp1["code"]) != 7 { |
|||
t.Fatalf("expected code length 7, got %d: %q", len(resp1["code"]), resp1["code"]) |
|||
} |
|||
if len(resp2["code"]) != 7 { |
|||
t.Fatalf("expected code length 7, got %d: %q", len(resp2["code"]), resp2["code"]) |
|||
} |
|||
if resp1["code"] == resp2["code"] { |
|||
t.Fatal("consecutive short links should have different codes") |
|||
} |
|||
} |
|||
|
|||
func TestHandleCreate_MissingURL(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{}`)) |
|||
r.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w, r) |
|||
|
|||
if w.Code != http.StatusBadRequest { |
|||
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String()) |
|||
} |
|||
|
|||
resp := decodeBody(t, w.Body.String()) |
|||
if resp["error"] == "" { |
|||
t.Fatal("expected error message in response") |
|||
} |
|||
} |
|||
|
|||
func TestHandleCreate_EmptyURL(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":""}`)) |
|||
r.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w, r) |
|||
|
|||
if w.Code != http.StatusBadRequest { |
|||
t.Fatalf("expected status 400, got %d", w.Code) |
|||
} |
|||
} |
|||
|
|||
func TestHandleCreate_InvalidScheme(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":"ftp://example.com"}`)) |
|||
r.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w, r) |
|||
|
|||
if w.Code != http.StatusBadRequest { |
|||
t.Fatalf("expected status 400 for unsupported scheme, got %d", w.Code) |
|||
} |
|||
} |
|||
|
|||
func TestHandleCreate_WrongMethod(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodGet, "/api/shorten", nil) |
|||
h.HandleCreate(w, r) |
|||
|
|||
// HandleCreate itself does not check method (mux handles routing)
|
|||
if w.Code != http.StatusBadRequest { |
|||
t.Fatalf("expected status 400 for GET request (no body), got %d", w.Code) |
|||
} |
|||
} |
|||
|
|||
func TestHandleCreate_InvalidBody(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`not json`)) |
|||
r.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w, r) |
|||
|
|||
if w.Code != http.StatusBadRequest { |
|||
t.Fatalf("expected status 400 for invalid JSON, got %d", w.Code) |
|||
} |
|||
} |
|||
|
|||
func TestHandleRedirect_Valid(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
// Create a short link first
|
|||
body := `{"url":"https://example.com"}` |
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body)) |
|||
r.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w, r) |
|||
|
|||
resp := decodeBody(t, w.Body.String()) |
|||
code := resp["code"] |
|||
|
|||
// Test redirect
|
|||
w = httptest.NewRecorder() |
|||
r = httptest.NewRequest(http.MethodGet, "/"+code, nil) |
|||
r.SetPathValue("code", code) |
|||
h.HandleRedirect(w, r) |
|||
|
|||
if w.Code != http.StatusFound { |
|||
t.Fatalf("expected status 302 for valid code, got %d", w.Code) |
|||
} |
|||
if loc := w.Header().Get("Location"); loc != "https://example.com" { |
|||
t.Fatalf("expected Location header %q, got %q", "https://example.com", loc) |
|||
} |
|||
} |
|||
|
|||
func TestHandleRedirect_NotFound(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodGet, "/nonexist", nil) |
|||
r.SetPathValue("code", "nonexist") |
|||
h.HandleRedirect(w, r) |
|||
|
|||
if w.Code != http.StatusNotFound { |
|||
t.Fatalf("expected status 404 for non-existent code, got %d", w.Code) |
|||
} |
|||
} |
|||
|
|||
func TestHandleRedirect_EmptyCode(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodGet, "/", nil) |
|||
r.SetPathValue("code", "") |
|||
h.HandleRedirect(w, r) |
|||
|
|||
if w.Code != http.StatusBadRequest { |
|||
t.Fatalf("expected status 400 for empty code, got %d", w.Code) |
|||
} |
|||
} |
|||
|
|||
func TestStore_IncrementVisit(t *testing.T) { |
|||
h := newTestHandler(t) |
|||
|
|||
// Create a short link
|
|||
w := httptest.NewRecorder() |
|||
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":"https://example.com"}`)) |
|||
r.Header.Set("Content-Type", "application/json") |
|||
h.HandleCreate(w, r) |
|||
|
|||
resp := decodeBody(t, w.Body.String()) |
|||
code := resp["code"] |
|||
|
|||
rec, err := h.store.FindByCode(code) |
|||
if err != nil || rec == nil { |
|||
t.Fatal("record not found after create") |
|||
} |
|||
|
|||
if err := h.store.IncrementVisit(code); err != nil { |
|||
t.Fatalf("IncrementVisit() failed: %v", err) |
|||
} |
|||
if err := h.store.IncrementVisit(code); err != nil { |
|||
t.Fatalf("IncrementVisit() failed: %v", err) |
|||
} |
|||
|
|||
rec, err = h.store.FindByCode(code) |
|||
if err != nil || rec == nil { |
|||
t.Fatal("record not found after increment") |
|||
} |
|||
if rec.VisitCount != 2 { |
|||
t.Fatalf("expected VisitCount 2, got %d", rec.VisitCount) |
|||
} |
|||
} |
|||
@ -0,0 +1,70 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"context" |
|||
"flag" |
|||
"log" |
|||
"net/http" |
|||
"os" |
|||
"os/signal" |
|||
"strings" |
|||
"syscall" |
|||
"time" |
|||
) |
|||
|
|||
func main() { |
|||
port := flag.String("port", "8080", "listen port") |
|||
dbPath := flag.String("db", "shortlink.db", "sqlite database path") |
|||
baseURL := flag.String("base-url", "", "base url for short links (e.g. https://xieyaxin.top:8899)") |
|||
flag.Parse() |
|||
|
|||
store, err := NewStore(*dbPath) |
|||
if err != nil { |
|||
log.Fatalf("failed to open database: %v", err) |
|||
} |
|||
defer store.Close() |
|||
|
|||
adminToken := strings.TrimSpace(os.Getenv("ADMIN_TOKEN")) |
|||
h := NewHandler(store, *baseURL, adminToken) |
|||
|
|||
mux := http.NewServeMux() |
|||
mux.HandleFunc("POST /api/shorten", h.HandleCreate) |
|||
mux.HandleFunc("GET /{code}", h.HandleRedirect) |
|||
|
|||
// Admin routes
|
|||
mux.HandleFunc("GET /api/admin/links", h.requireAdmin(h.HandleListLinks)) |
|||
mux.HandleFunc("PUT /api/admin/links/{code}", h.requireAdmin(h.HandleUpdateLink)) |
|||
mux.HandleFunc("DELETE /api/admin/links/{code}", h.requireAdmin(h.HandleDeleteLink)) |
|||
mux.HandleFunc("GET /api/admin/export", h.requireAdmin(h.HandleExport)) |
|||
|
|||
srv := &http.Server{ |
|||
Addr: ":" + *port, |
|||
Handler: mux, |
|||
ReadTimeout: 10 * time.Second, |
|||
WriteTimeout: 10 * time.Second, |
|||
IdleTimeout: 60 * time.Second, |
|||
} |
|||
|
|||
// Graceful shutdown — wait for SIGINT/SIGTERM, then drain connections
|
|||
quit := make(chan os.Signal, 1) |
|||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) |
|||
|
|||
go func() { |
|||
log.Printf("listening on :%s", *port) |
|||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
|||
log.Fatalf("server error: %v", err) |
|||
} |
|||
}() |
|||
|
|||
<-quit |
|||
log.Println("shutting down...") |
|||
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
|||
defer cancel() |
|||
|
|||
if err := srv.Shutdown(ctx); err != nil { |
|||
log.Fatalf("forced shutdown: %v", err) |
|||
} |
|||
|
|||
log.Println("bye") |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
[ |
|||
{ |
|||
"code": "BRmGN8s", |
|||
"original_url": "https://platform.deepseek.com/usage", |
|||
"visit_count": 2, |
|||
"created_at": "2026-05-06T22:50:16.706444479+08:00" |
|||
}, |
|||
{ |
|||
"code": "YLHlgN0", |
|||
"original_url": "https://platform.deepseek.com/usage", |
|||
"visit_count": 2, |
|||
"created_at": "2026-05-06T23:09:34.035144208+08:00" |
|||
}, |
|||
{ |
|||
"code": "DSqH5EQ", |
|||
"original_url": "https://platform.deepseek.com/usage", |
|||
"visit_count": 4, |
|||
"created_at": "2026-05-06T23:12:26.936674827+08:00" |
|||
} |
|||
] |
|||
@ -0,0 +1,149 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"os" |
|||
"sort" |
|||
"sync" |
|||
"sync/atomic" |
|||
"time" |
|||
) |
|||
|
|||
// URLRecord represents a stored link.
|
|||
type URLRecord struct { |
|||
Code string `json:"code"` |
|||
OriginalURL string `json:"original_url"` |
|||
VisitCount int64 `json:"visit_count"` |
|||
CreatedAt time.Time `json:"created_at"` |
|||
} |
|||
|
|||
// Store is a simple JSON-file-backed key-value store.
|
|||
type Store struct { |
|||
mu sync.RWMutex |
|||
file string |
|||
links map[string]*URLRecord // code -> record
|
|||
} |
|||
|
|||
// NewStore opens (or creates) the JSON-backed store.
|
|||
func NewStore(dbPath string) (*Store, error) { |
|||
s := &Store{ |
|||
file: dbPath, |
|||
links: make(map[string]*URLRecord), |
|||
} |
|||
|
|||
data, err := os.ReadFile(dbPath) |
|||
if err == nil { |
|||
var records []*URLRecord |
|||
if err := json.Unmarshal(data, &records); err == nil { |
|||
for _, rec := range records { |
|||
s.links[rec.Code] = rec |
|||
} |
|||
} |
|||
} else if !os.IsNotExist(err) { |
|||
return nil, fmt.Errorf("open store: %w", err) |
|||
} |
|||
|
|||
return s, nil |
|||
} |
|||
|
|||
// Create inserts a new URL with the given code. Returns an error if the code
|
|||
// already exists.
|
|||
func (s *Store) Create(code, originalURL string) error { |
|||
s.mu.Lock() |
|||
defer s.mu.Unlock() |
|||
|
|||
if _, exists := s.links[code]; exists { |
|||
return fmt.Errorf("code %q already exists", code) |
|||
} |
|||
|
|||
s.links[code] = &URLRecord{ |
|||
Code: code, |
|||
OriginalURL: originalURL, |
|||
CreatedAt: time.Now(), |
|||
} |
|||
return s.persist() |
|||
} |
|||
|
|||
// FindByCode looks up a URL record by its short code.
|
|||
func (s *Store) FindByCode(code string) (*URLRecord, error) { |
|||
s.mu.RLock() |
|||
defer s.mu.RUnlock() |
|||
|
|||
rec, ok := s.links[code] |
|||
if !ok { |
|||
return nil, nil |
|||
} |
|||
return rec, nil |
|||
} |
|||
|
|||
// IncrementVisit atomically bumps the visit counter for the given code.
|
|||
func (s *Store) IncrementVisit(code string) error { |
|||
s.mu.Lock() |
|||
defer s.mu.Unlock() |
|||
|
|||
rec, ok := s.links[code] |
|||
if !ok { |
|||
return fmt.Errorf("code %q not found", code) |
|||
} |
|||
|
|||
atomic.AddInt64(&rec.VisitCount, 1) |
|||
return s.persist() |
|||
} |
|||
|
|||
// Close is a no-op for the JSON store.
|
|||
func (s *Store) Close() error { |
|||
return nil |
|||
} |
|||
|
|||
// ListAll returns all records sorted by CreatedAt descending (newest first).
|
|||
func (s *Store) ListAll() ([]*URLRecord, error) { |
|||
s.mu.RLock() |
|||
defer s.mu.RUnlock() |
|||
|
|||
records := make([]*URLRecord, 0, len(s.links)) |
|||
for _, rec := range s.links { |
|||
records = append(records, rec) |
|||
} |
|||
sort.Slice(records, func(i, j int) bool { |
|||
return records[i].CreatedAt.After(records[j].CreatedAt) |
|||
}) |
|||
return records, nil |
|||
} |
|||
|
|||
// Update changes the OriginalURL of the record identified by code.
|
|||
func (s *Store) Update(code, newURL string) error { |
|||
s.mu.Lock() |
|||
defer s.mu.Unlock() |
|||
|
|||
rec, ok := s.links[code] |
|||
if !ok { |
|||
return fmt.Errorf("code %q not found", code) |
|||
} |
|||
rec.OriginalURL = newURL |
|||
return s.persist() |
|||
} |
|||
|
|||
// Delete removes the record identified by code.
|
|||
func (s *Store) Delete(code string) error { |
|||
s.mu.Lock() |
|||
defer s.mu.Unlock() |
|||
|
|||
if _, ok := s.links[code]; !ok { |
|||
return fmt.Errorf("code %q not found", code) |
|||
} |
|||
delete(s.links, code) |
|||
return s.persist() |
|||
} |
|||
|
|||
func (s *Store) persist() error { |
|||
records := make([]*URLRecord, 0, len(s.links)) |
|||
for _, rec := range s.links { |
|||
records = append(records, rec) |
|||
} |
|||
data, err := json.MarshalIndent(records, "", " ") |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return os.WriteFile(s.file, data, 0644) |
|||
} |
|||
@ -0,0 +1,121 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"os" |
|||
"path/filepath" |
|||
"testing" |
|||
) |
|||
|
|||
func newTestStore(t *testing.T) *Store { |
|||
t.Helper() |
|||
s, err := NewStore(filepath.Join(t.TempDir(), "test.db")) |
|||
if err != nil { |
|||
t.Fatalf("NewStore() failed: %v", err) |
|||
} |
|||
t.Cleanup(func() { s.Close() }) |
|||
return s |
|||
} |
|||
|
|||
func TestStore_CreateAndFindByCode(t *testing.T) { |
|||
s := newTestStore(t) |
|||
|
|||
originalURL := "https://example.com/create-and-find" |
|||
|
|||
if err := s.Create("testabc", originalURL); err != nil { |
|||
t.Fatalf("Create() failed: %v", err) |
|||
} |
|||
|
|||
rec, err := s.FindByCode("testabc") |
|||
if err != nil { |
|||
t.Fatalf("FindByCode() failed: %v", err) |
|||
} |
|||
if rec == nil { |
|||
t.Fatal("FindByCode() returned nil, expected a record") |
|||
} |
|||
if rec.OriginalURL != originalURL { |
|||
t.Fatalf("expected OriginalURL %q, got %q", originalURL, rec.OriginalURL) |
|||
} |
|||
if rec.Code != "testabc" { |
|||
t.Fatalf("expected Code %q, got %q", "testabc", rec.Code) |
|||
} |
|||
} |
|||
|
|||
func TestStore_FindByCode_NonExistent(t *testing.T) { |
|||
s := newTestStore(t) |
|||
|
|||
rec, err := s.FindByCode("nonexist") |
|||
if err != nil { |
|||
t.Fatalf("FindByCode() for non-existent code failed: %v", err) |
|||
} |
|||
if rec != nil { |
|||
t.Fatal("expected nil for non-existent code, got a record") |
|||
} |
|||
} |
|||
|
|||
func TestStore_Create_Duplicate(t *testing.T) { |
|||
s := newTestStore(t) |
|||
|
|||
url := "https://example.com/dup" |
|||
if err := s.Create("dupcode", url); err != nil { |
|||
t.Fatalf("first Create() failed: %v", err) |
|||
} |
|||
if err := s.Create("dupcode", url); err == nil { |
|||
t.Fatal("second Create() with same code should fail") |
|||
} |
|||
} |
|||
|
|||
func TestStore_Persistence(t *testing.T) { |
|||
tmpFile, err := os.CreateTemp("", "shortlink-*.json") |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
tmpPath := tmpFile.Name() |
|||
tmpFile.Close() |
|||
defer os.Remove(tmpPath) |
|||
|
|||
s1, err := NewStore(tmpPath) |
|||
if err != nil { |
|||
t.Fatalf("NewStore() failed: %v", err) |
|||
} |
|||
s1.Create("persist", "https://example.com/persist") |
|||
s1.Close() |
|||
|
|||
s2, err := NewStore(tmpPath) |
|||
if err != nil { |
|||
t.Fatalf("reopen store failed: %v", err) |
|||
} |
|||
defer s2.Close() |
|||
|
|||
rec, err := s2.FindByCode("persist") |
|||
if err != nil { |
|||
t.Fatalf("FindByCode() after reopen failed: %v", err) |
|||
} |
|||
if rec == nil { |
|||
t.Fatal("expected record after reopening store") |
|||
} |
|||
if rec.OriginalURL != "https://example.com/persist" { |
|||
t.Fatalf("unexpected OriginalURL: %q", rec.OriginalURL) |
|||
} |
|||
} |
|||
|
|||
func TestGenerateCode(t *testing.T) { |
|||
seen := make(map[string]bool) |
|||
for i := 0; i < 100; i++ { |
|||
code, err := generateCode() |
|||
if err != nil { |
|||
t.Fatalf("generateCode() failed: %v", err) |
|||
} |
|||
if len(code) != codeLength { |
|||
t.Fatalf("expected code length %d, got %d: %q", codeLength, len(code), code) |
|||
} |
|||
for _, c := range code { |
|||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { |
|||
t.Fatalf("invalid character %c in code %q", c, code) |
|||
} |
|||
} |
|||
if seen[code] { |
|||
t.Fatal("unexpected collision: duplicate code generated") |
|||
} |
|||
seen[code] = true |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="zh-CN"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>短链接工具</title> |
|||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔗</text></svg>" /> |
|||
</head> |
|||
<body> |
|||
<div id="app"></div> |
|||
<script type="module" src="/src/main.js"></script> |
|||
</body> |
|||
</html> |
|||
File diff suppressed because it is too large
@ -0,0 +1,16 @@ |
|||
{ |
|||
"name": "short-link-frontend", |
|||
"private": true, |
|||
"version": "0.1.0", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "vite", |
|||
"build": "vite build", |
|||
"preview": "vite preview" |
|||
}, |
|||
"devDependencies": { |
|||
"@sveltejs/vite-plugin-svelte": "^3.1.0", |
|||
"svelte": "^4.2.18", |
|||
"vite": "^5.4.0" |
|||
} |
|||
} |
|||
@ -0,0 +1,576 @@ |
|||
<script> |
|||
import { onMount } from 'svelte'; |
|||
import { getToken, setToken, clearToken, fetchLinks, updateLink, deleteLink, exportData, downloadBlob } from './api.js'; |
|||
|
|||
let tokenInput = ''; |
|||
let authenticated = false; |
|||
let links = []; |
|||
let loading = false; |
|||
let error = ''; |
|||
|
|||
// Edit state |
|||
let editing = null; // code being edited, or null |
|||
let editUrl = ''; |
|||
let editSaving = false; |
|||
|
|||
// Delete state |
|||
let deleteConfirm = null; // code awaiting confirmation, or null |
|||
|
|||
// Export state |
|||
let exporting = false; |
|||
|
|||
onMount(async () => { |
|||
if (getToken()) { |
|||
await loadLinks(); |
|||
} |
|||
}); |
|||
|
|||
async function handleLogin() { |
|||
const t = tokenInput.trim(); |
|||
if (!t) return; |
|||
setToken(t); |
|||
await loadLinks(); |
|||
} |
|||
|
|||
async function handleLogout() { |
|||
clearToken(); |
|||
authenticated = false; |
|||
links = []; |
|||
tokenInput = ''; |
|||
} |
|||
|
|||
async function loadLinks() { |
|||
loading = true; |
|||
error = ''; |
|||
try { |
|||
links = await fetchLinks(); |
|||
authenticated = true; |
|||
} catch (e) { |
|||
error = e.message; |
|||
authenticated = false; |
|||
} finally { |
|||
loading = false; |
|||
} |
|||
} |
|||
|
|||
function startEdit(rec) { |
|||
editing = rec.code; |
|||
editUrl = rec.original_url; |
|||
editSaving = false; |
|||
error = ''; |
|||
} |
|||
|
|||
function cancelEdit() { |
|||
editing = null; |
|||
editUrl = ''; |
|||
} |
|||
|
|||
async function saveEdit(code) { |
|||
const url = editUrl.trim(); |
|||
if (!url) return; |
|||
editSaving = true; |
|||
error = ''; |
|||
try { |
|||
const updated = await updateLink(code, url); |
|||
links = links.map(l => l.code === code ? updated : l); |
|||
editing = null; |
|||
editUrl = ''; |
|||
} catch (e) { |
|||
error = e.message; |
|||
} finally { |
|||
editSaving = false; |
|||
} |
|||
} |
|||
|
|||
function confirmDelete(code) { |
|||
deleteConfirm = code; |
|||
error = ''; |
|||
} |
|||
|
|||
function cancelDelete() { |
|||
deleteConfirm = null; |
|||
} |
|||
|
|||
async function executeDelete(code) { |
|||
error = ''; |
|||
try { |
|||
await deleteLink(code); |
|||
links = links.filter(l => l.code !== code); |
|||
deleteConfirm = null; |
|||
} catch (e) { |
|||
error = e.message; |
|||
deleteConfirm = null; |
|||
} |
|||
} |
|||
|
|||
async function handleExport(format) { |
|||
exporting = true; |
|||
error = ''; |
|||
try { |
|||
const blob = await exportData(format); |
|||
const ext = format === 'csv' ? 'csv' : 'json'; |
|||
downloadBlob(blob, `shortlinks.${ext}`); |
|||
} catch (e) { |
|||
error = e.message; |
|||
} finally { |
|||
exporting = false; |
|||
} |
|||
} |
|||
|
|||
function formatTime(ts) { |
|||
const d = new Date(ts); |
|||
const pad = n => String(n).padStart(2, '0'); |
|||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; |
|||
} |
|||
</script> |
|||
|
|||
<div class="admin-container"> |
|||
<header class="admin-header"> |
|||
<div class="header-left"> |
|||
<h1>管理后台</h1> |
|||
<button class="back-link" on:click={() => window.location.hash = ''}>← 返回</button> |
|||
</div> |
|||
{#if authenticated} |
|||
<button class="btn-logout" on:click={handleLogout}>退出</button> |
|||
{/if} |
|||
</header> |
|||
|
|||
{#if !authenticated} |
|||
<div class="card token-card"> |
|||
<p class="token-prompt">请输入管理 Token</p> |
|||
<div class="token-row"> |
|||
<input |
|||
type="password" |
|||
class="token-input" |
|||
placeholder="输入 ADMIN_TOKEN..." |
|||
bind:value={tokenInput} |
|||
on:keydown={(e) => e.key === 'Enter' && handleLogin()} |
|||
/> |
|||
<button class="btn" on:click={handleLogin} disabled={!tokenInput.trim()}> |
|||
进入管理 |
|||
</button> |
|||
</div> |
|||
{#if error} |
|||
<div class="error">{error}</div> |
|||
{/if} |
|||
</div> |
|||
{:else} |
|||
{#if loading} |
|||
<p class="loading-text">加载中...</p> |
|||
{:else} |
|||
<div class="toolbar"> |
|||
<span class="count">共 {links.length} 条记录</span> |
|||
<div class="export-btns"> |
|||
<button class="btn-small" on:click={() => handleExport('json')} disabled={exporting}> |
|||
导出 JSON |
|||
</button> |
|||
<button class="btn-small" on:click={() => handleExport('csv')} disabled={exporting}> |
|||
导出 CSV |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
{#if error} |
|||
<div class="error">{error}</div> |
|||
{/if} |
|||
|
|||
{#if links.length === 0} |
|||
<div class="card"> |
|||
<p class="empty-text">暂无数据</p> |
|||
</div> |
|||
{:else} |
|||
<div class="table-wrap"> |
|||
<table> |
|||
<thead> |
|||
<tr> |
|||
<th>短码</th> |
|||
<th>原始链接</th> |
|||
<th class="col-num">访问</th> |
|||
<th class="col-time">创建时间</th> |
|||
<th class="col-action">操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{#each links as rec (rec.code)} |
|||
<tr> |
|||
<td class="col-code"> |
|||
<a href="/{rec.code}">{rec.code}</a> |
|||
</td> |
|||
<td class="col-url"> |
|||
{#if editing === rec.code} |
|||
<div class="edit-row"> |
|||
<input |
|||
type="text" |
|||
class="edit-input" |
|||
bind:value={editUrl} |
|||
on:keydown={(e) => e.key === 'Enter' && saveEdit(rec.code)} |
|||
disabled={editSaving} |
|||
/> |
|||
<button |
|||
class="btn-small btn-save" |
|||
on:click={() => saveEdit(rec.code)} |
|||
disabled={editSaving || !editUrl.trim()} |
|||
>保存</button> |
|||
<button class="btn-small" on:click={cancelEdit} disabled={editSaving}>取消</button> |
|||
</div> |
|||
{:else} |
|||
<span class="url-text" title={rec.original_url}>{rec.original_url}</span> |
|||
{/if} |
|||
</td> |
|||
<td class="col-num">{rec.visit_count}</td> |
|||
<td class="col-time">{formatTime(rec.created_at)}</td> |
|||
<td class="col-action"> |
|||
{#if editing === rec.code} |
|||
<!-- buttons shown inline above --> |
|||
{:else if deleteConfirm === rec.code} |
|||
<span class="confirm-text">确认删除?</span> |
|||
<button class="btn-small btn-yes" on:click={() => executeDelete(rec.code)}>是</button> |
|||
<button class="btn-small" on:click={cancelDelete}>否</button> |
|||
{:else} |
|||
<button class="btn-small" on:click={() => startEdit(rec)}>编辑</button> |
|||
<button class="btn-small btn-del" on:click={() => confirmDelete(rec.code)}>删除</button> |
|||
{/if} |
|||
</td> |
|||
</tr> |
|||
{/each} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
{/if} |
|||
{/if} |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
.admin-container { |
|||
max-width: 960px; |
|||
margin: 0 auto; |
|||
padding: 40px 16px; |
|||
} |
|||
|
|||
.admin-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
margin-bottom: 24px; |
|||
} |
|||
|
|||
.header-left { |
|||
display: flex; |
|||
align-items: baseline; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.admin-header h1 { |
|||
font-size: 24px; |
|||
font-weight: 700; |
|||
} |
|||
|
|||
.back-link { |
|||
font-size: 14px; |
|||
color: #4a6cf7; |
|||
background: none; |
|||
border: none; |
|||
cursor: pointer; |
|||
padding: 0; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.back-link:hover { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.btn-logout { |
|||
padding: 8px 16px; |
|||
background: #fff; |
|||
color: #d32f2f; |
|||
border: 1px solid #d32f2f; |
|||
border-radius: 6px; |
|||
font-size: 13px; |
|||
cursor: pointer; |
|||
transition: background 0.2s; |
|||
} |
|||
|
|||
.btn-logout:hover { |
|||
background: #fff0f0; |
|||
} |
|||
|
|||
.card { |
|||
background: #fff; |
|||
border-radius: 12px; |
|||
padding: 24px; |
|||
box-shadow: 0 2px 8px rgba(0,0,0,0.06); |
|||
} |
|||
|
|||
.token-card { |
|||
max-width: 420px; |
|||
margin: 40px auto; |
|||
} |
|||
|
|||
.token-prompt { |
|||
font-size: 15px; |
|||
color: #444; |
|||
margin-bottom: 12px; |
|||
text-align: center; |
|||
} |
|||
|
|||
.token-row { |
|||
display: flex; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.token-input { |
|||
flex: 1; |
|||
padding: 10px 14px; |
|||
border: 2px solid #e0e0e0; |
|||
border-radius: 8px; |
|||
font-size: 15px; |
|||
outline: none; |
|||
transition: border-color 0.2s; |
|||
} |
|||
|
|||
.token-input:focus { |
|||
border-color: #4a6cf7; |
|||
} |
|||
|
|||
.btn { |
|||
padding: 10px 20px; |
|||
background: #4a6cf7; |
|||
color: #fff; |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-size: 15px; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
white-space: nowrap; |
|||
transition: background 0.2s; |
|||
} |
|||
|
|||
.btn:hover:not(:disabled) { |
|||
background: #3b5de7; |
|||
} |
|||
|
|||
.btn:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.btn-small { |
|||
padding: 5px 12px; |
|||
background: #f0f2f5; |
|||
color: #444; |
|||
border: none; |
|||
border-radius: 6px; |
|||
font-size: 13px; |
|||
cursor: pointer; |
|||
white-space: nowrap; |
|||
transition: background 0.2s; |
|||
} |
|||
|
|||
.btn-small:hover:not(:disabled) { |
|||
background: #e0e0e0; |
|||
} |
|||
|
|||
.btn-small:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.btn-del { |
|||
color: #d32f2f; |
|||
} |
|||
|
|||
.btn-del:hover:not(:disabled) { |
|||
background: #fff0f0; |
|||
} |
|||
|
|||
.btn-save { |
|||
background: #4a6cf7; |
|||
color: #fff; |
|||
} |
|||
|
|||
.btn-save:hover:not(:disabled) { |
|||
background: #3b5de7; |
|||
} |
|||
|
|||
.btn-yes { |
|||
background: #d32f2f; |
|||
color: #fff; |
|||
} |
|||
|
|||
.btn-yes:hover:not(:disabled) { |
|||
background: #b71c1c; |
|||
} |
|||
|
|||
.error { |
|||
margin-top: 12px; |
|||
padding: 10px 14px; |
|||
background: #fff0f0; |
|||
color: #d32f2f; |
|||
border-radius: 8px; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.loading-text { |
|||
text-align: center; |
|||
color: #888; |
|||
padding: 40px; |
|||
} |
|||
|
|||
.toolbar { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.count { |
|||
font-size: 14px; |
|||
color: #666; |
|||
} |
|||
|
|||
.export-btns { |
|||
display: flex; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.empty-text { |
|||
text-align: center; |
|||
color: #999; |
|||
padding: 32px; |
|||
} |
|||
|
|||
.table-wrap { |
|||
background: #fff; |
|||
border-radius: 12px; |
|||
box-shadow: 0 2px 8px rgba(0,0,0,0.06); |
|||
overflow: hidden; |
|||
} |
|||
|
|||
table { |
|||
width: 100%; |
|||
border-collapse: collapse; |
|||
} |
|||
|
|||
thead { |
|||
background: #f9fafb; |
|||
} |
|||
|
|||
th { |
|||
padding: 12px 16px; |
|||
text-align: left; |
|||
font-size: 13px; |
|||
font-weight: 600; |
|||
color: #666; |
|||
border-bottom: 1px solid #eee; |
|||
} |
|||
|
|||
td { |
|||
padding: 12px 16px; |
|||
font-size: 14px; |
|||
border-bottom: 1px solid #f5f5f5; |
|||
vertical-align: middle; |
|||
} |
|||
|
|||
tr:last-child td { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.col-code { |
|||
font-weight: 600; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.col-code a { |
|||
color: #4a6cf7; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.col-code a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.col-url { |
|||
max-width: 320px; |
|||
} |
|||
|
|||
.url-text { |
|||
display: block; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
max-width: 320px; |
|||
} |
|||
|
|||
.col-num { |
|||
width: 60px; |
|||
text-align: right; |
|||
} |
|||
|
|||
.col-time { |
|||
width: 140px; |
|||
white-space: nowrap; |
|||
font-size: 13px; |
|||
color: #888; |
|||
} |
|||
|
|||
.col-action { |
|||
width: 140px; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.col-action .btn-small { |
|||
margin-left: 4px; |
|||
} |
|||
|
|||
.col-action .btn-small:first-child { |
|||
margin-left: 0; |
|||
} |
|||
|
|||
.edit-row { |
|||
display: flex; |
|||
gap: 4px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.edit-input { |
|||
flex: 1; |
|||
padding: 6px 10px; |
|||
border: 2px solid #4a6cf7; |
|||
border-radius: 6px; |
|||
font-size: 14px; |
|||
outline: none; |
|||
min-width: 200px; |
|||
} |
|||
|
|||
.confirm-text { |
|||
font-size: 13px; |
|||
color: #d32f2f; |
|||
margin-right: 4px; |
|||
} |
|||
|
|||
@media (max-width: 768px) { |
|||
.admin-container { |
|||
padding: 20px 8px; |
|||
} |
|||
|
|||
.table-wrap { |
|||
overflow-x: auto; |
|||
} |
|||
|
|||
table { |
|||
min-width: 700px; |
|||
} |
|||
|
|||
.header-left { |
|||
flex-direction: column; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.toolbar { |
|||
flex-direction: column; |
|||
gap: 8px; |
|||
align-items: flex-start; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,301 @@ |
|||
<script> |
|||
import AdminPanel from './AdminPanel.svelte'; |
|||
|
|||
let url = ''; |
|||
let loading = false; |
|||
let result = null; |
|||
let error = ''; |
|||
let copied = false; |
|||
|
|||
let isAdmin = false; |
|||
|
|||
function syncHash() { |
|||
isAdmin = window.location.hash === '#admin'; |
|||
} |
|||
syncHash(); |
|||
window.addEventListener('hashchange', syncHash); |
|||
|
|||
async function shorten() { |
|||
const trimmed = url.trim(); |
|||
if (!trimmed) return; |
|||
|
|||
loading = true; |
|||
error = ''; |
|||
result = null; |
|||
|
|||
try { |
|||
const res = await fetch('/api/shorten', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ url: trimmed }) |
|||
}); |
|||
|
|||
if (!res.ok) { |
|||
const body = await res.json().catch(() => ({})); |
|||
throw new Error(body.error || `请求失败 (${res.status})`); |
|||
} |
|||
|
|||
const data = await res.json(); |
|||
result = data; |
|||
} catch (e) { |
|||
error = e.message; |
|||
} finally { |
|||
loading = false; |
|||
} |
|||
} |
|||
|
|||
function handleKeydown(e) { |
|||
if (e.key === 'Enter') shorten(); |
|||
} |
|||
|
|||
async function copy(text) { |
|||
try { |
|||
await navigator.clipboard.writeText(text); |
|||
copied = true; |
|||
setTimeout(() => { copied = false; }, 2000); |
|||
} catch { |
|||
const ta = document.createElement('textarea'); |
|||
ta.value = text; |
|||
ta.style.position = 'fixed'; |
|||
ta.style.opacity = '0'; |
|||
document.body.appendChild(ta); |
|||
ta.select(); |
|||
document.execCommand('copy'); |
|||
document.body.removeChild(ta); |
|||
copied = true; |
|||
setTimeout(() => { copied = false; }, 2000); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
{#if isAdmin} |
|||
<AdminPanel /> |
|||
{:else} |
|||
<div class="container"> |
|||
<header class="header"> |
|||
<h1>短链接</h1> |
|||
<p class="subtitle">粘贴长链接,一键变短</p> |
|||
<a href="#admin" class="admin-link">管理</a> |
|||
</header> |
|||
|
|||
<div class="card"> |
|||
<div class="input-row"> |
|||
<input |
|||
type="text" |
|||
class="url-input" |
|||
placeholder="粘贴长链接,比如 https://example.com/very-long-url..." |
|||
bind:value={url} |
|||
on:keydown={handleKeydown} |
|||
disabled={loading} |
|||
/> |
|||
<button |
|||
class="btn" |
|||
on:click={shorten} |
|||
disabled={loading || !url.trim()} |
|||
> |
|||
{loading ? '生成中...' : '缩短'} |
|||
</button> |
|||
</div> |
|||
|
|||
{#if error} |
|||
<div class="error">{error}</div> |
|||
{/if} |
|||
|
|||
{#if result} |
|||
<div class="result"> |
|||
<div class="result-row"> |
|||
<input |
|||
type="text" |
|||
class="result-input" |
|||
value={result.short_url || `${location.origin}/${result.code}`} |
|||
readonly |
|||
/> |
|||
<button class="btn btn-copy" on:click={() => copy(result.short_url || `${location.origin}/${result.code}`)}> |
|||
{copied ? '已复制' : '复制'} |
|||
</button> |
|||
</div> |
|||
<div class="result-meta"> |
|||
<span class="meta-item">原始链接:{result.original_url}</span> |
|||
</div> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
|
|||
</div> |
|||
{/if} |
|||
|
|||
<style> |
|||
:global(*) { |
|||
margin: 0; |
|||
padding: 0; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
:global(body) { |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|||
background: #f0f2f5; |
|||
color: #1a1a2e; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
.container { |
|||
max-width: 640px; |
|||
margin: 0 auto; |
|||
padding: 60px 16px 40px; |
|||
} |
|||
|
|||
.header { |
|||
text-align: center; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.header h1 { |
|||
font-size: 28px; |
|||
font-weight: 700; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.subtitle { |
|||
color: #666; |
|||
font-size: 15px; |
|||
} |
|||
|
|||
.admin-link { |
|||
display: inline-block; |
|||
margin-top: 8px; |
|||
font-size: 13px; |
|||
color: #999; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.admin-link:hover { |
|||
color: #4a6cf7; |
|||
} |
|||
|
|||
.card { |
|||
background: #fff; |
|||
border-radius: 12px; |
|||
padding: 24px; |
|||
box-shadow: 0 2px 8px rgba(0,0,0,0.06); |
|||
} |
|||
|
|||
.input-row { |
|||
display: flex; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.url-input { |
|||
flex: 1; |
|||
padding: 12px 16px; |
|||
border: 2px solid #e0e0e0; |
|||
border-radius: 8px; |
|||
font-size: 15px; |
|||
outline: none; |
|||
transition: border-color 0.2s; |
|||
} |
|||
|
|||
.url-input:focus { |
|||
border-color: #4a6cf7; |
|||
} |
|||
|
|||
.url-input:disabled { |
|||
background: #f5f5f5; |
|||
} |
|||
|
|||
.btn { |
|||
padding: 12px 24px; |
|||
background: #4a6cf7; |
|||
color: #fff; |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-size: 15px; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
white-space: nowrap; |
|||
transition: background 0.2s; |
|||
} |
|||
|
|||
.btn:hover:not(:disabled) { |
|||
background: #3b5de7; |
|||
} |
|||
|
|||
.btn:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.error { |
|||
margin-top: 12px; |
|||
padding: 10px 14px; |
|||
background: #fff0f0; |
|||
color: #d32f2f; |
|||
border-radius: 8px; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.result { |
|||
margin-top: 16px; |
|||
padding-top: 16px; |
|||
border-top: 1px solid #f0f0f0; |
|||
} |
|||
|
|||
.result-row { |
|||
display: flex; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.result-input { |
|||
flex: 1; |
|||
padding: 12px 16px; |
|||
border: 2px solid #e8f5e9; |
|||
border-radius: 8px; |
|||
font-size: 15px; |
|||
background: #f1f8f2; |
|||
color: #2e7d32; |
|||
outline: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.btn-copy { |
|||
background: #2e7d32; |
|||
} |
|||
|
|||
.btn-copy:hover:not(:disabled) { |
|||
background: #1b5e20; |
|||
} |
|||
|
|||
.result-meta { |
|||
margin-top: 8px; |
|||
font-size: 13px; |
|||
color: #999; |
|||
} |
|||
|
|||
.meta-item { |
|||
display: block; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
@media (max-width: 480px) { |
|||
.container { |
|||
padding: 32px 12px 24px; |
|||
} |
|||
|
|||
.header h1 { |
|||
font-size: 24px; |
|||
} |
|||
|
|||
.input-row { |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.btn { |
|||
width: 100%; |
|||
} |
|||
|
|||
.result-row { |
|||
flex-direction: column; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,72 @@ |
|||
const TOKEN_KEY = 'admin_token'; |
|||
|
|||
export function getToken() { |
|||
return sessionStorage.getItem(TOKEN_KEY) || ''; |
|||
} |
|||
|
|||
export function setToken(t) { |
|||
sessionStorage.setItem(TOKEN_KEY, t); |
|||
} |
|||
|
|||
export function clearToken() { |
|||
sessionStorage.removeItem(TOKEN_KEY); |
|||
} |
|||
|
|||
async function adminFetch(path, options = {}) { |
|||
const res = await fetch(path, { |
|||
...options, |
|||
headers: { |
|||
...options.headers, |
|||
'Authorization': `Bearer ${getToken()}`, |
|||
}, |
|||
}); |
|||
if (res.status === 401) { |
|||
clearToken(); |
|||
throw new Error('认证失败,请检查 Token'); |
|||
} |
|||
return res; |
|||
} |
|||
|
|||
export async function fetchLinks() { |
|||
const res = await adminFetch('/api/admin/links'); |
|||
if (!res.ok) throw new Error('加载失败'); |
|||
return res.json(); |
|||
} |
|||
|
|||
export async function updateLink(code, url) { |
|||
const res = await adminFetch(`/api/admin/links/${code}`, { |
|||
method: 'PUT', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ url }), |
|||
}); |
|||
if (!res.ok) { |
|||
const body = await res.json().catch(() => ({})); |
|||
throw new Error(body.error || '更新失败'); |
|||
} |
|||
return res.json(); |
|||
} |
|||
|
|||
export async function deleteLink(code) { |
|||
const res = await adminFetch(`/api/admin/links/${code}`, { |
|||
method: 'DELETE', |
|||
}); |
|||
if (!res.ok) throw new Error('删除失败'); |
|||
return res.json(); |
|||
} |
|||
|
|||
export async function exportData(format = 'json') { |
|||
const res = await adminFetch(`/api/admin/export?format=${format}`); |
|||
if (!res.ok) throw new Error('导出失败'); |
|||
return res.blob(); |
|||
} |
|||
|
|||
export function downloadBlob(blob, filename) { |
|||
const url = URL.createObjectURL(blob); |
|||
const a = document.createElement('a'); |
|||
a.href = url; |
|||
a.download = filename; |
|||
document.body.appendChild(a); |
|||
a.click(); |
|||
document.body.removeChild(a); |
|||
URL.revokeObjectURL(url); |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
import App from './App.svelte'; |
|||
|
|||
const app = new App({ |
|||
target: document.getElementById('app') |
|||
}); |
|||
|
|||
export default app; |
|||
@ -0,0 +1,12 @@ |
|||
import { defineConfig } from 'vite'; |
|||
import { svelte } from '@sveltejs/vite-plugin-svelte'; |
|||
|
|||
export default defineConfig({ |
|||
plugins: [svelte()], |
|||
server: { |
|||
proxy: { |
|||
'/api': 'http://localhost:8080', |
|||
'^/[a-zA-Z0-9]{7}$': 'http://localhost:8080' |
|||
} |
|||
} |
|||
}); |
|||
@ -0,0 +1,18 @@ |
|||
[Unit] |
|||
Description=Short Link Service |
|||
After=network.target |
|||
|
|||
[Service] |
|||
Type=simple |
|||
ExecStart=/opt/short-link/short-link-server |
|||
Environment=DB_PATH=/opt/short-link/data |
|||
Environment=DOMAIN=https://xieyaxin.top:8899 |
|||
Environment=ADDR=:8080 |
|||
Environment=ADMIN_TOKEN=asdaersfdgfdhtretewfsddfgfdgdsfs |
|||
Restart=always |
|||
RestartSec=5 |
|||
User=nobody |
|||
Group=nogroup |
|||
|
|||
[Install] |
|||
WantedBy=multi-user.target |
|||
Loading…
Reference in new issue