feat: add Stow prototype iOS app and Gin backend

This commit is contained in:
2026-10-02 21:23:31 +08:00
commit fe72c1ebd2
30 changed files with 3252 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
package api
import "github.com/gin-gonic/gin"
type Response struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
func success(c *gin.Context, status int, data any) {
c.JSON(status, Response{Code: "ok", Message: "success", Data: data})
}
func failure(c *gin.Context, status int, code, message string) {
c.JSON(status, Response{Code: code, Message: message, Data: nil})
}
+292
View File
@@ -0,0 +1,292 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"stow/backend/internal/store"
)
type DataStore interface {
CreateAnonymousSession(context.Context) (string, string, error)
Authenticate(context.Context, string) (string, error)
CreateItem(context.Context, string, store.NewItem) (store.Item, error)
GetItem(context.Context, string, string) (store.Item, error)
ListItems(context.Context, string, int) ([]store.Item, error)
SearchItems(context.Context, string, string, int) ([]store.Item, error)
UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error)
DeleteItem(context.Context, string, string) error
}
type Server struct{ store DataStore }
func New(dataStore DataStore) http.Handler {
server := &Server{store: dataStore}
router := gin.New()
router.HandleMethodNotAllowed = true
router.Use(gin.CustomRecovery(func(c *gin.Context, _ any) {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
}))
_ = router.SetTrustedProxies(nil)
router.NoRoute(func(c *gin.Context) { failure(c, http.StatusNotFound, "not_found", "接口不存在") })
router.NoMethod(func(c *gin.Context) {
failure(c, http.StatusMethodNotAllowed, "method_not_allowed", "请求方法不支持")
})
v1 := router.Group("/v1")
v1.GET("/health", func(c *gin.Context) { success(c, http.StatusOK, gin.H{"status": "ok"}) })
v1.POST("/session/anonymous", server.anonymousSession)
authorized := v1.Group("")
authorized.Use(server.requireAuth())
authorized.POST("/items", server.createItem)
authorized.GET("/items", server.listItems)
authorized.GET("/items/:id", server.getItem)
authorized.PATCH("/items/:id", server.updateItem)
authorized.DELETE("/items/:id", server.deleteItem)
authorized.POST("/search", server.search)
return router
}
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") || strings.TrimSpace(header[7:]) == "" {
failure(c, http.StatusUnauthorized, "missing_token", "请先创建匿名会话")
c.Abort()
return
}
userID, err := s.store.Authenticate(c.Request.Context(), strings.TrimSpace(header[7:]))
if errors.Is(err, sql.ErrNoRows) {
failure(c, http.StatusUnauthorized, "invalid_token", "会话无效或已过期")
c.Abort()
return
}
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
c.Abort()
return
}
c.Set("userID", userID)
c.Next()
}
}
func (s *Server) anonymousSession(c *gin.Context) {
userID, token, err := s.store.CreateAnonymousSession(c.Request.Context())
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "创建会话失败")
return
}
success(c, http.StatusCreated, gin.H{"user_id": userID, "access_token": token, "token_type": "Bearer"})
}
func (s *Server) createItem(c *gin.Context) {
var input store.NewItem
if !readJSON(c, &input) {
return
}
if message := validateNewItem(&input); message != "" {
failure(c, http.StatusBadRequest, "invalid_item", message)
return
}
item, err := s.store.CreateItem(c.Request.Context(), c.GetString("userID"), input)
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "保存失败")
return
}
success(c, http.StatusCreated, item)
}
func (s *Server) listItems(c *gin.Context) {
items, err := s.store.ListItems(c.Request.Context(), c.GetString("userID"), parseLimit(c.Query("limit")))
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "读取失败")
return
}
success(c, http.StatusOK, gin.H{"items": items})
}
func (s *Server) getItem(c *gin.Context) {
item, err := s.store.GetItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))
if !handleItemError(c, err) {
return
}
success(c, http.StatusOK, item)
}
func (s *Server) updateItem(c *gin.Context) {
var patch store.ItemPatch
if !readJSON(c, &patch) {
return
}
if message := validatePatch(patch); message != "" {
failure(c, http.StatusBadRequest, "invalid_item", message)
return
}
item, err := s.store.UpdateItem(c.Request.Context(), c.GetString("userID"), c.Param("id"), patch)
if !handleItemError(c, err) {
return
}
success(c, http.StatusOK, item)
}
func (s *Server) deleteItem(c *gin.Context) {
if !handleItemError(c, s.store.DeleteItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))) {
return
}
success(c, http.StatusOK, nil)
}
func (s *Server) search(c *gin.Context) {
var request struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if !readJSON(c, &request) {
return
}
request.Query = strings.TrimSpace(request.Query)
if request.Query == "" || len([]rune(request.Query)) > 200 {
failure(c, http.StatusBadRequest, "invalid_query", "搜索内容需为 1 到 200 字")
return
}
if request.Limit <= 0 {
request.Limit = 20
}
if request.Limit > 100 {
request.Limit = 100
}
items, err := s.store.SearchItems(c.Request.Context(), c.GetString("userID"), request.Query, request.Limit)
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "搜索失败")
return
}
success(c, http.StatusOK, gin.H{"items": items, "query": request.Query})
}
func handleItemError(c *gin.Context, err error) bool {
if err == nil {
return true
}
if errors.Is(err, store.ErrNotFound) {
failure(c, http.StatusNotFound, "not_found", "收藏不存在")
} else if errors.Is(err, store.ErrConflict) {
failure(c, http.StatusConflict, "conflict", "收藏已更新,请重试")
} else {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
}
return false
}
func readJSON(c *gin.Context, destination any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
failure(c, http.StatusBadRequest, "invalid_json", "请求内容不是有效 JSON")
return false
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
failure(c, http.StatusBadRequest, "invalid_json", "请求只能包含一个 JSON 对象")
return false
}
return true
}
func parseLimit(raw string) int {
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
return 20
}
if limit > 100 {
return 100
}
return limit
}
func validateNewItem(input *store.NewItem) string {
input.Title = strings.TrimSpace(input.Title)
input.Summary = strings.TrimSpace(input.Summary)
if input.Title == "" || len([]rune(input.Title)) > 160 {
return "标题需为 1 到 160 字"
}
if len([]rune(input.Summary)) > 2000 {
return "摘要不能超过 2000 字"
}
if input.PrimaryType == "" {
input.PrimaryType = "other"
}
if !validType(input.PrimaryType) {
return "无效的分类"
}
if !validSource(input.SourceType) {
return "无效的来源类型"
}
if input.SourceURL != nil {
parsed, err := url.Parse(*input.SourceURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "无效的来源链接"
}
}
if len(input.Tags) > 20 {
return "标签不能超过 20 个"
}
for _, tag := range input.Tags {
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
return "标签需为 1 到 40 字"
}
}
if input.CapturedAt != nil && input.CapturedAt.After(time.Now().Add(24*time.Hour)) {
return "收藏时间不能晚于明天"
}
return ""
}
func validatePatch(patch store.ItemPatch) string {
if patch.Title != nil && (strings.TrimSpace(*patch.Title) == "" || len([]rune(*patch.Title)) > 160) {
return "标题需为 1 到 160 字"
}
if patch.Summary != nil && len([]rune(*patch.Summary)) > 2000 {
return "摘要不能超过 2000 字"
}
if patch.PrimaryType != nil && !validType(*patch.PrimaryType) {
return "无效的分类"
}
if patch.Tags != nil {
if len(*patch.Tags) > 20 {
return "标签不能超过 20 个"
}
for _, tag := range *patch.Tags {
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
return "标签需为 1 到 40 字"
}
}
}
return ""
}
func validType(value string) bool {
switch value {
case "place", "product", "content", "event", "task", "document", "conversation", "other":
return true
}
return false
}
func validSource(value string) bool {
switch value {
case "screenshot", "manual_photo", "share_image", "share_url", "share_text", "manual_url", "manual_text":
return true
}
return false
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"stow/backend/internal/store"
)
type fakeStore struct {
created bool
lastUser string
lastItem store.NewItem
}
func (f *fakeStore) CreateAnonymousSession(context.Context) (string, string, error) {
return "user-1", "token-1", nil
}
func (f *fakeStore) Authenticate(_ context.Context, token string) (string, error) {
if token != "token-1" {
return "", sql.ErrNoRows
}
return "user-1", nil
}
func (f *fakeStore) CreateItem(_ context.Context, userID string, item store.NewItem) (store.Item, error) {
f.created, f.lastUser, f.lastItem = true, userID, item
return store.Item{ID: "item-1", SourceType: item.SourceType, PrimaryType: item.PrimaryType, Title: item.Title, Summary: item.Summary, Status: "ready", Tags: item.Tags, CreatedAt: time.Now()}, nil
}
func (f *fakeStore) GetItem(context.Context, string, string) (store.Item, error) {
return store.Item{}, store.ErrNotFound
}
func (f *fakeStore) ListItems(context.Context, string, int) ([]store.Item, error) {
return []store.Item{}, nil
}
func (f *fakeStore) SearchItems(context.Context, string, string, int) ([]store.Item, error) {
return []store.Item{}, nil
}
func (f *fakeStore) UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error) {
return store.Item{}, store.ErrNotFound
}
func (f *fakeStore) DeleteItem(context.Context, string, string) error { return store.ErrNotFound }
func TestItemsRequireSession(t *testing.T) {
fake := &fakeStore{}
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(`{"source_type":"manual_text","title":"咖啡店"}`))
response := httptest.NewRecorder()
New(fake).ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.Code)
}
if fake.created {
t.Fatal("item was created without authentication")
}
}
func TestCreateItemValidatesAndUsesAuthenticatedUser(t *testing.T) {
fake := &fakeStore{}
handler := New(fake)
for _, test := range []struct {
name, body string
want int
}{
{"invalid source", `{"source_type":"unknown","title":"咖啡店"}`, http.StatusBadRequest},
{"valid item", `{"source_type":"manual_text","title":"咖啡店","tags":["上海"]}`, http.StatusCreated},
} {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(test.body))
request.Header.Set("Authorization", "Bearer token-1")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != test.want {
t.Fatalf("status = %d, want %d: %s", response.Code, test.want, response.Body.String())
}
})
}
if !fake.created || fake.lastUser != "user-1" || fake.lastItem.Title != "咖啡店" {
t.Fatalf("wrong authenticated create: %+v", fake)
}
}
func TestAnonymousSessionReturnsToken(t *testing.T) {
response := httptest.NewRecorder()
New(&fakeStore{}).ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/session/anonymous", nil))
if response.Code != http.StatusCreated {
t.Fatalf("status = %d", response.Code)
}
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != "ok" || body.Data["access_token"] != "token-1" || body.Data["token_type"] != "Bearer" {
t.Fatalf("unexpected response: %v", body)
}
}
func TestAllResponsesUseEnvelope(t *testing.T) {
handler := New(&fakeStore{})
for _, test := range []struct {
method string
path string
want int
}{
{http.MethodGet, "/v1/health", http.StatusOK},
{http.MethodGet, "/v1/items", http.StatusUnauthorized},
{http.MethodGet, "/missing", http.StatusNotFound},
{http.MethodPut, "/v1/health", http.StatusMethodNotAllowed},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.want {
t.Fatalf("%s %s: status %d, want %d", test.method, test.path, response.Code, test.want)
}
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatalf("%s %s: %v", test.method, test.path, err)
}
if body.Code == "" || body.Message == "" || body.Data == nil {
t.Fatalf("%s %s: invalid envelope: %s", test.method, test.path, response.Body.String())
}
}
}