feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-11 18:54:59 +08:00
co-authored by Cursor
parent 0ee4d4f5ae
commit 5ceb3ce749
85 changed files with 2098 additions and 103 deletions
+35 -2
View File
@@ -94,24 +94,57 @@ export type UserDetail = {
}>
}
export type AdminRole = 'super' | 'ops'
export type AdminMe = { id: string; username: string; role: AdminRole }
export type PushJob = {
id: string
title: string
body: string
audience: string
status: 'draft' | 'cancelled'
created_by: string
created_at: string
updated_at: string
}
export type AdminListItem = {
id: string
username: string
role: AdminRole
status: string
created_at: string
}
export const adminApi = {
login: (username: string, password: string) =>
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
request<{ token: string; admin: AdminMe }>('POST', '/auth/login', {
username,
password,
}),
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
me: () => request<{ id: string; username: string }>('GET', '/me'),
me: () => request<AdminMe>('GET', '/me'),
stats: () => request<DashboardStats>('GET', '/stats'),
users: (q = '') =>
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`),
unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`),
grant: (id: string, plan: string) =>
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
grantAskQuota: (id: string, delta: number) =>
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
delta,
}),
admins: () => request<{ items: AdminListItem[] }>('GET', '/admins'),
patchAdminRole: (id: string, role: AdminRole) =>
request<{ ok: boolean }>('PATCH', `/admins/${id}`, { role }),
pushJobs: () => request<{ items: PushJob[] }>('GET', '/push-jobs'),
createPushJob: (body: { title: string; body?: string; audience?: string }) =>
request<PushJob>('POST', '/push-jobs', body),
patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) =>
request<PushJob>('PATCH', `/push-jobs/${id}`, body),
orders: (params?: {
status?: string
kind?: string
+4 -2
View File
@@ -29,11 +29,13 @@ async function onLogout() {
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/pricing">定价</RouterLink>
<RouterLink v-if="auth.isSuper" to="/pricing">定价</RouterLink>
<RouterLink to="/push">推送</RouterLink>
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
<div class="foot">
<span class="muted">{{ auth.username || '管理员' }}</span>
<span class="muted">{{ auth.username || '管理员' }} · {{ auth.role }}</span>
<button class="btn ghost" type="button" @click="onLogout">退出</button>
</div>
</aside>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi, type AdminListItem, type AdminRole } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<AdminListItem[]>([])
const msg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.admins()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function setRole(id: string, role: AdminRole) {
msg.value = ''
try {
await adminApi.patchAdminRole(id, role)
msg.value = '角色已更新'
await load()
} catch (e) {
msg.value = e instanceof Error ? e.message : '更新失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>管理员</h1>
<p class="muted">仅超级管理员可访问角色super全权/ ops只读+封禁+推送草稿</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<table v-else class="tbl">
<thead>
<tr><th>用户名</th><th>角色</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="a in items" :key="a.id">
<td>{{ a.username }}</td>
<td>{{ a.role }}</td>
<td>{{ a.status }}</td>
<td class="ops">
<button class="btn ghost" type="button" @click="setRole(a.id, 'super')">设为 super</button>
<button class="btn ghost" type="button" @click="setRole(a.id, 'ops')">设为 ops</button>
</td>
</tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.tbl { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.tbl th, .tbl td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--line); }
.ops { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.err { color: var(--accent); }
</style>
+11 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi, type HomeToolAdmin, type ScaleAdmin } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const tab = ref<'grid' | 'scales'>('grid')
const loading = ref(false)
const saving = ref(false)
@@ -112,7 +114,8 @@ async function toggleScale(s: ScaleAdmin) {
<p v-if="msg" class="ok">{{ msg }}</p>
<template v-if="tab === 'grid' && !loading">
<div class="toolbar">
<p v-if="!auth.isSuper" class="muted">只读内容写入需超级管理员</p>
<div v-if="auth.isSuper" class="toolbar">
<button class="btn ghost" type="button" @click="addTool">添加</button>
<button class="btn" type="button" :disabled="saving" @click="saveTools">保存宫格</button>
</div>
@@ -153,9 +156,15 @@ async function toggleScale(s: ScaleAdmin) {
</span>
</td>
<td>
<button class="btn ghost" type="button" @click="toggleScale(s)">
<button
v-if="auth.isSuper"
class="btn ghost"
type="button"
@click="toggleScale(s)"
>
{{ s.status === 'published' ? '下架' : '上架' }}
</button>
<span v-else class="muted"></span>
</td>
</tr>
</tbody>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi, type PushJob } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<PushJob[]>([])
const title = ref('')
const body = ref('')
const msg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.pushJobs()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function create() {
msg.value = ''
try {
await adminApi.createPushJob({ title: title.value.trim(), body: body.value.trim(), audience: 'all' })
title.value = ''
body.value = ''
msg.value = '已创建草稿(占位,不下发)'
await load()
} catch (e) {
msg.value = e instanceof Error ? e.message : '创建失败'
}
}
async function cancel(id: string) {
try {
await adminApi.patchPushJob(id, { status: 'cancelled' })
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '取消失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>推送任务</h1>
<p class="muted">占位能力仅草稿 / 取消不接厂商不下发</p>
<div class="card block">
<h2>新建草稿</h2>
<input v-model="title" class="inp" placeholder="标题" maxlength="128" />
<textarea v-model="body" class="ta" rows="3" placeholder="正文(可选)" />
<button class="btn" type="button" :disabled="!title.trim()" @click="create">创建草稿</button>
<span v-if="msg" class="muted">{{ msg }}</span>
</div>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<table v-else class="tbl">
<thead>
<tr><th>标题</th><th>受众</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="j in items" :key="j.id">
<td>{{ j.title }}</td>
<td>{{ j.audience }}</td>
<td>{{ j.status }}</td>
<td>
<button
v-if="j.status === 'draft'"
class="btn ghost"
type="button"
@click="cancel(j.id)"
>取消</button>
<span v-else class="muted"></span>
</td>
</tr>
<tr v-if="!items.length"><td colspan="4" class="muted">暂无任务</td></tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.block { margin: 1rem 0; display: flex; flex-direction: column; gap: 0.5rem; max-width: 480px; }
.inp, .ta {
border: 1px solid var(--line); border-radius: 10px; padding: 0.55rem 0.7rem;
font: inherit; background: #fff;
}
.tbl { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.tbl th, .tbl td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--line); }
.err { color: var(--accent); }
</style>
+28 -2
View File
@@ -2,8 +2,10 @@
import { onMounted, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { adminApi, type UserDetail } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const detail = ref<UserDetail | null>(null)
@@ -11,6 +13,7 @@ const plan = ref('month')
const askDelta = ref(10)
const grantMsg = ref('')
const askMsg = ref('')
const banMsg = ref('')
async function load() {
loading.value = true
@@ -46,6 +49,22 @@ async function grantAsk() {
}
}
async function toggleBan() {
banMsg.value = ''
try {
if (detail.value?.status === 'banned') {
await adminApi.unbanUser(String(route.params.id))
banMsg.value = '已解封'
} else {
await adminApi.banUser(String(route.params.id))
banMsg.value = '已封禁'
}
await load()
} catch (e) {
banMsg.value = e instanceof Error ? e.message : '操作失败'
}
}
function fmtTime(iso?: string | null) {
if (!iso) return '—'
try {
@@ -83,6 +102,12 @@ onMounted(load)
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
</div>
<div class="grant">
<button class="btn" type="button" @click="toggleBan">
{{ detail.status === 'banned' ? '解封' : '封禁' }}
</button>
<span v-if="banMsg" class="muted">{{ banMsg }}</span>
</div>
</div>
<div class="card block">
@@ -93,7 +118,7 @@ onMounted(load)
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
到期 {{ fmtTime(detail.membership.expires_at) }}
</p>
<div class="grant">
<div v-if="auth.isSuper" class="grant">
<select v-model="plan">
<option value="month">月卡</option>
<option value="quarter">季卡</option>
@@ -102,12 +127,13 @@ onMounted(load)
<button class="btn" type="button" @click="grant">授予 / 延长</button>
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
</div>
<p v-else class="muted">仅超级管理员可授予会员</p>
</div>
<div class="card block">
<h2>问答额度已购</h2>
<p>已购余量<strong>{{ detail.ask_paid_quota_left }}</strong> </p>
<div class="grant">
<div v-if="auth.isSuper" class="grant">
<select v-model.number="askDelta">
<option :value="10">+10</option>
<option :value="30">+30</option>
+10 -2
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -15,16 +16,23 @@ const router = createRouter({
{ path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { superOnly: true } },
{ path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') },
{ path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},
],
})
router.beforeEach((to) => {
router.beforeEach(async (to) => {
if (to.meta.public) return true
if (!getToken()) return { name: 'login', query: { redirect: to.fullPath } }
if (to.meta.superOnly) {
const auth = useAuthStore()
if (!auth.username) await auth.hydrate()
if (!auth.isSuper) return { name: 'dashboard' }
}
return true
})
+9 -3
View File
@@ -1,16 +1,20 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { adminApi, getToken, setToken } from '@/api/client'
import { computed, ref } from 'vue'
import { adminApi, getToken, setToken, type AdminRole } from '@/api/client'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(getToken())
const username = ref<string>('')
const role = ref<AdminRole>('ops')
const isSuper = computed(() => role.value === 'super')
async function login(user: string, password: string) {
const res = await adminApi.login(user, password)
setToken(res.token)
token.value = res.token
username.value = res.admin.username
role.value = res.admin.role || 'super'
}
async function hydrate() {
@@ -18,6 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
try {
const me = await adminApi.me()
username.value = me.username
role.value = me.role || 'super'
return true
} catch {
setToken(null)
@@ -35,7 +40,8 @@ export const useAuthStore = defineStore('auth', () => {
setToken(null)
token.value = null
username.value = ''
role.value = 'ops'
}
return { token, username, login, logout, hydrate }
return { token, username, role, isSuper, login, logout, hydrate }
})
+13
View File
@@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
authed.GET("/analytics/clicks", h.AnalyticsClicks)
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
h.registerContent(authed)
h.registerSystem(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -143,6 +144,10 @@ func (h *AdminHandler) GrantMembership(c *gin.Context) {
return
}
if err := h.Svc.GrantMembership(c.Request.Context(), adminID, userID, body.Plan); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, admin.ErrInvalidPlan) {
response.Fail(c, http.StatusBadRequest, 40004, "invalid plan")
return
@@ -175,6 +180,10 @@ func (h *AdminHandler) GrantAskQuota(c *gin.Context) {
}
left, err := h.Svc.GrantAskQuota(c.Request.Context(), adminID, userID, body.Delta)
if err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, admin.ErrInvalidAskDelta) {
response.Fail(c, http.StatusBadRequest, 40006, "invalid delta")
return
@@ -239,6 +248,10 @@ func (h *AdminHandler) PutPlanPrices(c *gin.Context) {
prices = append(prices, repository.PlanPrice{Plan: it.Plan, DisplayCents: it.DisplayCents})
}
if err := h.Svc.UpsertPlanPrices(c.Request.Context(), adminID, prices); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
response.Fail(c, http.StatusBadRequest, 40041, "upsert plan prices failed")
return
}
@@ -43,6 +43,10 @@ func (h *AdminHandler) ReplaceHomeTools(c *gin.Context) {
return
}
if err := h.Svc.ReplaceHomeTools(c.Request.Context(), adminID, body.Items); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, homesvc.ErrInvalidTools) || errors.Is(err, homesvc.ErrTooManyTools) {
response.Fail(c, http.StatusBadRequest, 40041, err.Error())
return
@@ -81,6 +85,10 @@ func (h *AdminHandler) PatchScale(c *gin.Context) {
return
}
if err := h.Svc.PatchScaleStatus(c.Request.Context(), adminID, id, body.Status); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, admin.ErrInvalidScaleStatus) {
response.Fail(c, http.StatusBadRequest, 40044, "invalid status")
return
+201
View File
@@ -0,0 +1,201 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
authed.POST("/users/:id/ban", h.BanUser)
authed.POST("/users/:id/unban", h.UnbanUser)
authed.GET("/admins", h.ListAdmins)
authed.PATCH("/admins/:id", h.PatchAdmin)
authed.GET("/push-jobs", h.ListPushJobs)
authed.POST("/push-jobs", h.CreatePushJob)
authed.PATCH("/push-jobs/:id", h.PatchPushJob)
}
func (h *AdminHandler) BanUser(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
uid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40050, "invalid user id")
return
}
if err := h.Svc.BanUser(c.Request.Context(), adminID, uid); err != nil {
if errors.Is(err, admin.ErrUserNotFound) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50040, "ban failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) UnbanUser(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
uid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40050, "invalid user id")
return
}
if err := h.Svc.UnbanUser(c.Request.Context(), adminID, uid); err != nil {
if errors.Is(err, admin.ErrUserNotFound) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50041, "unban failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) ListAdmins(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
items, err := h.Svc.ListAdmins(c.Request.Context(), adminID)
if err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
response.Fail(c, http.StatusInternalServerError, 50042, "list admins failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) PatchAdmin(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
targetID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40051, "invalid admin id")
return
}
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" {
response.Fail(c, http.StatusBadRequest, 40052, "role required")
return
}
if err := h.Svc.UpdateAdminRole(c.Request.Context(), adminID, targetID, body.Role); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, admin.ErrInvalidRole) {
response.Fail(c, http.StatusBadRequest, 40053, "invalid role")
return
}
if errors.Is(err, admin.ErrLastSuper) {
response.Fail(c, http.StatusConflict, 40901, "cannot demote last super")
return
}
if errors.Is(err, admin.ErrAdminNotFound) {
response.Fail(c, http.StatusNotFound, 40420, "admin not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50043, "patch admin failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) ListPushJobs(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, err := h.Svc.ListPushJobs(c.Request.Context(), limit, offset)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50044, "list push jobs failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) CreatePushJob(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body struct {
Title string `json:"title"`
Body string `json:"body"`
Audience string `json:"audience"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Title == "" {
response.Fail(c, http.StatusBadRequest, 40054, "title required")
return
}
job, err := h.Svc.CreatePushJob(c.Request.Context(), adminID, body.Title, body.Body, body.Audience)
if err != nil {
if errors.Is(err, admin.ErrInvalidPush) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid push job")
return
}
response.Fail(c, http.StatusInternalServerError, 50045, "create push job failed")
return
}
response.OK(c, job)
}
func (h *AdminHandler) PatchPushJob(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
jobID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40056, "invalid job id")
return
}
var body struct {
Title *string `json:"title"`
Body *string `json:"body"`
Status *string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40057, "invalid body")
return
}
job, err := h.Svc.UpdatePushJob(c.Request.Context(), adminID, jobID, body.Title, body.Body, body.Status)
if err != nil {
if errors.Is(err, admin.ErrPushNotFound) {
response.Fail(c, http.StatusNotFound, 40430, "push job not found")
return
}
if errors.Is(err, admin.ErrInvalidPush) {
response.Fail(c, http.StatusBadRequest, 40058, "invalid push job")
return
}
response.Fail(c, http.StatusInternalServerError, 50046, "patch push job failed")
return
}
response.OK(c, job)
}
+2 -1
View File
@@ -77,7 +77,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
// Logout handles POST /auth/logout.
func (h *AuthHandler) Logout(c *gin.Context) {
tok := bearerToken(c)
_ = h.Svc.Logout(c.Request.Context(), tok)
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
_ = h.Svc.Logout(c.Request.Context(), tok, deviceKey)
response.OK(c, gin.H{"ok": true})
}
@@ -0,0 +1,25 @@
package integration_test
import (
"net/http"
"testing"
)
// Logout must revoke session AND unbind device so refresh is not still "logged in".
func TestAuthLogoutUnbindsDevice(t *testing.T) {
r, _ := setupAPI(t)
key := mustRegister(t, r)
_, key = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, key)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/auth/logout", nil, key)
testBearer = ""
_, _, status := doJSONExpect(t, r, http.MethodGet, "/api/v1/auth/me", nil, key, 40112)
if status != http.StatusUnauthorized {
t.Fatalf("after logout GET /auth/me want HTTP 401, got %d", status)
}
_, key, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40112)
_ = key
}
@@ -0,0 +1,197 @@
package integration_test
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
)
func TestOpsESystemRBACBanPush(t *testing.T) {
r, _ := setupAPI(t)
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
"username": "admin",
"password": "change-me",
}, "")
if code != 200 || env.Code != 0 {
t.Fatalf("super login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
var login struct {
Token string `json:"token"`
Admin struct {
ID string `json:"id"`
Role string `json:"role"`
} `json:"admin"`
}
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
t.Fatalf("login parse: %v %s", err, env.Data)
}
if login.Admin.Role != "super" {
t.Fatalf("bootstrap admin role want super got %q", login.Admin.Role)
}
superTok := login.Token
opsUser := "ops_" + uuid.NewString()[:8]
opsPass := "ops-pass-1"
insertOpsAdmin(t, opsUser, opsPass)
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
"username": opsUser,
"password": opsPass,
}, "")
if code != 200 || env.Code != 0 {
t.Fatalf("ops login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
_ = json.Unmarshal(env.Data, &login)
opsTok := login.Token
if login.Admin.Role != "ops" {
t.Fatalf("ops role want ops got %q", login.Admin.Role)
}
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership/plan-prices", map[string]any{
"items": []map[string]any{{"plan": "month", "display_cents": 990}},
}, opsTok)
if code != http.StatusForbidden || env.Code != 40301 {
t.Fatalf("ops put prices want 40301, got http=%d code=%d", code, env.Code)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, opsTok)
if code != http.StatusForbidden || env.Code != 40301 {
t.Fatalf("ops list admins want 40301, got http=%d code=%d", code, env.Code)
}
key := mustRegister(t, r)
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, opsTok)
if code != 200 || env.Code != 0 {
t.Fatalf("ops list users failed http=%d code=%d", code, env.Code)
}
var list struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
if err := json.Unmarshal(env.Data, &list); err != nil || len(list.Items) == 0 {
t.Fatalf("users: %v %s", err, env.Data)
}
userID := list.Items[0].ID
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/membership/grant", map[string]string{
"plan": "month",
}, opsTok)
if code != http.StatusForbidden || env.Code != 40301 {
t.Fatalf("ops grant want 40301, got http=%d code=%d", code, env.Code)
}
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/ban", nil, opsTok)
if code != 200 || env.Code != 0 {
t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
env, _, httpStatus := doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40310)
if httpStatus != http.StatusForbidden {
t.Fatalf("banned device want HTTP 403, got %d code=%d", httpStatus, env.Code)
}
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/unban", nil, opsTok)
if code != 200 || env.Code != 0 {
t.Fatalf("unban failed http=%d code=%d", code, env.Code)
}
_, key = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, key)
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
"title": "测试推送",
"body": "不下发",
}, opsTok)
if code != 200 || env.Code != 0 {
t.Fatalf("create push failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
var job struct {
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(env.Data, &job); err != nil || job.ID == "" || job.Status != "draft" {
t.Fatalf("push job: %v %s", err, env.Data)
}
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/push-jobs/"+job.ID, map[string]string{
"status": "cancelled",
}, opsTok)
if code != 200 || env.Code != 0 {
t.Fatalf("cancel push failed http=%d code=%d", code, env.Code)
}
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
"title": strings.Repeat("超", 129),
"body": "too long",
}, opsTok)
if code != http.StatusBadRequest || env.Code != 40055 {
t.Fatalf("long title want 40055, got http=%d code=%d", code, env.Code)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, superTok)
if code != 200 || env.Code != 0 {
t.Fatalf("list admins as super failed")
}
var admins struct {
Items []struct {
ID string `json:"id"`
Role string `json:"role"`
} `json:"items"`
}
if err := json.Unmarshal(env.Data, &admins); err != nil {
t.Fatalf("admins parse: %v", err)
}
var soleSuper string
superCount := 0
for _, a := range admins.Items {
if a.Role == "super" {
superCount++
soleSuper = a.ID
}
}
if superCount != 1 || soleSuper == "" {
t.Fatalf("want exactly 1 super, got %d", superCount)
}
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/admins/"+soleSuper, map[string]string{
"role": "ops",
}, superTok)
if code != http.StatusConflict || env.Code != 40901 {
t.Fatalf("demote last super want 40901, got http=%d code=%d msg=%s", code, env.Code, env.Message)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok)
if code != 200 || env.Code != 0 {
t.Fatalf("super me failed")
}
_ = env
}
func insertOpsAdmin(t *testing.T, username, password string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cfg := config.Load()
pool, err := db.Connect(ctx, cfg.DatabaseURL)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash: %v", err)
}
_, err = pool.Exec(ctx, `
INSERT INTO admin_accounts(username, password_hash, role, status)
VALUES ($1,$2,'ops','active')`, username, string(hash))
if err != nil {
t.Fatalf("insert ops admin: %v", err)
}
}
+31
View File
@@ -38,6 +38,15 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, tok,
).Scan(&uid)
if err == nil {
if banned, berr := userIsBanned(c.Request.Context(), pool, uid); berr != nil {
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
c.Abort()
return
} else if banned {
response.Fail(c, http.StatusForbidden, 40310, "账号已封禁")
c.Abort()
return
}
_, _ = pool.Exec(c.Request.Context(), `
INSERT INTO device_identities(device_key, user_id)
VALUES ($1,$2)
@@ -56,6 +65,15 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
c.Abort()
return
}
if banned, berr := userIsBanned(c.Request.Context(), pool, userID); berr != nil {
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
c.Abort()
return
} else if banned {
response.Fail(c, http.StatusForbidden, 40310, "账号已封禁")
c.Abort()
return
}
c.Set(string(UserIDKey), userID.String())
c.Header(DeviceKeyHeader, key)
c.Next()
@@ -149,3 +167,16 @@ func newDeviceKey() string {
_, _ = rand.Read(b)
return "dev_" + hex.EncodeToString(b)
}
func userIsBanned(ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID) (bool, error) {
var status string
err := pool.QueryRow(ctx, `
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return status == "banned", nil
}
@@ -0,0 +1,276 @@
package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var (
// ErrAdminNotFound is returned when an admin account row is missing.
ErrAdminNotFound = errors.New("admin not found")
// ErrPushJobNotFound is returned when a push_jobs row is missing.
ErrPushJobNotFound = errors.New("push job not found")
// ErrUserStatusNotFound is returned when users row is missing for status update.
ErrUserStatusNotFound = errors.New("user not found")
)
// AdminListItem is a public admin row (no password).
type AdminListItem struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// PushJob is a push campaign stub (never dispatched).
type PushJob struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Audience string `json:"audience"`
Status string `json:"status"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SetUserStatusWithAudit updates users.status and writes audit.
func (r *AdminRepo) SetUserStatusWithAudit(
ctx context.Context,
adminID, userID uuid.UUID,
status, action string,
meta json.RawMessage,
) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE users SET status=$2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`, userID, status)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrUserStatusNotFound
}
if status == "banned" {
_, _ = tx.Exec(ctx, `
UPDATE user_sessions SET revoked_at=now()
WHERE user_id=$1 AND revoked_at IS NULL`, userID)
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,$2,'user',$3,$4)`, adminID, action, userID.String(), meta); err != nil {
return err
}
return tx.Commit(ctx)
}
// ListAdmins returns non-deleted admin accounts.
func (r *AdminRepo) ListAdmins(ctx context.Context) ([]AdminListItem, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, username, role, status, created_at
FROM admin_accounts
WHERE deleted_at IS NULL
ORDER BY created_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AdminListItem
for rows.Next() {
var a AdminListItem
if err := rows.Scan(&a.ID, &a.Username, &a.Role, &a.Status, &a.CreatedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// CountAdminsByRole counts non-deleted admins with the given role.
func (r *AdminRepo) CountAdminsByRole(ctx context.Context, role string) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT count(*) FROM admin_accounts
WHERE deleted_at IS NULL AND role=$1`, role).Scan(&n)
return n, err
}
// UpdateAdminRoleWithAudit sets role for an admin account.
func (r *AdminRepo) UpdateAdminRoleWithAudit(
ctx context.Context,
actorID, targetID uuid.UUID,
role string,
meta json.RawMessage,
) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE admin_accounts SET role=$2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`, targetID, role)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrAdminNotFound
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'admin.role_update','admin',$2,$3)`, actorID, targetID.String(), meta); err != nil {
return err
}
return tx.Commit(ctx)
}
// ListPushJobs returns newest push stubs.
func (r *AdminRepo) ListPushJobs(ctx context.Context, limit, offset int) ([]PushJob, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
rows, err := r.Pool.Query(ctx, `
SELECT id, title, body, audience, status, created_by, created_at, updated_at
FROM push_jobs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PushJob
for rows.Next() {
var j PushJob
if err := rows.Scan(
&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
// CreatePushJobWithAudit inserts a draft push job.
func (r *AdminRepo) CreatePushJobWithAudit(
ctx context.Context,
adminID uuid.UUID,
title, body, audience string,
meta json.RawMessage,
) (*PushJob, error) {
if audience == "" {
audience = "all"
}
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var j PushJob
err = tx.QueryRow(ctx, `
INSERT INTO push_jobs(title, body, audience, status, created_by)
VALUES ($1,$2,$3,'draft',$4)
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
title, body, audience, adminID,
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
if err != nil {
return nil, err
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'push_job.create','push_job',$2,$3)`, adminID, j.ID.String(), meta); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &j, nil
}
// UpdatePushJobWithAudit updates title/body/status (draft|cancelled only).
func (r *AdminRepo) UpdatePushJobWithAudit(
ctx context.Context,
adminID, jobID uuid.UUID,
title, body, status *string,
meta json.RawMessage,
) (*PushJob, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var j PushJob
err = tx.QueryRow(ctx, `
SELECT id, title, body, audience, status, created_by, created_at, updated_at
FROM push_jobs WHERE id=$1`, jobID,
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPushJobNotFound
}
if err != nil {
return nil, err
}
if title != nil {
j.Title = *title
}
if body != nil {
j.Body = *body
}
if status != nil {
j.Status = *status
}
err = tx.QueryRow(ctx, `
UPDATE push_jobs SET title=$2, body=$3, status=$4, updated_at=now()
WHERE id=$1
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
jobID, j.Title, j.Body, j.Status,
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
if err != nil {
return nil, err
}
if meta == nil {
meta = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'push_job.update','push_job',$2,$3)`, adminID, jobID.String(), meta); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &j, nil
}
// UserStatus returns users.status or empty if missing.
func (r *AdminRepo) UserStatus(ctx context.Context, userID uuid.UUID) (string, error) {
var status string
err := r.Pool.QueryRow(ctx, `
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return "", errors.New("user not found")
}
return status, err
}
+8 -7
View File
@@ -23,6 +23,7 @@ type AdminAccount struct {
Username string
PasswordHash string
Status string
Role string
}
// CountAccounts returns non-deleted admin count.
@@ -33,12 +34,12 @@ func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
return n, err
}
// CreateAccount inserts an admin account.
// CreateAccount inserts an admin account (role defaults to super).
func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
INSERT INTO admin_accounts(username, password_hash)
VALUES ($1,$2) RETURNING id`, username, hash).Scan(&id)
INSERT INTO admin_accounts(username, password_hash, role)
VALUES ($1,$2,'super') RETURNING id`, username, hash).Scan(&id)
return id, err
}
@@ -46,10 +47,10 @@ func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (u
func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*AdminAccount, error) {
var a AdminAccount
err := r.Pool.QueryRow(ctx, `
SELECT id, username, password_hash, status
SELECT id, username, password_hash, status, role
FROM admin_accounts
WHERE username=$1 AND deleted_at IS NULL`, username,
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status, &a.Role)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@@ -63,10 +64,10 @@ func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*Admin
func (r *AdminRepo) FindAccountByID(ctx context.Context, id uuid.UUID) (*AdminAccount, error) {
var a AdminAccount
err := r.Pool.QueryRow(ctx, `
SELECT id, username, password_hash, status
SELECT id, username, password_hash, status, role
FROM admin_accounts
WHERE id=$1 AND deleted_at IS NULL`, id,
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status, &a.Role)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
+27
View File
@@ -116,6 +116,33 @@ func (r *AuthRepo) BindDevice(ctx context.Context, deviceKey string, userID uuid
return err
}
// RebindDeviceAnonymous creates a fresh anonymous user and points the device at it.
// Used on logout so the device is no longer tied to the registered account (Spec R5).
func (r *AuthRepo) RebindDeviceAnonymous(ctx context.Context, deviceKey string) error {
if deviceKey == "" {
return nil
}
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var uid uuid.UUID
if err := tx.QueryRow(ctx, `INSERT INTO users DEFAULT VALUES RETURNING id`).Scan(&uid); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO device_identities(device_key, user_id)
VALUES ($1,$2)
ON CONFLICT (device_key) DO UPDATE SET user_id=$2, updated_at=now(), deleted_at=NULL`,
deviceKey, uid,
); err != nil {
return err
}
return tx.Commit(ctx)
}
// CreateSession inserts a session token.
func (r *AuthRepo) CreateSession(ctx context.Context, userID uuid.UUID, token string, expires time.Time) error {
_, err := r.Pool.Exec(ctx, `
@@ -27,6 +27,9 @@ func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, err
// ReplaceHomeTools replaces grid and audits in one transaction.
func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
if err := s.RequireSuper(ctx, adminID); err != nil {
return err
}
if s.Home == nil {
return errors.New("home unavailable")
}
@@ -44,6 +47,9 @@ func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminI
// PatchScaleStatus updates published|draft and audits in one transaction.
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
if err := s.RequireSuper(ctx, adminID); err != nil {
return err
}
if status != "published" && status != "draft" {
return ErrInvalidScaleStatus
}
+12 -2
View File
@@ -58,6 +58,7 @@ type LoginResult struct {
type AdminMe struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
}
var (
@@ -93,7 +94,7 @@ func (s *Service) Login(ctx context.Context, username, password string) (*LoginR
return &LoginResult{
Token: token,
ExpiresAt: exp,
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
Admin: AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role},
}, nil
}
@@ -116,7 +117,7 @@ func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
if err != nil || acc == nil {
return nil, errors.New("admin not found")
}
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role}, nil
}
// ListUsers lists terminal users.
@@ -197,6 +198,9 @@ type GrantInput struct {
// GrantMembership extends membership and writes audit.
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
if err := s.RequireSuper(ctx, adminID); err != nil {
return err
}
days, err := planDays(plan)
if err != nil {
return err
@@ -221,6 +225,9 @@ var ErrInvalidAskDelta = errString("invalid ask quota delta")
// GrantAskQuota adds purchased ask quota and audits.
func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID, delta int) (int, error) {
if err := s.RequireSuper(ctx, adminID); err != nil {
return 0, err
}
if delta <= 0 || delta > 1000 {
return 0, ErrInvalidAskDelta
}
@@ -247,6 +254,9 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e
// UpsertPlanPrices updates display prices (not historical order amounts).
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
if err := s.RequireSuper(ctx, adminID); err != nil {
return err
}
if len(items) == 0 {
return ErrInvalidPlan
}
+154
View File
@@ -0,0 +1,154 @@
package admin
import (
"context"
"encoding/json"
"errors"
"unicode/utf8"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
const (
RoleSuper = "super"
RoleOps = "ops"
pushTitleMaxRunes = 128
)
var (
ErrForbidden = errString("forbidden")
ErrInvalidRole = errString("invalid role")
ErrPushNotFound = errString("push job not found")
ErrInvalidPush = errString("invalid push job")
ErrAdminNotFound = errString("admin not found")
ErrLastSuper = errString("cannot demote last super")
)
// RequireSuper returns ErrForbidden unless admin role is super.
func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error {
acc, err := s.Repo.FindAccountByID(ctx, adminID)
if err != nil || acc == nil {
return ErrAdminNotFound
}
if acc.Role != RoleSuper {
return ErrForbidden
}
return nil
}
// BanUser sets users.status=banned.
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"status": "banned"})
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "banned", "user.ban", meta)
if errors.Is(err, repository.ErrUserStatusNotFound) {
return ErrUserNotFound
}
return err
}
// UnbanUser sets users.status=active.
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"status": "active"})
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "active", "user.unban", meta)
if errors.Is(err, repository.ErrUserStatusNotFound) {
return ErrUserNotFound
}
return err
}
// ListAdmins returns admin accounts (super only caller).
func (s *Service) ListAdmins(ctx context.Context, actorID uuid.UUID) ([]repository.AdminListItem, error) {
if err := s.RequireSuper(ctx, actorID); err != nil {
return nil, err
}
return s.Repo.ListAdmins(ctx)
}
// UpdateAdminRole changes an admin role (super only).
func (s *Service) UpdateAdminRole(ctx context.Context, actorID, targetID uuid.UUID, role string) error {
if err := s.RequireSuper(ctx, actorID); err != nil {
return err
}
if role != RoleSuper && role != RoleOps {
return ErrInvalidRole
}
target, err := s.Repo.FindAccountByID(ctx, targetID)
if err != nil || target == nil {
return ErrAdminNotFound
}
if target.Role == RoleSuper && role != RoleSuper {
n, err := s.Repo.CountAdminsByRole(ctx, RoleSuper)
if err != nil {
return err
}
if n <= 1 {
return ErrLastSuper
}
}
meta, _ := json.Marshal(map[string]any{"role": role})
err = s.Repo.UpdateAdminRoleWithAudit(ctx, actorID, targetID, role, meta)
if errors.Is(err, repository.ErrAdminNotFound) {
return ErrAdminNotFound
}
return err
}
// ListPushJobs lists push stubs.
func (s *Service) ListPushJobs(ctx context.Context, limit, offset int) ([]repository.PushJob, error) {
return s.Repo.ListPushJobs(ctx, limit, offset)
}
// CreatePushJob creates a draft push stub.
func (s *Service) CreatePushJob(ctx context.Context, adminID uuid.UUID, title, body, audience string) (*repository.PushJob, error) {
if err := validatePushTitle(title); err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"title": title})
return s.Repo.CreatePushJobWithAudit(ctx, adminID, title, body, audience, meta)
}
// UpdatePushJob updates a push stub.
func (s *Service) UpdatePushJob(
ctx context.Context,
adminID, jobID uuid.UUID,
title, body, status *string,
) (*repository.PushJob, error) {
if status != nil && *status != "draft" && *status != "cancelled" {
return nil, ErrInvalidPush
}
if title != nil {
if err := validatePushTitle(*title); err != nil {
return nil, err
}
}
meta, _ := json.Marshal(map[string]any{})
job, err := s.Repo.UpdatePushJobWithAudit(ctx, adminID, jobID, title, body, status, meta)
if errors.Is(err, repository.ErrPushJobNotFound) {
return nil, ErrPushNotFound
}
return job, err
}
func validatePushTitle(title string) error {
if title == "" || utf8.RuneCountInString(title) > pushTitleMaxRunes {
return ErrInvalidPush
}
return nil
}
+8 -5
View File
@@ -93,12 +93,15 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
return s.issue(ctx, uid, phone, nickname)
}
// Logout revokes bearer token.
func (s *Service) Logout(ctx context.Context, token string) error {
if token == "" {
return nil
// Logout revokes the current bearer session and unbinds the device from the account
// so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5).
func (s *Service) Logout(ctx context.Context, token, deviceKey string) error {
if token != "" {
if err := s.Repo.RevokeSession(ctx, token); err != nil {
return err
}
}
return s.Repo.RevokeSession(ctx, token)
return s.Repo.RebindDeviceAnonymous(ctx, deviceKey)
}
// Me returns account if registered.
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_push_jobs_created;
DROP TABLE IF EXISTS push_jobs;
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_status_check;
ALTER TABLE admin_accounts DROP CONSTRAINT IF EXISTS admin_accounts_role_check;
ALTER TABLE admin_accounts DROP COLUMN IF EXISTS role;
@@ -0,0 +1,34 @@
-- Ops-E: RBAC role · user status check · push_jobs stub
ALTER TABLE admin_accounts
ADD COLUMN IF NOT EXISTS role varchar(16) NOT NULL DEFAULT 'super';
ALTER TABLE admin_accounts
DROP CONSTRAINT IF EXISTS admin_accounts_role_check;
ALTER TABLE admin_accounts
ADD CONSTRAINT admin_accounts_role_check
CHECK (role IN ('super', 'ops'));
UPDATE admin_accounts SET role = 'super' WHERE role IS NULL OR role = '';
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_status_check;
ALTER TABLE users
ADD CONSTRAINT users_status_check
CHECK (status IN ('active', 'banned'));
CREATE TABLE IF NOT EXISTS push_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title varchar(128) NOT NULL,
body text NOT NULL DEFAULT '',
audience varchar(64) NOT NULL DEFAULT 'all',
status varchar(32) NOT NULL DEFAULT 'draft',
created_by uuid NOT NULL REFERENCES admin_accounts(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT push_jobs_status_check CHECK (status IN ('draft', 'cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_push_jobs_created ON push_jobs(created_at DESC);
@@ -14,6 +14,14 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
const url = route.request().url()
const method = route.request().method()
if (url.includes('/auth/me')) {
await route.fulfill(ok({ id: 'u1', phone: '13800000000' }))
return
}
if (url.includes('/profiles') && method === 'GET') {
await route.fulfill(ok({ items: [] }))
return
}
if (url.includes('/profiles') && method === 'POST') {
await route.fulfill(ok({
id: 'prof-e2e',
+23 -1
View File
@@ -7,6 +7,28 @@ test('home birth form opens portrait with summary and deep CTA', async ({ page }
const url = req.url()
const method = req.method()
if (url.includes('/auth/me')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'success',
data: { id: 'u1', phone: '13800000000' },
}),
headers: { 'X-Device-Key': 'e2e-device' },
})
return
}
if (url.includes('/profiles') && method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, message: 'success', data: { items: [] } }),
headers: { 'X-Device-Key': 'e2e-device' },
})
return
}
if (url.includes('/profiles') && method === 'POST') {
await route.fulfill({
status: 200,
@@ -64,7 +86,7 @@ test('home birth form opens portrait with summary and deep CTA', async ({ page }
await page.goto('/psy/portrait?y=1990&m=5&d=12')
await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible()
await expect(page.getByRole('heading', { name: '愈心解码' })).toBeVisible()
await expect(page.getByRole('heading', { name: '愈心解码', level: 1 })).toBeVisible()
await expect(page.getByText('模拟摘要').first()).toBeVisible()
await expect(page.getByRole('link', { name: /在报告页打开/ })).toBeVisible()
await expect(page.getByRole('button', { name: /解锁完整分析/ }).first()).toBeVisible()
+7 -1
View File
@@ -1,7 +1,8 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
export const askScenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
@@ -25,6 +26,7 @@ export const askAdvisors = [
export function useAskPage() {
const router = useRouter()
const route = useRoute()
const rail = ref<'ai' | 'advisor'>('ai')
const bootLoading = ref(true)
@@ -43,6 +45,10 @@ export function useAskPage() {
async function boot() {
bootLoading.value = true
bootError.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
bootLoading.value = false
return
}
try {
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
profiles.value = list.items || []
@@ -1,8 +1,10 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
export const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
@@ -12,6 +14,8 @@ export const promptRows = [
]
export function useImageCardPage() {
const router = useRouter()
const route = useRoute()
const scenes = ref<{ key: string; label: string }[]>([])
const scene = ref('情绪整理')
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
@@ -70,6 +74,7 @@ export function useImageCardPage() {
}
async function boot() {
if (!(await ensureAccount(router, route.fullPath))) return
try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || []
@@ -82,6 +87,7 @@ export function useImageCardPage() {
}
async function draw() {
if (!(await ensureAccount(router, route.fullPath))) return
error.value = ''
const y = Number(year.value)
const m = Number(month.value)
@@ -70,7 +70,7 @@ export function useLifeRhythmPage() {
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.getLatestReport(profile.id, 'rhythm')
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
@@ -46,7 +46,7 @@ export function usePortraitPage() {
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.getLatestReport(profile.id, 'portrait')
report.value = await api.createPortrait(profile.id)
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
@@ -1,7 +1,9 @@
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
import type { RelationSharePayload } from '../lib/shareLink'
export const focusTabs = [
@@ -24,6 +26,8 @@ export const resultTabs = [
]
export function useRelationPage() {
const router = useRouter()
const route = useRoute()
const otherName = ref('TA')
const relationType = ref('partner')
const oy = ref('1992')
@@ -97,6 +101,7 @@ export function useRelationPage() {
}
async function run() {
if (!(await ensureAccount(router, route.fullPath))) return
loading.value = true
error.value = ''
needSelf.value = false
@@ -1,7 +1,8 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import {
@@ -33,6 +34,7 @@ export { reportStarPanels, reportSynTabs, reportFortuneKeys }
export function useReportPage() {
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
@@ -91,6 +93,10 @@ export function useReportPage() {
loading.value = true
error.value = ''
report.value = null
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const id = String(route.params.id || '')
if (!id) throw new Error('缺少报告 id')
+7 -1
View File
@@ -1,11 +1,13 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
export function useScalePage() {
const route = useRoute()
const router = useRouter()
const slug = String(route.params.slug || '')
const title = ref('')
const description = ref('')
@@ -100,6 +102,10 @@ export function useScalePage() {
loadingScale.value = true
loadError.value = ''
needProfile.value = false
if (!(await ensureAccount(router, route.fullPath))) {
loadingScale.value = false
return
}
try {
const d = await api.getScale(slug)
title.value = d.title
@@ -118,7 +118,7 @@ export function useStarProfilePage() {
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.getLatestReport(profile.id, 'star')
report.value = await api.createStar(profile.id)
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
@@ -18,6 +18,7 @@ import {
sectionsFromDetail,
type SynastryMainTab,
} from '../lib/synastryChart'
import { ensureAccount } from '../lib/authSession'
import { createSynastryPageActions } from './synastryPageActions'
export type MainTab = SynastryMainTab
@@ -135,6 +136,7 @@ export function useSynastryPage() {
})
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) return
await loadProfiles()
if (route.query.invite === '1') {
inviteHighlight.value = true
+1 -1
View File
@@ -29,7 +29,7 @@ export async function ensureAccount(router: Router, redirect: string): Promise<A
export async function findSelfProfile(): Promise<Profile | null> {
const { items } = await api.listProfiles()
return items.find((p) => p.relation === 'self') || null
return (items ?? []).find((p) => p.relation === 'self') || null
}
/** Load cached solo report for self profile; null report means need archive. */
+4 -2
View File
@@ -5,6 +5,7 @@ import AskPage from './AskPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getAskQuota: vi.fn(),
createAskThread: vi.fn(),
@@ -16,8 +17,8 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn(), push: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/ask' }),
useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }),
}))
const RouterLinkStub = defineComponent({
@@ -30,6 +31,7 @@ const RouterLinkStub = defineComponent({
describe('AskPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('shows empty state when no profiles', async () => {
+9 -3
View File
@@ -4,20 +4,26 @@ import CompanionPage from './CompanionPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getSolarTermsToday: vi.fn(),
getMoodToday: vi.fn(),
getMoodsRecent: vi.fn(),
saveMood: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ path: '/companion' }),
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ path: '/companion', fullPath: '/companion' }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
describe('CompanionPage', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.getMoodsRecent.mockResolvedValue({ items: [] })
})
it('shows solar term and saves mood', async () => {
api.getSolarTermsToday.mockResolvedValue({ name: '立秋', tip: '收敛节奏', date: '2026-08-02' })
+6
View File
@@ -91,11 +91,15 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const moodMarks = ['◦', '○', '◎', '●', '◉']
const term = ref({ name: '…', tip: '加载中…', date: '' })
const score = ref<number | null>(null)
@@ -111,6 +115,7 @@ const todayLabel = computed(() =>
)
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
track('companion_viewed')
try {
term.value = await api.getSolarTermsToday()
@@ -135,6 +140,7 @@ async function load() {
async function save() {
if (!score.value) return
if (!(await ensureAccount(router, route.fullPath))) return
saving.value = true
moodErr.value = ''
saved.value = false
@@ -33,11 +33,12 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
type Item = {
@@ -59,6 +60,7 @@ type Cat = {
}
const route = useRoute()
const router = useRouter()
const cat = ref<Cat | null>(null)
const loading = ref(true)
const error = ref('')
@@ -67,6 +69,10 @@ async function load() {
loading.value = true
error.value = ''
cat.value = null
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
cat.value = await api.getExploreCategory(String(route.params.category))
} catch (e) {
+9
View File
@@ -53,11 +53,13 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
type Cat = {
@@ -69,6 +71,9 @@ type Cat = {
items?: { badge?: string }[]
}
const router = useRouter()
const route = useRoute()
const featured: { to: string; label: string; icon: HomeToolIconName; badge?: string; badgeTone?: 'hot' | 'new' }[] = [
{ to: '/scales/mbti-lite', label: '人格测试', icon: 'mbti' },
{ to: '/star', label: '星座', icon: 'star' },
@@ -96,6 +101,10 @@ function hotBadge(c: Cat) {
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.getExploreCatalog()
categories.value = res.categories || []
@@ -41,11 +41,15 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const plans = ref<{ id: string; title: string; focus: string }[]>([])
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
const title = ref('')
@@ -57,6 +61,10 @@ const err = ref('')
async function load() {
loading.value = true
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.listGrowthPlans()
plans.value = res.items || []
+4 -2
View File
@@ -4,6 +4,7 @@ import ImageCardPage from './ImageCardPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listImageCardScenes: vi.fn(),
getImageCardQuota: vi.fn(),
createProfile: vi.fn(),
@@ -16,13 +17,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/cards' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('ImageCardPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listImageCardScenes.mockResolvedValue({
items: [
{ key: '情绪整理', label: '情绪整理' },
+10 -3
View File
@@ -4,6 +4,9 @@ import LifeRhythmPage from './LifeRhythmPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getLatestReport: vi.fn(),
createProfile: vi.fn(),
createRhythm: vi.fn(),
getReport: vi.fn(),
@@ -14,12 +17,16 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' } }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' }, fullPath: '/rhythm' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('LifeRhythmPage', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({ items: [] })
})
it('creates rhythm report from birth query', async () => {
api.createProfile.mockResolvedValue({ id: 'p1' })
+10 -1
View File
@@ -83,7 +83,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
@@ -126,6 +126,15 @@ async function submit() {
busy.value = false
}
}
onMounted(async () => {
try {
await api.authMe()
await router.replace(String(route.query.redirect || '/mine'))
} catch {
/* stay on login */
}
})
</script>
<style scoped>
@@ -4,6 +4,7 @@ import MembershipPage from './MembershipPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getMembership: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
@@ -13,13 +14,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/membership' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('MembershipPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('shows subscribe CTA when inactive', async () => {
@@ -55,6 +55,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
@@ -62,6 +63,10 @@ import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const plans = [
{ key: 'month', label: '月卡', price: '模拟开通', desc: '按月灵活体验', featured: false, tag: '' },
@@ -93,6 +98,10 @@ function formatDate(iso: string) {
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
me.value = await api.getMembership()
} catch (e) {
+13 -6
View File
@@ -71,7 +71,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import BrandLogo from '../components/BrandLogo.vue'
@@ -80,8 +79,6 @@ import PageShell from '../components/PageShell.vue'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { clearAuthToken } from '../lib/authSession'
const router = useRouter()
const groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/profile', label: '个人档案', icon: 'portrait' },
{ to: '/reports', label: '我的成长报告', icon: 'reports' },
@@ -121,14 +118,22 @@ async function load() {
error.value = ''
try {
me.value = await api.authMe()
} catch {
// + 401
me.value = null
membership.value = null
profileCount.value = 0
loading.value = false
return
}
try {
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
me.value = null
membership.value = null
profileCount.value = 0
error.value = e instanceof Error ? e.message : '请先登录'
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
@@ -142,7 +147,9 @@ async function logout() {
}
clearAuthToken()
me.value = null
await router.push('/login')
membership.value = null
profileCount.value = 0
await load()
}
onMounted(load)
+7 -2
View File
@@ -4,6 +4,9 @@ import PortraitPage from './PortraitPage.vue'
const { api, routeQuery } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getLatestReport: vi.fn(),
createProfile: vi.fn(),
createPortrait: vi.fn(),
getReport: vi.fn(),
@@ -16,8 +19,8 @@ const { api, routeQuery } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: routeQuery, fullPath: '/portrait' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
RouterLink: {
name: 'RouterLink',
props: ['to'],
@@ -57,6 +60,8 @@ describe('PortraitPage', () => {
routeQuery.y = ''
routeQuery.m = ''
routeQuery.d = ''
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({ items: [] })
})
it('shows birth form when opened without query', async () => {
+6 -2
View File
@@ -5,6 +5,7 @@ import ProfilePage from './ProfilePage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: vi.fn(),
@@ -14,7 +15,7 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRoute: () => ({ query: {}, fullPath: '/profile' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
@@ -26,7 +27,10 @@ const RouterLinkStub = defineComponent({
})
describe('ProfilePage', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('shows empty state', async () => {
api.listProfiles.mockResolvedValue({ items: [] })
+4 -2
View File
@@ -4,6 +4,7 @@ import RelationPage from './RelationPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
createProfile: vi.fn(),
createRelationInsight: vi.fn(),
@@ -16,13 +17,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/relation' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('RelationPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('generates relation insight summary and gates tips', async () => {
+8
View File
@@ -65,13 +65,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
const router = useRouter()
const route = useRoute()
const items = ref<GrowthReport[]>([])
const loading = ref(true)
const error = ref('')
@@ -132,6 +136,10 @@ function badgeLabel(b: 'new' | 'hot') {
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.listReports()
items.value = res.items || []
+4 -1
View File
@@ -4,6 +4,7 @@ import ScalePage from './ScalePage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getScale: vi.fn(),
listProfiles: vi.fn(),
submitScale: vi.fn(),
@@ -17,7 +18,8 @@ vi.mock('../lib/scaleDraft', () => ({
clearScaleDraft: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { slug: 'mbti-lite' } }),
useRoute: () => ({ params: { slug: 'mbti-lite' }, fullPath: '/scales/mbti-lite' }),
useRouter: () => ({ replace: vi.fn() }),
}))
const scaleDetail = {
@@ -53,6 +55,7 @@ const scaleDetail = {
describe('ScalePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.getScale.mockResolvedValue(scaleDetail)
api.listProfiles.mockResolvedValue({
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
@@ -4,6 +4,9 @@ import StarProfilePage from './StarProfilePage.vue'
const { api, routeQuery } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getLatestReport: vi.fn(),
createProfile: vi.fn(),
createStar: vi.fn(),
getReport: vi.fn(),
@@ -15,8 +18,8 @@ const { api, routeQuery } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: routeQuery, fullPath: '/star' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
const summary = {
@@ -42,6 +45,8 @@ const summary = {
describe('StarProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({ items: [] })
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createStar.mockResolvedValue({
id: 'r1',
@@ -58,6 +58,7 @@ import BirthDateInputs from '../components/BirthDateInputs.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
const route = useRoute()
const router = useRouter()
@@ -71,6 +72,10 @@ const td = ref('')
const meta = ref<{ host_name: string; already_accepted: boolean } | null>(null)
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
const token = String(route.params.token || '')
try {
meta.value = await api.getSynastryInvite(token)
@@ -82,6 +87,7 @@ onMounted(async () => {
})
async function accept() {
if (!(await ensureAccount(router, route.fullPath))) return
const y = Number(ty.value)
const m = Number(tm.value)
const d = Number(td.value)
+3 -1
View File
@@ -4,6 +4,7 @@ import SynastryPage from './SynastryPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
createProfile: vi.fn(),
createSynastry: vi.fn(),
@@ -18,13 +19,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRoute: () => ({ query: {}, fullPath: '/synastry' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('SynastryPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({
items: [
{ id: 'a1', relation: 'self', display_name: '我', birth_date: '1990-05-12' },