feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-06 18:35:53 +08:00
co-authored by Cursor
parent 4a583c9480
commit 879bf70cb7
59 changed files with 2462 additions and 20 deletions
+10 -1
View File
@@ -1,3 +1,12 @@
# admin-h5
运营后台(内容库、订单、会员)。**业务后置**,当前仅占位
运营后台(内容库、订单、会员)。**Phase AECR-006**:登录 · 用户 · 订单 · 会员授予 · 审计
```bash
# 根目录
npm install
npm run dev:admin
# http://localhost:5174/
```
默认管理员:见 `apps/api/config.example.yaml``admin.bootstrap_*`(仅空库种子)。
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>愈心谷 · 运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+19 -1
View File
@@ -2,5 +2,23 @@
"name": "@yuxingu/admin-h5",
"version": "0.1.0",
"private": true,
"description": "运营后台占位,业务后置"
"type": "module",
"description": "运营后台 Phase AECR-006",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+95
View File
@@ -0,0 +1,95 @@
/** Thin admin API client → /api/v1/admin (proxied to Go). */
export type ApiEnvelope<T> = { code: number; message: string; data?: T }
const TOKEN_KEY = 'yuxingu_admin_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (body !== undefined) headers['Content-Type'] = 'application/json'
const token = getToken()
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`/api/v1/admin${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const env = (await res.json()) as ApiEnvelope<T>
if (!res.ok || env.code !== 0) {
throw new Error(env.message || `HTTP ${res.status}`)
}
return env.data as T
}
export const adminApi = {
login: (username: string, password: string) =>
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
username,
password,
}),
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
me: () => request<{ id: string; username: string }>('GET', '/me'),
users: (q = '') =>
request<{ items: Array<{ id: string; status: string; created_at: string }> }>(
'GET',
`/users?q=${encodeURIComponent(q)}`,
),
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
grant: (id: string, plan: string) =>
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
orders: () =>
request<{
items: Array<{
id: string
user_id: string
kind: string
plan?: string
amount_cents: number
status: string
created_at: string
}>
}>('GET', '/orders'),
audit: () =>
request<{
items: Array<{
id: string
admin_id: string
action: string
target_type: string
target_id: string
meta: unknown
created_at: string
}>
}>('GET', '/audit-logs'),
}
export type UserDetail = {
id: string
status: string
created_at: string
profiles: Array<{ id: string; relation: string; display_name: string }>
membership: {
active: boolean
plan?: string
status: string
expires_at?: string
ask_quota_left?: number
}
recent_orders: Array<{
id: string
kind: string
plan?: string
status: string
amount_cents: number
created_at: string
}>
}
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
onMounted(() => {
void auth.hydrate()
})
async function onLogout() {
await auth.logout()
await router.push({ name: 'login' })
}
</script>
<template>
<div class="shell">
<aside class="nav">
<div class="brand">愈心谷 · 运营</div>
<nav>
<RouterLink to="/">用户</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
<div class="foot">
<span class="muted">{{ auth.username || '管理员' }}</span>
<button class="btn ghost" type="button" @click="onLogout">退出</button>
</div>
</aside>
<main class="main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.nav {
padding: 1.25rem 1rem; border-right: 1px solid var(--line);
background: rgba(255, 253, 249, 0.85); display: flex; flex-direction: column; gap: 1.25rem;
}
.brand { font-weight: 700; letter-spacing: 0.02em; }
nav { display: flex; flex-direction: column; gap: 0.35rem; }
nav a { padding: 0.45rem 0.6rem; border-radius: 8px; color: var(--muted); }
nav a.router-link-active { background: #f5ebe6; color: var(--accent); font-weight: 600; }
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.main { padding: 1.5rem 1.75rem; }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; }
.nav { border-right: 0; border-bottom: 1px solid var(--line); }
}
</style>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<
Array<{
id: string
admin_id: string
action: string
target_type: string
target_id: string
meta: unknown
created_at: string
}>
>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.audit()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<h1>审计</h1>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="card">
<p v-if="!items.length" class="muted">暂无审计记录</p>
<table v-else>
<thead>
<tr><th>时间</th><th>动作</th><th>目标</th><th>管理员</th><th>meta</th></tr>
</thead>
<tbody>
<tr v-for="a in items" :key="a.id">
<td>{{ a.created_at }}</td>
<td>{{ a.action }}</td>
<td>{{ a.target_type }} {{ a.target_id }}</td>
<td>{{ a.admin_id }}</td>
<td><code>{{ JSON.stringify(a.meta) }}</code></td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
code { font-size: 0.8rem; }
</style>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const username = ref('admin')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function onSubmit() {
error.value = ''
loading.value = true
try {
await auth.login(username.value.trim(), password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.replace(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="wrap">
<form class="card login" @submit.prevent="onSubmit">
<h1>运营后台</h1>
<p class="muted">愈心谷内部工具 · Phase A</p>
<label class="field">
<span>用户名</span>
<input v-model="username" autocomplete="username" required />
</label>
<label class="field">
<span>密码</span>
<input v-model="password" type="password" autocomplete="current-password" required />
</label>
<p v-if="error" class="err">{{ error }}</p>
<button class="btn" type="submit" :disabled="loading">
{{ loading ? '登录中…' : '登录' }}
</button>
</form>
</div>
</template>
<style scoped>
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
.login { width: min(380px, 100%); }
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; }
.muted { margin: 0 0 1.2rem; }
.btn { width: 100%; }
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<
Array<{
id: string
user_id: string
kind: string
plan?: string
amount_cents: number
status: string
created_at: string
}>
>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.orders()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<h1>订单</h1>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="card">
<p v-if="!items.length" class="muted">暂无订单</p>
<table v-else>
<thead>
<tr><th>ID</th><th>用户</th><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr>
</thead>
<tbody>
<tr v-for="o in items" :key="o.id">
<td>{{ o.id }}</td>
<td>{{ o.user_id }}</td>
<td>{{ o.kind }} {{ o.plan || '' }}</td>
<td>{{ o.status }}</td>
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
<td>{{ o.created_at }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { adminApi, type UserDetail } from '@/api/client'
const route = useRoute()
const loading = ref(false)
const error = ref('')
const detail = ref<UserDetail | null>(null)
const plan = ref('month')
const grantMsg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
detail.value = await adminApi.user(String(route.params.id))
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function grant() {
grantMsg.value = ''
try {
await adminApi.grant(String(route.params.id), plan.value)
grantMsg.value = '已授予'
await load()
} catch (e) {
grantMsg.value = e instanceof Error ? e.message : '授予失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>用户详情</h1>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<template v-else-if="detail">
<div class="card block">
<p><strong>ID</strong> {{ detail.id }}</p>
<p><strong>状态</strong> {{ detail.status }}</p>
<p><strong>创建</strong> {{ detail.created_at }}</p>
</div>
<div class="card block">
<h2>成长会员</h2>
<p v-if="detail.membership">
{{ detail.membership.active ? '有效' : '无效' }} ·
{{ detail.membership.plan || '' }} ·
{{ detail.membership.status }} ·
到期 {{ detail.membership.expires_at || '' }}
</p>
<div class="grant">
<select v-model="plan">
<option value="month"></option>
<option value="quarter"></option>
<option value="year"></option>
</select>
<button class="btn" type="button" @click="grant">授予 / 延长</button>
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
</div>
</div>
<div class="card block">
<h2>档案</h2>
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
<ul v-else>
<li v-for="p in detail.profiles" :key="p.id">
{{ p.display_name || '未命名' }}{{ p.relation }}· {{ p.id }}
</li>
</ul>
</div>
<div class="card block">
<h2>近订单</h2>
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
<table v-else>
<thead><tr><th>ID</th><th>类型</th><th>状态</th><th>金额</th></tr></thead>
<tbody>
<tr v-for="o in detail.recent_orders" :key="o.id">
<td>{{ o.id }}</td>
<td>{{ o.kind }} {{ o.plan || '' }}</td>
<td>{{ o.status }}</td>
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
<style scoped>
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.block { margin-bottom: 1rem; }
.grant { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem; }
.grant select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { adminApi } from '@/api/client'
const q = ref('')
const loading = ref(false)
const error = ref('')
const items = ref<Array<{ id: string; status: string; created_at: string }>>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.users(q.value.trim())
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<header class="head">
<h1>用户</h1>
<form class="search" @submit.prevent="load">
<input v-model="q" placeholder="精确 user id (UUID)" />
<button class="btn" type="submit">查询</button>
</form>
</header>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="card">
<p v-if="!items.length" class="muted">暂无用户</p>
<table v-else>
<thead>
<tr><th>ID</th><th>状态</th><th>创建时间</th></tr>
</thead>
<tbody>
<tr v-for="u in items" :key="u.id">
<td><RouterLink :to="`/users/${u.id}`">{{ u.id }}</RouterLink></td>
<td>{{ u.status }}</td>
<td>{{ u.created_at }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; justify-content: space-between; margin-bottom: 1rem; }
h1 { margin: 0; font-size: 1.35rem; }
.search { display: flex; gap: 0.5rem; }
.search input { min-width: 280px; border: 1px solid var(--line); border-radius: 8px; padding: 0.55rem 0.75rem; }
</style>
+27
View File
@@ -0,0 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('@/pages/LoginPage.vue'), meta: { public: true } },
{
path: '/',
component: () => import('@/layouts/AdminShell.vue'),
children: [
{ path: '', 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: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},
],
})
router.beforeEach((to) => {
if (to.meta.public) return true
if (!getToken()) return { name: 'login', query: { redirect: to.fullPath } }
return true
})
export default router
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { adminApi, getToken, setToken } from '@/api/client'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(getToken())
const username = ref<string>('')
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
}
async function hydrate() {
if (!token.value) return false
try {
const me = await adminApi.me()
username.value = me.username
return true
} catch {
setToken(null)
token.value = null
return false
}
}
async function logout() {
try {
await adminApi.logout()
} catch {
/* ignore */
}
setToken(null)
token.value = null
username.value = ''
}
return { token, username, login, logout, hydrate }
})
+36
View File
@@ -0,0 +1,36 @@
:root {
--bg: #f3f0eb;
--panel: #fffdf9;
--ink: #1c1917;
--muted: #78716c;
--line: #e7e5e4;
--accent: #c45c4a;
--accent-ink: #fff;
--danger: #b91c1c;
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
color: var(--ink);
background: var(--bg);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, #f7f3ee 0%, #ebe6df 45%, #f3efea 100%); }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--line); font-size: 0.92rem; }
th { color: var(--muted); font-weight: 600; }
.btn {
border: 0; border-radius: 8px; padding: 0.55rem 1rem; cursor: pointer;
background: var(--accent); color: var(--accent-ink);
}
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
.field input, .field select {
border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.75rem; background: #fff;
}
.err { color: var(--danger); font-size: 0.9rem; }
.muted { color: var(--muted); }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.1rem 1.2rem;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
base: '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})
+5
View File
@@ -19,6 +19,11 @@ deepseek:
model: "deepseek-chat"
timeout_sec: 60
# Ops admin bootstrap (only seeds when admin_accounts is empty)
admin:
bootstrap_username: admin
bootstrap_password: "change-me" # override in config.local.yaml; never commit secrets
# jwt:
# secret: ""
# payment:
+6 -6
View File
@@ -1,6 +1,6 @@
module github.com/yuxingu/digital-psychology/apps/api
go 1.25
go 1.25.0
require (
github.com/gin-gonic/gin v1.10.0
@@ -36,10 +36,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
)
+10
View File
@@ -87,16 +87,26 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+23
View File
@@ -18,6 +18,13 @@ type Config struct {
DatabaseURL string
AppEnv string
DeepSeek DeepSeekConfig
Admin AdminConfig
}
// AdminConfig for ops console bootstrap (ECR-006).
type AdminConfig struct {
BootstrapUsername string
BootstrapPassword string
}
// DeepSeekConfig for Ask LLM.
@@ -47,6 +54,10 @@ type fileConfig struct {
Model string `yaml:"model"`
TimeoutSec int `yaml:"timeout_sec"`
} `yaml:"deepseek"`
Admin struct {
BootstrapUsername string `yaml:"bootstrap_username"`
BootstrapPassword string `yaml:"bootstrap_password"`
} `yaml:"admin"`
}
// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides.
@@ -123,6 +134,12 @@ func mergeFile(cfg *Config, path string) error {
if f.DeepSeek.TimeoutSec > 0 {
cfg.DeepSeek.TimeoutSec = f.DeepSeek.TimeoutSec
}
if f.Admin.BootstrapUsername != "" {
cfg.Admin.BootstrapUsername = f.Admin.BootstrapUsername
}
if f.Admin.BootstrapPassword != "" {
cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword
}
return nil
}
@@ -184,6 +201,12 @@ func applyEnv(cfg *Config) {
cfg.DeepSeek.TimeoutSec = n
}
}
if v := os.Getenv("ADMIN_BOOTSTRAP_USERNAME"); v != "" {
cfg.Admin.BootstrapUsername = v
}
if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" {
cfg.Admin.BootstrapPassword = v
}
}
// Enabled reports whether DeepSeek can be called.
+158
View File
@@ -0,0 +1,158 @@
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"
)
// AdminHandler serves /api/v1/admin/* (no DeviceAuth).
type AdminHandler struct {
Svc *admin.Service
}
// Register mounts public login + authed admin routes.
func (h *AdminHandler) Register(api *gin.RouterGroup) {
g := api.Group("/admin")
g.POST("/auth/login", h.Login)
authed := g.Group("")
authed.Use(middleware.AdminAuth(h.Svc))
authed.POST("/auth/logout", h.Logout)
authed.GET("/me", h.Me)
authed.GET("/users", h.ListUsers)
authed.GET("/users/:id", h.GetUser)
authed.POST("/users/:id/membership/grant", h.GrantMembership)
authed.GET("/orders", h.ListOrders)
authed.GET("/audit-logs", h.ListAudit)
}
func (h *AdminHandler) Login(c *gin.Context) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Username == "" || body.Password == "" {
response.Fail(c, http.StatusBadRequest, 40001, "username and password required")
return
}
res, err := h.Svc.Login(c.Request.Context(), body.Username, body.Password)
if err != nil {
if errors.Is(err, admin.ErrBadCredentials) {
response.Fail(c, http.StatusUnauthorized, 40103, "invalid credentials")
return
}
response.Fail(c, http.StatusInternalServerError, 50010, "login failed")
return
}
response.OK(c, res)
}
func (h *AdminHandler) Logout(c *gin.Context) {
token := middleware.BearerToken(c.GetHeader("Authorization"))
_ = h.Svc.Logout(c.Request.Context(), token)
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) Me(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
me, err := h.Svc.Me(c.Request.Context(), adminID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50011, "me failed")
return
}
response.OK(c, me)
}
func (h *AdminHandler) ListUsers(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, err := h.Svc.ListUsers(c.Request.Context(), c.Query("q"), limit, offset)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50012, "list users failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) GetUser(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
return
}
detail, err := h.Svc.GetUser(c.Request.Context(), id)
if err != nil {
if errors.Is(err, admin.ErrUserNotFound) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50013, "get user failed")
return
}
response.OK(c, detail)
}
func (h *AdminHandler) GrantMembership(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
userID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
return
}
var body admin.GrantInput
if err := c.ShouldBindJSON(&body); err != nil || body.Plan == "" {
response.Fail(c, http.StatusBadRequest, 40003, "plan required")
return
}
if err := h.Svc.GrantMembership(c.Request.Context(), adminID, userID, body.Plan); err != nil {
if errors.Is(err, admin.ErrInvalidPlan) {
response.Fail(c, http.StatusBadRequest, 40004, "invalid plan")
return
}
if errors.Is(err, admin.ErrUserNotFound) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50014, "grant failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) ListOrders(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, err := h.Svc.ListOrders(c.Request.Context(), limit, offset)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50015, "list orders failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) ListAudit(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, err := h.Svc.ListAuditLogs(c.Request.Context(), limit, offset)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50016, "list audit failed")
return
}
response.OK(c, gin.H{"items": items})
}
+13
View File
@@ -2,6 +2,9 @@
package httpserver
import (
"context"
"log"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
@@ -10,6 +13,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
adminsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
@@ -27,6 +31,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
reportRepo := &repository.ReportRepo{Pool: pool}
relationRepo := &repository.RelationRepo{Pool: pool}
askRepo := &repository.AskRepo{Pool: pool}
adminRepo := &repository.AdminRepo{Pool: pool}
var llm *deepseek.Client
if cfg.DeepSeek.Enabled() {
@@ -49,6 +54,13 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
Reports: reportRepo,
Quotas: &repository.ImageCardRepo{Pool: pool},
}
adminSvc := &adminsvc.Service{Repo: adminRepo, Reports: reportRepo}
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
Username: cfg.Admin.BootstrapUsername,
Password: cfg.Admin.BootstrapPassword,
}); err != nil {
log.Printf("admin bootstrap failed: %v", err)
}
r := gin.New()
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
@@ -62,6 +74,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
api.GET("/ping", func(c *gin.Context) {
response.OK(c, gin.H{"pong": true})
})
(&handler.AdminHandler{Svc: adminSvc}).Register(api)
authed := api.Group("")
authed.Use(middleware.DeviceAuth(pool))
@@ -0,0 +1,126 @@
package integration_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func doAdminJSON(t *testing.T, r http.Handler, method, path string, body any, token string) (envelope, int) {
t.Helper()
auth := ""
if token != "" {
auth = "Bearer " + token
}
return doAdminAuth(t, r, method, path, body, auth)
}
func doAdminAuth(t *testing.T, r http.Handler, method, path string, body any, authorization string) (envelope, int) {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encode: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
if authorization != "" {
req.Header.Set("Authorization", authorization)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var env envelope
_ = json.Unmarshal(w.Body.Bytes(), &env)
return env, w.Code
}
func TestAdminOpsPhaseA(t *testing.T) {
r, _ := setupAPI(t)
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, "")
if code != http.StatusUnauthorized || env.Code == 0 {
t.Fatalf("expected 401 without token, got http=%d code=%d", code, env.Code)
}
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("login failed http=%d code=%d msg=%s body=%s", code, env.Code, env.Message, string(env.Data))
}
var login struct {
Token string `json:"token"`
}
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
t.Fatalf("login token missing: %v %s", err, env.Data)
}
_, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, login.Token)
if code != 200 || env.Code != 0 {
t.Fatalf("list users failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
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("expected users, got %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",
}, login.Token)
if code != 200 || env.Code != 0 {
t.Fatalf("grant failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
// Atomicity: membership active AND audit row for same grant.
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID, nil, login.Token)
if code != 200 || env.Code != 0 {
t.Fatalf("get user failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
var detail struct {
Membership struct {
Active bool `json:"active"`
Status string `json:"status"`
} `json:"membership"`
}
if err := json.Unmarshal(env.Data, &detail); err != nil || !detail.Membership.Active {
t.Fatalf("expected active membership after grant: %v %s", err, env.Data)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, login.Token)
if code != 200 || env.Code != 0 {
t.Fatalf("audit failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
var audit struct {
Items []struct {
Action string `json:"action"`
TargetID string `json:"target_id"`
} `json:"items"`
}
if err := json.Unmarshal(env.Data, &audit); err != nil || len(audit.Items) == 0 {
t.Fatalf("expected audit rows: %v %s", err, env.Data)
}
if audit.Items[0].Action != "membership.grant" || audit.Items[0].TargetID != userID {
t.Fatalf("unexpected audit %#v", audit.Items[0])
}
// Logout with lowercase bearer must invalidate session.
env, code = doAdminAuth(t, r, http.MethodPost, "/api/v1/admin/auth/logout", nil, "bearer "+login.Token)
if code != 200 || env.Code != 0 {
t.Fatalf("logout failed http=%d code=%d msg=%s", code, env.Code, env.Message)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, login.Token)
if code != http.StatusUnauthorized || env.Code == 0 {
t.Fatalf("expected 401 after logout, got http=%d code=%d", code, env.Code)
}
}
@@ -31,6 +31,8 @@ func setupAPI(t *testing.T) (*gin.Engine, string) {
t.Cleanup(cancel)
cfg := config.Load()
cfg.Admin.BootstrapUsername = "admin"
cfg.Admin.BootstrapPassword = "change-me"
pool, err := db.Connect(ctx, cfg.DatabaseURL)
if err != nil {
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
@@ -0,0 +1,63 @@
package middleware
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
const AdminIDKey ctxKey = "admin_id"
const AdminTokenHeader = "Authorization"
// AdminSessionResolver looks up a valid admin session by token.
type AdminSessionResolver interface {
ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error)
}
// AdminAuth requires Bearer token for /admin routes.
func AdminAuth(resolver AdminSessionResolver) gin.HandlerFunc {
return func(c *gin.Context) {
token := BearerToken(c.GetHeader(AdminTokenHeader))
if token == "" {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
c.Abort()
return
}
adminID, err := resolver.ResolveAdminID(c.Request.Context(), token)
if err != nil || adminID == uuid.Nil {
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
c.Abort()
return
}
c.Set(string(AdminIDKey), adminID.String())
c.Next()
}
}
// AdminIDFromContext returns the authenticated admin id.
func AdminIDFromContext(c *gin.Context) (uuid.UUID, bool) {
v, ok := c.Get(string(AdminIDKey))
if !ok {
return uuid.Nil, false
}
id, err := uuid.Parse(v.(string))
return id, err == nil
}
// BearerToken extracts an opaque token from Authorization (Bearer / bearer).
func BearerToken(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
lower := strings.ToLower(h)
if strings.HasPrefix(lower, "bearer ") {
return strings.TrimSpace(h[len("bearer "):])
}
return h
}
@@ -0,0 +1,21 @@
package middleware
import "testing"
func TestBearerToken(t *testing.T) {
cases := []struct {
in, want string
}{
{"", ""},
{"adm_abc", "adm_abc"},
{"Bearer adm_x", "adm_x"},
{"bearer adm_y", "adm_y"},
{"BEARER adm_z", "adm_z"},
{" Bearer adm_w ", "adm_w"},
}
for _, tc := range cases {
if got := BearerToken(tc.in); got != tc.want {
t.Fatalf("BearerToken(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
+307
View File
@@ -0,0 +1,307 @@
package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// AdminRepo persists ops-admin accounts, sessions, and audit logs.
type AdminRepo struct {
Pool *pgxpool.Pool
}
// AdminAccount is an internal operator account.
type AdminAccount struct {
ID uuid.UUID
Username string
PasswordHash string
Status string
}
// CountAccounts returns non-deleted admin count.
func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT count(*) FROM admin_accounts WHERE deleted_at IS NULL`).Scan(&n)
return n, err
}
// CreateAccount inserts an admin account.
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)
return id, err
}
// FindByUsername loads an active admin by username.
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
FROM admin_accounts
WHERE username=$1 AND deleted_at IS NULL`, username,
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
// FindAccountByID loads admin by id.
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
FROM admin_accounts
WHERE id=$1 AND deleted_at IS NULL`, id,
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
// CreateSession stores an opaque admin session token.
func (r *AdminRepo) CreateSession(ctx context.Context, adminID uuid.UUID, token string, expires time.Time) error {
_, err := r.Pool.Exec(ctx, `
INSERT INTO admin_sessions(admin_id, token, expires_at)
VALUES ($1,$2,$3)`, adminID, token, expires)
return err
}
// ResolveSession returns admin_id for a valid token.
func (r *AdminRepo) ResolveSession(ctx context.Context, token string) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
SELECT s.admin_id FROM admin_sessions s
JOIN admin_accounts a ON a.id=s.admin_id AND a.deleted_at IS NULL AND a.status='active'
WHERE s.token=$1 AND s.expires_at > now()`, token).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, errors.New("invalid session")
}
return id, err
}
// DeleteSession removes a session by token.
func (r *AdminRepo) DeleteSession(ctx context.Context, token string) error {
_, err := r.Pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token=$1`, token)
return err
}
// InsertAudit appends an immutable audit row.
func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action, targetType, targetID string, meta json.RawMessage) error {
if meta == nil {
meta = json.RawMessage(`{}`)
}
_, err := r.Pool.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,$2,$3,$4,$5)`, adminID, action, targetType, targetID, meta)
return err
}
// UserListItem is a compact user row for admin tables.
type UserListItem struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ListUsers returns users newest first; q matches id when UUID.
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
rows, err := r.Pool.Query(ctx, `
SELECT id, status, created_at FROM users
WHERE deleted_at IS NULL
AND ($1 = '' OR id::text = $1)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []UserListItem
for rows.Next() {
var u UserListItem
if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// UserExists reports whether user id is present.
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL`, id).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// ProfileBrief for admin user detail.
type ProfileBrief struct {
ID uuid.UUID `json:"id"`
Relation string `json:"relation"`
DisplayName string `json:"display_name"`
}
// ListProfilesForUser returns profile briefs.
func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) ([]ProfileBrief, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, relation, display_name FROM profiles
WHERE user_id=$1 AND deleted_at IS NULL
ORDER BY created_at ASC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProfileBrief
for rows.Next() {
var p ProfileBrief
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// OrderListItem for admin order tables.
type OrderListItem struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Plan *string `json:"plan,omitempty"`
AmountCents int `json:"amount_cents"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ListOrders lists orders; optional user filter.
func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]OrderListItem, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, kind, plan, amount_cents, status, created_at
FROM orders
WHERE deleted_at IS NULL
AND ($1::uuid IS NULL OR user_id = $1)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []OrderListItem
for rows.Next() {
var o OrderListItem
if err := rows.Scan(&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status, &o.CreatedAt); err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
// GrantMembershipWithAudit upserts membership and appends audit in one transaction.
func (r *AdminRepo) GrantMembershipWithAudit(
ctx context.Context,
adminID, userID uuid.UUID,
plan string,
days int,
meta json.RawMessage,
) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100)
ON CONFLICT (user_id) DO UPDATE SET
plan=EXCLUDED.plan, status='active',
expires_at=(CASE
WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now()
THEN memberships.expires_at ELSE now()
END) + ($3 * interval '1 day'),
ask_quota_left=100, updated_at=now()`,
userID, plan, days); err != nil {
return 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,'membership.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
return err
}
return tx.Commit(ctx)
}
// AuditListItem for admin audit table.
type AuditListItem struct {
ID uuid.UUID `json:"id"`
AdminID uuid.UUID `json:"admin_id"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Meta json.RawMessage `json:"meta"`
CreatedAt time.Time `json:"created_at"`
}
// ListAuditLogs returns newest audit rows.
func (r *AdminRepo) ListAuditLogs(ctx context.Context, limit, offset int) ([]AuditListItem, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
rows, err := r.Pool.Query(ctx, `
SELECT id, admin_id, action, target_type, target_id, meta, created_at
FROM admin_audit_logs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AuditListItem
for rows.Next() {
var a AuditListItem
if err := rows.Scan(&a.ID, &a.AdminID, &a.Action, &a.TargetType, &a.TargetID, &a.Meta, &a.CreatedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
+5 -5
View File
@@ -91,11 +91,11 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID)
// MembershipRow is the current membership snapshot for a user.
type MembershipRow struct {
Plan string
Status string
ExpiresAt *time.Time
AskQuotaLeft int
Active bool
Plan string `json:"plan,omitempty"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AskQuotaLeft int `json:"ask_quota_left,omitempty"`
Active bool `json:"active"`
}
// GetMembership returns membership status; missing row → inactive.
+220
View File
@@ -0,0 +1,220 @@
// Package admin implements ops-console use cases (ECR-006).
package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// Service is ops-admin application layer.
type Service struct {
Repo *repository.AdminRepo
Reports *repository.ReportRepo
}
// BootstrapConfig seeds the first admin when table is empty.
type BootstrapConfig struct {
Username string
Password string
}
// EnsureBootstrap creates the first admin from config when needed.
func (s *Service) EnsureBootstrap(ctx context.Context, cfg BootstrapConfig) error {
if cfg.Username == "" || cfg.Password == "" {
return nil
}
n, err := s.Repo.CountAccounts(ctx)
if err != nil || n > 0 {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.Repo.CreateAccount(ctx, cfg.Username, string(hash))
return err
}
// LoginResult is returned after successful login.
type LoginResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Admin AdminMe `json:"admin"`
}
// AdminMe is the public admin profile.
type AdminMe struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
}
var (
ErrBadCredentials = errString("invalid credentials")
ErrInvalidPlan = errString("invalid plan")
ErrUserNotFound = errString("user not found")
)
type errString string
func (e errString) Error() string { return string(e) }
// Login verifies password and issues a session token.
func (s *Service) Login(ctx context.Context, username, password string) (*LoginResult, error) {
acc, err := s.Repo.FindByUsername(ctx, username)
if err != nil {
return nil, err
}
if acc == nil || acc.Status != "active" {
return nil, ErrBadCredentials
}
if bcrypt.CompareHashAndPassword([]byte(acc.PasswordHash), []byte(password)) != nil {
return nil, ErrBadCredentials
}
token, err := newToken()
if err != nil {
return nil, err
}
exp := time.Now().UTC().Add(12 * time.Hour)
if err := s.Repo.CreateSession(ctx, acc.ID, token, exp); err != nil {
return nil, err
}
return &LoginResult{
Token: token,
ExpiresAt: exp,
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
}, nil
}
// ResolveAdminID implements middleware.AdminSessionResolver.
func (s *Service) ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error) {
return s.Repo.ResolveSession(ctx, token)
}
// Logout deletes the session for token.
func (s *Service) Logout(ctx context.Context, token string) error {
if token == "" {
return nil
}
return s.Repo.DeleteSession(ctx, token)
}
// Me returns the current admin profile.
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
acc, err := s.Repo.FindAccountByID(ctx, adminID)
if err != nil || acc == nil {
return nil, errors.New("admin not found")
}
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
}
// ListUsers lists terminal users.
func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([]repository.UserListItem, error) {
return s.Repo.ListUsers(ctx, q, limit, offset)
}
// UserDetail is admin view of one user.
type UserDetail struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
Profiles []repository.ProfileBrief `json:"profiles"`
Membership *repository.MembershipRow `json:"membership"`
Orders []repository.OrderListItem `json:"recent_orders"`
}
// GetUser loads user detail for admin.
func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, error) {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrUserNotFound
}
users, err := s.Repo.ListUsers(ctx, userID.String(), 1, 0)
if err != nil || len(users) == 0 {
return nil, ErrUserNotFound
}
profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
if err != nil {
return nil, err
}
mem, err := s.Reports.GetMembership(ctx, userID)
if err != nil {
return nil, err
}
orders, err := s.Repo.ListOrders(ctx, &userID, 10, 0)
if err != nil {
return nil, err
}
return &UserDetail{
ID: users[0].ID,
Status: users[0].Status,
CreatedAt: users[0].CreatedAt,
Profiles: profiles,
Membership: mem,
Orders: orders,
}, nil
}
// GrantInput for membership grant.
type GrantInput struct {
Plan string `json:"plan"`
}
// GrantMembership extends membership and writes audit.
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
days, err := planDays(plan)
if err != nil {
return err
}
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"plan": plan, "days": days})
return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta)
}
// ListOrders lists commerce orders.
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
return s.Repo.ListOrders(ctx, nil, limit, offset)
}
// ListAuditLogs lists audit entries.
func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repository.AuditListItem, error) {
return s.Repo.ListAuditLogs(ctx, limit, offset)
}
func planDays(plan string) (int, error) {
switch plan {
case "month":
return 31, nil
case "quarter":
return 92, nil
case "year":
return 366, nil
default:
return 0, ErrInvalidPlan
}
}
func newToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "adm_" + hex.EncodeToString(b), nil
}
@@ -0,0 +1,16 @@
package admin
import "testing"
func TestPlanDays(t *testing.T) {
cases := map[string]int{"month": 31, "quarter": 92, "year": 366}
for plan, want := range cases {
got, err := planDays(plan)
if err != nil || got != want {
t.Fatalf("planDays(%s)=%d,%v want %d", plan, got, err, want)
}
}
if _, err := planDays("week"); err == nil {
t.Fatal("expected invalid plan")
}
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS admin_audit_logs;
DROP TABLE IF EXISTS admin_sessions;
DROP TABLE IF EXISTS admin_accounts;
@@ -0,0 +1,33 @@
-- Ops admin Phase A (ECR-006)
CREATE TABLE IF NOT EXISTS admin_accounts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username varchar(64) NOT NULL UNIQUE,
password_hash text NOT NULL,
status varchar(32) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid NOT NULL REFERENCES admin_accounts(id),
token varchar(128) NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_admin_id ON admin_sessions(admin_id);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at);
CREATE TABLE IF NOT EXISTS admin_audit_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid NOT NULL REFERENCES admin_accounts(id),
action varchar(64) NOT NULL,
target_type varchar(32) NOT NULL DEFAULT '',
target_id varchar(64) NOT NULL DEFAULT '',
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_admin_audit_admin ON admin_audit_logs(admin_id);