From 032560347051c9ffa0483485f13b494aa41a5817 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 13:43:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(server/notices):=20GET=20/v1/notices(?= =?UTF-8?q?=E5=90=88=E5=B9=B6+unread=5Fcount)+=20POST=20/read=20+=20openap?= =?UTF-8?q?i=20=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/openapi.yaml | 49 +++++++++++++++++-- server/internal/httpapi/account.go | 1 + server/internal/notices/handler.go | 61 +++++++++++++++++++++++ server/internal/notices/handler_test.go | 64 +++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 server/internal/notices/handler.go create mode 100644 server/internal/notices/handler_test.go diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 1d494a3..6755892 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -857,10 +857,10 @@ paths: /notices: get: operationId: listNotices - summary: 获取公告列表 + summary: 获取公告列表(含未读数) description: | - 返回当前有效公告。同一份内容亦以多镜像签名静态 JSON 发布,客户端优先拉静态副本, - 失败时回落到本端点。 + 返回当前用户可见的公告(全员广播 ∪ 定向),按发布时间倒序,并附带 unread_count。 + 同一份内容亦以多镜像签名静态 JSON 发布,客户端优先拉静态副本,失败时回落到本端点。 tags: [Notices] responses: "200": @@ -869,12 +869,39 @@ paths: application/json: schema: type: object - required: [notices] + required: [notices, unread_count] properties: notices: type: array items: $ref: "#/components/schemas/Notice" + unread_count: + type: integer + description: 未读公告数 + example: 2 + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/Internal" + + /notices/read: + post: + operationId: markNoticesRead + summary: 标记公告已读 + description: 将当前用户的公告已读水位推进到当前时间,清空 unread_count。 + tags: [Notices] + responses: + "200": + description: 标记成功 + content: + application/json: + schema: + type: object + required: [ok] + properties: + ok: + type: boolean + example: true "401": $ref: "#/components/responses/Unauthorized" "500": @@ -1319,12 +1346,17 @@ components: Notice: type: object - required: [id, title_zh, title_en, body_zh, body_en, published_at] + required: [id, type, title_zh, title_en, body_zh, body_en, published_at, unread] properties: id: type: string format: uuid description: 公告 UUID + type: + type: string + enum: [important, feature, news, reward, version, promo] + description: 公告类型,驱动客户端图标/分组展示 + example: news title_zh: type: string description: 公告中文标题 @@ -1339,10 +1371,17 @@ components: body_en: type: string description: 公告英文正文(Markdown) + link: + type: string + description: 公告关联链接(可选,跳转详情/活动页) published_at: type: string format: date-time description: 发布时间(UTC ISO-8601) + unread: + type: boolean + description: 相对当前用户已读水位是否未读 + example: true # ── Pay(pay v2 统一支付网关代理)────────────────────── diff --git a/server/internal/httpapi/account.go b/server/internal/httpapi/account.go index f8b57c0..4ec90cd 100644 --- a/server/internal/httpapi/account.go +++ b/server/internal/httpapi/account.go @@ -218,6 +218,7 @@ func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) { // ─── GET /v1/notices ───────────────────────────────────────────────────────── // ListNotices handles GET /v1/notices. Returns an empty list for the MVP. +// TODO(Task7): 由 notices.Handler 替换后删除(main.go 路由挂载在 Task 7 统一装配)。 func (a *AccountAPI) ListNotices(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}}) diff --git a/server/internal/notices/handler.go b/server/internal/notices/handler.go new file mode 100644 index 0000000..dbcdcd0 --- /dev/null +++ b/server/internal/notices/handler.go @@ -0,0 +1,61 @@ +package notices + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" +) + +// listLimit 是 GET /v1/notices 单次返回的最大条数(合并广播+定向后按 published_at 倒序截断)。 +const listLimit = 50 + +// Handler 承载 /v1/notices、/v1/notices/read 两个受保护端点。 +type Handler struct { + st *Store +} + +func NewHandler(st *Store) *Handler { + return &Handler{st: st} +} + +// List handles GET /v1/notices: 返回该用户可见的通知(广播 ∪ 定向)与未读数。 +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + items, unreadCount, err := h.st.ListForUser(ctx, uid, time.Now().UTC(), listLimit) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if items == nil { + items = []Notice{} + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "notices": items, + "unread_count": unreadCount, + }) +} + +// MarkRead handles POST /v1/notices/read: 把用户已读水位推进到当前时间。 +func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + if err := h.st.MarkRead(ctx, uid, time.Now().UTC()); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} diff --git a/server/internal/notices/handler_test.go b/server/internal/notices/handler_test.go new file mode 100644 index 0000000..623a344 --- /dev/null +++ b/server/internal/notices/handler_test.go @@ -0,0 +1,64 @@ +package notices + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestListAndMarkRead(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + _, _ = st.InsertBroadcast(context.Background(), "news", "hello", "hello", "", "", "", time.Now().UTC(), nil) + h := NewHandler(st) + + req := httptest.NewRequest(http.MethodGet, "/v1/notices", nil) + req = req.WithContext(context.WithValue(req.Context(), codes.CtxKeyUserID, int64(1))) + w := httptest.NewRecorder() + h.List(w, req) + if w.Code != 200 { + t.Fatalf("code=%d body=%s", w.Code, w.Body) + } + var got struct { + Notices []map[string]any `json:"notices"` + UnreadCount int `json:"unread_count"` + } + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.Notices) != 1 || got.UnreadCount != 1 { + t.Fatalf("got %+v", got) + } + if got.Notices[0]["type"] != "news" || got.Notices[0]["unread"] != true { + t.Fatalf("字段契约: %+v", got.Notices[0]) + } + + // read → 再查 unread 清零 + req2 := httptest.NewRequest(http.MethodPost, "/v1/notices/read", nil) + req2 = req2.WithContext(context.WithValue(req2.Context(), codes.CtxKeyUserID, int64(1))) + w2 := httptest.NewRecorder() + h.MarkRead(w2, req2) + if w2.Code != 200 { + t.Fatalf("read code=%d", w2.Code) + } + w3 := httptest.NewRecorder() + h.List(w3, req) + _ = json.Unmarshal(w3.Body.Bytes(), &got) + if got.UnreadCount != 0 { + t.Fatalf("read 后 unread_count=%d", got.UnreadCount) + } +} + +func TestListUnauthorized(t *testing.T) { + db := openDB(t) + h := NewHandler(NewStore(db)) + w := httptest.NewRecorder() + h.List(w, httptest.NewRequest(http.MethodGet, "/v1/notices", nil)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("code=%d want 401", w.Code) + } +}