Skip to content

Commit fc71a0f

Browse files
committed
feat(user): display moemoepoint log in user dropdown
1 parent 0448a46 commit fc71a0f

8 files changed

Lines changed: 380 additions & 6 deletions

File tree

apps/api/internal/app/router.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ func (a *App) RegisterRoutes() {
158158
userRoutes.Post("/image", auth, a.UserHandler.UploadImage)
159159
userRoutes.Post("/check-in", auth, a.UserHandler.CheckIn)
160160
userRoutes.Get("/search", auth, a.UserHandler.SearchUsers)
161+
// Self-service moemoepoint ledger (own records only; id from session).
162+
// Registered BEFORE /:id so Fiber doesn't match "moemoepoint" as a :id.
163+
userRoutes.Get("/moemoepoint/log", auth, a.UserHandler.GetMoemoepointLog)
161164

162165
// Public user profiles
163166
userRoutes.Get("/:id", optionalAuth, a.UserHandler.GetUserInfo)

apps/api/internal/user/handler/handler.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,27 @@ func (h *UserHandler) CheckIn(c *fiber.Ctx) error {
311311
return response.OK(c, map[string]int{"moemoepoint": points})
312312
}
313313

314+
// GetMoemoepointLog GET /api/user/moemoepoint/log
315+
// The authenticated user's OWN moemoepoint ledger. The user id comes from the
316+
// session (never a path param), so one user can't read another's records. moyu
317+
// proxies OAuth's REDUCED s2s view (no admin note / actor). Cursor paginated via
318+
// before_id (0 / absent = newest page); optional reason filter.
319+
func (h *UserHandler) GetMoemoepointLog(c *fiber.Ctx) error {
320+
user := middleware.MustGetUser(c)
321+
322+
limit, _ := strconv.Atoi(c.Query("limit"))
323+
if limit <= 0 || limit > 50 {
324+
limit = 20
325+
}
326+
beforeID, _ := strconv.ParseInt(c.Query("before_id"), 10, 64)
327+
328+
items, hasMore, err := h.service.GetMoemoepointLog(c.Context(), user.ID, limit, beforeID, c.Query("reason"))
329+
if err != nil {
330+
return response.Error(c, errors.ErrInternal(""))
331+
}
332+
return response.OK(c, fiber.Map{"items": items, "has_more": hasMore})
333+
}
334+
314335
// SearchUsers GET /api/user/search
315336
func (h *UserHandler) SearchUsers(c *fiber.Ctx) error {
316337
var req dto.SearchUserRequest

apps/api/internal/user/service/service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,13 @@ func (s *UserService) CheckIn(userID int) (int, error) {
258258
return points, nil
259259
}
260260

261+
// GetMoemoepointLog reads a page of the user's OWN moemoepoint ledger from OAuth
262+
// (the unified source of truth — moyu stores no local ledger). Cursor paginated
263+
// via beforeID (0 = newest page); reason is an optional filter.
264+
func (s *UserService) GetMoemoepointLog(ctx context.Context, userID, limit int, beforeID int64, reason string) ([]moemoepoint.LogEntry, bool, error) {
265+
return s.mp.Log(ctx, userID, limit, beforeID, reason)
266+
}
267+
261268
// GetUserPatches retrieves the user's patch list.
262269
func (s *UserService) GetUserPatches(userID, page, limit int) ([]patchModel.Patch, int64, error) {
263270
return s.repo.GetUserPatches(userID, (page-1)*limit, limit)

apps/api/pkg/moemoepoint/awarder.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,14 @@ func (a *Awarder) Award(ctx context.Context, userID, delta int, reason, ref, ide
5252
"user_id", userID, "balance", res.Balance, "error", err)
5353
}
5454
}
55+
56+
// Log reads a page of the user's moemoepoint ledger from OAuth (the source of
57+
// truth — moyu keeps no local ledger). Read-only passthrough to the s2s
58+
// endpoint; used by the self-service "萌萌点记录" view. A nil Awarder/client
59+
// yields an empty page rather than an error so the UI degrades gracefully.
60+
func (a *Awarder) Log(ctx context.Context, userID, limit int, beforeID int64, reason string) ([]LogEntry, bool, error) {
61+
if a == nil || a.client == nil {
62+
return []LogEntry{}, false, nil
63+
}
64+
return a.client.Log(ctx, userID, limit, beforeID, reason)
65+
}

apps/api/pkg/moemoepoint/client.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
"encoding/json"
1818
"fmt"
1919
"net/http"
20+
"net/url"
21+
"strconv"
2022
"strings"
2123
"time"
2224
)
@@ -138,3 +140,67 @@ func (c *Client) Balance(ctx context.Context, userID int) (int, error) {
138140
}
139141
return env.Data.Balance, nil
140142
}
143+
144+
// LogEntry is one row of the REDUCED (end-user-facing) moemoepoint ledger from
145+
// GET /users/:id/moemoepoint/log. Admin-only fields (note / actor_user_id) are
146+
// deliberately absent — OAuth's s2s view omits them so moderation notes never
147+
// leak to the user.
148+
type LogEntry struct {
149+
ID int64 `json:"id"`
150+
Delta int `json:"delta"`
151+
Reason string `json:"reason"`
152+
SourceApp string `json:"source_app"`
153+
Ref string `json:"ref"`
154+
CreatedAt string `json:"created_at"`
155+
}
156+
157+
// Log reads a page of a user's moemoepoint ledger
158+
// (GET /users/:id/moemoepoint/log). Cursor pagination: pass beforeID=0 for the
159+
// newest page, then the last returned entry's ID to fetch older rows. reason is
160+
// an optional filter. Returns the page plus hasMore. The slice is never nil.
161+
func (c *Client) Log(ctx context.Context, userID, limit int, beforeID int64, reason string) ([]LogEntry, bool, error) {
162+
q := url.Values{}
163+
if limit > 0 {
164+
q.Set("limit", strconv.Itoa(limit))
165+
}
166+
if beforeID > 0 {
167+
q.Set("before_id", strconv.FormatInt(beforeID, 10))
168+
}
169+
if reason != "" {
170+
q.Set("reason", reason)
171+
}
172+
u := fmt.Sprintf("%s/users/%d/moemoepoint/log", c.baseURL, userID)
173+
if enc := q.Encode(); enc != "" {
174+
u += "?" + enc
175+
}
176+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
177+
if err != nil {
178+
return nil, false, err
179+
}
180+
req.Header.Set("Authorization", c.authHeader)
181+
req.Header.Set("Accept", "application/json")
182+
183+
resp, err := c.http.Do(req)
184+
if err != nil {
185+
return nil, false, fmt.Errorf("oauth moemoepoint log: %w", err)
186+
}
187+
defer resp.Body.Close()
188+
189+
var env struct {
190+
Code int `json:"code"`
191+
Data struct {
192+
Items []LogEntry `json:"items"`
193+
HasMore bool `json:"has_more"`
194+
} `json:"data"`
195+
}
196+
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
197+
return nil, false, fmt.Errorf("oauth moemoepoint log decode (status=%d): %w", resp.StatusCode, err)
198+
}
199+
if env.Code != 0 {
200+
return nil, false, fmt.Errorf("oauth moemoepoint log: code=%d", env.Code)
201+
}
202+
if env.Data.Items == nil {
203+
env.Data.Items = []LogEntry{}
204+
}
205+
return env.Data.Items, env.Data.HasMore, nil
206+
}

apps/api/pkg/moemoepoint/client_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,54 @@ func TestBalance(t *testing.T) {
9393
t.Fatalf("got %d, want 99", bal)
9494
}
9595
}
96+
97+
func TestLog(t *testing.T) {
98+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
99+
if r.Method != http.MethodGet || r.URL.Path != "/users/42/moemoepoint/log" {
100+
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
101+
}
102+
// cursor + limit must be forwarded as query params
103+
if got := r.URL.Query().Get("limit"); got != "20" {
104+
t.Errorf("limit query = %q, want 20", got)
105+
}
106+
if got := r.URL.Query().Get("before_id"); got != "100" {
107+
t.Errorf("before_id query = %q, want 100", got)
108+
}
109+
_ = json.NewEncoder(w).Encode(map[string]any{
110+
"code": 0, "data": map[string]any{
111+
"items": []map[string]any{
112+
{"id": 99, "delta": 3, "reason": "content_approved", "source_app": "moyu", "ref": "resource:7", "created_at": "2026-05-29T10:00:00Z"},
113+
{"id": 98, "delta": -1, "reason": "liked", "source_app": "moyu", "ref": "comment:3", "created_at": "2026-05-28T09:00:00Z"},
114+
},
115+
"has_more": true,
116+
},
117+
})
118+
}))
119+
defer srv.Close()
120+
121+
items, hasMore, err := newTestClient(srv).Log(context.Background(), 42, 20, 100, "")
122+
if err != nil {
123+
t.Fatalf("Log error: %v", err)
124+
}
125+
if !hasMore {
126+
t.Fatal("expected has_more=true")
127+
}
128+
if len(items) != 2 || items[0].ID != 99 || items[0].Delta != 3 || items[1].Delta != -1 {
129+
t.Fatalf("unexpected items: %+v", items)
130+
}
131+
}
132+
133+
func TestLog_EmptyNeverNil(t *testing.T) {
134+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
135+
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"has_more": false}})
136+
}))
137+
defer srv.Close()
138+
139+
items, _, err := newTestClient(srv).Log(context.Background(), 42, 20, 0, "")
140+
if err != nil {
141+
t.Fatalf("Log error: %v", err)
142+
}
143+
if items == nil {
144+
t.Fatal("items must be non-nil (empty slice) so JSON marshals to [] not null")
145+
}
146+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<script setup lang="ts">
2+
// 萌萌点记录 — the user's OWN moemoepoint ledger. OAuth is the source of truth;
3+
// moyu proxies the reduced s2s view via GET /user/moemoepoint/log (id from the
4+
// session, never a path param). Cursor paginated by the last row's id.
5+
const open = defineModel<boolean>({ required: true })
6+
7+
const api = useApi()
8+
const userStore = useUserStore()
9+
10+
interface MoemoepointLogEntry {
11+
id: number
12+
delta: number
13+
reason: string
14+
source_app: string
15+
ref: string
16+
created_at: string
17+
}
18+
19+
const LIMIT = 20
20+
21+
const items = ref<MoemoepointLogEntry[]>([])
22+
const hasMore = ref(false)
23+
const loading = ref(false)
24+
const loadingMore = ref(false)
25+
const loaded = ref(false)
26+
const failed = ref(false)
27+
28+
// reason → human label + icon + color. Mirrors OAuth's reason enum
29+
// (06-moemoepoint.md); admin_*/migration only appear for cross-channel rows.
30+
const REASONS: Record<string, { label: string; icon: string; class: string }> = {
31+
content_approved: {
32+
label: '内容被采纳',
33+
icon: 'lucide:badge-check',
34+
class: 'text-success-500'
35+
},
36+
content_removed: {
37+
label: '内容被移除',
38+
icon: 'lucide:badge-x',
39+
class: 'text-danger-500'
40+
},
41+
daily_checkin: {
42+
label: '每日签到',
43+
icon: 'lucide:calendar-check',
44+
class: 'text-primary-500'
45+
},
46+
liked: { label: '获得喜欢', icon: 'lucide:heart', class: 'text-danger-500' },
47+
admin_grant: { label: '管理员发放', icon: 'lucide:gift', class: 'text-success-500' },
48+
admin_deduct: { label: '管理员扣除', icon: 'lucide:gavel', class: 'text-warning-500' },
49+
migration: { label: '初始迁移', icon: 'lucide:database', class: 'text-default-400' }
50+
}
51+
52+
const reasonMeta = (reason: string) =>
53+
REASONS[reason] ?? {
54+
label: reason,
55+
icon: 'lucide:circle-dot',
56+
class: 'text-default-400'
57+
}
58+
59+
// ref is "type:id" (e.g. "resource:42", "galgame:1207"). Map the ones that have
60+
// an in-site page to a link; everything else (comment, admin:*, …) has no link.
61+
const refLink = (refStr: string): string | null => {
62+
const [type, id] = (refStr || '').split(':')
63+
if (!id) return null
64+
if (type === 'resource') return `/resource/${id}`
65+
if (type === 'galgame' || type === 'patch') return `/patch/${id}`
66+
return null
67+
}
68+
69+
const fetchPage = async (beforeID?: number) => {
70+
const params = new URLSearchParams({ limit: String(LIMIT) })
71+
if (beforeID) params.set('before_id', String(beforeID))
72+
const res = await api.get<{ items: MoemoepointLogEntry[]; has_more: boolean }>(
73+
`/user/moemoepoint/log?${params.toString()}`
74+
)
75+
if (res.code !== 0) throw new Error(res.message)
76+
return res.data
77+
}
78+
79+
const load = async () => {
80+
loading.value = true
81+
failed.value = false
82+
try {
83+
const data = await fetchPage()
84+
items.value = data.items ?? []
85+
hasMore.value = data.has_more
86+
loaded.value = true
87+
} catch {
88+
failed.value = true
89+
} finally {
90+
loading.value = false
91+
}
92+
}
93+
94+
const loadMore = async () => {
95+
const last = items.value[items.value.length - 1]
96+
if (loadingMore.value || !hasMore.value || !last) return
97+
loadingMore.value = true
98+
try {
99+
const data = await fetchPage(last.id)
100+
items.value.push(...(data.items ?? []))
101+
hasMore.value = data.has_more
102+
} catch {
103+
useKunMessage('加载更多失败', 'error')
104+
} finally {
105+
loadingMore.value = false
106+
}
107+
}
108+
109+
// Refetch on each open so a record earned this session (e.g. a fresh check-in)
110+
// shows without a page reload.
111+
watch(open, (v) => {
112+
if (v) load()
113+
})
114+
</script>
115+
116+
<template>
117+
<KunModal v-model="open" inner-class-name="max-w-lg w-full">
118+
<div class="space-y-4">
119+
<div class="flex items-center justify-between">
120+
<h3 class="flex items-center gap-2 text-lg font-semibold">
121+
<KunIcon name="lucide:lollipop" class="size-5" />
122+
萌萌点记录
123+
</h3>
124+
<span class="text-foreground/60 text-sm">
125+
当前 {{ userStore.user.moemoepoint }}
126+
</span>
127+
</div>
128+
129+
<KunLoading v-if="loading" description="加载记录中..." />
130+
131+
<KunNull v-else-if="failed" description="加载失败, 请稍后再试" />
132+
133+
<KunNull
134+
v-else-if="loaded && !items.length"
135+
description="还没有萌萌点记录哦"
136+
/>
137+
138+
<ul v-else class="max-h-[60vh] space-y-1 overflow-y-auto">
139+
<li
140+
v-for="item in items"
141+
:key="item.id"
142+
class="hover:bg-default-100 flex items-center gap-3 rounded-lg px-2 py-2"
143+
>
144+
<span
145+
class="bg-default-100 flex size-9 shrink-0 items-center justify-center rounded-full"
146+
:class="reasonMeta(item.reason).class"
147+
>
148+
<KunIcon :name="reasonMeta(item.reason).icon" class="size-4" />
149+
</span>
150+
<div class="min-w-0 flex-1">
151+
<p class="flex items-center gap-2 text-sm font-medium">
152+
<span class="truncate">{{ reasonMeta(item.reason).label }}</span>
153+
<NuxtLink
154+
v-if="refLink(item.ref)"
155+
:to="refLink(item.ref)!"
156+
class="text-primary-500 shrink-0 text-xs hover:underline"
157+
@click="open = false"
158+
>
159+
查看
160+
</NuxtLink>
161+
</p>
162+
<p class="text-foreground/50 text-xs">
163+
{{ formatTimeDifference(item.created_at) }}
164+
</p>
165+
</div>
166+
<span
167+
class="shrink-0 text-sm font-semibold tabular-nums"
168+
:class="item.delta >= 0 ? 'text-success-500' : 'text-danger-500'"
169+
>
170+
{{ item.delta >= 0 ? '+' : '' }}{{ item.delta }}
171+
</span>
172+
</li>
173+
174+
<li v-if="hasMore" class="pt-1">
175+
<KunButton
176+
variant="light"
177+
full-width
178+
:loading="loadingMore"
179+
:disabled="loadingMore"
180+
@click="loadMore"
181+
>
182+
加载更多
183+
</KunButton>
184+
</li>
185+
</ul>
186+
</div>
187+
</KunModal>
188+
</template>

0 commit comments

Comments
 (0)