feat(server/notices): GET /v1/notices(合并+unread_count)+ POST /read + openapi 扩展
This commit is contained in:
+44
-5
@@ -857,10 +857,10 @@ paths:
|
|||||||
/notices:
|
/notices:
|
||||||
get:
|
get:
|
||||||
operationId: listNotices
|
operationId: listNotices
|
||||||
summary: 获取公告列表
|
summary: 获取公告列表(含未读数)
|
||||||
description: |
|
description: |
|
||||||
返回当前有效公告。同一份内容亦以多镜像签名静态 JSON 发布,客户端优先拉静态副本,
|
返回当前用户可见的公告(全员广播 ∪ 定向),按发布时间倒序,并附带 unread_count。
|
||||||
失败时回落到本端点。
|
同一份内容亦以多镜像签名静态 JSON 发布,客户端优先拉静态副本,失败时回落到本端点。
|
||||||
tags: [Notices]
|
tags: [Notices]
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -869,12 +869,39 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [notices]
|
required: [notices, unread_count]
|
||||||
properties:
|
properties:
|
||||||
notices:
|
notices:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Notice"
|
$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":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"500":
|
"500":
|
||||||
@@ -1319,12 +1346,17 @@ components:
|
|||||||
|
|
||||||
Notice:
|
Notice:
|
||||||
type: object
|
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:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
description: 公告 UUID
|
description: 公告 UUID
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [important, feature, news, reward, version, promo]
|
||||||
|
description: 公告类型,驱动客户端图标/分组展示
|
||||||
|
example: news
|
||||||
title_zh:
|
title_zh:
|
||||||
type: string
|
type: string
|
||||||
description: 公告中文标题
|
description: 公告中文标题
|
||||||
@@ -1339,10 +1371,17 @@ components:
|
|||||||
body_en:
|
body_en:
|
||||||
type: string
|
type: string
|
||||||
description: 公告英文正文(Markdown)
|
description: 公告英文正文(Markdown)
|
||||||
|
link:
|
||||||
|
type: string
|
||||||
|
description: 公告关联链接(可选,跳转详情/活动页)
|
||||||
published_at:
|
published_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: 发布时间(UTC ISO-8601)
|
description: 发布时间(UTC ISO-8601)
|
||||||
|
unread:
|
||||||
|
type: boolean
|
||||||
|
description: 相对当前用户已读水位是否未读
|
||||||
|
example: true
|
||||||
|
|
||||||
# ── Pay(pay v2 统一支付网关代理)──────────────────────
|
# ── Pay(pay v2 统一支付网关代理)──────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ─── GET /v1/notices ─────────────────────────────────────────────────────────
|
// ─── GET /v1/notices ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ListNotices handles GET /v1/notices. Returns an empty list for the MVP.
|
// 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) {
|
func (a *AccountAPI) ListNotices(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}})
|
_ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}})
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user