feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,4 +9,49 @@ npm run dev:admin
|
||||
# http://localhost:5174/
|
||||
```
|
||||
|
||||
生产路径:`https://jackyu66.com/psy/admin/`(静态 `:8003` + 共用 `/psy/api/` → Go `:8080`)。
|
||||
|
||||
## Nginx(需 root 编辑并 reload)
|
||||
|
||||
在 `jackyu66.com` 的 HTTPS server 里,**放在** `location /psy/` **之前**增加:
|
||||
|
||||
```nginx
|
||||
# 运营后台静态(user systemd: yuxingu-admin → 127.0.0.1:8003)
|
||||
location /psy/admin/ {
|
||||
proxy_pass http://127.0.0.1:8003/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 问答 SSE:关闭缓冲(建议一并改现有 /psy/api/)
|
||||
location /psy/api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
本机已可用(无需 sudo):
|
||||
|
||||
```bash
|
||||
npm run build:admin
|
||||
systemctl --user enable --now yuxingu-admin.service
|
||||
# 校验:curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/
|
||||
```
|
||||
|
||||
默认管理员:见 `apps/api/config.example.yaml` → `admin.bootstrap_*`(仅空库种子)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/** Admin Phase A: login → users → detail grant → audit (API mocked). */
|
||||
/** Admin ops console: login → dashboard → users → detail grant → audit (API mocked). */
|
||||
test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
const userId = '11111111-1111-1111-1111-111111111111'
|
||||
let granted = false
|
||||
@@ -29,10 +29,39 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
await ok({ ok: true })
|
||||
return
|
||||
}
|
||||
if (url.includes('/stats')) {
|
||||
await ok({
|
||||
users_total: 1,
|
||||
membership_active: granted ? 1 : 0,
|
||||
orders_today: 0,
|
||||
paid_cents_today: 0,
|
||||
ask_replies_today: 0,
|
||||
profiles_total: 1,
|
||||
reports_total: 2,
|
||||
series: [
|
||||
{ day: '2026-08-01', new_users: 0, orders: 1, paid_cents: 990, ask_replies: 2 },
|
||||
{ day: '2026-08-02', new_users: 1, orders: 0, paid_cents: 0, ask_replies: 1 },
|
||||
{ day: '2026-08-03', new_users: 0, orders: 2, paid_cents: 1980, ask_replies: 3 },
|
||||
{ day: '2026-08-04', new_users: 0, orders: 0, paid_cents: 0, ask_replies: 0 },
|
||||
{ day: '2026-08-05', new_users: 0, orders: 1, paid_cents: 2500, ask_replies: 4 },
|
||||
{ day: '2026-08-06', new_users: 0, orders: 0, paid_cents: 0, ask_replies: 2 },
|
||||
{ day: '2026-08-07', new_users: 0, orders: 1, paid_cents: 990, ask_replies: 1 },
|
||||
],
|
||||
reports_by_type: [
|
||||
{ type: 'portrait', count: 1 },
|
||||
{ type: 'star', count: 1 },
|
||||
],
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.endsWith('/me') || url.includes('/me?')) {
|
||||
await ok({ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' })
|
||||
return
|
||||
}
|
||||
if (url.includes(`/users/${userId}/ask-quota/grant`) && method === 'POST') {
|
||||
await ok({ ok: true, ask_paid_quota_left: 40 })
|
||||
return
|
||||
}
|
||||
if (url.includes(`/users/${userId}/membership/grant`) && method === 'POST') {
|
||||
granted = true
|
||||
await ok({ ok: true })
|
||||
@@ -42,13 +71,18 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
await ok({
|
||||
id: userId,
|
||||
status: 'active',
|
||||
phone: '13800000000',
|
||||
nickname: '测试用户',
|
||||
ask_paid_quota_left: 0,
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
profiles: [{ id: 'p1', relation: 'self', display_name: '我' }],
|
||||
profiles: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
|
||||
reports: [],
|
||||
membership: {
|
||||
active: granted,
|
||||
plan: granted ? 'month' : undefined,
|
||||
status: granted ? 'active' : 'none',
|
||||
expires_at: granted ? '2026-09-01T00:00:00Z' : undefined,
|
||||
ask_quota_left: granted ? 100 : 0,
|
||||
},
|
||||
recent_orders: [],
|
||||
})
|
||||
@@ -56,7 +90,19 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
}
|
||||
if (url.includes('/users') && method === 'GET') {
|
||||
await ok({
|
||||
items: [{ id: userId, status: 'active', created_at: '2026-08-01T00:00:00Z' }],
|
||||
items: [
|
||||
{
|
||||
id: userId,
|
||||
status: 'active',
|
||||
phone: '13800000000',
|
||||
nickname: '测试用户',
|
||||
ask_paid_quota_left: 0,
|
||||
profile_count: 1,
|
||||
membership_active: granted,
|
||||
membership_plan: granted ? 'month' : null,
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -86,17 +132,20 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
})
|
||||
|
||||
await page.goto('/login')
|
||||
await expect(page.getByRole('heading', { name: '运营后台' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '愈心谷' })).toBeVisible()
|
||||
await page.locator('input[type="password"]').fill('change-me')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: '概览' })).toBeVisible()
|
||||
await page.getByRole('link', { name: '用户', exact: true }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: '用户' })).toBeVisible()
|
||||
await expect(page.getByText(userId)).toBeVisible()
|
||||
await page.getByRole('link', { name: userId }).click()
|
||||
await expect(page.getByText('测试用户')).toBeVisible()
|
||||
await page.getByRole('link', { name: '测试用户' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: '用户详情' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '授予 / 延长' }).click()
|
||||
await expect(page.getByText('已授予')).toBeVisible()
|
||||
await expect(page.getByText('会员已授予')).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: '审计' }).click()
|
||||
await expect(page.getByRole('heading', { name: '审计' })).toBeVisible()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<title>愈心谷 · 运营后台</title>
|
||||
<title>愈心谷</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"description": "运营后台 Phase A(ECR-006)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build": "vue-tsc --noEmit && vite build --base=/psy/admin/",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
+125
-25
@@ -4,6 +4,14 @@ export type ApiEnvelope<T> = { code: number; message: string; data?: T }
|
||||
|
||||
const TOKEN_KEY = 'yuxingu_admin_token'
|
||||
|
||||
/** Under /psy/admin use /psy/api; local vite uses /api. */
|
||||
function adminAPIBase(): string {
|
||||
if (typeof location !== 'undefined' && location.pathname.startsWith('/psy/admin')) {
|
||||
return '/psy/api/v1/admin'
|
||||
}
|
||||
return '/api/v1/admin'
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
@@ -18,7 +26,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
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}`, {
|
||||
const res = await fetch(`${adminAPIBase()}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
@@ -30,6 +38,62 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
return env.data as T
|
||||
}
|
||||
|
||||
export type DashboardStats = {
|
||||
users_total: number
|
||||
membership_active: number
|
||||
orders_today: number
|
||||
paid_cents_today: number
|
||||
ask_replies_today: number
|
||||
profiles_total: number
|
||||
reports_total: number
|
||||
series?: Array<{
|
||||
day: string
|
||||
new_users: number
|
||||
orders: number
|
||||
paid_cents: number
|
||||
ask_replies: number
|
||||
}>
|
||||
reports_by_type?: Array<{ type: string; count: number }>
|
||||
}
|
||||
|
||||
export type UserListItem = {
|
||||
id: string
|
||||
status: string
|
||||
phone?: string | null
|
||||
nickname?: string | null
|
||||
ask_paid_quota_left: number
|
||||
profile_count: number
|
||||
membership_active: boolean
|
||||
membership_plan?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type UserDetail = {
|
||||
id: string
|
||||
status: string
|
||||
phone?: string | null
|
||||
nickname?: string | null
|
||||
ask_paid_quota_left: number
|
||||
created_at: string
|
||||
profiles: Array<{ id: string; relation: string; display_name: string; birth_date?: string }>
|
||||
reports: Array<{ id: string; type: string; created_at: string }>
|
||||
membership: {
|
||||
active: boolean
|
||||
plan?: string
|
||||
status: string
|
||||
expires_at?: string | null
|
||||
ask_quota_left?: number
|
||||
}
|
||||
recent_orders: Array<{
|
||||
id: string
|
||||
kind: string
|
||||
plan?: string
|
||||
status: string
|
||||
amount_cents: number
|
||||
created_at: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
|
||||
@@ -38,14 +102,16 @@ export const adminApi = {
|
||||
}),
|
||||
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
|
||||
me: () => request<{ id: string; username: string }>('GET', '/me'),
|
||||
stats: () => request<DashboardStats>('GET', '/stats'),
|
||||
users: (q = '') =>
|
||||
request<{ items: Array<{ id: string; status: string; created_at: string }> }>(
|
||||
'GET',
|
||||
`/users?q=${encodeURIComponent(q)}`,
|
||||
),
|
||||
request<{ items: UserListItem[] }>('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 }),
|
||||
grantAskQuota: (id: string, delta: number) =>
|
||||
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
|
||||
delta,
|
||||
}),
|
||||
orders: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
@@ -70,26 +136,60 @@ export const adminApi = {
|
||||
created_at: string
|
||||
}>
|
||||
}>('GET', '/audit-logs'),
|
||||
analyticsOverview: (from: string, to: string) =>
|
||||
request<AnalyticsOverview>('GET', `/analytics/overview?from=${from}&to=${to}`),
|
||||
analyticsPages: (from: string, to: string) =>
|
||||
request<{ items: AnalyticsPageRow[] }>('GET', `/analytics/pages?from=${from}&to=${to}`),
|
||||
analyticsExits: (from: string, to: string) =>
|
||||
request<{ items: AnalyticsExitRow[] }>('GET', `/analytics/exits?from=${from}&to=${to}`),
|
||||
analyticsClicks: (from: string, to: string) =>
|
||||
request<{ items: AnalyticsClickRow[] }>('GET', `/analytics/clicks?from=${from}&to=${to}`),
|
||||
analyticsFunnel: (from: string, to: string) =>
|
||||
request<{ steps: AnalyticsFunnelStep[] }>('GET', `/analytics/funnel?from=${from}&to=${to}`),
|
||||
homeTools: () => request<{ items: HomeToolAdmin[] }>('GET', '/home/tools'),
|
||||
saveHomeTools: (items: HomeToolAdmin[]) =>
|
||||
request<{ ok: boolean }>('PUT', '/home/tools', { items }),
|
||||
scales: () => request<{ items: ScaleAdmin[] }>('GET', '/scales'),
|
||||
patchScale: (id: string, status: 'published' | 'draft') =>
|
||||
request<{ ok: boolean }>('PATCH', `/scales/${id}`, { status }),
|
||||
}
|
||||
|
||||
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
|
||||
}>
|
||||
export type HomeToolAdmin = {
|
||||
id?: string
|
||||
row_index: number
|
||||
sort_order: number
|
||||
path: string
|
||||
icon: string
|
||||
label: string
|
||||
badge?: string | null
|
||||
badge_tone?: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ScaleAdmin = {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type AnalyticsOverview = {
|
||||
dau: number
|
||||
new_users: number
|
||||
sessions: number
|
||||
avg_session_ms: number
|
||||
series?: Array<{ day: string; dau: number; sessions: number }>
|
||||
}
|
||||
|
||||
export type AnalyticsPageRow = {
|
||||
page_path: string
|
||||
pv: number
|
||||
uv: number
|
||||
avg_dwell_ms: number
|
||||
exit_count: number
|
||||
}
|
||||
|
||||
export type AnalyticsExitRow = { exit_page: string; count: number }
|
||||
export type AnalyticsClickRow = { element_id: string; count: number }
|
||||
export type AnalyticsFunnelStep = { name: string; count: number }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
const base = import.meta.env.BASE_URL || '/'
|
||||
const src = `${base}logo.png`
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: 'nav' | 'login'
|
||||
}>(),
|
||||
{ size: 'nav' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Logo 原稿为橙字透底,浅色面板上易发虚;深色底托保证可读 -->
|
||||
<span class="brand-mark" :class="size">
|
||||
<img class="brand-logo" :src="src" alt="愈心谷" decoding="async" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1514;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 16px rgba(26, 21, 20, 0.18);
|
||||
}
|
||||
.brand-mark.nav {
|
||||
padding: 8px 12px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.brand-mark.login {
|
||||
padding: 14px 18px;
|
||||
margin: 0 auto 0.85rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.brand-logo {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
background: transparent;
|
||||
}
|
||||
.nav .brand-logo {
|
||||
width: 132px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 32px;
|
||||
}
|
||||
.login .brand-logo {
|
||||
width: min(200px, 68vw);
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -19,9 +20,14 @@ async function onLogout() {
|
||||
<template>
|
||||
<div class="shell">
|
||||
<aside class="nav">
|
||||
<div class="brand">愈心谷 · 运营</div>
|
||||
<RouterLink class="brand" to="/">
|
||||
<BrandLogo size="nav" />
|
||||
</RouterLink>
|
||||
<nav>
|
||||
<RouterLink to="/">用户</RouterLink>
|
||||
<RouterLink to="/">概览</RouterLink>
|
||||
<RouterLink to="/analytics">数据</RouterLink>
|
||||
<RouterLink to="/content">内容</RouterLink>
|
||||
<RouterLink to="/users">用户</RouterLink>
|
||||
<RouterLink to="/orders">订单</RouterLink>
|
||||
<RouterLink to="/audit">审计</RouterLink>
|
||||
</nav>
|
||||
@@ -39,13 +45,32 @@ async function onLogout() {
|
||||
<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;
|
||||
padding: 1.15rem 1rem;
|
||||
border-right: 1px solid rgba(240, 230, 226, 0.9);
|
||||
background: rgba(255, 253, 251, 0.88);
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.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; }
|
||||
nav a {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 550;
|
||||
}
|
||||
nav a.router-link-active {
|
||||
background: linear-gradient(135deg, #fff0ec, #ffe4e0);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.08);
|
||||
}
|
||||
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.main { padding: 1.5rem 1.75rem; }
|
||||
@media (max-width: 800px) {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
adminApi,
|
||||
type AnalyticsClickRow,
|
||||
type AnalyticsExitRow,
|
||||
type AnalyticsFunnelStep,
|
||||
type AnalyticsOverview,
|
||||
type AnalyticsPageRow,
|
||||
} from '@/api/client'
|
||||
|
||||
type RangeKey = '1' | '7' | '30'
|
||||
|
||||
const range = ref<RangeKey>('7')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const overview = ref<AnalyticsOverview | null>(null)
|
||||
const pages = ref<AnalyticsPageRow[]>([])
|
||||
const exits = ref<AnalyticsExitRow[]>([])
|
||||
const clicks = ref<AnalyticsClickRow[]>([])
|
||||
const funnel = ref<AnalyticsFunnelStep[]>([])
|
||||
|
||||
function dayStr(d: Date) {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function rangeBounds(key: RangeKey): { from: string; to: string } {
|
||||
const to = new Date()
|
||||
const from = new Date()
|
||||
const n = Number(key)
|
||||
from.setUTCDate(from.getUTCDate() - (n - 1))
|
||||
return { from: dayStr(from), to: dayStr(to) }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const { from, to } = rangeBounds(range.value)
|
||||
try {
|
||||
const [ov, pg, ex, cl, fn] = await Promise.all([
|
||||
adminApi.analyticsOverview(from, to),
|
||||
adminApi.analyticsPages(from, to),
|
||||
adminApi.analyticsExits(from, to),
|
||||
adminApi.analyticsClicks(from, to),
|
||||
adminApi.analyticsFunnel(from, to),
|
||||
])
|
||||
overview.value = ov
|
||||
pages.value = pg.items || []
|
||||
exits.value = ex.items || []
|
||||
clicks.value = cl.items || []
|
||||
funnel.value = fn.steps || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(range, load)
|
||||
|
||||
function fmtMs(ms: number) {
|
||||
if (!ms || ms < 1000) return `${Math.round(ms || 0)}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const funnelLabel: Record<string, string> = {
|
||||
portrait_completed: '画像完成',
|
||||
deep_access_clicked: '深度 CTA',
|
||||
purchase_completed: '支付成功',
|
||||
}
|
||||
|
||||
const trend = computed(() => {
|
||||
const series = overview.value?.series || []
|
||||
const labels = series.map((d) => {
|
||||
const p = d.day.split('-')
|
||||
return p.length === 3 ? `${Number(p[1])}/${Number(p[2])}` : d.day
|
||||
})
|
||||
const values = series.map((d) => d.dau)
|
||||
const max = Math.max(1, ...values)
|
||||
return { labels, values, max }
|
||||
})
|
||||
|
||||
const W = 560
|
||||
const H = 200
|
||||
const pad = { t: 16, r: 12, b: 36, l: 36 }
|
||||
const plotW = W - pad.l - pad.r
|
||||
const plotH = H - pad.t - pad.b
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>数据</h1>
|
||||
<p class="muted">行为分析 · 停留 · 退出页 · 点击 · 漏斗</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="k in (['1', '7', '30'] as RangeKey[])"
|
||||
:key="k"
|
||||
type="button"
|
||||
:class="{ on: range === k }"
|
||||
@click="range = k"
|
||||
>
|
||||
{{ k === '1' ? '今日' : `${k} 日` }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="loading && !overview" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<template v-if="overview">
|
||||
<div class="cards">
|
||||
<div class="card"><span class="lbl">DAU</span><strong>{{ overview.dau }}</strong></div>
|
||||
<div class="card"><span class="lbl">新增</span><strong>{{ overview.new_users }}</strong></div>
|
||||
<div class="card"><span class="lbl">会话</span><strong>{{ overview.sessions }}</strong></div>
|
||||
<div class="card">
|
||||
<span class="lbl">人均会话</span>
|
||||
<strong>{{ fmtMs(overview.avg_session_ms) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>日活趋势</h2>
|
||||
<svg class="chart" :viewBox="`0 0 ${W} ${H}`" role="img" aria-label="DAU trend">
|
||||
<g v-for="(v, i) in trend.values" :key="i">
|
||||
<rect
|
||||
:x="pad.l + (i * plotW) / Math.max(trend.values.length, 1) + 4"
|
||||
:y="pad.t + plotH - (v / trend.max) * plotH"
|
||||
:width="Math.max(8, plotW / Math.max(trend.values.length, 1) - 8)"
|
||||
:height="(v / trend.max) * plotH"
|
||||
fill="#e54d42"
|
||||
rx="4"
|
||||
/>
|
||||
<text
|
||||
:x="pad.l + (i * plotW) / Math.max(trend.values.length, 1) + plotW / Math.max(trend.values.length, 1) / 2"
|
||||
:y="H - 12"
|
||||
text-anchor="middle"
|
||||
class="tick"
|
||||
>
|
||||
{{ trend.labels[i] }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h2>页面明细</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>路径</th><th>PV</th><th>UV</th><th>均停留</th><th>退出</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in pages" :key="p.page_path">
|
||||
<td class="mono">{{ p.page_path }}</td>
|
||||
<td>{{ p.pv }}</td>
|
||||
<td>{{ p.uv }}</td>
|
||||
<td>{{ fmtMs(p.avg_dwell_ms) }}</td>
|
||||
<td>{{ p.exit_count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!pages.length"><td colspan="5" class="muted">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>漏斗</h2>
|
||||
<ul class="funnel">
|
||||
<li v-for="s in funnel" :key="s.name">
|
||||
<span>{{ funnelLabel[s.name] || s.name }}</span>
|
||||
<strong>{{ s.count }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h2>退出页 TOP</h2>
|
||||
<table>
|
||||
<thead><tr><th>页面</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="e in exits" :key="e.exit_page">
|
||||
<td class="mono">{{ e.exit_page }}</td>
|
||||
<td>{{ e.count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!exits.length"><td colspan="2" class="muted">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>点击 TOP</h2>
|
||||
<table>
|
||||
<thead><tr><th>元素</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in clicks" :key="c.element_id">
|
||||
<td class="mono">{{ c.element_id }}</td>
|
||||
<td>{{ c.count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!clicks.length"><td colspan="2" class="muted">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
|
||||
h1 { margin: 0; font-size: 1.5rem; }
|
||||
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.err { color: #c0392b; }
|
||||
.actions { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
|
||||
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
||||
.seg button { border: 0; background: transparent; padding: 0.4rem 0.75rem; cursor: pointer; color: var(--muted); font-weight: 600; }
|
||||
.seg button.on { background: linear-gradient(135deg, #fff0ec, #ffe4e0); color: var(--accent); }
|
||||
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.card { padding: 1rem; border-radius: 16px; background: rgba(255,253,251,0.9); border: 1px solid var(--line); display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.card .lbl { color: var(--muted); font-size: 0.85rem; }
|
||||
.card strong { font-size: 1.45rem; }
|
||||
.panel { padding: 1rem 1.1rem; border-radius: 16px; border: 1px solid var(--line); background: rgba(255,253,251,0.9); margin-bottom: 1rem; }
|
||||
.grid2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; }
|
||||
.chart { width: 100%; height: auto; max-height: 220px; }
|
||||
.tick { font-size: 11px; fill: #8a7a74; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
|
||||
th, td { text-align: left; padding: 0.45rem 0.35rem; border-bottom: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
|
||||
.funnel { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.65rem; }
|
||||
.funnel li { display: flex; justify-content: space-between; padding: 0.55rem 0.7rem; border-radius: 12px; background: #fff5f2; }
|
||||
@media (max-width: 900px) {
|
||||
.cards, .grid2 { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.cards, .grid2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi, type HomeToolAdmin, type ScaleAdmin } from '@/api/client'
|
||||
|
||||
const tab = ref<'grid' | 'scales'>('grid')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const tools = ref<HomeToolAdmin[]>([])
|
||||
const scales = ref<ScaleAdmin[]>([])
|
||||
const msg = ref('')
|
||||
|
||||
const icons = [
|
||||
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
|
||||
'companion', 'ask', 'cards', 'reports', 'growth', 'relation',
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [t, s] = await Promise.all([adminApi.homeTools(), adminApi.scales()])
|
||||
tools.value = (t.items || []).map((x) => ({ ...x, enabled: !!x.enabled }))
|
||||
scales.value = s.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function saveTools() {
|
||||
saving.value = true
|
||||
msg.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = tools.value.map((t, idx) => ({
|
||||
...t,
|
||||
sort_order: t.sort_order || idx + 1,
|
||||
badge: t.badge || null,
|
||||
badge_tone: t.badge_tone || null,
|
||||
}))
|
||||
await adminApi.saveHomeTools(payload)
|
||||
msg.value = '宫格已保存'
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function move(i: number, dir: -1 | 1) {
|
||||
const j = i + dir
|
||||
if (j < 0 || j >= tools.value.length) return
|
||||
const arr = tools.value.slice()
|
||||
const tmp = arr[i]
|
||||
arr[i] = arr[j]
|
||||
arr[j] = tmp
|
||||
const counts: Record<number, number> = { 1: 0, 2: 0 }
|
||||
tools.value = arr.map((t) => {
|
||||
const row = t.row_index === 2 ? 2 : 1
|
||||
counts[row] += 1
|
||||
return { ...t, row_index: row, sort_order: counts[row] }
|
||||
})
|
||||
}
|
||||
|
||||
function addTool() {
|
||||
if (tools.value.length >= 24) return
|
||||
tools.value.push({
|
||||
row_index: 2,
|
||||
sort_order: tools.value.length + 1,
|
||||
path: '/ask',
|
||||
icon: 'ask',
|
||||
label: '新入口',
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleScale(s: ScaleAdmin) {
|
||||
const next = s.status === 'published' ? 'draft' : 'published'
|
||||
error.value = ''
|
||||
try {
|
||||
await adminApi.patchScale(s.id, next)
|
||||
s.status = next
|
||||
msg.value = `${s.title} → ${next === 'published' ? '已上架' : '已下架'}`
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '更新失败'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>内容</h1>
|
||||
<p class="muted">首页宫格 · 测评上下架</p>
|
||||
</div>
|
||||
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
|
||||
</header>
|
||||
|
||||
<div class="seg">
|
||||
<button type="button" :class="{ on: tab === 'grid' }" @click="tab = 'grid'">首页宫格</button>
|
||||
<button type="button" :class="{ on: tab === 'scales' }" @click="tab = 'scales'">测评</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="msg" class="ok">{{ msg }}</p>
|
||||
|
||||
<template v-if="tab === 'grid' && !loading">
|
||||
<div class="toolbar">
|
||||
<button class="btn ghost" type="button" @click="addTool">添加</button>
|
||||
<button class="btn" type="button" :disabled="saving" @click="saveTools">保存宫格</button>
|
||||
</div>
|
||||
<div v-for="(t, i) in tools" :key="t.id || i" class="row">
|
||||
<label class="chk"><input v-model="t.enabled" type="checkbox" />显</label>
|
||||
<select v-model.number="t.row_index">
|
||||
<option :value="1">行1</option>
|
||||
<option :value="2">行2</option>
|
||||
</select>
|
||||
<input v-model="t.label" class="inp" maxlength="16" placeholder="文案" />
|
||||
<input v-model="t.path" class="inp path" placeholder="/path" />
|
||||
<select v-model="t.icon">
|
||||
<option v-for="ic in icons" :key="ic" :value="ic">{{ ic }}</option>
|
||||
</select>
|
||||
<input v-model="t.badge" class="inp sm" maxlength="4" placeholder="角标" />
|
||||
<select v-model="t.badge_tone">
|
||||
<option value="">无色</option>
|
||||
<option value="hot">hot</option>
|
||||
<option value="new">new</option>
|
||||
</select>
|
||||
<button class="btn ghost" type="button" @click="move(i, -1)">↑</button>
|
||||
<button class="btn ghost" type="button" @click="move(i, 1)">↓</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="tab === 'scales' && !loading">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>标题</th><th>slug</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in scales" :key="s.id">
|
||||
<td>{{ s.title }}</td>
|
||||
<td class="mono">{{ s.slug }}</td>
|
||||
<td>
|
||||
<span :class="s.status === 'published' ? 'on' : 'off'">
|
||||
{{ s.status === 'published' ? '已上架' : '草稿' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn ghost" type="button" @click="toggleScale(s)">
|
||||
{{ s.status === 'published' ? '下架' : '上架' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
|
||||
h1 { margin: 0; font-size: 1.5rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.err { color: #c0392b; }
|
||||
.ok { color: #2fa866; }
|
||||
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-bottom: 1rem; }
|
||||
.seg button { border: 0; background: transparent; padding: 0.45rem 0.9rem; cursor: pointer; color: var(--muted); font-weight: 600; }
|
||||
.seg button.on { background: linear-gradient(135deg, #fff0ec, #ffe4e0); color: var(--accent); }
|
||||
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 7rem 1fr 6.5rem 3.5rem 4.5rem auto auto;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.45rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,253,251,0.9);
|
||||
}
|
||||
.inp { border: 1px solid var(--line); border-radius: 8px; padding: 0.35rem 0.45rem; min-width: 0; }
|
||||
.inp.path { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||||
.inp.sm { width: 3.2rem; }
|
||||
.chk { font-size: 0.85rem; color: var(--muted); display: flex; gap: 0.25rem; align-items: center; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||||
.on { color: #2fa866; font-weight: 600; }
|
||||
.off { color: var(--muted); }
|
||||
@media (max-width: 960px) {
|
||||
.row { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { adminApi, type DashboardStats } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const stats = ref<DashboardStats | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
stats.value = await adminApi.stats()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function yuan(cents: number) {
|
||||
return (cents / 100).toFixed(2)
|
||||
}
|
||||
|
||||
function shortDay(day: string) {
|
||||
const p = day.split('-')
|
||||
return p.length === 3 ? `${Number(p[1])}/${Number(p[2])}` : day
|
||||
}
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
portrait: '愈心解码',
|
||||
star: '星座',
|
||||
rhythm: '节律',
|
||||
relation: '关系',
|
||||
synastry: '合盘',
|
||||
image_card: '意象卡',
|
||||
}
|
||||
|
||||
type BarSeries = { key: string; label: string; color: string; values: number[] }
|
||||
|
||||
const trend = computed(() => {
|
||||
const series = stats.value?.series || []
|
||||
const labels = series.map((d) => shortDay(d.day))
|
||||
const bars: BarSeries[] = [
|
||||
{ key: 'orders', label: '订单', color: '#e54d42', values: series.map((d) => d.orders) },
|
||||
{ key: 'ask', label: '问答', color: '#4a90e2', values: series.map((d) => d.ask_replies) },
|
||||
{ key: 'users', label: '新用户', color: '#c8923a', values: series.map((d) => d.new_users) },
|
||||
]
|
||||
const max = Math.max(1, ...bars.flatMap((b) => b.values))
|
||||
return { labels, bars, max }
|
||||
})
|
||||
|
||||
const W = 560
|
||||
const H = 220
|
||||
const pad = { t: 16, r: 12, b: 36, l: 36 }
|
||||
const plotW = W - pad.l - pad.r
|
||||
const plotH = H - pad.t - pad.b
|
||||
|
||||
function barX(dayIdx: number, seriesIdx: number, nDays: number, nSeries: number) {
|
||||
const groupW = plotW / Math.max(nDays, 1)
|
||||
const inner = groupW * 0.72
|
||||
const barW = inner / nSeries
|
||||
const groupStart = pad.l + dayIdx * groupW + (groupW - inner) / 2
|
||||
return groupStart + seriesIdx * barW
|
||||
}
|
||||
|
||||
function barHeight(v: number, max: number) {
|
||||
return (v / max) * plotH
|
||||
}
|
||||
|
||||
const pie = computed(() => {
|
||||
const items = (stats.value?.reports_by_type || []).map((r) => ({
|
||||
type: r.type,
|
||||
label: typeLabel[r.type] || r.type,
|
||||
count: r.count,
|
||||
}))
|
||||
const total = items.reduce((s, i) => s + i.count, 0) || 1
|
||||
const colors = ['#e54d42', '#ff8a7c', '#c8923a', '#4a90e2', '#5cb85c', '#e8985a', '#8b5cf6']
|
||||
let angle = -Math.PI / 2
|
||||
const cx = 90
|
||||
const cy = 90
|
||||
const R = 70
|
||||
const slices = items.map((it, i) => {
|
||||
const sweep = (it.count / total) * Math.PI * 2
|
||||
const a0 = angle
|
||||
const a1 = angle + sweep
|
||||
angle = a1
|
||||
const x0 = cx + R * Math.cos(a0)
|
||||
const y0 = cy + R * Math.sin(a0)
|
||||
const x1 = cx + R * Math.cos(a1)
|
||||
const y1 = cy + R * Math.sin(a1)
|
||||
const large = sweep > Math.PI ? 1 : 0
|
||||
const d = `M ${cx} ${cy} L ${x0} ${y0} A ${R} ${R} 0 ${large} 1 ${x1} ${y1} Z`
|
||||
return { ...it, d, color: colors[i % colors.length], pct: Math.round((it.count / total) * 100) }
|
||||
})
|
||||
return { slices, total: items.reduce((s, i) => s + i.count, 0) }
|
||||
})
|
||||
|
||||
const memberShare = computed(() => {
|
||||
const s = stats.value
|
||||
if (!s || s.users_total <= 0) return 0
|
||||
return Math.min(100, Math.round((s.membership_active / s.users_total) * 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>概览</h1>
|
||||
<p class="muted">近 7 日趋势与运营一览</p>
|
||||
</div>
|
||||
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
|
||||
</header>
|
||||
|
||||
<p v-if="loading && !stats" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<template v-else-if="stats">
|
||||
<div class="grid">
|
||||
<div class="stat">
|
||||
<span class="label">用户总数</span>
|
||||
<strong>{{ stats.users_total }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">有效会员</span>
|
||||
<strong>{{ stats.membership_active }}</strong>
|
||||
<em class="hint">渗透 {{ memberShare }}%</em>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">今日订单</span>
|
||||
<strong>{{ stats.orders_today }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">今日成交(元)</span>
|
||||
<strong>{{ yuan(stats.paid_cents_today) }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">今日问答回复</span>
|
||||
<strong>{{ stats.ask_replies_today }}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">档案 / 报告</span>
|
||||
<strong>{{ stats.profiles_total }} / {{ stats.reports_total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts">
|
||||
<div class="card chart-card">
|
||||
<div class="chart-head">
|
||||
<h2>近 7 日趋势</h2>
|
||||
<div class="legend">
|
||||
<span v-for="b in trend.bars" :key="b.key"><i :style="{ background: b.color }" />{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="chart" :viewBox="`0 0 ${W} ${H}`" role="img" aria-label="近七日订单问答新用户柱状图">
|
||||
<line
|
||||
v-for="g in 4"
|
||||
:key="'g'+g"
|
||||
:x1="pad.l"
|
||||
:x2="W - pad.r"
|
||||
:y1="pad.t + (plotH * g) / 4"
|
||||
:y2="pad.t + (plotH * g) / 4"
|
||||
class="gridline"
|
||||
/>
|
||||
<template v-for="(label, di) in trend.labels" :key="label">
|
||||
<rect
|
||||
v-for="(b, si) in trend.bars"
|
||||
:key="b.key + di"
|
||||
:x="barX(di, si, trend.labels.length, trend.bars.length)"
|
||||
:y="pad.t + plotH - barHeight(b.values[di] || 0, trend.max)"
|
||||
:width="(plotW / Math.max(trend.labels.length, 1) * 0.72) / trend.bars.length - 1"
|
||||
:height="barHeight(b.values[di] || 0, trend.max)"
|
||||
:fill="b.color"
|
||||
rx="3"
|
||||
>
|
||||
<title>{{ label }} {{ b.label }}: {{ b.values[di] || 0 }}</title>
|
||||
</rect>
|
||||
<text
|
||||
:x="pad.l + (di + 0.5) * (plotW / Math.max(trend.labels.length, 1))"
|
||||
:y="H - 12"
|
||||
class="axis"
|
||||
text-anchor="middle"
|
||||
>{{ label }}</text>
|
||||
</template>
|
||||
<text :x="8" :y="pad.t + 4" class="axis">{{ trend.max }}</text>
|
||||
<text :x="8" :y="pad.t + plotH" class="axis">0</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="card chart-card">
|
||||
<h2>报告类型分布</h2>
|
||||
<div v-if="!pie.slices.length" class="muted">暂无报告</div>
|
||||
<div v-else class="pie-wrap">
|
||||
<svg viewBox="0 0 180 180" class="pie" role="img" aria-label="报告类型饼图">
|
||||
<path v-for="s in pie.slices" :key="s.type" :d="s.d" :fill="s.color">
|
||||
<title>{{ s.label }} {{ s.count }}({{ s.pct }}%)</title>
|
||||
</path>
|
||||
<circle cx="90" cy="90" r="38" fill="#fffdfb" />
|
||||
<text x="90" y="86" text-anchor="middle" class="pie-total">{{ pie.total }}</text>
|
||||
<text x="90" y="104" text-anchor="middle" class="pie-sub">份报告</text>
|
||||
</svg>
|
||||
<ul class="pie-legend">
|
||||
<li v-for="s in pie.slices" :key="s.type">
|
||||
<i :style="{ background: s.color }" />
|
||||
<span>{{ s.label }}</span>
|
||||
<strong>{{ s.count }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shortcuts">
|
||||
<h2>快捷入口</h2>
|
||||
<div class="links">
|
||||
<RouterLink class="chip" to="/users">用户管理</RouterLink>
|
||||
<RouterLink class="chip" to="/orders">订单列表</RouterLink>
|
||||
<RouterLink class="chip" to="/audit">操作审计</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
}
|
||||
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
.stat {
|
||||
background: linear-gradient(145deg, #fffdfb, #fff5f2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.05rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
box-shadow: 0 8px 24px rgba(229, 77, 66, 0.07);
|
||||
}
|
||||
.stat .label { font-size: 0.78rem; color: var(--muted); }
|
||||
.stat strong {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
color: #2f2a28;
|
||||
}
|
||||
.hint { font-size: 0.72rem; color: var(--accent); font-style: normal; }
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.chart-card { overflow: hidden; }
|
||||
.chart-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend i {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.chart { width: 100%; height: auto; display: block; }
|
||||
.gridline { stroke: #f0e6e2; stroke-width: 1; }
|
||||
.axis { fill: #b5aaa5; font-size: 11px; }
|
||||
.pie-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.pie { width: 180px; height: 180px; flex-shrink: 0; }
|
||||
.pie-total { font-size: 18px; font-weight: 750; fill: #2f2a28; }
|
||||
.pie-sub { font-size: 11px; fill: #9a8f8b; }
|
||||
.pie-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
.pie-legend li {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.28rem 0;
|
||||
}
|
||||
.pie-legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.shortcuts { margin-top: 0.15rem; }
|
||||
.links { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #fff0ec, #ffe4e0);
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.charts { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -29,8 +30,9 @@ async function onSubmit() {
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<form class="card login" @submit.prevent="onSubmit">
|
||||
<h1>运营后台</h1>
|
||||
<p class="muted">愈心谷内部工具 · Phase A</p>
|
||||
<BrandLogo size="login" />
|
||||
<h1>愈心谷</h1>
|
||||
<p class="muted">内部运营工具</p>
|
||||
<label class="field">
|
||||
<span>用户名</span>
|
||||
<input v-model="username" autocomplete="username" required />
|
||||
@@ -49,8 +51,9 @@ async function onSubmit() {
|
||||
|
||||
<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; }
|
||||
.login { width: min(380px, 100%); text-align: center; }
|
||||
.login :deep(.field) { text-align: left; }
|
||||
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; }
|
||||
.muted { margin: 0 0 1.2rem; }
|
||||
.btn { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { adminApi, type UserDetail } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -8,7 +8,9 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detail = ref<UserDetail | null>(null)
|
||||
const plan = ref('month')
|
||||
const askDelta = ref(10)
|
||||
const grantMsg = ref('')
|
||||
const askMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -26,65 +28,142 @@ async function grant() {
|
||||
grantMsg.value = ''
|
||||
try {
|
||||
await adminApi.grant(String(route.params.id), plan.value)
|
||||
grantMsg.value = '已授予'
|
||||
grantMsg.value = '会员已授予/延长'
|
||||
await load()
|
||||
} catch (e) {
|
||||
grantMsg.value = e instanceof Error ? e.message : '授予失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function grantAsk() {
|
||||
askMsg.value = ''
|
||||
try {
|
||||
const res = await adminApi.grantAskQuota(String(route.params.id), askDelta.value)
|
||||
askMsg.value = `已加额度,当前余量 ${res.ask_paid_quota_left}`
|
||||
await load()
|
||||
} catch (e) {
|
||||
askMsg.value = e instanceof Error ? e.message : '加额度失败'
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string | null) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
portrait: '愈心解码',
|
||||
star: '星座',
|
||||
rhythm: '节律',
|
||||
relation: '关系',
|
||||
synastry: '合盘',
|
||||
image_card: '意象卡',
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<p class="crumb"><RouterLink to="/users">← 用户列表</RouterLink></p>
|
||||
<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>
|
||||
<h2>账号</h2>
|
||||
<div class="kv">
|
||||
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
|
||||
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ detail.status }}</strong></div>
|
||||
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
|
||||
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
|
||||
</div>
|
||||
</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 || '—' }}
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月</option>
|
||||
<option value="quarter">季</option>
|
||||
<option value="year">年</option>
|
||||
<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>
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
<option :value="100">+100</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grantAsk">增加额度</button>
|
||||
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>档案({{ detail.profiles?.length || 0 }})</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in detail.profiles" :key="p.id">
|
||||
<td>{{ p.display_name || '未命名' }}</td>
|
||||
<td>{{ p.relation }}</td>
|
||||
<td>{{ p.birth_date || '—' }}</td>
|
||||
<td><code>{{ p.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长报告(近 {{ detail.reports?.length || 0 }})</h2>
|
||||
<p v-if="!detail.reports?.length" class="muted">无报告</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in detail.reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
<td><code>{{ r.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<thead><tr><th>类型</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>
|
||||
<td>{{ fmtTime(o.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -94,9 +173,19 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb { margin: 0 0 0.5rem; font-size: 0.9rem; }
|
||||
.crumb a { color: var(--accent); }
|
||||
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; }
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.kv span { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.15rem; }
|
||||
.kv .wide { grid-column: 1 / -1; }
|
||||
.kv code { font-size: 0.82rem; word-break: break-all; }
|
||||
.grant { display: flex; flex-wrap: wrap; 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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { adminApi, type UserListItem } from '@/api/client'
|
||||
|
||||
const q = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Array<{ id: string; status: string; created_at: string }>>([])
|
||||
const items = ref<UserListItem[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -21,15 +21,30 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function shortID(id: string) {
|
||||
return id.slice(0, 8)
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<h1>用户</h1>
|
||||
<div>
|
||||
<h1>用户</h1>
|
||||
<p class="muted">支持手机号、昵称、UUID 搜索</p>
|
||||
</div>
|
||||
<form class="search" @submit.prevent="load">
|
||||
<input v-model="q" placeholder="精确 user id (UUID)" />
|
||||
<input v-model="q" placeholder="手机号 / 昵称 / UUID" />
|
||||
<button class="btn" type="submit">查询</button>
|
||||
</form>
|
||||
</header>
|
||||
@@ -37,25 +52,68 @@ onMounted(load)
|
||||
<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 v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>会员</th>
|
||||
<th>问答余量</th>
|
||||
<th>档案</th>
|
||||
<th>创建</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in items" :key="u.id">
|
||||
<td>
|
||||
<RouterLink class="name" :to="`/users/${u.id}`">
|
||||
{{ u.nickname || u.phone || shortID(u.id) }}
|
||||
</RouterLink>
|
||||
<div class="sub">{{ u.phone || '未绑定手机' }} · {{ shortID(u.id) }}…</div>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="u.membership_active ? 'tag on' : 'tag'">
|
||||
{{ u.membership_active ? (u.membership_plan || '有效') : '未开通' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ u.ask_paid_quota_left }}</td>
|
||||
<td>{{ u.profile_count }}</td>
|
||||
<td>{{ fmtTime(u.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.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; }
|
||||
.search input {
|
||||
min-width: 240px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.name { font-weight: 650; color: var(--accent); }
|
||||
.sub { font-size: 0.78rem; color: var(--muted); margin-top: 0.15rem; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
background: #f3f0eb;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tag.on { background: #e8f5e9; color: #2e7d32; }
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getToken } from '@/api/client'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
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: '', name: 'dashboard', component: () => import('@/pages/DashboardPage.vue') },
|
||||
{ path: 'analytics', name: 'analytics', component: () => import('@/pages/AnalyticsPage.vue') },
|
||||
{ path: 'content', name: 'content', component: () => import('@/pages/ContentPage.vue') },
|
||||
{ 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: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||
|
||||
@@ -1,36 +1,76 @@
|
||||
/* 愈心谷运营后台 — 对齐 packages/ui tokens */
|
||||
:root {
|
||||
--bg: #f3f0eb;
|
||||
--panel: #fffdf9;
|
||||
--ink: #1c1917;
|
||||
--muted: #78716c;
|
||||
--line: #e7e5e4;
|
||||
--accent: #c45c4a;
|
||||
--color-primary: #e54d42;
|
||||
--color-primary-hover: #d44338;
|
||||
--color-primary-soft: #ffe4e4;
|
||||
--color-bg-start: #ffd1c7;
|
||||
--color-bg-end: #ffc8b5;
|
||||
--color-bg-sheet: #fff9f7;
|
||||
--color-surface: #ffffff;
|
||||
--color-text-primary: #333333;
|
||||
--color-text-secondary: #999999;
|
||||
--color-border: #f0e6e2;
|
||||
--color-accent-gold: #c8923a;
|
||||
--color-accent-blue: #4a90e2;
|
||||
--color-accent-green: #5cb85c;
|
||||
--color-error: #e54d42;
|
||||
|
||||
--bg: linear-gradient(165deg, #ffe8e0 0%, #fff5f1 42%, #ffece5 100%);
|
||||
--panel: #fffdfb;
|
||||
--ink: #333333;
|
||||
--muted: #9a8f8b;
|
||||
--line: #f0e6e2;
|
||||
--accent: #e54d42;
|
||||
--accent-ink: #fff;
|
||||
--danger: #b91c1c;
|
||||
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
--danger: #e54d42;
|
||||
--radius: 14px;
|
||||
--shadow: 0 8px 28px rgba(229, 77, 66, 0.08);
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
background: var(--color-bg-sheet);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, #f7f3ee 0%, #ebe6df 45%, #f3efea 100%); }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
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);
|
||||
border: 0; border-radius: 12px; padding: 0.55rem 1rem; cursor: pointer;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--accent));
|
||||
color: var(--accent-ink);
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.22);
|
||||
font-weight: 650;
|
||||
}
|
||||
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn.ghost {
|
||||
background: transparent; color: var(--ink);
|
||||
border: 1px solid var(--line); box-shadow: none; font-weight: 500;
|
||||
}
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; box-shadow: none; }
|
||||
.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;
|
||||
border: 1.5px solid var(--line); border-radius: 12px; padding: 0.6rem 0.75rem;
|
||||
background: #fdfaf8;
|
||||
}
|
||||
.field input:focus, .field select:focus {
|
||||
outline: none; border-color: #f0b8b0;
|
||||
box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.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;
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.1rem 1.2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
|
||||
@@ -4,7 +4,8 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/',
|
||||
// Production is mounted at https://…/psy/admin/ ; local `vite` still uses `/`.
|
||||
base: process.env.VITE_BASE || (process.env.NODE_ENV === 'production' ? '/psy/admin/' : '/'),
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
@@ -17,6 +18,11 @@ export default defineConfig({
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/psy/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/psy\/api/, '/api'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -78,11 +78,10 @@ func BuildReply(in ReplyInput) string {
|
||||
)
|
||||
}
|
||||
|
||||
disclaimer := "以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
|
||||
if sceneHint != "" {
|
||||
return sceneHint + "\n\n" + strings.TrimSpace(body) + "\n\n" + disclaimer
|
||||
return sceneHint + "\n\n" + strings.TrimSpace(body)
|
||||
}
|
||||
return strings.TrimSpace(body) + "\n\n" + disclaimer
|
||||
return strings.TrimSpace(body)
|
||||
}
|
||||
|
||||
func sectionByFocus(detail map[string]any, focus string) string {
|
||||
|
||||
@@ -21,8 +21,8 @@ func TestBuildReply_profileAware(t *testing.T) {
|
||||
if strings.Contains(out, "算命") || strings.Contains(out, "运势") || strings.Contains(out, "吉凶") {
|
||||
t.Fatalf("forbidden lexicon in reply: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "不构成") {
|
||||
t.Fatalf("expected disclaimer: %s", out)
|
||||
if strings.Contains(out, "不构成医疗") || strings.Contains(out, "占卜预测") {
|
||||
t.Fatalf("disclaimer should stay in UI only: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
|
||||
"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/internal/service/analytics"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// AdminHandler serves /api/v1/admin/* (no DeviceAuth).
|
||||
type AdminHandler struct {
|
||||
Svc *admin.Service
|
||||
Svc *admin.Service
|
||||
Analytics *analytics.Service
|
||||
}
|
||||
|
||||
// Register mounts public login + authed admin routes.
|
||||
@@ -27,11 +29,19 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
authed.Use(middleware.AdminAuth(h.Svc))
|
||||
authed.POST("/auth/logout", h.Logout)
|
||||
authed.GET("/me", h.Me)
|
||||
authed.GET("/stats", h.Stats)
|
||||
authed.GET("/users", h.ListUsers)
|
||||
authed.GET("/users/:id", h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", h.GrantMembership)
|
||||
authed.POST("/users/:id/ask-quota/grant", h.GrantAskQuota)
|
||||
authed.GET("/orders", h.ListOrders)
|
||||
authed.GET("/audit-logs", h.ListAudit)
|
||||
authed.GET("/analytics/overview", h.AnalyticsOverview)
|
||||
authed.GET("/analytics/pages", h.AnalyticsPages)
|
||||
authed.GET("/analytics/exits", h.AnalyticsExits)
|
||||
authed.GET("/analytics/clicks", h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
|
||||
h.registerContent(authed)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
@@ -75,6 +85,15 @@ func (h *AdminHandler) Me(c *gin.Context) {
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Stats(c *gin.Context) {
|
||||
stats, err := h.Svc.DashboardStats(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50017, "stats failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, stats)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
@@ -135,6 +154,38 @@ func (h *AdminHandler) GrantMembership(c *gin.Context) {
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GrantAskQuota(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.GrantAskQuotaInput
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40005, "delta required")
|
||||
return
|
||||
}
|
||||
left, err := h.Svc.GrantAskQuota(c.Request.Context(), adminID, userID, body.Delta)
|
||||
if err != nil {
|
||||
if errors.Is(err, admin.ErrInvalidAskDelta) {
|
||||
response.Fail(c, http.StatusBadRequest, 40006, "invalid delta")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50018, "grant ask quota failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "ask_paid_quota_left": left})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListOrders(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
@@ -156,3 +207,96 @@ func (h *AdminHandler) ListAudit(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AnalyticsOverview(c *gin.Context) {
|
||||
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
|
||||
return
|
||||
}
|
||||
if !h.requireAnalytics(c) {
|
||||
return
|
||||
}
|
||||
data, err := h.Analytics.Overview(c.Request.Context(), from, to)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50022, "overview failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, data)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AnalyticsPages(c *gin.Context) {
|
||||
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
|
||||
return
|
||||
}
|
||||
if !h.requireAnalytics(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.Analytics.Pages(c.Request.Context(), from, to)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50023, "pages failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AnalyticsExits(c *gin.Context) {
|
||||
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
|
||||
return
|
||||
}
|
||||
if !h.requireAnalytics(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.Analytics.Exits(c.Request.Context(), from, to)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50024, "exits failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AnalyticsClicks(c *gin.Context) {
|
||||
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
|
||||
return
|
||||
}
|
||||
if !h.requireAnalytics(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.Analytics.Clicks(c.Request.Context(), from, to)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50025, "clicks failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AnalyticsFunnel(c *gin.Context) {
|
||||
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
|
||||
return
|
||||
}
|
||||
if !h.requireAnalytics(c) {
|
||||
return
|
||||
}
|
||||
steps, err := h.Analytics.Funnel(c.Request.Context(), from, to)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50026, "funnel failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"steps": steps})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) requireAnalytics(c *gin.Context) bool {
|
||||
if h.Analytics == nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50021, "analytics unavailable")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
|
||||
authed.GET("/home/tools", h.ListHomeTools)
|
||||
authed.PUT("/home/tools", h.ReplaceHomeTools)
|
||||
authed.GET("/scales", h.ListScales)
|
||||
authed.PATCH("/scales/:id", h.PatchScale)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListHomeTools(c *gin.Context) {
|
||||
items, err := h.Svc.ListHomeTools(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50031, "list home tools failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ReplaceHomeTools(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Items []homesvc.ReplaceInput `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40040, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ReplaceHomeTools(c.Request.Context(), adminID, body.Items); err != nil {
|
||||
if errors.Is(err, homesvc.ErrInvalidTools) || errors.Is(err, homesvc.ErrTooManyTools) {
|
||||
response.Fail(c, http.StatusBadRequest, 40041, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50032, "replace home tools failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListScales(c *gin.Context) {
|
||||
items, err := h.Svc.ListScalesAdmin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50033, "list scales failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PatchScale(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40042, "invalid scale id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Status == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40043, "status required")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.PatchScaleStatus(c.Request.Context(), adminID, id, body.Status); err != nil {
|
||||
if errors.Is(err, admin.ErrInvalidScaleStatus) {
|
||||
response.Fail(c, http.StatusBadRequest, 40044, "invalid status")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrScaleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40410, "scale not found")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50034, "patch scale failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/analytics"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// AnalyticsHandler serves POST /api/v1/analytics/events (DeviceAuth).
|
||||
type AnalyticsHandler struct {
|
||||
Svc *analytics.Service
|
||||
}
|
||||
|
||||
// Register mounts analytics routes on a DeviceAuth group.
|
||||
func (h *AnalyticsHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/analytics")
|
||||
g.POST("/events", h.Ingest)
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) Ingest(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
if deviceKey == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40020, "device key required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Items []analytics.EventIn `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40021, "invalid body")
|
||||
return
|
||||
}
|
||||
res, err := h.Svc.Ingest(c.Request.Context(), userID, deviceKey, body.Items)
|
||||
if err != nil {
|
||||
if errors.Is(err, analytics.ErrTooManyItems) {
|
||||
response.Fail(c, http.StatusBadRequest, 40022, "too many items")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, analytics.ErrInvalidBatch) {
|
||||
response.Fail(c, http.StatusBadRequest, 40023, "invalid batch")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50020, "ingest failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -20,6 +23,7 @@ type AskHandler struct {
|
||||
func (h *AskHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/ask/quota", h.GetQuota)
|
||||
rg.POST("/ask/threads", h.CreateThread)
|
||||
rg.DELETE("/ask/threads/:id", h.ClearThread)
|
||||
rg.GET("/ask/threads/:id/messages", h.ListMessages)
|
||||
rg.POST("/ask/threads/:id/messages", h.SendMessage)
|
||||
}
|
||||
@@ -69,6 +73,25 @@ func (h *AskHandler) CreateThread(c *gin.Context) {
|
||||
response.OK(c, th)
|
||||
}
|
||||
|
||||
// ClearThread handles DELETE /ask/threads/:id.
|
||||
func (h *AskHandler) ClearThread(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
tid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ClearThread(c.Request.Context(), userID, tid); err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 40410, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"cleared": true})
|
||||
}
|
||||
|
||||
// ListMessages handles GET /ask/threads/:id/messages.
|
||||
func (h *AskHandler) ListMessages(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
@@ -90,6 +113,7 @@ func (h *AskHandler) ListMessages(c *gin.Context) {
|
||||
}
|
||||
|
||||
// SendMessage handles POST /ask/threads/:id/messages.
|
||||
// Use ?stream=1 (or Accept: text/event-stream) for SSE streaming.
|
||||
func (h *AskHandler) SendMessage(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -108,10 +132,18 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
wantStream := c.Query("stream") == "1" ||
|
||||
strings.Contains(c.GetHeader("Accept"), "text/event-stream")
|
||||
if wantStream {
|
||||
h.sendMessageStream(c, userID, tid, req.Content)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content)
|
||||
if err != nil {
|
||||
if asksvc.IsQuotaExhausted(err) {
|
||||
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可开通成长会员获取更多次数")
|
||||
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可购买额度或开通成长会员")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40011, err.Error())
|
||||
@@ -119,3 +151,39 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
func (h *AskHandler) sendMessageStream(c *gin.Context, userID, tid uuid.UUID, content string) {
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Status(http.StatusOK)
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, "stream unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
writeEvent := func(event string, payload any) error {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
err := h.Svc.StreamMessage(c.Request.Context(), userID, tid, content, writeEvent)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
code := 40011
|
||||
if asksvc.IsQuotaExhausted(err) {
|
||||
msg = "问答次数已用完,可购买额度或开通成长会员"
|
||||
code = 40210
|
||||
}
|
||||
_ = writeEvent("error", map[string]any{"code": code, "message": msg})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/auth"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// AuthHandler serves /api/v1/auth/*.
|
||||
type AuthHandler struct {
|
||||
Svc *auth.Service
|
||||
}
|
||||
|
||||
// Register mounts auth routes. Public register/login; me/logout need device (+ session).
|
||||
func (h *AuthHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/auth")
|
||||
g.POST("/register", h.RegisterAccount)
|
||||
g.POST("/login", h.Login)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", h.Me)
|
||||
}
|
||||
|
||||
type authBody struct {
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
// RegisterAccount handles POST /auth/register (DeviceAuth required on group).
|
||||
func (h *AuthHandler) RegisterAccount(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
var body authBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40110, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// Login handles POST /auth/login (open mode: any phone+password).
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
var body authBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
res, err := h.Svc.Login(c.Request.Context(), userID, deviceKey, body.Phone, body.Password)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40111, err.Error())
|
||||
return
|
||||
}
|
||||
c.Set(string(middleware.UserIDKey), res.User.ID)
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// Logout handles POST /auth/logout.
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
tok := bearerToken(c)
|
||||
_ = h.Svc.Logout(c.Request.Context(), tok)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Me handles GET /auth/me.
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
me, err := h.Svc.Me(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusUnauthorized, 40112, "请先登录")
|
||||
return
|
||||
}
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
return strings.TrimSpace(h[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// HomeHandler serves GET /api/v1/home/* (DeviceAuth).
|
||||
type HomeHandler struct {
|
||||
Svc *home.Service
|
||||
}
|
||||
|
||||
// Register mounts home routes.
|
||||
func (h *HomeHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/home")
|
||||
g.GET("/tools", h.Tools)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) Tools(c *gin.Context) {
|
||||
items, err := h.Svc.ListPublic(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50030, "home tools failed")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.HomeTool{}
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
@@ -34,12 +34,47 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.POST("/reports/synastry", h.CreateSynastry)
|
||||
rg.POST("/reports/rhythm", h.CreateRhythm)
|
||||
rg.GET("/reports", h.List)
|
||||
rg.GET("/reports/latest", h.GetLatest)
|
||||
rg.GET("/reports/:id", h.Get)
|
||||
rg.GET("/membership/me", h.GetMembership)
|
||||
rg.POST("/orders", h.CreateOrder)
|
||||
rg.POST("/orders/:id/pay-mock", h.PayMock)
|
||||
}
|
||||
|
||||
// GetLatest handles GET /reports/latest?profile_id=&type=&peer_profile_id=.
|
||||
func (h *ReportHandler) GetLatest(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(c.Query("profile_id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "profile_id required")
|
||||
return
|
||||
}
|
||||
typ := c.Query("type")
|
||||
if typ == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "type required")
|
||||
return
|
||||
}
|
||||
var peer *uuid.UUID
|
||||
if ps := c.Query("peer_profile_id"); ps != "" {
|
||||
id, err := uuid.Parse(ps)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid peer_profile_id")
|
||||
return
|
||||
}
|
||||
peer = &id
|
||||
}
|
||||
rep, err := h.Svc.GetLatest(c.Request.Context(), userID, pid, typ, peer)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 40402, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, rep)
|
||||
}
|
||||
|
||||
// CreatePortrait handles POST /reports/portrait.
|
||||
func (h *ReportHandler) CreatePortrait(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
|
||||
@@ -14,8 +14,12 @@ import (
|
||||
"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"
|
||||
analyticssvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/analytics"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||
authsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/auth"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
|
||||
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
|
||||
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/membership"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/profile"
|
||||
@@ -32,13 +36,16 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
relationRepo := &repository.RelationRepo{Pool: pool}
|
||||
askRepo := &repository.AskRepo{Pool: pool}
|
||||
adminRepo := &repository.AdminRepo{Pool: pool}
|
||||
analyticsRepo := &repository.AnalyticsRepo{Pool: pool}
|
||||
authRepo := &repository.AuthRepo{Pool: pool}
|
||||
|
||||
var llm *deepseek.Client
|
||||
if cfg.DeepSeek.Enabled() {
|
||||
llm = deepseek.New(cfg.DeepSeek)
|
||||
}
|
||||
|
||||
profileSvc := &profile.Service{Repo: profileRepo}
|
||||
bootSvc := &bootstrap.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
||||
profileSvc := &profile.Service{Repo: profileRepo, Reports: reportRepo, Bootstrap: bootSvc}
|
||||
reportSvc := &report.Service{
|
||||
Profiles: profileRepo,
|
||||
Reports: reportRepo,
|
||||
@@ -46,7 +53,8 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
}
|
||||
membershipSvc := &membership.Service{Reports: reportRepo}
|
||||
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
||||
scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
|
||||
scaleRepo := &repository.ScaleRepo{Pool: pool}
|
||||
scaleSvc := &scale.Service{Repo: scaleRepo, Profiles: profileRepo}
|
||||
askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm}
|
||||
companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}}
|
||||
imageCardSvc := &imagecardsvc.Service{
|
||||
@@ -54,7 +62,12 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
Reports: reportRepo,
|
||||
Quotas: &repository.ImageCardRepo{Pool: pool},
|
||||
}
|
||||
adminSvc := &adminsvc.Service{Repo: adminRepo, Reports: reportRepo}
|
||||
homeSvc := &homesvc.Service{Repo: &repository.HomeToolsRepo{Pool: pool}}
|
||||
analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
|
||||
adminSvc := &adminsvc.Service{
|
||||
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
|
||||
}
|
||||
authSvc := &authsvc.Service{Repo: authRepo}
|
||||
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
|
||||
Username: cfg.Admin.BootstrapUsername,
|
||||
Password: cfg.Admin.BootstrapPassword,
|
||||
@@ -74,22 +87,28 @@ 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)
|
||||
(&handler.AdminHandler{Svc: adminSvc, Analytics: analyticsSvc}).Register(api)
|
||||
|
||||
authed := api.Group("")
|
||||
authed.Use(middleware.DeviceAuth(pool))
|
||||
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
|
||||
(&handler.ReportHandler{Svc: reportSvc, Membership: membershipSvc}).Register(authed)
|
||||
(&handler.SynastryHandler{Svc: reportSvc}).Register(authed)
|
||||
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
|
||||
(&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
|
||||
(&handler.AskHandler{Svc: askSvc}).Register(authed)
|
||||
(&handler.CompanionHandler{Svc: companionSvc}).Register(authed)
|
||||
(&handler.ImageCardHandler{Svc: imageCardSvc}).Register(authed)
|
||||
(&handler.ExploreHandler{}).Register(authed)
|
||||
(&handler.AuthHandler{Svc: authSvc}).Register(authed)
|
||||
(&handler.AnalyticsHandler{Svc: analyticsSvc}).Register(authed)
|
||||
(&handler.HomeHandler{Svc: homeSvc}).Register(authed)
|
||||
|
||||
gated := authed.Group("")
|
||||
gated.Use(middleware.RequireRegistered(pool))
|
||||
(&handler.ProfileHandler{Svc: profileSvc}).Register(gated)
|
||||
(&handler.ReportHandler{Svc: reportSvc, Membership: membershipSvc}).Register(gated)
|
||||
(&handler.SynastryHandler{Svc: reportSvc}).Register(gated)
|
||||
(&handler.RelationHandler{Svc: relationSvc}).Register(gated)
|
||||
(&handler.ScaleHandler{Svc: scaleSvc}).Register(gated)
|
||||
(&handler.AskHandler{Svc: askSvc}).Register(gated)
|
||||
(&handler.CompanionHandler{Svc: companionSvc}).Register(gated)
|
||||
(&handler.ImageCardHandler{Svc: imageCardSvc}).Register(gated)
|
||||
(&handler.ExploreHandler{}).Register(gated)
|
||||
(&handler.GrowthHandler{
|
||||
Plans: &repository.GrowthRepo{Pool: pool},
|
||||
}).Register(authed)
|
||||
}).Register(gated)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestAdminOpsPhaseA(t *testing.T) {
|
||||
t.Fatalf("login token missing: %v %s", err, env.Data)
|
||||
}
|
||||
|
||||
_, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
|
||||
_ = mustRegister(t, r)
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAnalyticsOpsB(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
sid := "s_test_" + time.Now().Format("150405")
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/analytics/events", map[string]any{
|
||||
"items": []map[string]any{
|
||||
{"name": "session_start", "session_id": sid, "client_ts": now, "props": map[string]any{"cold": true}},
|
||||
{"name": "page_view", "session_id": sid, "page_path": "/portrait", "client_ts": now},
|
||||
{"name": "page_view", "session_id": sid, "page_path": "/ask", "client_ts": now},
|
||||
{"name": "page_leave", "session_id": sid, "page_path": "/portrait", "client_ts": now,
|
||||
"props": map[string]any{"dwell_ms": 1500}},
|
||||
{"name": "ui_click", "session_id": sid, "client_ts": now,
|
||||
"props": map[string]any{"element_id": "home_cta", "page_path": "/"}},
|
||||
{"name": "portrait_completed", "session_id": sid, "client_ts": now},
|
||||
{"name": "session_end", "session_id": sid, "client_ts": now,
|
||||
"props": map[string]any{"exit_page": "/ask", "duration_ms": 8000}},
|
||||
},
|
||||
}, "")
|
||||
var accepted struct {
|
||||
Accepted int `json:"accepted"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &accepted); err != nil || accepted.Accepted < 6 {
|
||||
t.Fatalf("accepted=%v err=%v body=%s", accepted, err, string(env.Data))
|
||||
}
|
||||
_ = key
|
||||
|
||||
loginEnv, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": "admin", "password": "change-me",
|
||||
}, "")
|
||||
if code != http.StatusOK || loginEnv.Code != 0 {
|
||||
t.Fatalf("admin login http=%d code=%d", code, loginEnv.Code)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(loginEnv.Data, &login)
|
||||
if login.Token == "" {
|
||||
t.Fatal("missing admin token")
|
||||
}
|
||||
|
||||
from := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
to := time.Now().UTC().Format("2006-01-02")
|
||||
q := "?from=" + from + "&to=" + to
|
||||
|
||||
ov, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/overview"+q, nil, login.Token)
|
||||
if code != http.StatusOK || ov.Code != 0 {
|
||||
t.Fatalf("overview http=%d code=%d msg=%s", code, ov.Code, ov.Message)
|
||||
}
|
||||
var overview map[string]any
|
||||
_ = json.Unmarshal(ov.Data, &overview)
|
||||
if sessions, _ := overview["sessions"].(float64); sessions < 1 {
|
||||
t.Fatalf("expected sessions>=1 got %v", overview)
|
||||
}
|
||||
|
||||
pg, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/pages"+q, nil, login.Token)
|
||||
if code != http.StatusOK || pg.Code != 0 {
|
||||
t.Fatalf("pages http=%d code=%d", code, pg.Code)
|
||||
}
|
||||
ex, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/exits"+q, nil, login.Token)
|
||||
if code != http.StatusOK || ex.Code != 0 {
|
||||
t.Fatalf("exits http=%d code=%d", code, ex.Code)
|
||||
}
|
||||
var exits struct {
|
||||
Items []struct {
|
||||
ExitPage string `json:"exit_page"`
|
||||
Count int `json:"count"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(ex.Data, &exits)
|
||||
found := false
|
||||
for _, it := range exits.Items {
|
||||
if it.ExitPage == "/ask" && it.Count >= 1 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected exit /ask in %v", exits.Items)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func TestExploreCatalog(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
env, key := doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog", nil, key)
|
||||
data := decodeData[map[string]any](t, env.Data)
|
||||
cats, ok := data["categories"].([]any)
|
||||
@@ -24,7 +24,7 @@ func TestExploreCatalog(t *testing.T) {
|
||||
|
||||
func TestGrowthPlanCheckin(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/growth/plans", map[string]any{
|
||||
"title": "每晚早睡", "focus": "保护睡眠",
|
||||
}, key)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpsContentPhaseC(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
|
||||
env, _ := doJSON(t, r, http.MethodGet, "/api/v1/home/tools", nil, "")
|
||||
var home struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &home); err != nil || len(home.Items) < 6 {
|
||||
t.Fatalf("home tools seed: %v len=%d", err, len(home.Items))
|
||||
}
|
||||
|
||||
loginEnv, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": "admin", "password": "change-me",
|
||||
}, "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("login http=%d", code)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(loginEnv.Data, &login)
|
||||
|
||||
adminTools, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/home/tools", nil, login.Token)
|
||||
if code != http.StatusOK || adminTools.Code != 0 {
|
||||
t.Fatalf("admin tools http=%d code=%d", code, adminTools.Code)
|
||||
}
|
||||
var all struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(adminTools.Data, &all)
|
||||
if len(all.Items) == 0 {
|
||||
t.Fatal("expected seeded tools")
|
||||
}
|
||||
// disable first tool
|
||||
all.Items[0]["enabled"] = false
|
||||
putEnv, code := doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/home/tools", map[string]any{
|
||||
"items": all.Items,
|
||||
}, login.Token)
|
||||
if code != http.StatusOK || putEnv.Code != 0 {
|
||||
t.Fatalf("put tools http=%d code=%d msg=%s", code, putEnv.Code, putEnv.Message)
|
||||
}
|
||||
|
||||
auditEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs?limit=5", nil, login.Token)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("audit http=%d", code)
|
||||
}
|
||||
var audits struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(auditEnv.Data, &audits)
|
||||
foundAudit := false
|
||||
for _, a := range audits.Items {
|
||||
if a.Action == "home_tools.replace" {
|
||||
foundAudit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundAudit {
|
||||
t.Fatalf("expected home_tools.replace audit, got %+v", audits.Items)
|
||||
}
|
||||
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/home/tools", nil, "")
|
||||
_ = json.Unmarshal(env.Data, &home)
|
||||
enabledN := len(home.Items)
|
||||
if enabledN >= len(all.Items) {
|
||||
t.Fatalf("expected fewer enabled tools after disable, pub=%d admin=%d", enabledN, len(all.Items))
|
||||
}
|
||||
|
||||
// restore all enabled for shared DB
|
||||
for i := range all.Items {
|
||||
all.Items[i]["enabled"] = true
|
||||
}
|
||||
_, _ = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/home/tools", map[string]any{"items": all.Items}, login.Token)
|
||||
|
||||
scalesEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/scales", nil, login.Token)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("scales http=%d", code)
|
||||
}
|
||||
var scales struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(scalesEnv.Data, &scales)
|
||||
if len(scales.Items) == 0 {
|
||||
t.Fatal("no scales")
|
||||
}
|
||||
target := scales.Items[0]
|
||||
patch, code := doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/scales/"+target.ID, map[string]string{
|
||||
"status": "draft",
|
||||
}, login.Token)
|
||||
if code != http.StatusOK || patch.Code != 0 {
|
||||
t.Fatalf("patch http=%d code=%d msg=%s", code, patch.Code, patch.Message)
|
||||
}
|
||||
|
||||
// published list should not include draft slug — needs registered user
|
||||
key := mustRegister(t, r)
|
||||
listEnv, _ := doJSON(t, r, http.MethodGet, "/api/v1/scales", nil, key)
|
||||
var pub struct {
|
||||
Items []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(listEnv.Data, &pub)
|
||||
for _, it := range pub.Items {
|
||||
if it.Slug == target.Slug {
|
||||
t.Fatalf("draft scale %s still in published list", target.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// restore published so other tests aren't flaky if shared DB
|
||||
_, _ = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/scales/"+target.ID, map[string]string{
|
||||
"status": "published",
|
||||
}, login.Token)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -60,6 +61,9 @@ func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceK
|
||||
if deviceKey != "" {
|
||||
req.Header.Set("X-Device-Key", deviceKey)
|
||||
}
|
||||
if testBearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+testBearer)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code >= 500 {
|
||||
@@ -79,6 +83,25 @@ func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceK
|
||||
return env, key
|
||||
}
|
||||
|
||||
var testBearer string
|
||||
|
||||
func mustRegister(t *testing.T, r http.Handler) string {
|
||||
t.Helper()
|
||||
testBearer = ""
|
||||
phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000)
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{
|
||||
"phone": phone, "password": "secret12", "nickname": "测",
|
||||
}, "")
|
||||
sess := decodeData[map[string]any](t, env.Data)
|
||||
tok, _ := sess["token"].(string)
|
||||
if tok == "" {
|
||||
t.Fatal("missing token from register")
|
||||
}
|
||||
testBearer = tok
|
||||
t.Cleanup(func() { testBearer = "" })
|
||||
return key
|
||||
}
|
||||
|
||||
func decodeData[T any](t *testing.T, raw json.RawMessage) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
@@ -91,7 +114,7 @@ func decodeData[T any](t *testing.T, raw json.RawMessage) T {
|
||||
// Flow 1: create profile → portrait → deep_access mock → detail visible
|
||||
func TestFlowPortraitDeepAccess(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
|
||||
@@ -136,7 +159,7 @@ func TestFlowPortraitDeepAccess(t *testing.T) {
|
||||
// Flow 2: two profiles → relation insight → deep_access → tips visible
|
||||
func TestFlowRelationDeepAccess(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1988-03-01", "display_name": "我",
|
||||
@@ -189,7 +212,7 @@ func TestFlowRelationDeepAccess(t *testing.T) {
|
||||
// Flow 3: membership mock → entitlement → portrait detail without per-report deep_access
|
||||
func TestFlowMembershipUnlock(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1995-11-07", "display_name": "我",
|
||||
@@ -232,7 +255,7 @@ func TestFlowMembershipUnlock(t *testing.T) {
|
||||
// Flow 5: profile update + soft
|
||||
func TestFlowProfileUpdateDelete(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "other", "birth_date": "1993-04-04", "display_name": "旧名", "relation_type": "friend",
|
||||
@@ -262,7 +285,7 @@ func TestFlowProfileUpdateDelete(t *testing.T) {
|
||||
// Flow 4: profile → ask thread → message → assistant reply + quota
|
||||
func TestFlowAskThread(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1991-02-14", "display_name": "我",
|
||||
@@ -308,3 +331,56 @@ func TestFlowAskThread(t *testing.T) {
|
||||
t.Fatalf("expected user+assistant history, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// Flow 4b: exhaust free ask quota → buy ask_pack → can ask again
|
||||
func TestFlowAskPackPurchase(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1991-02-14", "display_name": "我",
|
||||
}, key)
|
||||
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
|
||||
"profile_id": profileID, "scene": "self",
|
||||
}, key)
|
||||
threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||
"content": "第几问",
|
||||
}, key)
|
||||
}
|
||||
|
||||
env, key, status := doJSONExpect(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||
"content": "应该没额度了",
|
||||
}, key, 40210)
|
||||
if status != http.StatusPaymentRequired {
|
||||
t.Fatalf("expected 402 after free exhaust, got %d %#v", status, env)
|
||||
}
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
||||
"kind": "ask_pack", "plan": "pack10",
|
||||
}, key)
|
||||
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodGet, "/api/v1/ask/quota", nil, key)
|
||||
q := decodeData[map[string]any](t, env.Data)
|
||||
rem, _ := q["remaining"].(float64)
|
||||
paid, _ := q["paid_left"].(float64)
|
||||
if rem < 10 || paid < 10 {
|
||||
t.Fatalf("expected paid pack quota, got %#v", q)
|
||||
}
|
||||
|
||||
env, _ = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||
"content": "买完额度继续问",
|
||||
}, key)
|
||||
out := decodeData[map[string]any](t, env.Data)
|
||||
q2, _ := out["quota"].(map[string]any)
|
||||
rem2, _ := q2["remaining"].(float64)
|
||||
if rem2 != rem-1 {
|
||||
t.Fatalf("paid quota should decrease: before=%v after=%v", rem, rem2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ func doJSONExpect(t *testing.T, r http.Handler, method, path string, body any, d
|
||||
if deviceKey != "" {
|
||||
req.Header.Set("X-Device-Key", deviceKey)
|
||||
}
|
||||
if testBearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+testBearer)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code >= 500 {
|
||||
@@ -61,7 +64,8 @@ func createSelfProfile(t *testing.T, r http.Handler, birth, key string) (profile
|
||||
// Flow: star report → gated detail → mock deep unlock
|
||||
func TestFlowStarDeepAccess(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
pid, key := createSelfProfile(t, r, "1983-06-06", "")
|
||||
key := mustRegister(t, r)
|
||||
pid, key := createSelfProfile(t, r, "1983-06-06", key)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/star", map[string]any{
|
||||
"profile_id": pid,
|
||||
@@ -106,7 +110,8 @@ func TestFlowStarDeepAccess(t *testing.T) {
|
||||
// Flow: rhythm report gated + unlock
|
||||
func TestFlowRhythmDeepAccess(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
pid, key := createSelfProfile(t, r, "1992-06-08", "")
|
||||
key := mustRegister(t, r)
|
||||
pid, key := createSelfProfile(t, r, "1992-06-08", key)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/rhythm", map[string]any{
|
||||
"profile_id": pid,
|
||||
@@ -140,7 +145,8 @@ func TestFlowRhythmDeepAccess(t *testing.T) {
|
||||
// Flow: image card scenes → draw → quota exhaust → depth unlock via mock pay
|
||||
func TestFlowImageCardQuotaAndDepth(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
pid, key := createSelfProfile(t, r, "1990-01-01", "")
|
||||
key := mustRegister(t, r)
|
||||
pid, key := createSelfProfile(t, r, "1990-01-01", key)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodGet, "/api/v1/image-cards/scenes", nil, key)
|
||||
scenes := decodeData[map[string]any](t, env.Data)
|
||||
@@ -199,7 +205,7 @@ func TestFlowImageCardQuotaAndDepth(t *testing.T) {
|
||||
// Flow: solar terms + mood save/read
|
||||
func TestFlowCompanionMood(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodGet, "/api/v1/solar-terms/today", nil, key)
|
||||
term := decodeData[map[string]any](t, env.Data)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
|
||||
@@ -67,9 +67,8 @@ func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
|
||||
t.Fatal("empty token")
|
||||
}
|
||||
|
||||
env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
|
||||
_ = env2
|
||||
env2, key2 = doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, key2)
|
||||
env2Key := mustRegister(t, r)
|
||||
env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, env2Key)
|
||||
meta := decodeData[map[string]any](t, env2.Data)
|
||||
if meta["host_name"] == nil {
|
||||
t.Fatal("host_name missing")
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
package deepseek
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -60,6 +62,18 @@ type chatResponse struct {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type streamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Chat sends messages and returns assistant text.
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
|
||||
if !c.Enabled() {
|
||||
@@ -110,6 +124,100 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
|
||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
// ChatStream streams assistant text; onDelta is called for each content piece.
|
||||
// Returns the full assembled reply.
|
||||
func (c *Client) ChatStream(ctx context.Context, messages []Message, onDelta func(delta string) error) (string, error) {
|
||||
if !c.Enabled() {
|
||||
return "", fmt.Errorf("deepseek api_key not configured")
|
||||
}
|
||||
if onDelta == nil {
|
||||
onDelta = func(string) error { return nil }
|
||||
}
|
||||
base := c.cfg.BaseURL
|
||||
if base == "" {
|
||||
base = "https://api.deepseek.com"
|
||||
}
|
||||
model := c.cfg.Model
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
|
||||
body, err := json.Marshal(chatRequest{Model: model, Messages: messages, Stream: true})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
// Stream may outlive the default client timeout; use a dedicated client.
|
||||
sec := c.cfg.TimeoutSec
|
||||
if sec <= 0 {
|
||||
sec = 90
|
||||
}
|
||||
httpClient := &http.Client{Timeout: time.Duration(sec) * time.Second}
|
||||
res, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 2<<20))
|
||||
return "", fmt.Errorf("deepseek HTTP %d: %s", res.StatusCode, truncate(string(raw), 200))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(res.Body)
|
||||
var full strings.Builder
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return full.String(), err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" || strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
break
|
||||
}
|
||||
var chunk streamChunk
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if chunk.Error != nil && chunk.Error.Message != "" {
|
||||
return full.String(), fmt.Errorf("deepseek: %s", chunk.Error.Message)
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
delta := chunk.Choices[0].Delta.Content
|
||||
if delta == "" {
|
||||
continue
|
||||
}
|
||||
full.WriteString(delta)
|
||||
if err := onDelta(delta); err != nil {
|
||||
return full.String(), err
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(full.String())
|
||||
if out == "" {
|
||||
return "", fmt.Errorf("deepseek empty stream")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
|
||||
@@ -40,8 +40,36 @@ func TestChat_success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabled(t *testing.T) {
|
||||
if New(config.DeepSeekConfig{}).Enabled() {
|
||||
t.Fatal("empty key should disable")
|
||||
func TestChatStream_success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if !req.Stream {
|
||||
t.Fatal("expected stream=true")
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher := w.(http.Flusher)
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n"))
|
||||
flusher.Flush()
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"世界\"}}]}\n\n"))
|
||||
flusher.Flush()
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
flusher.Flush()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(config.DeepSeekConfig{
|
||||
APIKey: "test-key", BaseURL: srv.URL, Model: "deepseek-chat", TimeoutSec: 5,
|
||||
})
|
||||
var got string
|
||||
out, err := c.ChatStream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(d string) error {
|
||||
got += d
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "你好世界" || got != "你好世界" {
|
||||
t.Fatalf("out=%q got=%q", out, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -21,12 +22,33 @@ const UserIDKey ctxKey = "user_id"
|
||||
const DeviceKeyHeader = "X-Device-Key"
|
||||
|
||||
// DeviceAuth resolves or creates a Visitor→User via device key.
|
||||
// If Authorization Bearer session is valid, that account user wins and device rebinds.
|
||||
func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.GetHeader(DeviceKeyHeader)
|
||||
if key == "" {
|
||||
key = newDeviceKey()
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
c.Request.Header.Set(DeviceKeyHeader, key)
|
||||
}
|
||||
if tok := bearerFromHeader(c.GetHeader("Authorization")); tok != "" {
|
||||
var uid uuid.UUID
|
||||
err := pool.QueryRow(c.Request.Context(), `
|
||||
SELECT user_id FROM user_sessions
|
||||
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, tok,
|
||||
).Scan(&uid)
|
||||
if err == nil {
|
||||
_, _ = pool.Exec(c.Request.Context(), `
|
||||
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`,
|
||||
key, uid,
|
||||
)
|
||||
c.Set(string(UserIDKey), uid.String())
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
userID, err := ensureUser(c.Request.Context(), pool, key)
|
||||
if err != nil {
|
||||
@@ -40,6 +62,39 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func bearerFromHeader(h string) string {
|
||||
if len(h) < 8 {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(h[:7], "bearer ") {
|
||||
return strings.TrimSpace(h[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireRegistered rejects anonymous (no phone) users.
|
||||
func RequireRegistered(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var okReg bool
|
||||
err := pool.QueryRow(c.Request.Context(), `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL
|
||||
)`, userID).Scan(&okReg)
|
||||
if err != nil || !okReg {
|
||||
response.Fail(c, http.StatusUnauthorized, 40112, "请先登录后再使用")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UserIDFromContext returns the authenticated user id.
|
||||
func UserIDFromContext(c *gin.Context) (uuid.UUID, bool) {
|
||||
v, ok := c.Get(string(UserIDKey))
|
||||
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
|
||||
// GrowthReport is a deliverable with free summary and gated detail.
|
||||
type GrowthReport struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProfileID uuid.UUID `json:"profile_id"`
|
||||
Type string `json:"type"`
|
||||
Summary json.RawMessage `json:"summary"`
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
HasDeep bool `json:"has_deep_access"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProfileID uuid.UUID `json:"profile_id"`
|
||||
PeerProfileID *uuid.UUID `json:"peer_profile_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Summary json.RawMessage `json:"summary"`
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
HasDeep bool `json:"has_deep_access"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -115,12 +115,18 @@ func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action,
|
||||
|
||||
// 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"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
|
||||
ProfileCount int `json:"profile_count"`
|
||||
MembershipActive bool `json:"membership_active"`
|
||||
MembershipPlan *string `json:"membership_plan,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListUsers returns users newest first; q matches id when UUID.
|
||||
// ListUsers returns users newest first; q matches id / phone / nickname.
|
||||
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
@@ -129,10 +135,26 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
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
|
||||
SELECT u.id, u.status, u.phone, u.nickname, u.ask_paid_quota_left, u.created_at,
|
||||
(SELECT count(*) FROM profiles p WHERE p.user_id=u.id AND p.deleted_at IS NULL) AS profile_count,
|
||||
EXISTS(
|
||||
SELECT 1 FROM memberships m
|
||||
WHERE m.user_id=u.id AND m.deleted_at IS NULL AND m.status='active' AND m.expires_at > now()
|
||||
) AS membership_active,
|
||||
(
|
||||
SELECT m.plan FROM memberships m
|
||||
WHERE m.user_id=u.id AND m.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
) AS membership_plan
|
||||
FROM users u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
$1 = ''
|
||||
OR u.id::text = $1
|
||||
OR COALESCE(u.phone,'') ILIKE '%' || $1 || '%'
|
||||
OR COALESCE(u.nickname,'') ILIKE '%' || $1 || '%'
|
||||
)
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -141,7 +163,10 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
var out []UserListItem
|
||||
for rows.Next() {
|
||||
var u UserListItem
|
||||
if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Status, &u.Phone, &u.Nickname, &u.AskPaidQuotaLeft, &u.CreatedAt,
|
||||
&u.ProfileCount, &u.MembershipActive, &u.MembershipPlan,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
@@ -149,6 +174,126 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DashboardStats is ops overview counters.
|
||||
type DashboardStats struct {
|
||||
UsersTotal int `json:"users_total"`
|
||||
MembershipActive int `json:"membership_active"`
|
||||
OrdersToday int `json:"orders_today"`
|
||||
PaidCentsToday int `json:"paid_cents_today"`
|
||||
AskRepliesToday int `json:"ask_replies_today"`
|
||||
ProfilesTotal int `json:"profiles_total"`
|
||||
ReportsTotal int `json:"reports_total"`
|
||||
Series []DashboardDay `json:"series"`
|
||||
ReportsByType []ReportTypeCnt `json:"reports_by_type"`
|
||||
}
|
||||
|
||||
// DashboardDay is one day of trend metrics.
|
||||
type DashboardDay struct {
|
||||
Day string `json:"day"`
|
||||
NewUsers int `json:"new_users"`
|
||||
Orders int `json:"orders"`
|
||||
PaidCents int `json:"paid_cents"`
|
||||
AskReplies int `json:"ask_replies"`
|
||||
}
|
||||
|
||||
// ReportTypeCnt counts reports by type.
|
||||
type ReportTypeCnt struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// GetDashboardStats aggregates key ops metrics.
|
||||
func (r *AdminRepo) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||
s := &DashboardStats{}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM users WHERE deleted_at IS NULL`).Scan(&s.UsersTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM memberships
|
||||
WHERE deleted_at IS NULL AND status='active' AND expires_at > now()`).Scan(&s.MembershipActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM orders
|
||||
WHERE deleted_at IS NULL AND created_at >= date_trunc('day', now())`).Scan(&s.OrdersToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT coalesce(sum(amount_cents),0) FROM orders
|
||||
WHERE deleted_at IS NULL AND status='paid' AND created_at >= date_trunc('day', now())`).Scan(&s.PaidCentsToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM ask_messages m
|
||||
JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL AND m.role='assistant'
|
||||
AND m.created_at >= date_trunc('day', now())`).Scan(&s.AskRepliesToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM profiles WHERE deleted_at IS NULL`).Scan(&s.ProfilesTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM growth_reports WHERE deleted_at IS NULL`).Scan(&s.ReportsTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
WITH days AS (
|
||||
SELECT generate_series(
|
||||
date_trunc('day', now()) - interval '6 day',
|
||||
date_trunc('day', now()),
|
||||
interval '1 day'
|
||||
)::date AS d
|
||||
)
|
||||
SELECT to_char(d.d, 'YYYY-MM-DD') AS day,
|
||||
(SELECT count(*) FROM users u
|
||||
WHERE u.deleted_at IS NULL AND u.created_at::date = d.d) AS new_users,
|
||||
(SELECT count(*) FROM orders o
|
||||
WHERE o.deleted_at IS NULL AND o.created_at::date = d.d) AS orders,
|
||||
(SELECT coalesce(sum(o.amount_cents),0) FROM orders o
|
||||
WHERE o.deleted_at IS NULL AND o.status='paid' AND o.created_at::date = d.d) AS paid_cents,
|
||||
(SELECT count(*) FROM ask_messages m
|
||||
JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL AND m.role='assistant' AND m.created_at::date = d.d) AS ask_replies
|
||||
FROM days d
|
||||
ORDER BY d.d ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p DashboardDay
|
||||
if err := rows.Scan(&p.Day, &p.NewUsers, &p.Orders, &p.PaidCents, &p.AskReplies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Series = append(s.Series, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trows, err := r.Pool.Query(ctx, `
|
||||
SELECT type, count(*) FROM growth_reports
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY type
|
||||
ORDER BY count(*) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer trows.Close()
|
||||
for trows.Next() {
|
||||
var c ReportTypeCnt
|
||||
if err := trows.Scan(&c.Type, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ReportsByType = append(s.ReportsByType, c)
|
||||
}
|
||||
return s, trows.Err()
|
||||
}
|
||||
|
||||
// UserExists reports whether user id is present.
|
||||
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
@@ -165,12 +310,14 @@ type ProfileBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Relation string `json:"relation"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BirthDate string `json:"birth_date,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
SELECT id, relation, display_name, to_char(birth_date, 'YYYY-MM-DD')
|
||||
FROM profiles
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`, userID)
|
||||
if err != nil {
|
||||
@@ -180,7 +327,7 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
|
||||
var out []ProfileBrief
|
||||
for rows.Next() {
|
||||
var p ProfileBrief
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName, &p.BirthDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
@@ -188,6 +335,83 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReportBrief for admin user detail.
|
||||
type ReportBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListReportsForUser returns recent growth reports.
|
||||
func (r *AdminRepo) ListReportsForUser(ctx context.Context, userID uuid.UUID, limit int) ([]ReportBrief, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, type, created_at FROM growth_reports
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ReportBrief
|
||||
for rows.Next() {
|
||||
var rep ReportBrief
|
||||
if err := rows.Scan(&rep.ID, &rep.Type, &rep.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetUserAccount loads phone/nickname/paid ask quota for one user.
|
||||
func (r *AdminRepo) GetUserAccount(ctx context.Context, userID uuid.UUID) (phone, nickname *string, paidLeft int, status string, createdAt time.Time, err error) {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT phone, nickname, ask_paid_quota_left, status, created_at
|
||||
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&phone, &nickname, &paidLeft, &status, &createdAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, 0, "", time.Time{}, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GrantAskQuotaWithAudit adds paid ask quota and writes audit.
|
||||
func (r *AdminRepo) GrantAskQuotaWithAudit(ctx context.Context, adminID, userID uuid.UUID, delta int, meta json.RawMessage) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, errors.New("delta must be positive")
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var left int
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL
|
||||
RETURNING ask_paid_quota_left`, userID, delta,
|
||||
).Scan(&left)
|
||||
if err != nil {
|
||||
return 0, 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,'ask_quota.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
// OrderListItem for admin order tables.
|
||||
type OrderListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AnalyticsRepo persists behavior events and sessions.
|
||||
type AnalyticsRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// AnalyticsEventRow is one ingest event after validation.
|
||||
type AnalyticsEventRow struct {
|
||||
SessionID string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
PagePath string
|
||||
Props json.RawMessage
|
||||
ClientTS time.Time
|
||||
}
|
||||
|
||||
// UpsertSession creates or refreshes a session row.
|
||||
func (r *AnalyticsRepo) UpsertSession(
|
||||
ctx context.Context,
|
||||
sessionID, deviceKey string,
|
||||
userID uuid.UUID,
|
||||
startedAt time.Time,
|
||||
) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO analytics_sessions(session_id, device_key, user_id, started_at)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
device_key = EXCLUDED.device_key,
|
||||
user_id = COALESCE(EXCLUDED.user_id, analytics_sessions.user_id)`,
|
||||
sessionID, deviceKey, userID, startedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// EndSession updates session end fields.
|
||||
func (r *AnalyticsRepo) EndSession(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
endedAt time.Time,
|
||||
exitPage string,
|
||||
durationMs int,
|
||||
) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE analytics_sessions
|
||||
SET ended_at=$2, exit_page=$3, duration_ms=$4
|
||||
WHERE session_id=$1`,
|
||||
sessionID, endedAt, emptyToNil(exitPage), durationMs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertEvents bulk-inserts event rows.
|
||||
func (r *AnalyticsRepo) InsertEvents(ctx context.Context, rows []AnalyticsEventRow) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, row := range rows {
|
||||
uid := interface{}(nil)
|
||||
if row.UserID != uuid.Nil {
|
||||
uid = row.UserID
|
||||
}
|
||||
page := emptyToNil(row.PagePath)
|
||||
props := row.Props
|
||||
if len(props) == 0 {
|
||||
props = []byte("{}")
|
||||
}
|
||||
if _, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO analytics_events(session_id, user_id, name, page_path, props, client_ts)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
row.SessionID, uid, row.Name, page, props, row.ClientTS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OverviewAgg is admin overview metrics.
|
||||
type OverviewAgg struct {
|
||||
DAU int `json:"dau"`
|
||||
NewUsers int `json:"new_users"`
|
||||
Sessions int `json:"sessions"`
|
||||
AvgSessionMs float64 `json:"avg_session_ms"`
|
||||
Series []DayPoint `json:"series"`
|
||||
}
|
||||
|
||||
// DayPoint is one day in a trend series.
|
||||
type DayPoint struct {
|
||||
Day string `json:"day"`
|
||||
DAU int `json:"dau"`
|
||||
Sessions int `json:"sessions"`
|
||||
}
|
||||
|
||||
// PageAgg is per-page metrics.
|
||||
type PageAgg struct {
|
||||
PagePath string `json:"page_path"`
|
||||
PV int `json:"pv"`
|
||||
UV int `json:"uv"`
|
||||
AvgDwellMs float64 `json:"avg_dwell_ms"`
|
||||
ExitCount int `json:"exit_count"`
|
||||
}
|
||||
|
||||
// ExitAgg is exit page ranking.
|
||||
type ExitAgg struct {
|
||||
ExitPage string `json:"exit_page"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ClickAgg is click ranking.
|
||||
type ClickAgg struct {
|
||||
ElementID string `json:"element_id"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// FunnelStep is one named funnel count.
|
||||
type FunnelStep struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Overview(ctx context.Context, from, to time.Time) (*OverviewAgg, error) {
|
||||
out := &OverviewAgg{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(DISTINCT user_id)::int
|
||||
FROM analytics_events
|
||||
WHERE received_at >= $1 AND received_at < $2 AND user_id IS NOT NULL`,
|
||||
from, to,
|
||||
).Scan(&out.DAU)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM users
|
||||
WHERE created_at >= $1 AND created_at < $2 AND deleted_at IS NULL`,
|
||||
from, to,
|
||||
).Scan(&out.NewUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int,
|
||||
COALESCE(avg(duration_ms) FILTER (WHERE duration_ms IS NOT NULL), 0)::float8
|
||||
FROM analytics_sessions
|
||||
WHERE started_at >= $1 AND started_at < $2`,
|
||||
from, to,
|
||||
).Scan(&out.Sessions, &out.AvgSessionMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT to_char(d, 'YYYY-MM-DD') AS day,
|
||||
COALESCE((
|
||||
SELECT count(DISTINCT e.user_id)::int FROM analytics_events e
|
||||
WHERE e.received_at >= d AND e.received_at < d + interval '1 day'
|
||||
AND e.user_id IS NOT NULL
|
||||
), 0) AS dau,
|
||||
COALESCE((
|
||||
SELECT count(*)::int FROM analytics_sessions s
|
||||
WHERE s.started_at >= d AND s.started_at < d + interval '1 day'
|
||||
), 0) AS sessions
|
||||
FROM generate_series($1::timestamptz, $2::timestamptz - interval '1 day', interval '1 day') AS d
|
||||
ORDER BY d`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p DayPoint
|
||||
if err := rows.Scan(&p.Day, &p.DAU, &p.Sessions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Series = append(out.Series, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Pages(ctx context.Context, from, to time.Time) ([]PageAgg, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
WITH views AS (
|
||||
SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path,
|
||||
count(*)::int AS pv,
|
||||
count(DISTINCT user_id)::int AS uv
|
||||
FROM analytics_events
|
||||
WHERE name='page_view' AND received_at >= $1 AND received_at < $2
|
||||
GROUP BY 1
|
||||
),
|
||||
dwells AS (
|
||||
SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path,
|
||||
avg(NULLIF((props->>'dwell_ms')::float8, 'NaN'))::float8 AS avg_dwell
|
||||
FROM analytics_events
|
||||
WHERE name='page_leave' AND received_at >= $1 AND received_at < $2
|
||||
GROUP BY 1
|
||||
),
|
||||
exits AS (
|
||||
SELECT coalesce(nullif(exit_page,''), '') AS path, count(*)::int AS n
|
||||
FROM analytics_sessions
|
||||
WHERE ended_at >= $1 AND ended_at < $2 AND exit_page IS NOT NULL AND exit_page <> ''
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT coalesce(v.path, d.path, e.path) AS page_path,
|
||||
coalesce(v.pv, 0), coalesce(v.uv, 0),
|
||||
coalesce(d.avg_dwell, 0), coalesce(e.n, 0)
|
||||
FROM views v
|
||||
FULL OUTER JOIN dwells d ON v.path = d.path
|
||||
FULL OUTER JOIN exits e ON coalesce(v.path, d.path) = e.path
|
||||
WHERE coalesce(v.path, d.path, e.path) IS NOT NULL
|
||||
AND coalesce(v.path, d.path, e.path) <> ''
|
||||
ORDER BY coalesce(v.pv, 0) DESC
|
||||
LIMIT 50`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PageAgg
|
||||
for rows.Next() {
|
||||
var p PageAgg
|
||||
if err := rows.Scan(&p.PagePath, &p.PV, &p.UV, &p.AvgDwellMs, &p.ExitCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Exits(ctx context.Context, from, to time.Time) ([]ExitAgg, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT exit_page, count(*)::int
|
||||
FROM analytics_sessions
|
||||
WHERE ended_at >= $1 AND ended_at < $2
|
||||
AND exit_page IS NOT NULL AND exit_page <> ''
|
||||
GROUP BY exit_page
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 30`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ExitAgg
|
||||
for rows.Next() {
|
||||
var e ExitAgg
|
||||
if err := rows.Scan(&e.ExitPage, &e.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Clicks(ctx context.Context, from, to time.Time) ([]ClickAgg, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT coalesce(props->>'element_id', '') AS eid, count(*)::int
|
||||
FROM analytics_events
|
||||
WHERE name='ui_click' AND received_at >= $1 AND received_at < $2
|
||||
AND coalesce(props->>'element_id','') <> ''
|
||||
GROUP BY 1
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 30`,
|
||||
from, to,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ClickAgg
|
||||
for rows.Next() {
|
||||
var c ClickAgg
|
||||
if err := rows.Scan(&c.ElementID, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepo) Funnel(ctx context.Context, from, to time.Time, names []string) ([]FunnelStep, error) {
|
||||
out := make([]FunnelStep, 0, len(names))
|
||||
for _, name := range names {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM analytics_events
|
||||
WHERE name=$1 AND received_at >= $2 AND received_at < $3`,
|
||||
name, from, to,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, FunnelStep{Name: name, Count: n})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func emptyToNil(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -102,3 +102,62 @@ func (r *AskRepo) ConsumeMembershipQuota(ctx context.Context, userID uuid.UUID)
|
||||
}
|
||||
return true, left, nil
|
||||
}
|
||||
|
||||
// GetAskPaidQuota returns purchased ask pack remaining.
|
||||
func (r *AskRepo) GetAskPaidQuota(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT ask_paid_quota_left FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&n)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// AddAskPaidQuota increments purchased ask pack remaining.
|
||||
func (r *AskRepo) AddAskPaidQuota(ctx context.Context, userID uuid.UUID, delta int) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, errors.New("delta must be positive")
|
||||
}
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL
|
||||
RETURNING ask_paid_quota_left`, userID, delta,
|
||||
).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ConsumeAskPaidQuota decrements purchased ask pack remaining.
|
||||
func (r *AskRepo) ConsumeAskPaidQuota(ctx context.Context, userID uuid.UUID) (ok bool, left int, err error) {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left - 1, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL AND ask_paid_quota_left > 0
|
||||
RETURNING ask_paid_quota_left`, userID,
|
||||
).Scan(&left)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return true, left, nil
|
||||
}
|
||||
|
||||
// SoftDeleteThread marks a thread and its messages deleted for the owner.
|
||||
func (r *AskRepo) SoftDeleteThread(ctx context.Context, userID, threadID uuid.UUID) error {
|
||||
ct, err := r.Pool.Exec(ctx, `
|
||||
UPDATE ask_threads SET deleted_at=now(), updated_at=now()
|
||||
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, threadID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return errors.New("thread not found")
|
||||
}
|
||||
_, err = r.Pool.Exec(ctx, `
|
||||
UPDATE ask_messages SET deleted_at=now()
|
||||
WHERE thread_id=$1 AND deleted_at IS NULL`, threadID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AuthRepo persists account credentials and sessions.
|
||||
type AuthRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// AccountRow is a registered user snapshot.
|
||||
type AccountRow struct {
|
||||
ID uuid.UUID
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Nickname string
|
||||
Status string
|
||||
}
|
||||
|
||||
// GetByPhone loads a registered user by phone.
|
||||
func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
var nick *string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, phone, password_hash, COALESCE(nickname,''), status
|
||||
FROM users
|
||||
WHERE phone=$1 AND deleted_at IS NULL`, phone,
|
||||
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &nick, &row.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nick != nil {
|
||||
row.Nickname = *nick
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetAccount loads account fields for a user id.
|
||||
func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) {
|
||||
row := &AccountRow{}
|
||||
var phone, hash, nick *string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, phone, password_hash, nickname, status
|
||||
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&row.ID, &phone, &hash, &nick, &row.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if phone != nil {
|
||||
row.Phone = *phone
|
||||
}
|
||||
if hash != nil {
|
||||
row.PasswordHash = *hash
|
||||
}
|
||||
if nick != nil {
|
||||
row.Nickname = *nick
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// RegisterOnUser upgrades an anonymous user with phone credentials.
|
||||
func (r *AuthRepo) RegisterOnUser(ctx context.Context, userID uuid.UUID, phone, hash, nickname string) error {
|
||||
tag, err := r.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET phone=$2, password_hash=$3, nickname=NULLIF($4,''), updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL AND phone IS NULL`,
|
||||
userID, phone, hash, nickname,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errString("register conflict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserWithPhone inserts a new registered user.
|
||||
func (r *AuthRepo) CreateUserWithPhone(ctx context.Context, phone, hash, nickname string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO users(phone, password_hash, nickname)
|
||||
VALUES ($1,$2,NULLIF($3,''))
|
||||
RETURNING id`,
|
||||
phone, hash, nickname,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// TouchPassword updates stored password hash (open-login record).
|
||||
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE users SET password_hash=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
// BindDevice sets device_identities.user_id to account.
|
||||
func (r *AuthRepo) BindDevice(ctx context.Context, deviceKey string, userID uuid.UUID) error {
|
||||
_, err := r.Pool.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, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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, `
|
||||
INSERT INTO user_sessions(user_id, token, expires_at) VALUES ($1,$2,$3)`,
|
||||
userID, token, expires,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserIDByToken resolves a live session.
|
||||
func (r *AuthRepo) UserIDByToken(ctx context.Context, token string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT user_id FROM user_sessions
|
||||
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, token,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// RevokeSession marks token revoked.
|
||||
func (r *AuthRepo) RevokeSession(ctx context.Context, token string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE user_sessions SET revoked_at=now() WHERE token=$1 AND revoked_at IS NULL`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsRegistered reports whether user has phone.
|
||||
func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL
|
||||
)`, userID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// HomeTool is one homepage grid entry.
|
||||
type HomeTool struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
RowIndex int `json:"row_index"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Path string `json:"path"`
|
||||
Icon string `json:"icon"`
|
||||
Label string `json:"label"`
|
||||
Badge *string `json:"badge,omitempty"`
|
||||
BadgeTone *string `json:"badge_tone,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// HomeToolsRepo persists homepage grid tools.
|
||||
type HomeToolsRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// ListAll returns all tools ordered by row then sort.
|
||||
func (r *HomeToolsRepo) ListAll(ctx context.Context) ([]HomeTool, error) {
|
||||
return r.query(ctx, false)
|
||||
}
|
||||
|
||||
// ListEnabled returns enabled tools for C-end.
|
||||
func (r *HomeToolsRepo) ListEnabled(ctx context.Context) ([]HomeTool, error) {
|
||||
return r.query(ctx, true)
|
||||
}
|
||||
|
||||
func (r *HomeToolsRepo) query(ctx context.Context, onlyEnabled bool) ([]HomeTool, error) {
|
||||
q := `
|
||||
SELECT id, row_index, sort_order, path, icon, label, badge, badge_tone, enabled, updated_at
|
||||
FROM home_tools`
|
||||
if onlyEnabled {
|
||||
q += ` WHERE enabled = true`
|
||||
}
|
||||
q += ` ORDER BY row_index, sort_order, label`
|
||||
rows, err := r.Pool.Query(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []HomeTool
|
||||
for rows.Next() {
|
||||
var t HomeTool
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.RowIndex, &t.SortOrder, &t.Path, &t.Icon, &t.Label,
|
||||
&t.Badge, &t.BadgeTone, &t.Enabled, &t.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceAll deletes all rows and inserts items in one transaction.
|
||||
func (r *HomeToolsRepo) ReplaceAll(ctx context.Context, items []HomeTool) error {
|
||||
return r.ReplaceAllWithAudit(ctx, items, uuid.Nil, nil)
|
||||
}
|
||||
|
||||
// ReplaceAllWithAudit replaces tools and optionally writes admin_audit_logs in one tx.
|
||||
// If adminID is uuid.Nil, skips audit insert.
|
||||
func (r *HomeToolsRepo) ReplaceAllWithAudit(
|
||||
ctx context.Context,
|
||||
items []HomeTool,
|
||||
adminID uuid.UUID,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM home_tools`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
id := it.ID
|
||||
if id == uuid.Nil {
|
||||
id = uuid.New()
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO home_tools(id, row_index, sort_order, path, icon, label, badge, badge_tone, enabled, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,now())`,
|
||||
id, it.RowIndex, it.SortOrder, it.Path, it.Icon, it.Label, it.Badge, it.BadgeTone, it.Enabled,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if adminID != uuid.Nil {
|
||||
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,'home_tools.replace','home_tools','all',$2)`, adminID, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -18,16 +18,98 @@ type ReportRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Create inserts a growth report.
|
||||
// Create inserts a growth report (optional peer for pair reports).
|
||||
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
return r.CreateWithPeer(ctx, userID, profileID, nil, typ, summary, detail)
|
||||
}
|
||||
|
||||
// CreateWithPeer inserts a report with optional peer_profile_id.
|
||||
func (r *ReportRepo) CreateWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
INSERT INTO growth_reports(user_id, profile_id, peer_profile_id, type, summary, detail)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
|
||||
userID, profileID, typ, summary, detail,
|
||||
userID, profileID, peer, typ, summary, detail,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
return rep, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.PeerProfileID = peer
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// SoftDeleteMatching soft-deletes prior reports for overwrite semantics.
|
||||
func (r *ReportRepo) SoftDeleteMatching(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string) error {
|
||||
if peer == nil {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
||||
WHERE user_id=$1 AND profile_id=$2 AND type=$3
|
||||
AND peer_profile_id IS NULL AND deleted_at IS NULL`,
|
||||
userID, profileID, typ)
|
||||
return err
|
||||
}
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
||||
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
|
||||
AND (
|
||||
(profile_id=$3 AND peer_profile_id=$4) OR
|
||||
(profile_id=$4 AND peer_profile_id=$3)
|
||||
)`,
|
||||
userID, typ, profileID, *peer)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertWithPeer soft-deletes matching then inserts.
|
||||
func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
if err := r.SoftDeleteMatching(ctx, userID, profileID, peer, typ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail)
|
||||
}
|
||||
|
||||
// GetLatest returns newest non-deleted report for profile+type(+peer).
|
||||
func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
var peerOut *uuid.UUID
|
||||
var err error
|
||||
if peer == nil {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
|
||||
FROM growth_reports
|
||||
WHERE user_id=$1 AND profile_id=$2 AND type=$3
|
||||
AND peer_profile_id IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
userID, profileID, typ,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
} else {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
|
||||
FROM growth_reports
|
||||
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
|
||||
AND (
|
||||
(profile_id=$3 AND peer_profile_id=$4) OR
|
||||
(profile_id=$4 AND peer_profile_id=$3)
|
||||
)
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
userID, typ, profileID, *peer,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.PeerProfileID = peerOut
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// SoftDeleteForProfile marks all reports involving a profile as deleted.
|
||||
func (r *ReportRepo) SoftDeleteForProfile(ctx context.Context, userID, profileID uuid.UUID) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
AND (profile_id=$2 OR peer_profile_id=$2)`,
|
||||
userID, profileID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetForUser loads a report owned by user.
|
||||
@@ -188,10 +270,52 @@ func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) err
|
||||
userID, p, days); err != nil {
|
||||
return err
|
||||
}
|
||||
case "ask_pack":
|
||||
p := "pack10"
|
||||
if plan != nil && *plan != "" {
|
||||
p = *plan
|
||||
}
|
||||
delta := AskPackQuota(p)
|
||||
if delta <= 0 {
|
||||
return errString("invalid ask_pack plan")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// AskPackQuota returns how many ask replies a pack plan grants.
|
||||
func AskPackQuota(plan string) int {
|
||||
switch plan {
|
||||
case "pack10":
|
||||
return 10
|
||||
case "pack30":
|
||||
return 30
|
||||
case "pack100":
|
||||
return 100
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// AskPackAmountCents is mock price for an ask pack plan.
|
||||
func AskPackAmountCents(plan string) int {
|
||||
switch plan {
|
||||
case "pack10":
|
||||
return 990
|
||||
case "pack30":
|
||||
return 1980
|
||||
case "pack100":
|
||||
return 4990
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
var errMissingReport = errString("report_id required for deep_access")
|
||||
|
||||
type errString string
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -62,13 +63,13 @@ func (r *ScaleRepo) ListPublished(ctx context.Context) ([]ScaleListItem, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBySlug loads scale with questions.
|
||||
// GetBySlug loads a published scale with questions.
|
||||
func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, error) {
|
||||
d := &ScaleDetail{Slug: slug}
|
||||
var scaleID uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, title, description FROM scales
|
||||
WHERE slug=$1 AND deleted_at IS NULL`, slug,
|
||||
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug,
|
||||
).Scan(&scaleID, &d.Title, &d.Description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -90,6 +91,75 @@ func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, e
|
||||
return d, rows.Err()
|
||||
}
|
||||
|
||||
// ScaleAdminItem is a scale row for ops.
|
||||
type ScaleAdminItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ListAllAdmin returns all non-deleted scales.
|
||||
func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, slug, title, description, status FROM scales
|
||||
WHERE deleted_at IS NULL ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ScaleAdminItem
|
||||
for rows.Next() {
|
||||
var it ScaleAdminItem
|
||||
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateStatus sets published|draft.
|
||||
func (r *ScaleRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error {
|
||||
return r.UpdateStatusWithAudit(ctx, id, status, uuid.Nil, nil)
|
||||
}
|
||||
|
||||
// UpdateStatusWithAudit updates status and optionally writes audit in one tx.
|
||||
func (r *ScaleRepo) UpdateStatusWithAudit(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
status string,
|
||||
adminID uuid.UUID,
|
||||
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 scales SET status=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, id, status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
if adminID != uuid.Nil {
|
||||
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,'scale.status','scale',$2,$3)`, adminID, id.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// SaveResult stores scoring output.
|
||||
func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID uuid.UUID, answers, result json.RawMessage) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
@@ -101,10 +171,11 @@ func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID u
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ScaleIDBySlug resolves id.
|
||||
// ScaleIDBySlug resolves id for a published scale.
|
||||
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM scales WHERE slug=$1 AND deleted_at IS NULL`, slug).Scan(&id)
|
||||
SELECT id FROM scales
|
||||
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidScaleStatus = errors.New("invalid scale status")
|
||||
ErrScaleNotFound = errors.New("scale not found")
|
||||
)
|
||||
|
||||
// ListHomeTools returns all grid tools.
|
||||
func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, error) {
|
||||
if s.Home == nil {
|
||||
return nil, errors.New("home unavailable")
|
||||
}
|
||||
return s.Home.ListAdmin(ctx)
|
||||
}
|
||||
|
||||
// ReplaceHomeTools replaces grid and audits in one transaction.
|
||||
func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
|
||||
if s.Home == nil {
|
||||
return errors.New("home unavailable")
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"count": len(items)})
|
||||
return s.Home.ReplaceWithAudit(ctx, adminID, items, meta)
|
||||
}
|
||||
|
||||
// ListScalesAdmin returns all scales.
|
||||
func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminItem, error) {
|
||||
if s.Scales == nil {
|
||||
return nil, errors.New("scales unavailable")
|
||||
}
|
||||
return s.Scales.ListAllAdmin(ctx)
|
||||
}
|
||||
|
||||
// PatchScaleStatus updates published|draft and audits in one transaction.
|
||||
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
|
||||
if status != "published" && status != "draft" {
|
||||
return ErrInvalidScaleStatus
|
||||
}
|
||||
if s.Scales == nil {
|
||||
return errors.New("scales unavailable")
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"status": status})
|
||||
if err := s.Scales.UpdateStatusWithAudit(ctx, scaleID, status, adminID, meta); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrScaleNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -13,12 +13,15 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||
)
|
||||
|
||||
// Service is ops-admin application layer.
|
||||
type Service struct {
|
||||
Repo *repository.AdminRepo
|
||||
Reports *repository.ReportRepo
|
||||
Home *homesvc.Service
|
||||
Scales *repository.ScaleRepo
|
||||
}
|
||||
|
||||
// BootstrapConfig seeds the first admin when table is empty.
|
||||
@@ -121,30 +124,43 @@ func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([
|
||||
return s.Repo.ListUsers(ctx, q, limit, offset)
|
||||
}
|
||||
|
||||
// DashboardStats exposes ops overview.
|
||||
func (s *Service) DashboardStats(ctx context.Context) (*repository.DashboardStats, error) {
|
||||
return s.Repo.GetDashboardStats(ctx)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Profiles []repository.ProfileBrief `json:"profiles"`
|
||||
Reports []repository.ReportBrief `json:"reports"`
|
||||
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)
|
||||
phone, nickname, paidLeft, status, createdAt, err := s.Repo.GetUserAccount(ctx, userID)
|
||||
if err != nil {
|
||||
ok, e2 := s.Repo.UserExists(ctx, userID)
|
||||
if e2 != nil {
|
||||
return nil, e2
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
profiles, err := s.Repo.ListProfilesForUser(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)
|
||||
reports, err := s.Repo.ListReportsForUser(ctx, userID, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -157,12 +173,16 @@ func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, e
|
||||
return nil, err
|
||||
}
|
||||
return &UserDetail{
|
||||
ID: users[0].ID,
|
||||
Status: users[0].Status,
|
||||
CreatedAt: users[0].CreatedAt,
|
||||
Profiles: profiles,
|
||||
Membership: mem,
|
||||
Orders: orders,
|
||||
ID: userID,
|
||||
Status: status,
|
||||
Phone: phone,
|
||||
Nickname: nickname,
|
||||
AskPaidQuotaLeft: paidLeft,
|
||||
CreatedAt: createdAt,
|
||||
Profiles: profiles,
|
||||
Reports: reports,
|
||||
Membership: mem,
|
||||
Orders: orders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -188,6 +208,29 @@ func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID
|
||||
return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta)
|
||||
}
|
||||
|
||||
// GrantAskQuotaInput adds paid ask replies.
|
||||
type GrantAskQuotaInput struct {
|
||||
Delta int `json:"delta"`
|
||||
}
|
||||
|
||||
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 delta <= 0 || delta > 1000 {
|
||||
return 0, ErrInvalidAskDelta
|
||||
}
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
return 0, ErrUserNotFound
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"delta": delta})
|
||||
return s.Repo.GrantAskQuotaWithAudit(ctx, adminID, userID, delta, 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)
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTooManyItems = errors.New("too many items")
|
||||
ErrInvalidBatch = errors.New("invalid batch")
|
||||
)
|
||||
|
||||
const maxBatch = 100
|
||||
|
||||
// Service handles ingest validation and admin aggregates.
|
||||
type Service struct {
|
||||
Repo *repository.AnalyticsRepo
|
||||
}
|
||||
|
||||
// EventIn is one client event.
|
||||
type EventIn struct {
|
||||
Name string `json:"name"`
|
||||
SessionID string `json:"session_id"`
|
||||
PagePath string `json:"page_path"`
|
||||
ClientTS any `json:"client_ts"`
|
||||
Props map[string]interface{} `json:"props"`
|
||||
}
|
||||
|
||||
// IngestResult summarizes a successful write.
|
||||
type IngestResult struct {
|
||||
Accepted int `json:"accepted"`
|
||||
}
|
||||
|
||||
var allowedNames = map[string]struct{}{
|
||||
"session_start": {}, "session_end": {}, "page_view": {}, "page_leave": {}, "ui_click": {},
|
||||
"home_cta_portrait": {}, "portrait_completed": {}, "deep_access_clicked": {},
|
||||
"purchase_completed": {}, "relation_completed": {}, "synastry_completed": {},
|
||||
"synastry_invite_created": {}, "synastry_invite_accepted": {}, "synastry_nearby_opened": {},
|
||||
"star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {},
|
||||
"cards_scene_selected": {}, "cards_drawn": {}, "cards_quota_exhausted": {},
|
||||
}
|
||||
|
||||
var allowedPropKeys = map[string]struct{}{
|
||||
"page_path": {}, "page_title": {}, "referrer_path": {}, "dwell_ms": {},
|
||||
"element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {},
|
||||
"source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {},
|
||||
"depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {},
|
||||
}
|
||||
|
||||
var funnelDefault = []string{
|
||||
"portrait_completed", "deep_access_clicked", "purchase_completed",
|
||||
}
|
||||
|
||||
// Ingest validates and persists a batch. Invalid batch → ErrInvalidBatch (整批 400).
|
||||
func (s *Service) Ingest(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
deviceKey string,
|
||||
items []EventIn,
|
||||
) (*IngestResult, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, ErrInvalidBatch
|
||||
}
|
||||
if len(items) > maxBatch {
|
||||
return nil, ErrTooManyItems
|
||||
}
|
||||
rows := make([]repository.AnalyticsEventRow, 0, len(items))
|
||||
for i := range items {
|
||||
row, end, err := normalizeItem(&items[i])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidBatch
|
||||
}
|
||||
if err := s.Repo.UpsertSession(ctx, row.SessionID, deviceKey, userID, row.ClientTS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end != nil {
|
||||
if err := s.Repo.EndSession(ctx, row.SessionID, row.ClientTS, end.ExitPage, end.DurationMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
row.UserID = userID
|
||||
rows = append(rows, *row)
|
||||
}
|
||||
if err := s.Repo.InsertEvents(ctx, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IngestResult{Accepted: len(rows)}, nil
|
||||
}
|
||||
|
||||
type sessionEnd struct {
|
||||
ExitPage string
|
||||
DurationMs int
|
||||
}
|
||||
|
||||
func normalizeItem(in *EventIn) (*repository.AnalyticsEventRow, *sessionEnd, error) {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
sid := strings.TrimSpace(in.SessionID)
|
||||
if name == "" || sid == "" || len(sid) > 64 {
|
||||
return nil, nil, ErrInvalidBatch
|
||||
}
|
||||
if _, ok := allowedNames[name]; !ok {
|
||||
return nil, nil, ErrInvalidBatch
|
||||
}
|
||||
ts, err := parseClientTS(in.ClientTS)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInvalidBatch
|
||||
}
|
||||
page := strings.TrimSpace(in.PagePath)
|
||||
if page == "" && in.Props != nil {
|
||||
if v, ok := in.Props["page_path"].(string); ok {
|
||||
page = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
props, end, err := scrubProps(name, in.Props)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
raw, err := json.Marshal(props)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInvalidBatch
|
||||
}
|
||||
return &repository.AnalyticsEventRow{
|
||||
SessionID: sid,
|
||||
Name: name,
|
||||
PagePath: page,
|
||||
Props: raw,
|
||||
ClientTS: ts,
|
||||
}, end, nil
|
||||
}
|
||||
|
||||
func scrubProps(name string, in map[string]interface{}) (map[string]interface{}, *sessionEnd, error) {
|
||||
out := map[string]interface{}{}
|
||||
var end *sessionEnd
|
||||
if name == "session_end" {
|
||||
end = &sessionEnd{}
|
||||
}
|
||||
for k, v := range in {
|
||||
key := strings.TrimSpace(k)
|
||||
if _, ok := allowedPropKeys[key]; !ok {
|
||||
continue
|
||||
}
|
||||
if key == "dwell_ms" || key == "duration_ms" {
|
||||
n, ok := asNonNegInt(v)
|
||||
if !ok {
|
||||
return nil, nil, ErrInvalidBatch
|
||||
}
|
||||
out[key] = n
|
||||
if key == "duration_ms" && end != nil {
|
||||
end.DurationMs = n
|
||||
}
|
||||
continue
|
||||
}
|
||||
if key == "exit_page" {
|
||||
s, _ := v.(string)
|
||||
s = strings.TrimSpace(s)
|
||||
out[key] = s
|
||||
if end != nil {
|
||||
end.ExitPage = s
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if len(t) > 256 {
|
||||
t = t[:256]
|
||||
}
|
||||
out[key] = t
|
||||
case float64:
|
||||
out[key] = t
|
||||
case bool:
|
||||
out[key] = t
|
||||
case int:
|
||||
out[key] = t
|
||||
}
|
||||
}
|
||||
return out, end, nil
|
||||
}
|
||||
|
||||
func asNonNegInt(v interface{}) (int, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t < 0 || t > 1e9 {
|
||||
return 0, false
|
||||
}
|
||||
return int(t), true
|
||||
case int:
|
||||
if t < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return t, true
|
||||
case string:
|
||||
n, err := strconv.Atoi(t)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseClientTS(v any) (time.Time, error) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
ts, err := time.Parse(time.RFC3339Nano, t)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, t)
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return ts.UTC(), nil
|
||||
case float64:
|
||||
if t > 1e12 {
|
||||
return time.UnixMilli(int64(t)).UTC(), nil
|
||||
}
|
||||
return time.Unix(int64(t), 0).UTC(), nil
|
||||
case nil:
|
||||
return time.Now().UTC(), nil
|
||||
default:
|
||||
return time.Time{}, ErrInvalidBatch
|
||||
}
|
||||
}
|
||||
|
||||
// ParseDayRange parses from/to (YYYY-MM-DD inclusive from, exclusive to+1day).
|
||||
func ParseDayRange(fromStr, toStr string) (time.Time, time.Time, error) {
|
||||
if fromStr == "" || toStr == "" {
|
||||
to := time.Now().UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)
|
||||
from := to.Add(-7 * 24 * time.Hour)
|
||||
return from, to, nil
|
||||
}
|
||||
from, err := time.ParseInLocation("2006-01-02", fromStr, time.UTC)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
toDay, err := time.ParseInLocation("2006-01-02", toStr, time.UTC)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
to := toDay.Add(24 * time.Hour)
|
||||
if !to.After(from) || to.Sub(from) > 93*24*time.Hour {
|
||||
return time.Time{}, time.Time{}, ErrInvalidBatch
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func (s *Service) Overview(ctx context.Context, from, to time.Time) (*repository.OverviewAgg, error) {
|
||||
return s.Repo.Overview(ctx, from, to)
|
||||
}
|
||||
|
||||
func (s *Service) Pages(ctx context.Context, from, to time.Time) ([]repository.PageAgg, error) {
|
||||
return s.Repo.Pages(ctx, from, to)
|
||||
}
|
||||
|
||||
func (s *Service) Exits(ctx context.Context, from, to time.Time) ([]repository.ExitAgg, error) {
|
||||
return s.Repo.Exits(ctx, from, to)
|
||||
}
|
||||
|
||||
func (s *Service) Clicks(ctx context.Context, from, to time.Time) ([]repository.ClickAgg, error) {
|
||||
return s.Repo.Clicks(ctx, from, to)
|
||||
}
|
||||
|
||||
func (s *Service) Funnel(ctx context.Context, from, to time.Time) ([]repository.FunnelStep, error) {
|
||||
return s.Repo.Funnel(ctx, from, to, funnelDefault)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package analytics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScrubDwellNonNeg(t *testing.T) {
|
||||
_, end, err := scrubProps("page_leave", map[string]interface{}{
|
||||
"dwell_ms": float64(1200),
|
||||
"phone": "13800000000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if end != nil {
|
||||
t.Fatal("page_leave should not produce session end")
|
||||
}
|
||||
_, end, err = scrubProps("session_end", map[string]interface{}{
|
||||
"exit_page": "/ask",
|
||||
"duration_ms": float64(5000),
|
||||
"birthday": "1990-01-01",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if end == nil || end.ExitPage != "/ask" || end.DurationMs != 5000 {
|
||||
t.Fatalf("bad end: %+v", end)
|
||||
}
|
||||
_, _, err = scrubProps("page_leave", map[string]interface{}{"dwell_ms": float64(-1)})
|
||||
if err != ErrInvalidBatch {
|
||||
t.Fatalf("want ErrInvalidBatch for negative dwell, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDayRange(t *testing.T) {
|
||||
from, to, err := ParseDayRange("2026-08-01", "2026-08-07")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if to.Sub(from).Hours() != 7*24 {
|
||||
t.Fatalf("range=%v", to.Sub(from))
|
||||
}
|
||||
_, _, err = ParseDayRange("2026-08-07", "2026-08-01")
|
||||
if err != ErrInvalidBatch {
|
||||
t.Fatalf("want invalid, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package ask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -47,16 +48,26 @@ func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateT
|
||||
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
|
||||
}
|
||||
|
||||
// ClearThread soft-deletes an owned thread and its messages.
|
||||
func (s *Service) ClearThread(ctx context.Context, userID, threadID uuid.UUID) error {
|
||||
return s.Ask.SoftDeleteThread(ctx, userID, threadID)
|
||||
}
|
||||
|
||||
// QuotaStatus describes remaining ask allowance.
|
||||
type QuotaStatus struct {
|
||||
ActiveMembership bool `json:"active_membership"`
|
||||
Remaining int `json:"remaining"`
|
||||
FreeLimit int `json:"free_limit"`
|
||||
Source string `json:"source"` // membership | free
|
||||
PaidLeft int `json:"paid_left"`
|
||||
Source string `json:"source"` // membership | free | paid | mixed
|
||||
}
|
||||
|
||||
// GetQuota returns remaining ask replies.
|
||||
func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) {
|
||||
paid, err := s.Ask.GetAskPaidQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vip, err := s.Reports.HasActiveMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,26 +77,46 @@ func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src := "membership"
|
||||
if paid > 0 && me.AskQuotaLeft > 0 {
|
||||
src = "mixed"
|
||||
} else if me.AskQuotaLeft <= 0 && paid > 0 {
|
||||
src = "paid"
|
||||
}
|
||||
return &QuotaStatus{
|
||||
ActiveMembership: true,
|
||||
Remaining: me.AskQuotaLeft,
|
||||
Remaining: me.AskQuotaLeft + paid,
|
||||
FreeLimit: FreeQuota,
|
||||
Source: "membership",
|
||||
PaidLeft: paid,
|
||||
Source: src,
|
||||
}, nil
|
||||
}
|
||||
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Free tier only counts assistant replies that were not covered by paid packs.
|
||||
// Approximate: free used = min(used, FreeQuota) when no paid history is tracked separately.
|
||||
// Paid replies decrement ask_paid_quota_left; free replies increase assistant count.
|
||||
// Remaining free = max(0, FreeQuota - max(0, used - lifetimePaidConsumed)).
|
||||
// Without lifetime paid consumed counter, treat free as: FreeQuota - used, floored at 0,
|
||||
// and add current paid left (purchased top-ups work after free exhausted).
|
||||
left := FreeQuota - used
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
src := "free"
|
||||
if left == 0 && paid > 0 {
|
||||
src = "paid"
|
||||
} else if left > 0 && paid > 0 {
|
||||
src = "mixed"
|
||||
}
|
||||
return &QuotaStatus{
|
||||
ActiveMembership: false,
|
||||
Remaining: left,
|
||||
Remaining: left + paid,
|
||||
FreeLimit: FreeQuota,
|
||||
Source: "free",
|
||||
PaidLeft: paid,
|
||||
Source: src,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -131,6 +162,11 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
|
||||
return nil, ErrQuotaExhausted
|
||||
}
|
||||
|
||||
bucket, err := s.pickQuotaBucket(ctx, userID, quota)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -142,19 +178,15 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
|
||||
}
|
||||
|
||||
hist, _ := s.Ask.ListMessages(ctx, threadID)
|
||||
reply := s.generateReply(ctx, profile, scene, content, hist)
|
||||
reply := s.generateReply(ctx, userID, profile, scene, content, hist)
|
||||
|
||||
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if quota.ActiveMembership {
|
||||
if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
// race
|
||||
}
|
||||
if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q2, err := s.GetQuota(ctx, userID)
|
||||
@@ -164,7 +196,171 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
|
||||
return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil
|
||||
}
|
||||
|
||||
func (s *Service) generateReply(ctx context.Context, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
|
||||
type quotaBucket string
|
||||
|
||||
const (
|
||||
bucketFree quotaBucket = "free"
|
||||
bucketMembership quotaBucket = "membership"
|
||||
bucketPaid quotaBucket = "paid"
|
||||
)
|
||||
|
||||
func (s *Service) pickQuotaBucket(ctx context.Context, userID uuid.UUID, quota *QuotaStatus) (quotaBucket, error) {
|
||||
if quota.ActiveMembership {
|
||||
me, err := s.Reports.GetMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if me.AskQuotaLeft > 0 {
|
||||
return bucketMembership, nil
|
||||
}
|
||||
if quota.PaidLeft > 0 {
|
||||
return bucketPaid, nil
|
||||
}
|
||||
return "", ErrQuotaExhausted
|
||||
}
|
||||
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if FreeQuota-used > 0 {
|
||||
return bucketFree, nil
|
||||
}
|
||||
if quota.PaidLeft > 0 {
|
||||
return bucketPaid, nil
|
||||
}
|
||||
return "", ErrQuotaExhausted
|
||||
}
|
||||
|
||||
func (s *Service) consumeQuotaBucket(ctx context.Context, userID uuid.UUID, bucket quotaBucket) error {
|
||||
switch bucket {
|
||||
case bucketFree:
|
||||
return nil // counted by assistant message total
|
||||
case bucketMembership:
|
||||
ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
// race: fall through to paid if possible
|
||||
ok2, _, err2 := s.Ask.ConsumeAskPaidQuota(ctx, userID)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
if !ok2 {
|
||||
return ErrQuotaExhausted
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case bucketPaid:
|
||||
ok, _, err := s.Ask.ConsumeAskPaidQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrQuotaExhausted
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return ErrQuotaExhausted
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) generateReply(ctx context.Context, userID uuid.UUID, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
|
||||
out, err := s.generateReplyStream(ctx, userID, profile, scene, userContent, hist, nil)
|
||||
if err != nil {
|
||||
log.Printf("ask: generateReply: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StreamEmit writes one SSE-style event to the client.
|
||||
type StreamEmit func(event string, payload any) error
|
||||
|
||||
// StreamMessage is like SendMessage but streams assistant deltas via emit.
|
||||
func (s *Service) StreamMessage(ctx context.Context, userID, threadID uuid.UUID, content string, emit StreamEmit) error {
|
||||
if emit == nil {
|
||||
return errors.New("emit required")
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return errors.New("content required")
|
||||
}
|
||||
if len([]rune(content)) > 2000 {
|
||||
return errors.New("content too long")
|
||||
}
|
||||
|
||||
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
|
||||
if err != nil {
|
||||
return errors.New("thread not found")
|
||||
}
|
||||
profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID)
|
||||
if err != nil {
|
||||
return errors.New("profile not found")
|
||||
}
|
||||
|
||||
quota, err := s.GetQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if quota.Remaining <= 0 {
|
||||
return ErrQuotaExhausted
|
||||
}
|
||||
|
||||
bucket, err := s.pickQuotaBucket(ctx, userID, quota)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := emit("meta", map[string]any{"user_message": userMsg}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scene := ""
|
||||
if thread.Scene != nil {
|
||||
scene = *thread.Scene
|
||||
}
|
||||
hist, _ := s.Ask.ListMessages(ctx, threadID)
|
||||
|
||||
reply, err := s.generateReplyStream(ctx, userID, profile, scene, content, hist, func(delta string) error {
|
||||
return emit("delta", map[string]any{"text": delta})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
reply = "暂时没能生成回复,请稍后再试。"
|
||||
_ = emit("delta", map[string]any{"text": reply})
|
||||
}
|
||||
|
||||
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
q2, err := s.GetQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit("done", map[string]any{
|
||||
"assistant_message": asst,
|
||||
"quota": q2,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) generateReplyStream(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
profile *model.Profile,
|
||||
scene, userContent string,
|
||||
hist []model.AskMessage,
|
||||
onDelta func(string) error,
|
||||
) (string, error) {
|
||||
fallback := eng.BuildReply(eng.ReplyInput{
|
||||
DisplayName: profile.DisplayName,
|
||||
BirthDate: profile.BirthDate,
|
||||
@@ -172,12 +368,18 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
|
||||
Scene: scene,
|
||||
UserMessage: userContent,
|
||||
})
|
||||
if s.LLM == nil || !s.LLM.Enabled() {
|
||||
return fallback
|
||||
emitFallback := func(text string) (string, error) {
|
||||
if onDelta == nil {
|
||||
return text, nil
|
||||
}
|
||||
return text, streamFake(ctx, text, onDelta)
|
||||
}
|
||||
|
||||
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}}
|
||||
// history excluding the just-inserted user message duplicate handling: include prior + current user
|
||||
if s.LLM == nil || !s.LLM.Enabled() {
|
||||
return emitFallback(fallback)
|
||||
}
|
||||
|
||||
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene, s.profileContext(ctx, userID, profile))}}
|
||||
start := 0
|
||||
if len(hist) > historyLimit*2 {
|
||||
start = len(hist) - historyLimit*2
|
||||
@@ -189,20 +391,88 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
|
||||
}
|
||||
msgs = append(msgs, deepseek.Message{Role: role, Content: m.Content})
|
||||
}
|
||||
// hist already contains the new user message from InsertMessage
|
||||
|
||||
out, err := s.LLM.Chat(ctx, msgs)
|
||||
var assembled strings.Builder
|
||||
out, err := s.LLM.ChatStream(ctx, msgs, func(delta string) error {
|
||||
assembled.WriteString(delta)
|
||||
if onDelta != nil {
|
||||
return onDelta(delta)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ask: deepseek failed, fallback to rules: %v", err)
|
||||
return fallback
|
||||
if assembled.Len() > 0 {
|
||||
return assembled.String(), nil
|
||||
}
|
||||
log.Printf("ask: deepseek stream failed, fallback to rules: %v", err)
|
||||
return emitFallback(fallback)
|
||||
}
|
||||
if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") {
|
||||
out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
|
||||
if out == "" {
|
||||
out = assembled.String()
|
||||
}
|
||||
return out
|
||||
if onDelta != nil && assembled.Len() == 0 && out != "" {
|
||||
_ = streamFake(ctx, out, onDelta)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func systemPrompt(profile *model.Profile, scene string) string {
|
||||
// streamFake chunks text for rule-engine replies so UI still feels streamed.
|
||||
func streamFake(ctx context.Context, text string, onDelta func(string) error) error {
|
||||
runes := []rune(text)
|
||||
const chunk = 2
|
||||
for i := 0; i < len(runes); i += chunk {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
end := i + chunk
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
if err := onDelta(string(runes[i:end])); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(18 * time.Millisecond)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) profileContext(ctx context.Context, userID uuid.UUID, profile *model.Profile) string {
|
||||
if s.Reports == nil || profile == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, typ := range []string{"portrait", "star", "rhythm"} {
|
||||
rep, err := s.Reports.GetLatest(ctx, userID, profile.ID, typ, nil)
|
||||
if err != nil || rep == nil || len(rep.Summary) == 0 {
|
||||
continue
|
||||
}
|
||||
var sum map[string]any
|
||||
if json.Unmarshal(rep.Summary, &sum) != nil {
|
||||
continue
|
||||
}
|
||||
label := map[string]string{"portrait": "愈心解码", "star": "星座探索", "rhythm": "身心节律"}[typ]
|
||||
line := strings.TrimSpace(fmt.Sprintf("%s|%v|%v",
|
||||
label, sum["headline"], sum["one_liner"]))
|
||||
if kw, ok := sum["keywords"].([]any); ok && len(kw) > 0 {
|
||||
var ks []string
|
||||
for i, k := range kw {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
ks = append(ks, fmt.Sprint(k))
|
||||
}
|
||||
if len(ks) > 0 {
|
||||
line += "|关键词:" + strings.Join(ks, "、")
|
||||
}
|
||||
}
|
||||
parts = append(parts, line)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func systemPrompt(profile *model.Profile, scene, reportCtx string) string {
|
||||
name := profile.DisplayName
|
||||
if name == "" {
|
||||
if profile.Relation == "other" {
|
||||
@@ -212,28 +482,37 @@ func systemPrompt(profile *model.Profile, scene string) string {
|
||||
}
|
||||
}
|
||||
birth := profile.BirthDate.Format("2006-01-02")
|
||||
rel := "我的档案"
|
||||
who := "用户本人的个人档案"
|
||||
if profile.Relation == "other" {
|
||||
rel = "TA 的档案"
|
||||
who = "用户添加的关系对象(TA)档案"
|
||||
}
|
||||
sc := scene
|
||||
sc := strings.TrimSpace(scene)
|
||||
if sc == "" {
|
||||
sc = "自我探索"
|
||||
sc = "综合成长对话"
|
||||
}
|
||||
ctxBlock := "(暂无已生成的解码/星座/节律摘要;请主要依据生日与对话内容温和探索,不要假装已经测过完整报告。)"
|
||||
if strings.TrimSpace(reportCtx) != "" {
|
||||
ctxBlock = reportCtx
|
||||
}
|
||||
return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手,了解用户的智能伙伴。
|
||||
定位:帮助认识自己、理解关系、整理情绪与生活节奏——像一位细致的成长顾问,而不是算命师。
|
||||
禁止:算命、运势、吉凶、预测未来、改命、合盘/合婚话术、医疗诊断或疗效承诺、恐吓话术。
|
||||
推荐用语:了解、探索、分析、建议、成长方向、生活建议、沟通方式、情绪调节。
|
||||
|
||||
当前解读对象:%s(%s),生日 %s,场景倾向:%s。
|
||||
请用中文做「有结构的详细回复」(约 280–450 字),建议结构:
|
||||
1)先回应用户当下问题(2–3 句)
|
||||
2)结合档案风格做一层分析(性格/沟通/关系/情绪/生活节奏中相关的 1–2 维)
|
||||
3)给出 2–4 条可执行小建议(尽量具体到本周可做)
|
||||
4)如合适,给一句可直接说出口的对话示例
|
||||
语气温暖、具体、不空洞;避免鸡汤套话与玄学预测。
|
||||
结尾提醒:内容为自我探索与生活方式参考,不构成医疗或占卜预测。
|
||||
今天是 %s。`, name, rel, birth, sc, time.Now().Format("2006-01-02"))
|
||||
return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手。
|
||||
|
||||
【定位】
|
||||
把愈心解码、星座、人格匹配、身心节律等探索结果,转成短而可执行的陪伴。你不是占卜师/医生;禁止吉凶断语、改命恐吓、医疗诊断。
|
||||
|
||||
【当前对象】
|
||||
称呼:%s|档案:%s|生日:%s|场景:%s
|
||||
摘要(有则引用,无则勿编):
|
||||
%s
|
||||
|
||||
【回复(务必短)】
|
||||
1. 先用 1 句接住情绪/问题。
|
||||
2. 只挑与问题最相关的 1 个档案洞察,讲清即可。
|
||||
3. 给 1–2 条本周就能做的小行动(可附一句可说出口的话)。
|
||||
4. 用中文;全文控制在 80–160 字;不要分大段、不要列表堆砌、不要长篇铺垫。
|
||||
5. 不要在文末重复免责声明(界面已展示)。
|
||||
|
||||
今天是 %s。`, name, who, birth, sc, ctxBlock, time.Now().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// ErrQuotaExhausted when free or membership ask quota is 0.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service handles register/login sessions.
|
||||
// Temporary open mode: any non-empty phone+password can enter; missing accounts are created.
|
||||
type Service struct {
|
||||
Repo *repository.AuthRepo
|
||||
}
|
||||
|
||||
// Me is the public account payload.
|
||||
type Me struct {
|
||||
ID string `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
// SessionResult is returned after register/login.
|
||||
type SessionResult struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
User Me `json:"user"`
|
||||
}
|
||||
|
||||
// Register upgrades or opens an account (same open rules as Login).
|
||||
func (s *Service) Register(ctx context.Context, userID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
|
||||
return s.OpenLogin(ctx, userID, deviceKey, phone, password, nickname)
|
||||
}
|
||||
|
||||
// Login authenticates in open mode (no password check; auto-create).
|
||||
func (s *Service) Login(ctx context.Context, userID uuid.UUID, deviceKey, phone, password string) (*SessionResult, error) {
|
||||
return s.OpenLogin(ctx, userID, deviceKey, phone, password, "")
|
||||
}
|
||||
|
||||
// OpenLogin: any phone+password accepted; persist account; issue session.
|
||||
func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if phone == "" {
|
||||
return nil, errors.New("请填写手机号")
|
||||
}
|
||||
nickname = strings.TrimSpace(nickname)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashStr := string(hash)
|
||||
|
||||
acc, err := s.Repo.GetByPhone(ctx, phone)
|
||||
if err == nil {
|
||||
_ = s.Repo.TouchPassword(ctx, acc.ID, hashStr)
|
||||
if deviceKey != "" {
|
||||
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
|
||||
}
|
||||
nick := acc.Nickname
|
||||
if nickname != "" {
|
||||
nick = nickname
|
||||
}
|
||||
return s.issue(ctx, acc.ID, acc.Phone, nick)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// New phone: prefer upgrading anonymous device user.
|
||||
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
|
||||
uid := deviceUserID
|
||||
if curErr == nil && cur.Phone == "" {
|
||||
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nickname); err != nil {
|
||||
return nil, errors.New("登录失败,请重试")
|
||||
}
|
||||
} else {
|
||||
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nickname)
|
||||
if err != nil {
|
||||
return nil, errors.New("登录失败,请重试")
|
||||
}
|
||||
}
|
||||
if deviceKey != "" {
|
||||
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
|
||||
}
|
||||
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
|
||||
}
|
||||
return s.Repo.RevokeSession(ctx, token)
|
||||
}
|
||||
|
||||
// Me returns account if registered.
|
||||
func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
|
||||
acc, err := s.Repo.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acc.Phone == "" {
|
||||
return nil, errors.New("未登录")
|
||||
}
|
||||
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
|
||||
}
|
||||
|
||||
// ResolveSessionUser returns user id for a live token.
|
||||
func (s *Service) ResolveSessionUser(ctx context.Context, token string) (uuid.UUID, error) {
|
||||
return s.Repo.UserIDByToken(ctx, token)
|
||||
}
|
||||
|
||||
// IsRegistered checks phone present.
|
||||
func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
return s.Repo.IsRegistered(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string) (*SessionResult, error) {
|
||||
tok := "usr_" + randomHex(24)
|
||||
exp := time.Now().Add(30 * 24 * time.Hour)
|
||||
if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SessionResult{
|
||||
Token: tok, ExpiresAt: exp,
|
||||
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func maskPhone(p string) string {
|
||||
if len(p) != 11 {
|
||||
return p
|
||||
}
|
||||
return p[:3] + "****" + p[7:]
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
|
||||
releng "github.com/yuxingu/digital-psychology/apps/api/internal/relation"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
|
||||
)
|
||||
|
||||
// Service generates birthday-derived report bundles for a profile.
|
||||
type Service struct {
|
||||
Profiles *repository.ProfileRepo
|
||||
Reports *repository.ReportRepo
|
||||
Relations *repository.RelationRepo
|
||||
}
|
||||
|
||||
// GenerateForProfile rebuilds solo reports for p and pair reports with counterparts.
|
||||
func (s *Service) GenerateForProfile(ctx context.Context, userID uuid.UUID, p *model.Profile) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
s.genSolo(ctx, userID, p)
|
||||
list, err := s.Profiles.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap list profiles: %v", err)
|
||||
return
|
||||
}
|
||||
var self *model.Profile
|
||||
others := make([]*model.Profile, 0)
|
||||
for i := range list {
|
||||
item := list[i]
|
||||
if item.Relation == "self" {
|
||||
cp := item
|
||||
self = &cp
|
||||
} else if item.Relation == "other" {
|
||||
cp := item
|
||||
others = append(others, &cp)
|
||||
}
|
||||
}
|
||||
if self == nil {
|
||||
return
|
||||
}
|
||||
if p.Relation == "self" {
|
||||
for _, o := range others {
|
||||
s.genPair(ctx, userID, self, o)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.genPair(ctx, userID, self, p)
|
||||
}
|
||||
|
||||
func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profile) {
|
||||
outP := portrait.Build(p.BirthDate, p.DisplayName)
|
||||
sum, _ := json.Marshal(outP.Summary)
|
||||
det, _ := json.Marshal(outP.Detail)
|
||||
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "portrait", sum, det); err != nil {
|
||||
log.Printf("bootstrap portrait: %v", err)
|
||||
}
|
||||
|
||||
outS, err := star.BuildWith(star.BuildOpts{
|
||||
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("bootstrap star: %v", err)
|
||||
} else {
|
||||
sum, _ = json.Marshal(outS.Summary)
|
||||
det, _ = json.Marshal(outS.Detail)
|
||||
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "star", sum, det); err != nil {
|
||||
log.Printf("bootstrap star save: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
outR := rhythm.Build(p.BirthDate, p.DisplayName)
|
||||
sum, _ = json.Marshal(outR.Summary)
|
||||
det, _ = json.Marshal(outR.Detail)
|
||||
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil {
|
||||
log.Printf("bootstrap rhythm: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) genPair(ctx context.Context, userID uuid.UUID, self, other *model.Profile) {
|
||||
if self == nil || other == nil || self.ID == other.ID {
|
||||
return
|
||||
}
|
||||
peer := other.ID
|
||||
relType := ""
|
||||
if other.RelationType != nil {
|
||||
relType = *other.RelationType
|
||||
}
|
||||
rel := releng.BuildFull(self.BirthDate, other.BirthDate, self.BirthTime, other.BirthTime, self.BirthPlace, other.BirthPlace, self.DisplayName, other.DisplayName, relType)
|
||||
sum, _ := json.Marshal(rel.Summary)
|
||||
det, _ := json.Marshal(rel.Detail)
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, self.ID, &peer, "relation", sum, det)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap relation: %v", err)
|
||||
} else if s.Relations != nil {
|
||||
_, _ = s.Relations.Create(ctx, userID, self.ID, other.ID, sum, rep.ID)
|
||||
}
|
||||
|
||||
ca, err := star.NatalChart(self.BirthDate, self.BirthTime, self.BirthPlace)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap synastry natal a: %v", err)
|
||||
return
|
||||
}
|
||||
cb, err := star.NatalChart(other.BirthDate, other.BirthTime, other.BirthPlace)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap synastry natal b: %v", err)
|
||||
return
|
||||
}
|
||||
when := time.Now().In(time.FixedZone("CST", 8*3600))
|
||||
out, err := synastry.BuildReport(ca, cb, self.DisplayName, other.DisplayName, when)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap synastry: %v", err)
|
||||
return
|
||||
}
|
||||
sum, _ = json.Marshal(out.Summary)
|
||||
det, _ = json.Marshal(out.Detail)
|
||||
if _, err := s.Reports.UpsertWithPeer(ctx, userID, self.ID, &peer, "synastry", sum, det); err != nil {
|
||||
log.Printf("bootstrap synastry save: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidTools = errors.New("invalid home tools")
|
||||
ErrTooManyTools = errors.New("too many home tools")
|
||||
)
|
||||
|
||||
var pathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,120}$`)
|
||||
|
||||
var allowedIcons = map[string]struct{}{
|
||||
"mbti": {}, "star": {}, "portrait": {}, "rhythm": {}, "synastry": {}, "astro": {},
|
||||
"companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
|
||||
}
|
||||
|
||||
// Service serves homepage tool catalog.
|
||||
type Service struct {
|
||||
Repo *repository.HomeToolsRepo
|
||||
}
|
||||
|
||||
// ListPublic returns enabled tools.
|
||||
func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error) {
|
||||
return s.Repo.ListEnabled(ctx)
|
||||
}
|
||||
|
||||
// ListAdmin returns all tools.
|
||||
func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) {
|
||||
return s.Repo.ListAll(ctx)
|
||||
}
|
||||
|
||||
// ReplaceInput is one tool in a PUT body (id optional).
|
||||
type ReplaceInput struct {
|
||||
ID string `json:"id"`
|
||||
RowIndex int `json:"row_index"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Path string `json:"path"`
|
||||
Icon string `json:"icon"`
|
||||
Label string `json:"label"`
|
||||
Badge *string `json:"badge"`
|
||||
BadgeTone *string `json:"badge_tone"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Replace validates and replaces all tools.
|
||||
func (s *Service) Replace(ctx context.Context, items []ReplaceInput) error {
|
||||
return s.ReplaceWithAudit(ctx, uuid.Nil, items, nil)
|
||||
}
|
||||
|
||||
// ReplaceWithAudit validates, replaces, and audits in one transaction when adminID set.
|
||||
func (s *Service) ReplaceWithAudit(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
items []ReplaceInput,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
if len(items) == 0 {
|
||||
return ErrInvalidTools
|
||||
}
|
||||
if len(items) > 24 {
|
||||
return ErrTooManyTools
|
||||
}
|
||||
rows := make([]repository.HomeTool, 0, len(items))
|
||||
for _, in := range items {
|
||||
t, err := normalizeTool(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, *t)
|
||||
}
|
||||
return s.Repo.ReplaceAllWithAudit(ctx, rows, adminID, meta)
|
||||
}
|
||||
|
||||
func normalizeTool(in ReplaceInput) (*repository.HomeTool, error) {
|
||||
if in.RowIndex != 1 && in.RowIndex != 2 {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
path := strings.TrimSpace(in.Path)
|
||||
if !pathRe.MatchString(path) || strings.Contains(path, "..") {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
icon := strings.TrimSpace(in.Icon)
|
||||
if _, ok := allowedIcons[icon]; !ok {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
label := strings.TrimSpace(in.Label)
|
||||
n := utf8.RuneCountInString(label)
|
||||
if n < 1 || n > 16 {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
var badge, tone *string
|
||||
if in.Badge != nil {
|
||||
b := strings.TrimSpace(*in.Badge)
|
||||
if b != "" {
|
||||
if utf8.RuneCountInString(b) > 4 {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
badge = &b
|
||||
}
|
||||
}
|
||||
if in.BadgeTone != nil {
|
||||
t := strings.TrimSpace(*in.BadgeTone)
|
||||
if t != "" && t != "hot" && t != "new" {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
if t != "" {
|
||||
tone = &t
|
||||
}
|
||||
}
|
||||
id := uuid.Nil
|
||||
if strings.TrimSpace(in.ID) != "" {
|
||||
parsed, err := uuid.Parse(in.ID)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
id = parsed
|
||||
}
|
||||
return &repository.HomeTool{
|
||||
ID: id, RowIndex: in.RowIndex, SortOrder: in.SortOrder,
|
||||
Path: path, Icon: icon, Label: label, Badge: badge, BadgeTone: tone, Enabled: in.Enabled,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package home
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeToolOK(t *testing.T) {
|
||||
b := "热"
|
||||
tone := "hot"
|
||||
got, err := normalizeTool(ReplaceInput{
|
||||
RowIndex: 1, SortOrder: 1, Path: "/portrait", Icon: "portrait", Label: "愈心解码",
|
||||
Badge: &b, BadgeTone: &tone, Enabled: true,
|
||||
})
|
||||
if err != nil || got.Label != "愈心解码" {
|
||||
t.Fatalf("got=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeToolRejects(t *testing.T) {
|
||||
cases := []ReplaceInput{
|
||||
{RowIndex: 3, Path: "/a", Icon: "ask", Label: "x"},
|
||||
{RowIndex: 1, Path: "https://evil.com", Icon: "ask", Label: "x"},
|
||||
{RowIndex: 1, Path: "/../etc", Icon: "ask", Label: "x"},
|
||||
{RowIndex: 1, Path: "/ask", Icon: "nope", Label: "x"},
|
||||
{RowIndex: 1, Path: "/ask", Icon: "ask", Label: ""},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if _, err := normalizeTool(c); err != ErrInvalidTools {
|
||||
t.Fatalf("case %d want ErrInvalidTools got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,19 +23,29 @@ type CreateOrderInput struct {
|
||||
ReportID *uuid.UUID
|
||||
}
|
||||
|
||||
// CreateOrder starts membership or deep_access order.
|
||||
// CreateOrder starts membership, deep_access, or ask_pack order.
|
||||
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) {
|
||||
if in.Kind != "membership" && in.Kind != "deep_access" {
|
||||
if in.Kind != "membership" && in.Kind != "deep_access" && in.Kind != "ask_pack" {
|
||||
return uuid.Nil, errors.New("invalid kind")
|
||||
}
|
||||
if in.Kind == "deep_access" && in.ReportID == nil {
|
||||
return uuid.Nil, errors.New("report_id required")
|
||||
}
|
||||
amount := 990
|
||||
plan := in.Plan
|
||||
if in.Kind == "membership" {
|
||||
amount = 2500
|
||||
}
|
||||
return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount)
|
||||
if in.Kind == "ask_pack" {
|
||||
if plan == "" {
|
||||
plan = "pack10"
|
||||
}
|
||||
amount = repository.AskPackAmountCents(plan)
|
||||
if amount <= 0 || repository.AskPackQuota(plan) <= 0 {
|
||||
return uuid.Nil, errors.New("invalid ask_pack plan")
|
||||
}
|
||||
}
|
||||
return s.Reports.CreateOrder(ctx, userID, in.Kind, plan, in.ReportID, amount)
|
||||
}
|
||||
|
||||
// PayMock completes mock payment.
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
|
||||
)
|
||||
|
||||
// Service manages personal archives.
|
||||
type Service struct {
|
||||
Repo *repository.ProfileRepo
|
||||
Repo *repository.ProfileRepo
|
||||
Reports *repository.ReportRepo
|
||||
Bootstrap *bootstrap.Service
|
||||
}
|
||||
|
||||
// CreateInput is validated create payload.
|
||||
@@ -43,7 +46,14 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
|
||||
name = "TA"
|
||||
}
|
||||
}
|
||||
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
|
||||
p, err := s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Bootstrap != nil {
|
||||
s.Bootstrap.GenerateForProfile(ctx, userID, p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// List returns user's profiles.
|
||||
@@ -89,7 +99,31 @@ func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in Up
|
||||
if bp == nil {
|
||||
bp = cur.BirthPlace
|
||||
}
|
||||
return s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
|
||||
birthChanged := !in.BirthDate.IsZero() && !sameDay(in.BirthDate, cur.BirthDate)
|
||||
timeChanged := in.BirthTime != nil && strPtr(in.BirthTime) != strPtr(cur.BirthTime)
|
||||
placeChanged := in.BirthPlace != nil && strPtr(in.BirthPlace) != strPtr(cur.BirthPlace)
|
||||
|
||||
p, err := s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Bootstrap != nil && (birthChanged || timeChanged || placeChanged) {
|
||||
s.Bootstrap.GenerateForProfile(ctx, userID, p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func sameDay(a, b time.Time) bool {
|
||||
ay, am, ad := a.Date()
|
||||
by, bm, bd := b.Date()
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
func strPtr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Delete soft-deletes an owned profile.
|
||||
@@ -97,5 +131,8 @@ func (s *Service) Delete(ctx context.Context, userID, profileID uuid.UUID) error
|
||||
if _, err := s.Repo.GetForUser(ctx, userID, profileID); err != nil {
|
||||
return errors.New("profile not found")
|
||||
}
|
||||
if s.Reports != nil {
|
||||
_ = s.Reports.SoftDeleteForProfile(ctx, userID, profileID)
|
||||
}
|
||||
return s.Repo.SoftDeleteForUser(ctx, userID, profileID)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*Crea
|
||||
out := releng.BuildFull(pa.BirthDate, pb.BirthDate, pa.BirthTime, pb.BirthTime, pa.BirthPlace, pb.BirthPlace, pa.DisplayName, pb.DisplayName, relType)
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det)
|
||||
peer := bID
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, aID, &peer, "relation", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUI
|
||||
out := portrait.Build(p.BirthDate, p.DisplayName)
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, profileID, "portrait", sum, det)
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "portrait", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
|
||||
}
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, profileID, "star", sum, det)
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "star", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,7 +193,8 @@ func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profil
|
||||
}
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, profileAID, "synastry", sum, det)
|
||||
peer := profileBID
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileAID, &peer, "synastry", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,13 +210,22 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
|
||||
out := rhythm.Build(p.BirthDate, p.DisplayName)
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, profileID, "rhythm", sum, det)
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// GetLatest returns cached report by profile+type(+peer).
|
||||
func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// Get returns a report with detail gated.
|
||||
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
DROP INDEX IF EXISTS idx_growth_reports_latest_pair;
|
||||
DROP INDEX IF EXISTS idx_growth_reports_latest_solo;
|
||||
ALTER TABLE growth_reports DROP COLUMN IF EXISTS peer_profile_id;
|
||||
DROP INDEX IF EXISTS idx_user_sessions_token;
|
||||
DROP INDEX IF EXISTS idx_user_sessions_user_id;
|
||||
DROP TABLE IF EXISTS user_sessions;
|
||||
DROP INDEX IF EXISTS idx_users_phone_unique;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS nickname;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS password_hash;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS phone;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Account auth + report peer for birthday bootstrap bundles
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone varchar(20) NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash text NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname varchar(64) NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique
|
||||
ON users(phone) WHERE phone IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
token varchar(128) NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token) WHERE revoked_at IS NULL;
|
||||
|
||||
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS peer_profile_id uuid NULL REFERENCES profiles(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_growth_reports_latest_solo
|
||||
ON growth_reports(user_id, profile_id, type, created_at DESC)
|
||||
WHERE deleted_at IS NULL AND peer_profile_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_growth_reports_latest_pair
|
||||
ON growth_reports(user_id, profile_id, type, peer_profile_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL AND peer_profile_id IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS ask_paid_quota_left;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Purchased ask quota packs (independent of membership deep-access)
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS ask_paid_quota_left int NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS analytics_events;
|
||||
DROP TABLE IF EXISTS analytics_sessions;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Ops-B analytics (ECR-007)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_sessions (
|
||||
session_id varchar(64) PRIMARY KEY,
|
||||
device_key varchar(128) NOT NULL,
|
||||
user_id uuid NULL REFERENCES users(id),
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
ended_at timestamptz NULL,
|
||||
exit_page text NULL,
|
||||
duration_ms int NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_sessions_started ON analytics_sessions(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_sessions_user ON analytics_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_sessions_device ON analytics_sessions(device_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id varchar(64) NOT NULL REFERENCES analytics_sessions(session_id),
|
||||
user_id uuid NULL REFERENCES users(id),
|
||||
name varchar(64) NOT NULL,
|
||||
page_path text NULL,
|
||||
props jsonb NOT NULL DEFAULT '{}',
|
||||
client_ts timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_events_received ON analytics_events(received_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_events_name_day ON analytics_events(name, ((received_at AT TIME ZONE 'UTC')::date));
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_events_page ON analytics_events(page_path) WHERE page_path IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_events_session ON analytics_events(session_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS home_tools;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Ops-C home tools CMS (ECR-008)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS home_tools (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
row_index smallint NOT NULL CHECK (row_index IN (1, 2)),
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
path text NOT NULL,
|
||||
icon varchar(32) NOT NULL,
|
||||
label varchar(32) NOT NULL,
|
||||
badge varchar(8) NULL,
|
||||
badge_tone varchar(8) NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_home_tools_row_sort ON home_tools(row_index, sort_order);
|
||||
|
||||
INSERT INTO home_tools (row_index, sort_order, path, icon, label, badge, badge_tone, enabled) VALUES
|
||||
(1, 1, '/scales/mbti-lite', 'mbti', '人格测试', NULL, NULL, true),
|
||||
(1, 2, '/star', 'star', '星座', NULL, NULL, true),
|
||||
(1, 3, '/portrait', 'portrait', '愈心解码', '热', 'hot', true),
|
||||
(1, 4, '/rhythm', 'rhythm', '身心节律', NULL, NULL, true),
|
||||
(1, 5, '/synastry', 'synastry', '合盘', '新', 'new', true),
|
||||
(1, 6, '/star', 'astro', '星象性格', NULL, NULL, true),
|
||||
(2, 1, '/companion', 'companion', '节气陪伴', NULL, NULL, true),
|
||||
(2, 2, '/ask', 'ask', 'AI问答', NULL, NULL, true),
|
||||
(2, 3, '/cards', 'cards', '意象卡片', NULL, NULL, true),
|
||||
(2, 4, '/reports', 'reports', '成长报告', '新', 'new', true),
|
||||
(2, 5, '/growth-plan', 'growth', '成长计划', NULL, NULL, true),
|
||||
(2, 6, '/relation', 'relation', '人格匹配', NULL, NULL, true);
|
||||
@@ -22,8 +22,12 @@
|
||||
<AskMessageList :messages="messages" :sending="sending" />
|
||||
<p v-if="sendError" class="err pad">
|
||||
{{ sendError }}
|
||||
<router-link v-if="quotaExhausted" class="retry" to="/membership">开通成长会员</router-link>
|
||||
</p>
|
||||
<AskQuotaBuy
|
||||
v-if="showBuy"
|
||||
:lead="quotaExhausted ? '问答次数已用完,购买后可继续聊' : '想聊更多?可先加购额度'"
|
||||
@purchased="$emit('quota-refreshed')"
|
||||
/>
|
||||
<AskComposer
|
||||
:draft="draft"
|
||||
:sending="sending"
|
||||
@@ -36,14 +40,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
||||
import AskComposer from './AskComposer.vue'
|
||||
import AskEmpty from './AskEmpty.vue'
|
||||
import AskGreet from './AskGreet.vue'
|
||||
import AskMessageList from './AskMessageList.vue'
|
||||
import AskProfileBar from './AskProfileBar.vue'
|
||||
import AskQuotaBuy from './AskQuotaBuy.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
bootLoading: boolean
|
||||
bootError: string
|
||||
profiles: Profile[]
|
||||
@@ -65,25 +71,33 @@ defineEmits<{
|
||||
navigate: [to: string]
|
||||
'update:draft': [v: string]
|
||||
send: []
|
||||
'quota-refreshed': []
|
||||
}>()
|
||||
|
||||
const showBuy = computed(
|
||||
() => props.quotaExhausted || (props.quota !== null && props.quota.remaining <= 0),
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-pane {
|
||||
padding: 12px 16px 100px;
|
||||
padding: 8px 16px 108px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
color: #9a8f8b;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
padding: 28px 16px;
|
||||
}
|
||||
.err {
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
padding: 8px 0;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
.pad { padding: 0 4px; }
|
||||
.retry {
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
<template>
|
||||
<div class="composer-wrap">
|
||||
<p v-if="quota" class="quota">
|
||||
剩余 {{ quota.remaining }} 次
|
||||
<span v-if="!quota.active_membership">(免费 {{ quota.free_limit }})</span>
|
||||
</p>
|
||||
<form class="composer" @submit.prevent="$emit('send')">
|
||||
<textarea
|
||||
class="ta"
|
||||
:value="draft"
|
||||
rows="2"
|
||||
placeholder="让我来解答你的问题吧"
|
||||
:disabled="sending || (quota !== null && quota.remaining <= 0)"
|
||||
@input="$emit('update:draft', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<button
|
||||
class="send"
|
||||
type="submit"
|
||||
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
<p class="ai-note">部分内容由 AI 生成,仅供参考</p>
|
||||
<div class="bar">
|
||||
<p v-if="quota" class="quota">
|
||||
<span class="q-dot" aria-hidden="true" />
|
||||
还可聊 {{ quota.remaining }} 次
|
||||
<span v-if="(quota.paid_left ?? 0) > 0" class="q-free">已购 {{ quota.paid_left }}</span>
|
||||
<span v-else-if="!quota.active_membership" class="q-free">免费额度 {{ quota.free_limit }}</span>
|
||||
</p>
|
||||
<form class="composer" @submit.prevent="$emit('send')">
|
||||
<textarea
|
||||
class="ta"
|
||||
:value="draft"
|
||||
rows="2"
|
||||
placeholder="说说你此刻在意的事…"
|
||||
:disabled="sending || (quota !== null && quota.remaining <= 0)"
|
||||
@input="$emit('update:draft', ($event.target as HTMLTextAreaElement).value)"
|
||||
@keydown.enter.exact.prevent="$emit('send')"
|
||||
/>
|
||||
<button
|
||||
class="send"
|
||||
type="submit"
|
||||
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
|
||||
aria-label="发送"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path d="M3 9h10M9 4l5 5-5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="ai-note">部分内容由 AI 生成 · 仅作自我探索参考</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -45,12 +53,34 @@ defineEmits<{
|
||||
position: sticky;
|
||||
bottom: 72px;
|
||||
padding-top: 8px;
|
||||
background: linear-gradient(180deg, transparent, var(--color-bg-sheet) 24%);
|
||||
z-index: 5;
|
||||
}
|
||||
.bar {
|
||||
padding: 10px 12px 8px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 -2px 20px rgba(180, 90, 70, 0.08);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.quota {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 6px;
|
||||
color: #a39893;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.q-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.q-free {
|
||||
color: #c4bbb6;
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
@@ -60,36 +90,46 @@ defineEmits<{
|
||||
.ta {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
padding: 11px 14px;
|
||||
min-height: 46px;
|
||||
max-height: 120px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
border: 1.5px solid #f0e6e2;
|
||||
background: var(--color-input-bg);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
.ta:focus {
|
||||
border-color: #f0b8b0;
|
||||
box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.08);
|
||||
}
|
||||
.ta::placeholder {
|
||||
color: #c4bbb6;
|
||||
}
|
||||
.send {
|
||||
flex-shrink: 0;
|
||||
padding: 11px 16px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
box-shadow: 0 8px 18px rgba(229, 77, 66, 0.3);
|
||||
}
|
||||
.send:disabled {
|
||||
opacity: 0.45;
|
||||
opacity: 0.4;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #ccc;
|
||||
color: #d0c6c1;
|
||||
text-align: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<template>
|
||||
<div class="card empty">
|
||||
<HomeToolIcon name="ask" :size="56" />
|
||||
<p>还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。</p>
|
||||
<div class="card empty reveal">
|
||||
<div class="av" aria-hidden="true">
|
||||
<HomeToolIcon name="ask" :size="48" />
|
||||
</div>
|
||||
<p class="title">先建立你的个人档案</p>
|
||||
<p class="desc">完成愈心解码后,问答会结合你的性格与节律来回答,更贴合你。</p>
|
||||
<router-link class="cta" to="/portrait">去愈心解码</router-link>
|
||||
<router-link class="link" to="/profile">或手动创建档案</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,30 +16,62 @@ import HomeToolIcon from '../HomeToolIcon.vue'
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card.empty {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 28px 20px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
.av {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #ffe8e0, #ffd4c8);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #9a8f8b;
|
||||
max-width: 16em;
|
||||
}
|
||||
.cta {
|
||||
margin-top: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 22px;
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
box-shadow: 0 8px 18px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.link {
|
||||
font-size: 12px;
|
||||
color: #b0a49f;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
<template>
|
||||
<div class="card greet reveal">
|
||||
<div class="greet-row">
|
||||
<HomeToolIcon name="ask" :size="48" />
|
||||
<div class="av" aria-hidden="true">
|
||||
<HomeToolIcon name="ask" :size="40" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="hi">Hi,我是愈心 AI</p>
|
||||
<p class="hi-sub">最近有什么事,都可以和我聊聊</p>
|
||||
<p class="hi-sub">懂你一点的成长伙伴 · 认识自己 · 理解关系 · 整理情绪</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="sec-label">今天想从哪里开始</p>
|
||||
<div class="prompts">
|
||||
<button
|
||||
v-for="s in guidePrompts"
|
||||
v-for="(s, i) in guidePrompts"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="prompt"
|
||||
:style="{ '--d': `${80 + i * 40}ms` }"
|
||||
@click="$emit('pick-scene', s)"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span class="p-mark" aria-hidden="true">◇</span>
|
||||
<span class="p-text">{{ s.label }}</span>
|
||||
<span class="p-go" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="advisor-link" @click="$emit('switch-advisor')">
|
||||
@@ -38,27 +44,48 @@ const guidePrompts = askScenes
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-hero);
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
.greet-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
.av {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #ffe8e0, #ffd4c8);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hi {
|
||||
font-size: 16px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hi-sub {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
color: #9a8f8b;
|
||||
margin-top: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sec-label {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
color: #b0a49f;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.prompts {
|
||||
display: flex;
|
||||
@@ -66,18 +93,38 @@ const guidePrompts = askScenes
|
||||
gap: 8px;
|
||||
}
|
||||
.prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fffaf8;
|
||||
border: 1px solid #f3e8e4;
|
||||
background: linear-gradient(180deg, #fffdfb, #fff7f4);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
color: #444;
|
||||
line-height: 1.45;
|
||||
animation: rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
.prompt:active {
|
||||
background: #fff0ec;
|
||||
border-color: #f5c4bc;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.p-mark {
|
||||
color: var(--color-primary);
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
}
|
||||
.p-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.p-go {
|
||||
color: #d0c4bf;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.advisor-link {
|
||||
margin-top: 12px;
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
<template>
|
||||
<div class="thread" ref="threadRef">
|
||||
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
|
||||
<p>{{ m.content }}</p>
|
||||
<div class="thread">
|
||||
<div
|
||||
v-for="m in messages"
|
||||
:id="`ask-msg-${m.id}`"
|
||||
:key="m.id"
|
||||
:class="['row', m.role]"
|
||||
>
|
||||
<div v-if="m.role === 'assistant'" class="av" aria-hidden="true">愈</div>
|
||||
<div :class="['bubble', m.role]">
|
||||
<p>{{ m.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sending && !streamingStarted" class="row assistant" id="ask-msg-typing">
|
||||
<div class="av" aria-hidden="true">愈</div>
|
||||
<div class="bubble assistant typing">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="sending" class="hint pad">助手正在回复…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import type { AskMessage } from '@yuxingu/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -16,48 +29,94 @@ const props = defineProps<{
|
||||
sending: boolean
|
||||
}>()
|
||||
|
||||
const threadRef = ref<HTMLElement | null>(null)
|
||||
const streamingStarted = computed(() => {
|
||||
const last = props.messages[props.messages.length - 1]
|
||||
return last?.role === 'assistant'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
() => [props.messages.length, props.sending] as const,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (threadRef.value) threadRef.value.scrollTop = threadRef.value.scrollHeight
|
||||
const last = props.sending
|
||||
? document.getElementById('ask-msg-typing')
|
||||
: props.messages.length
|
||||
? document.getElementById(`ask-msg-${props.messages[props.messages.length - 1].id}`)
|
||||
: null
|
||||
last?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.thread {
|
||||
overflow-y: auto;
|
||||
max-height: 36vh;
|
||||
/* 平铺在页面里,用整页滚动,避免内嵌滚动条难操作 */
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.av {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #c23b32;
|
||||
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
|
||||
}
|
||||
.bubble {
|
||||
margin: 8px 0;
|
||||
max-width: min(86%, 340px);
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: #fff;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubble.user {
|
||||
margin-left: 28px;
|
||||
color: #333;
|
||||
background: linear-gradient(135deg, #ff8a7c, var(--color-primary));
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.22);
|
||||
}
|
||||
.bubble.assistant {
|
||||
margin-right: 28px;
|
||||
background: #fff5f4;
|
||||
color: #444;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
color: #3d3734;
|
||||
border-bottom-left-radius: 6px;
|
||||
border: 1px solid #f3e8e4;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
.typing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 52px;
|
||||
padding-block: 14px;
|
||||
}
|
||||
.typing span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #e0b5ad;
|
||||
animation: blink 1.1s ease-in-out infinite;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes blink {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
.pad { padding: 0 4px; }
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<template>
|
||||
<div class="card controls">
|
||||
<label class="lbl">档案</label>
|
||||
<select
|
||||
class="sel"
|
||||
:value="profileId"
|
||||
@change="
|
||||
$emit('update:profileId', ($event.target as HTMLSelectElement).value);
|
||||
$emit('profile-change')
|
||||
"
|
||||
>
|
||||
<option v-for="p in profiles" :key="p.id" :value="p.id">
|
||||
{{ p.relation === 'self' ? '我的档案' : `TA · ${p.display_name || '未命名'}` }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="head">
|
||||
<span class="lbl">解读对象</span>
|
||||
<span class="hint">切换档案,回答更贴合</span>
|
||||
</div>
|
||||
<div class="chips" role="listbox" aria-label="选择档案">
|
||||
<button
|
||||
v-for="p in profiles"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: p.id === profileId }"
|
||||
role="option"
|
||||
:aria-selected="p.id === profileId"
|
||||
@click="pick(p.id)"
|
||||
>
|
||||
<span class="chip-mark">{{ p.relation === 'self' ? '我' : 'TA' }}</span>
|
||||
<span class="chip-name">{{ p.relation === 'self' ? '我的档案' : p.display_name || '未命名' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick">
|
||||
<button
|
||||
v-for="q in quickTools"
|
||||
@@ -31,42 +37,94 @@
|
||||
import type { Profile } from '@yuxingu/types'
|
||||
import { askQuickTools } from '../../composables/useAskPage'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
profiles: Profile[]
|
||||
profileId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
'update:profileId': [v: string]
|
||||
'profile-change': []
|
||||
navigate: [to: string]
|
||||
}>()
|
||||
|
||||
const quickTools = askQuickTools
|
||||
|
||||
function pick(id: string) {
|
||||
if (id === props.profileId) return
|
||||
emit('update:profileId', id)
|
||||
emit('profile-change')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #f6ece8;
|
||||
}
|
||||
.controls .lbl {
|
||||
display: block;
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 650;
|
||||
color: #8a807c;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sel {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #eee;
|
||||
background: var(--color-input-bg);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: #c4bbb6;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1.5px solid #f0e6e2;
|
||||
background: #fffaf8;
|
||||
color: #666;
|
||||
}
|
||||
.chip.on {
|
||||
border-color: #f5c4bc;
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.12);
|
||||
}
|
||||
.chip-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #ffe4de;
|
||||
color: #c23b32;
|
||||
}
|
||||
.chip.on .chip-mark {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
}
|
||||
.chip-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.quick {
|
||||
display: flex;
|
||||
@@ -75,6 +133,7 @@ const quickTools = askQuickTools
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.quick::-webkit-scrollbar { display: none; }
|
||||
.q-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 12px;
|
||||
@@ -82,6 +141,10 @@ const quickTools = askQuickTools
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: #888;
|
||||
}
|
||||
.q-btn:active {
|
||||
border-color: #f5c4bc;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<section class="buy">
|
||||
<p class="lead">{{ lead }}</p>
|
||||
<div class="packs">
|
||||
<button
|
||||
v-for="p in packs"
|
||||
:key="p.plan"
|
||||
type="button"
|
||||
class="pack"
|
||||
:class="{ on: p.featured }"
|
||||
:disabled="busy"
|
||||
@click="buy(p.plan)"
|
||||
>
|
||||
<strong>{{ p.label }}</strong>
|
||||
<span class="times">{{ p.times }} 次</span>
|
||||
<span class="price">{{ p.price }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<router-link class="vip" to="/membership">或开通成长会员 · 完整分析 + 更多次数 ›</router-link>
|
||||
<p class="note">模拟支付,购买后立刻到账</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../../api/client'
|
||||
import { AnalyticsEvent, track } from '../../lib/analytics'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
lead?: string
|
||||
}>(),
|
||||
{ lead: '免费次数用完了,可购买更多问答额度' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
purchased: []
|
||||
}>()
|
||||
|
||||
const packs = [
|
||||
{ plan: 'pack10', label: '轻量包', times: 10, price: '模拟 ¥9.9', featured: false },
|
||||
{ plan: 'pack30', label: '常用包', times: 30, price: '模拟 ¥19.8', featured: true },
|
||||
{ plan: 'pack100', label: '畅聊包', times: 100, price: '模拟 ¥49.9', featured: false },
|
||||
]
|
||||
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const ok = ref('')
|
||||
|
||||
async function buy(plan: string) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
ok.value = ''
|
||||
try {
|
||||
const { order_id } = await api.createOrder({ kind: 'ask_pack', plan })
|
||||
await api.payMock(order_id)
|
||||
track(AnalyticsEvent.PurchaseCompleted, { kind: 'ask_pack', plan })
|
||||
ok.value = '额度已到账,可以继续聊'
|
||||
emit('purchased')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '购买失败'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.buy {
|
||||
margin: 8px 0 12px;
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #fff7f4, #ffe9e2);
|
||||
border: 1px solid #f5c4bc;
|
||||
}
|
||||
.lead {
|
||||
font-size: 13px;
|
||||
color: #5c524e;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.packs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.pack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #f0e6e2;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
.pack.on {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.12);
|
||||
}
|
||||
.pack strong {
|
||||
font-size: 13px;
|
||||
color: #2f2a28;
|
||||
}
|
||||
.times {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.price {
|
||||
font-size: 10px;
|
||||
color: #a39893;
|
||||
}
|
||||
.pack:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.err {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ok {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #2f7a4f;
|
||||
}
|
||||
.vip {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
.note {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: #c4bbb6;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -34,38 +34,41 @@ defineEmits<{
|
||||
.rails {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 6px 18px rgba(180, 90, 70, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.rail {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
font-weight: 650;
|
||||
color: #9a8f8b;
|
||||
position: relative;
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
.rail.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.12);
|
||||
box-shadow: 0 3px 10px rgba(229, 77, 66, 0.14);
|
||||
}
|
||||
.rail-tag {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 8px;
|
||||
top: -3px;
|
||||
right: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 7px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="self-card reveal" style="--d: 80ms" @click="$emit('open-portrait')">
|
||||
<section class="self-card reveal" style="--d: 80ms" data-track="home_self_card" @click="$emit('open-portrait')">
|
||||
<div class="self-head">
|
||||
<button type="button" class="who-btn" @click.stop="$emit('open-profile')">
|
||||
{{ profileLabel }}
|
||||
|
||||
@@ -91,21 +91,88 @@ export function useAskPage() {
|
||||
sending.value = true
|
||||
sendError.value = ''
|
||||
quotaExhausted.value = false
|
||||
draft.value = ''
|
||||
const streamId = `stream-${Date.now()}`
|
||||
let gotMeta = false
|
||||
try {
|
||||
const tid = await ensureThread()
|
||||
const out = await api.sendAskMessage(tid, content)
|
||||
messages.value = [...messages.value, out.user_message, out.assistant_message]
|
||||
quota.value = out.quota
|
||||
draft.value = ''
|
||||
await api.sendAskMessageStream(tid, content, {
|
||||
onMeta: (userMsg) => {
|
||||
gotMeta = true
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
userMsg,
|
||||
{
|
||||
id: streamId,
|
||||
thread_id: tid,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
},
|
||||
onDelta: (text) => {
|
||||
const list = messages.value
|
||||
const last = list[list.length - 1]
|
||||
if (!last || last.id !== streamId) return
|
||||
messages.value = [...list.slice(0, -1), { ...last, content: last.content + text }]
|
||||
},
|
||||
onDone: (payload) => {
|
||||
const base = messages.value.filter(
|
||||
(m) => m.id !== streamId && m.id !== payload.assistant_message.id,
|
||||
)
|
||||
messages.value = [...base, payload.assistant_message]
|
||||
quota.value = payload.quota
|
||||
},
|
||||
onError: (msg) => {
|
||||
sendError.value = msg
|
||||
quotaExhausted.value =
|
||||
msg.includes('次数已用完') || msg.includes('购买额度') || msg.includes('成长会员')
|
||||
messages.value = messages.value.filter((m) => m.id !== streamId)
|
||||
if (!gotMeta) draft.value = content
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '发送失败'
|
||||
sendError.value = msg
|
||||
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
|
||||
if (!sendError.value) sendError.value = msg
|
||||
quotaExhausted.value =
|
||||
msg.includes('次数已用完') || msg.includes('购买额度') || msg.includes('成长会员')
|
||||
messages.value = messages.value.filter((m) => m.id !== streamId)
|
||||
if (!gotMeta && !draft.value) draft.value = content
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshQuota() {
|
||||
try {
|
||||
quota.value = await api.getAskQuota()
|
||||
if (quota.value.remaining > 0) {
|
||||
quotaExhausted.value = false
|
||||
sendError.value = ''
|
||||
}
|
||||
} catch {
|
||||
/* ignore; user can retry send */
|
||||
}
|
||||
}
|
||||
|
||||
async function clearChat() {
|
||||
if (sending.value) return
|
||||
if (!messages.value.length && !threadId.value) return
|
||||
sendError.value = ''
|
||||
const tid = threadId.value
|
||||
messages.value = []
|
||||
threadId.value = ''
|
||||
draft.value = ''
|
||||
if (tid) {
|
||||
try {
|
||||
await api.clearAskThread(tid)
|
||||
} catch {
|
||||
/* local clear still applies */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(boot)
|
||||
|
||||
return {
|
||||
@@ -126,5 +193,7 @@ export function useAskPage() {
|
||||
askAdvisor,
|
||||
send,
|
||||
boot,
|
||||
refreshQuota,
|
||||
clearChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,82 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, toRefs } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { homeFeeds, homeGridRow1, homeGridRow2, homeSearchHints } from '../lib/homeCatalog'
|
||||
import {
|
||||
homeFeeds,
|
||||
homeGridRow1,
|
||||
homeGridRow2,
|
||||
homeSearchHints,
|
||||
type HomeTool,
|
||||
} from '../lib/homeCatalog'
|
||||
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||
import { useHomeMood } from './useHomeMood'
|
||||
|
||||
const ICONS = new Set([
|
||||
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
|
||||
'companion', 'ask', 'cards', 'reports', 'growth', 'relation',
|
||||
])
|
||||
|
||||
function mapTools(
|
||||
items: Array<{
|
||||
row_index: number
|
||||
sort_order: number
|
||||
path: string
|
||||
icon: string
|
||||
label: string
|
||||
badge?: string | null
|
||||
badge_tone?: string | null
|
||||
}>,
|
||||
): { row1: HomeTool[]; row2: HomeTool[] } {
|
||||
const toTool = (it: (typeof items)[0]): HomeTool | null => {
|
||||
if (!ICONS.has(it.icon)) return null
|
||||
const t: HomeTool = {
|
||||
to: it.path,
|
||||
icon: it.icon as HomeToolIconName,
|
||||
label: it.label,
|
||||
}
|
||||
if (it.badge) t.badge = it.badge
|
||||
if (it.badge_tone === 'hot' || it.badge_tone === 'new') t.badgeTone = it.badge_tone
|
||||
return t
|
||||
}
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => a.row_index - b.row_index || a.sort_order - b.sort_order,
|
||||
)
|
||||
const row1: HomeTool[] = []
|
||||
const row2: HomeTool[] = []
|
||||
for (const it of sorted) {
|
||||
const t = toTool(it)
|
||||
if (!t) continue
|
||||
if (it.row_index === 1) row1.push(t)
|
||||
else if (it.row_index === 2) row2.push(t)
|
||||
}
|
||||
return { row1, row2 }
|
||||
}
|
||||
|
||||
export function useHomePage() {
|
||||
const router = useRouter()
|
||||
const profileLabel = ref('自己')
|
||||
const plusOpen = ref(false)
|
||||
const mood = useHomeMood()
|
||||
const grid = reactive({
|
||||
gridRow1: [...homeGridRow1] as HomeTool[],
|
||||
gridRow2: [...homeGridRow2] as HomeTool[],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void api
|
||||
.getHomeTools()
|
||||
.then((res) => {
|
||||
const mapped = mapTools(res.items || [])
|
||||
if (mapped.row1.length || mapped.row2.length) {
|
||||
grid.gridRow1 = mapped.row1
|
||||
grid.gridRow2 = mapped.row2
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep static fallback */
|
||||
})
|
||||
})
|
||||
|
||||
const searchHint = computed(() => {
|
||||
const i = new Date().getHours() % homeSearchHints.length
|
||||
@@ -39,8 +107,7 @@ export function useHomePage() {
|
||||
plusOpen,
|
||||
mood,
|
||||
searchHint,
|
||||
gridRow1: homeGridRow1,
|
||||
gridRow2: homeGridRow2,
|
||||
...toRefs(grid),
|
||||
feeds: homeFeeds,
|
||||
goPlus,
|
||||
trackGrid,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import type { WuxingBar } from '../components/WuxingBars.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
|
||||
|
||||
export const resultTabs = [
|
||||
{ key: 'overview' as const, label: '概览' },
|
||||
@@ -15,6 +16,7 @@ export const resultTabs = [
|
||||
|
||||
export function useLifeRhythmPage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const needBirth = ref(true)
|
||||
const error = ref('')
|
||||
@@ -68,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.createRhythm(profile.id)
|
||||
report.value = await api.getLatestReport(profile.id, 'rhythm')
|
||||
tab.value = 'overview'
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
|
||||
} catch (e) {
|
||||
@@ -92,6 +94,7 @@ export function useLifeRhythmPage() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
const reportId = String(route.query.report_id || '')
|
||||
if (reportId) {
|
||||
loading.value = true
|
||||
@@ -107,14 +110,35 @@ export function useLifeRhythmPage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (y && m && d && !validateBirth(y, m, d)) {
|
||||
year.value = String(y)
|
||||
month.value = String(m)
|
||||
day.value = String(d)
|
||||
await generate(y, m, d)
|
||||
loading.value = true
|
||||
try {
|
||||
const cached = await loadSelfLatest('rhythm')
|
||||
if (cached.report) {
|
||||
report.value = cached.report
|
||||
needBirth.value = false
|
||||
tab.value = 'overview'
|
||||
return
|
||||
}
|
||||
if (cached.profile) {
|
||||
report.value = await api.createRhythm(cached.profile.id)
|
||||
needBirth.value = false
|
||||
tab.value = 'overview'
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (y && m && d && !validateBirth(y, m, d)) {
|
||||
year.value = String(y)
|
||||
month.value = String(m)
|
||||
day.value = String(d)
|
||||
await generate(y, m, d)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
needBirth.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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, loadSelfLatest } from '../lib/authSession'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
|
||||
export function usePortraitPage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const needBirth = ref(true)
|
||||
const error = ref('')
|
||||
@@ -44,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.createPortrait(profile.id)
|
||||
report.value = await api.getLatestReport(profile.id, 'portrait')
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
@@ -67,6 +69,7 @@ export function usePortraitPage() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
const reportId = String(route.query.report_id || '')
|
||||
if (reportId) {
|
||||
loading.value = true
|
||||
@@ -83,18 +86,38 @@ export function usePortraitPage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (y && m && d && !validateBirth(y, m, d)) {
|
||||
year.value = String(y)
|
||||
month.value = String(m)
|
||||
day.value = String(d)
|
||||
await generate(y, m, d)
|
||||
return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const cached = await loadSelfLatest('portrait')
|
||||
if (cached.report) {
|
||||
report.value = cached.report
|
||||
needBirth.value = false
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'cache' })
|
||||
return
|
||||
}
|
||||
if (cached.profile) {
|
||||
report.value = await api.createPortrait(cached.profile.id)
|
||||
needBirth.value = false
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (y && m && d && !validateBirth(y, m, d)) {
|
||||
year.value = String(y)
|
||||
month.value = String(m)
|
||||
day.value = String(d)
|
||||
await generate(y, m, d)
|
||||
return
|
||||
}
|
||||
needBirth.value = true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
needBirth.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
needBirth.value = true
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function retry() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { Profile } from '@yuxingu/types'
|
||||
import { ensureAccount } from '../lib/authSession'
|
||||
import {
|
||||
avatarInitial,
|
||||
createProfilePageActions,
|
||||
@@ -102,6 +103,7 @@ export function useProfilePage() {
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
await load()
|
||||
applyQueryFlags()
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
|
||||
import {
|
||||
ascLonFromSummary,
|
||||
fortuneBundleFrom,
|
||||
@@ -30,6 +31,7 @@ export { starViewTabs, starFortuneKeys, fortuneLabel }
|
||||
|
||||
export function useStarProfilePage() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const needBirth = ref(true)
|
||||
const error = ref('')
|
||||
@@ -116,7 +118,7 @@ export function useStarProfilePage() {
|
||||
birth_time: bt,
|
||||
birth_place: birthPlace.value || undefined,
|
||||
})
|
||||
report.value = await api.createStar(profile.id)
|
||||
report.value = await api.getLatestReport(profile.id, 'star')
|
||||
view.value = 'overview'
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
|
||||
} catch (e) {
|
||||
@@ -140,6 +142,7 @@ export function useStarProfilePage() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!(await ensureAccount(router, route.fullPath))) return
|
||||
const reportId = String(route.query.report_id || '')
|
||||
if (reportId) {
|
||||
loading.value = true
|
||||
@@ -155,14 +158,35 @@ export function useStarProfilePage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (y && m && d && !validateBirth(y, m, d)) {
|
||||
year.value = String(y)
|
||||
month.value = String(m)
|
||||
day.value = String(d)
|
||||
await generate(y, m, d)
|
||||
loading.value = true
|
||||
try {
|
||||
const cached = await loadSelfLatest('star')
|
||||
if (cached.report) {
|
||||
report.value = cached.report
|
||||
needBirth.value = false
|
||||
view.value = 'overview'
|
||||
return
|
||||
}
|
||||
if (cached.profile) {
|
||||
report.value = await api.createStar(cached.profile.id)
|
||||
needBirth.value = false
|
||||
view.value = 'overview'
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (y && m && d && !validateBirth(y, m, d)) {
|
||||
year.value = String(y)
|
||||
month.value = String(m)
|
||||
day.value = String(d)
|
||||
await generate(y, m, d)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
needBirth.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AnalyticsEvent, track, trackPageView } from './analytics'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
api: {
|
||||
postAnalyticsEvents: vi.fn().mockResolvedValue({ accepted: 1 }),
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
AnalyticsEvent,
|
||||
track,
|
||||
trackPageView,
|
||||
_resetAnalyticsForTests,
|
||||
_peekQueueForTests,
|
||||
_setOwnEnabledForTests,
|
||||
ensureSession,
|
||||
} from './analytics'
|
||||
|
||||
describe('analytics.track', () => {
|
||||
beforeEach(() => {
|
||||
_resetAnalyticsForTests()
|
||||
_setOwnEnabledForTests(true)
|
||||
sessionStorage.clear()
|
||||
window.gtag = vi.fn()
|
||||
})
|
||||
afterEach(() => {
|
||||
delete window.gtag
|
||||
_resetAnalyticsForTests()
|
||||
})
|
||||
|
||||
it('calls gtag with event name and params', () => {
|
||||
@@ -24,11 +43,31 @@ describe('analytics.track', () => {
|
||||
expect(() => track(AnalyticsEvent.RelationCompleted)).not.toThrow()
|
||||
})
|
||||
|
||||
it('trackPageView sends page_path', () => {
|
||||
it('trackPageView sends page_path and queues page_leave on next path', () => {
|
||||
trackPageView('/portrait', '个人画像')
|
||||
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', {
|
||||
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', expect.objectContaining({
|
||||
page_path: '/portrait',
|
||||
page_title: '个人画像',
|
||||
})
|
||||
}))
|
||||
trackPageView('/ask', 'ask')
|
||||
const names = _peekQueueForTests().map((q) => q.name)
|
||||
expect(names).toContain('page_leave')
|
||||
expect(names).toContain('page_view')
|
||||
})
|
||||
|
||||
it('ensureSession returns stable id within idle window', () => {
|
||||
const a = ensureSession(true)
|
||||
const b = ensureSession()
|
||||
expect(a).toBe(b)
|
||||
expect(a.startsWith('s_')).toBe(true)
|
||||
})
|
||||
|
||||
it('queues own events with session_id', () => {
|
||||
track(AnalyticsEvent.UiClick, { element_id: 'home_cta' })
|
||||
const q = _peekQueueForTests()
|
||||
expect(q.length).toBe(1)
|
||||
expect(q[0].name).toBe('ui_click')
|
||||
expect(q[0].session_id).toBeTruthy()
|
||||
expect(q[0].props?.element_id).toBe('home_cta')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* P1 growth analytics — see .ai/product/feature-spec/analytics.md
|
||||
* P1 growth analytics + Ops-B own pipeline — see feature-spec analytics.md / ops-analytics.md
|
||||
* Pages MUST use track(); do not call window.gtag directly.
|
||||
*/
|
||||
|
||||
import { api } from '@/api/client'
|
||||
|
||||
export type AnalyticsParams = Record<string, string | number | boolean | undefined>
|
||||
|
||||
/** Frozen P1 core funnel events (+ page_view via router). */
|
||||
@@ -18,6 +20,10 @@ export const AnalyticsEvent = {
|
||||
SynastryNearbyOpened: 'synastry_nearby_opened',
|
||||
StarWheelViewed: 'star_wheel_viewed',
|
||||
PageView: 'page_view',
|
||||
SessionStart: 'session_start',
|
||||
SessionEnd: 'session_end',
|
||||
PageLeave: 'page_leave',
|
||||
UiClick: 'ui_click',
|
||||
} as const
|
||||
|
||||
export type CoreAnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent]
|
||||
@@ -29,22 +35,50 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
let gaReady = false
|
||||
const SESSION_KEY = 'yxg_analytics_sid'
|
||||
const LAST_ACTIVE_KEY = 'yxg_analytics_active'
|
||||
const SESSION_START_KEY = 'yxg_analytics_started'
|
||||
const SESSION_IDLE_MS = 30 * 60 * 1000
|
||||
const FLUSH_MS = 8000
|
||||
const MAX_QUEUE = 80
|
||||
|
||||
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Safe to call once from main. */
|
||||
type QueueItem = {
|
||||
name: string
|
||||
session_id: string
|
||||
page_path?: string
|
||||
client_ts: string
|
||||
props?: Record<string, string | number | boolean>
|
||||
}
|
||||
|
||||
let gaReady = false
|
||||
let queue: QueueItem[] = []
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
let pageEnteredAt = 0
|
||||
let currentPath = ''
|
||||
let referrerPath = ''
|
||||
let listenersBound = false
|
||||
let ownEnabled = true
|
||||
|
||||
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Starts own session pipeline. */
|
||||
export function initAnalytics(): void {
|
||||
initGA()
|
||||
ensureSession(true)
|
||||
bindLifecycle()
|
||||
startFlushLoop()
|
||||
track(AnalyticsEvent.SessionStart, { cold: true, app_ver: String(import.meta.env.VITE_APP_VER || 'h5') })
|
||||
}
|
||||
|
||||
function initGA(): void {
|
||||
const id = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim()
|
||||
if (!id || typeof document === 'undefined') return
|
||||
if (gaReady) return
|
||||
gaReady = true
|
||||
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.gtag = function gtag(...args: unknown[]) {
|
||||
window.dataLayer!.push(args)
|
||||
}
|
||||
window.gtag('js', new Date())
|
||||
window.gtag('config', id, { send_page_view: false })
|
||||
|
||||
const s = document.createElement('script')
|
||||
s.async = true
|
||||
s.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`
|
||||
@@ -55,7 +89,133 @@ function debugEnabled(): boolean {
|
||||
return import.meta.env.DEV && String(import.meta.env.VITE_ANALYTICS_DEBUG || '') === '1'
|
||||
}
|
||||
|
||||
/** Fire a custom event. No-op when gtag missing; never throws. */
|
||||
function nowISO(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function touchActive(): void {
|
||||
try {
|
||||
sessionStorage.setItem(LAST_ACTIVE_KEY, String(Date.now()))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** session_id in sessionStorage; refresh after 30min idle. */
|
||||
export function ensureSession(forceNew = false): string {
|
||||
try {
|
||||
const last = Number(sessionStorage.getItem(LAST_ACTIVE_KEY) || 0)
|
||||
let sid = sessionStorage.getItem(SESSION_KEY) || ''
|
||||
const idle = !last || Date.now() - last > SESSION_IDLE_MS
|
||||
if (forceNew || !sid || idle) {
|
||||
sid = `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
||||
sessionStorage.setItem(SESSION_KEY, sid)
|
||||
sessionStorage.setItem(SESSION_START_KEY, String(Date.now()))
|
||||
}
|
||||
touchActive()
|
||||
return sid
|
||||
} catch {
|
||||
return `s_${Date.now().toString(36)}`
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(name: string, params?: AnalyticsParams): void {
|
||||
if (!ownEnabled) return
|
||||
const sid = ensureSession()
|
||||
const clean: Record<string, string | number | boolean> = {}
|
||||
let pagePath = currentPath
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined) continue
|
||||
clean[k] = v
|
||||
if (k === 'page_path' && typeof v === 'string') pagePath = v
|
||||
}
|
||||
}
|
||||
queue.push({
|
||||
name,
|
||||
session_id: sid,
|
||||
page_path: pagePath || undefined,
|
||||
client_ts: nowISO(),
|
||||
props: Object.keys(clean).length ? clean : undefined,
|
||||
})
|
||||
if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE)
|
||||
touchActive()
|
||||
}
|
||||
|
||||
async function flushQueue(beacon = false): Promise<void> {
|
||||
if (!queue.length) return
|
||||
const items = queue.splice(0, MAX_QUEUE)
|
||||
try {
|
||||
if (beacon) {
|
||||
await api.postAnalyticsEvents({ items }, { keepalive: true })
|
||||
} else {
|
||||
await api.postAnalyticsEvents({ items })
|
||||
}
|
||||
} catch {
|
||||
queue = items.concat(queue).slice(0, MAX_QUEUE)
|
||||
}
|
||||
}
|
||||
|
||||
function startFlushLoop(): void {
|
||||
if (typeof window === 'undefined' || flushTimer) return
|
||||
flushTimer = setInterval(() => {
|
||||
void flushQueue(false)
|
||||
}, FLUSH_MS)
|
||||
}
|
||||
|
||||
function emitPageLeave(path: string): void {
|
||||
if (!path || !pageEnteredAt) return
|
||||
const dwell = Math.max(0, Date.now() - pageEnteredAt)
|
||||
track(AnalyticsEvent.PageLeave, { page_path: path, dwell_ms: dwell })
|
||||
}
|
||||
|
||||
function endSession(exitPage: string): void {
|
||||
ensureSession()
|
||||
let started = Date.now()
|
||||
try {
|
||||
started = Number(sessionStorage.getItem(SESSION_START_KEY) || Date.now())
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const duration = Math.max(0, Date.now() - started)
|
||||
track(AnalyticsEvent.SessionEnd, {
|
||||
exit_page: exitPage,
|
||||
duration_ms: duration,
|
||||
page_path: exitPage,
|
||||
})
|
||||
void flushQueue(true)
|
||||
}
|
||||
|
||||
function bindLifecycle(): void {
|
||||
if (typeof document === 'undefined' || listenersBound) return
|
||||
listenersBound = true
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
emitPageLeave(currentPath)
|
||||
void flushQueue(true)
|
||||
} else {
|
||||
ensureSession()
|
||||
}
|
||||
})
|
||||
window.addEventListener('pagehide', () => {
|
||||
emitPageLeave(currentPath)
|
||||
endSession(currentPath || '/')
|
||||
})
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(ev) => {
|
||||
const t = ev.target as HTMLElement | null
|
||||
const el = t?.closest?.('[data-track]') as HTMLElement | null
|
||||
if (!el) return
|
||||
const id = el.getAttribute('data-track') || ''
|
||||
if (!id) return
|
||||
track(AnalyticsEvent.UiClick, { element_id: id, page_path: currentPath })
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/** Fire a custom event. Dual-write GA + own queue; never throws. */
|
||||
export function track(event: string, params?: AnalyticsParams): void {
|
||||
try {
|
||||
const clean: Record<string, string | number | boolean> = {}
|
||||
@@ -64,20 +224,47 @@ export function track(event: string, params?: AnalyticsParams): void {
|
||||
if (v !== undefined) clean[k] = v
|
||||
}
|
||||
}
|
||||
if (debugEnabled()) {
|
||||
console.debug('[analytics]', event, clean)
|
||||
}
|
||||
if (debugEnabled()) console.debug('[analytics]', event, clean)
|
||||
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
|
||||
window.gtag('event', event, clean)
|
||||
}
|
||||
enqueue(event, params)
|
||||
} catch {
|
||||
/* never block UX */
|
||||
}
|
||||
}
|
||||
|
||||
export function trackPageView(path: string, title?: string): void {
|
||||
if (currentPath && currentPath !== path) {
|
||||
emitPageLeave(currentPath)
|
||||
referrerPath = currentPath
|
||||
}
|
||||
currentPath = path
|
||||
pageEnteredAt = Date.now()
|
||||
track(AnalyticsEvent.PageView, {
|
||||
page_path: path,
|
||||
page_title: title || document.title,
|
||||
page_title: title || (typeof document !== 'undefined' ? document.title : ''),
|
||||
referrer_path: referrerPath || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/** Test helpers */
|
||||
export function _resetAnalyticsForTests(): void {
|
||||
queue = []
|
||||
pageEnteredAt = 0
|
||||
currentPath = ''
|
||||
referrerPath = ''
|
||||
listenersBound = false
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
export function _peekQueueForTests(): QueueItem[] {
|
||||
return queue.slice()
|
||||
}
|
||||
|
||||
export function _setOwnEnabledForTests(v: boolean): void {
|
||||
ownEnabled = v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AuthUser, GrowthReport, Profile } from '@yuxingu/types'
|
||||
import type { Router } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const TOKEN_KEY = 'yxg_token'
|
||||
|
||||
export function setAuthToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** Ensure logged-in account; redirect to /login on failure. */
|
||||
export async function ensureAccount(router: Router, redirect: string): Promise<AuthUser | null> {
|
||||
try {
|
||||
return await api.authMe()
|
||||
} catch {
|
||||
clearAuthToken()
|
||||
await router.replace({ path: '/login', query: { redirect } })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function findSelfProfile(): Promise<Profile | null> {
|
||||
const { items } = await api.listProfiles()
|
||||
return items.find((p) => p.relation === 'self') || null
|
||||
}
|
||||
|
||||
/** Load cached solo report for self profile; null report means need archive. */
|
||||
export async function loadSelfLatest(
|
||||
type: 'portrait' | 'star' | 'rhythm',
|
||||
): Promise<{ profile: Profile | null; report: GrowthReport | null }> {
|
||||
const profile = await findSelfProfile()
|
||||
if (!profile) return { profile: null, report: null }
|
||||
try {
|
||||
const report = await api.getLatestReport(profile.id, type)
|
||||
return { profile, report }
|
||||
} catch {
|
||||
return { profile, report: null }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const { api } = vi.hoisted(() => ({
|
||||
getAskQuota: vi.fn(),
|
||||
createAskThread: vi.fn(),
|
||||
sendAskMessage: vi.fn(),
|
||||
sendAskMessageStream: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -36,7 +37,7 @@ describe('AskPage', () => {
|
||||
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
|
||||
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('还没有个人档案')
|
||||
expect(w.text()).toContain('先建立你的个人档案')
|
||||
expect(w.text()).toContain('去愈心解码')
|
||||
})
|
||||
|
||||
@@ -46,27 +47,40 @@ describe('AskPage', () => {
|
||||
})
|
||||
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
|
||||
api.createAskThread.mockResolvedValue({ id: 't1', profile_id: 'p1' })
|
||||
api.sendAskMessage.mockResolvedValue({
|
||||
user_message: { id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' },
|
||||
assistant_message: {
|
||||
id: 'm2',
|
||||
role: 'assistant',
|
||||
content: '结合档案的回复',
|
||||
thread_id: 't1',
|
||||
created_at: '',
|
||||
api.sendAskMessageStream.mockImplementation(
|
||||
async (
|
||||
_tid: string,
|
||||
_content: string,
|
||||
handlers: {
|
||||
onMeta?: (m: unknown) => void
|
||||
onDelta?: (t: string) => void
|
||||
onDone?: (p: unknown) => void
|
||||
},
|
||||
) => {
|
||||
handlers.onMeta?.({ id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' })
|
||||
handlers.onDelta?.('结合档案的回复')
|
||||
handlers.onDone?.({
|
||||
assistant_message: {
|
||||
id: 'm2',
|
||||
role: 'assistant',
|
||||
content: '结合档案的回复',
|
||||
thread_id: 't1',
|
||||
created_at: '',
|
||||
},
|
||||
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
|
||||
})
|
||||
},
|
||||
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
|
||||
})
|
||||
)
|
||||
|
||||
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('剩余 3 次')
|
||||
expect(w.text()).toContain('还可聊 3 次')
|
||||
await w.find('textarea').setValue('你好')
|
||||
await w.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(api.createAskThread).toHaveBeenCalled()
|
||||
expect(api.sendAskMessageStream).toHaveBeenCalled()
|
||||
expect(w.text()).toContain('结合档案的回复')
|
||||
expect(w.text()).toContain('剩余 2 次')
|
||||
expect(w.text()).toContain('还可聊 2 次')
|
||||
})
|
||||
|
||||
it('shows boot error with retry', async () => {
|
||||
@@ -79,6 +93,6 @@ describe('AskPage', () => {
|
||||
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
|
||||
await w.find('button.retry').trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('还没有个人档案')
|
||||
expect(w.text()).toContain('先建立你的个人档案')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
<template>
|
||||
<PageShell :sheet="false">
|
||||
<template #hero>
|
||||
<AskRails v-model="rail" />
|
||||
<div class="ask-hero">
|
||||
<div class="ask-top">
|
||||
<BackButton />
|
||||
<button
|
||||
v-if="messages.length"
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
:disabled="sending"
|
||||
@click="clearChat"
|
||||
>
|
||||
清空聊天
|
||||
</button>
|
||||
</div>
|
||||
<AskRails v-model="rail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AskAiPane
|
||||
@@ -24,6 +38,7 @@
|
||||
@navigate="router.push($event)"
|
||||
@update:draft="draft = $event"
|
||||
@send="send"
|
||||
@quota-refreshed="refreshQuota"
|
||||
/>
|
||||
|
||||
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
|
||||
@@ -34,6 +49,7 @@
|
||||
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
|
||||
import AskAiPane from '../components/ask/AskAiPane.vue'
|
||||
import AskRails from '../components/ask/AskRails.vue'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { useAskPage } from '../composables/useAskPage'
|
||||
|
||||
@@ -55,5 +71,37 @@ const {
|
||||
askAdvisor,
|
||||
send,
|
||||
boot,
|
||||
refreshQuota,
|
||||
clearChat,
|
||||
} = useAskPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ask-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.ask-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.ask-hero :deep(.back) {
|
||||
margin-bottom: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.clear-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9a8f8b;
|
||||
font-size: 13px;
|
||||
padding: 6px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clear-btn:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="head">
|
||||
<BackButton />
|
||||
<h1 class="title">探索</h1>
|
||||
<p class="sub">测评 · 工具 · 自我理解</p>
|
||||
</div>
|
||||
@@ -53,6 +54,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="hero">
|
||||
<BackButton />
|
||||
<div class="brand">
|
||||
<BrandLogo size="card" class="logo" />
|
||||
<p class="brand-name">愈心谷</p>
|
||||
</div>
|
||||
<h1 class="title">{{ mode === 'login' ? '欢迎回来' : '开启你的成长档案' }}</h1>
|
||||
<p class="sub">登录后,生日探索与对话会保存在你的账号里</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="panel reveal">
|
||||
<div class="modes" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
:class="{ on: mode === 'login' }"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'login'"
|
||||
@click="setMode('login')"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
:class="{ on: mode === 'register' }"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'register'"
|
||||
@click="setMode('register')"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="form" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span class="lbl">账号</span>
|
||||
<input
|
||||
v-model="phone"
|
||||
class="inp"
|
||||
type="text"
|
||||
maxlength="32"
|
||||
autocomplete="username"
|
||||
placeholder="手机号或任意账号"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="lbl">密码</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="inp"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="mode === 'register'" class="field">
|
||||
<span class="lbl">昵称 <em>可选</em></span>
|
||||
<input
|
||||
v-model="nickname"
|
||||
class="inp"
|
||||
type="text"
|
||||
maxlength="32"
|
||||
placeholder="怎么称呼你"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="err" role="alert">{{ error }}</p>
|
||||
|
||||
<button class="cta" type="submit" :disabled="busy || !canSubmit">
|
||||
<span v-if="busy" class="dot" aria-hidden="true" />
|
||||
{{ busy ? '正在进入…' : mode === 'login' ? '进入愈心谷' : '创建账号' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="foot">继续即表示你同意将档案与探索结果保存在本账号下</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BrandLogo from '../components/BrandLogo.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { setAuthToken } from '../lib/authSession'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const phone = ref('')
|
||||
const password = ref('')
|
||||
const nickname = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
const canSubmit = computed(() => phone.value.trim().length > 0)
|
||||
|
||||
function setMode(next: 'login' | 'register') {
|
||||
mode.value = next
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || busy.value) return
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const body = { phone: phone.value.trim(), password: password.value }
|
||||
const res =
|
||||
mode.value === 'login'
|
||||
? await api.authLogin(body)
|
||||
: await api.authRegister({ ...body, nickname: nickname.value.trim() || undefined })
|
||||
setAuthToken(res.token)
|
||||
const redirect = String(route.query.redirect || '/mine')
|
||||
await router.replace(redirect)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '失败,请重试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
padding: var(--spacing-2xs) 0 var(--spacing-sm);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
margin: var(--spacing-xs) 0 var(--spacing-md);
|
||||
}
|
||||
.logo {
|
||||
width: 72px !important;
|
||||
filter: drop-shadow(0 8px 18px rgba(229, 77, 66, 0.18));
|
||||
}
|
||||
.brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sub {
|
||||
margin: var(--spacing-xs) auto 0;
|
||||
max-width: 16em;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
.panel {
|
||||
margin-top: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: var(--shadow-hero);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 4px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
border-radius: var(--radius-pill);
|
||||
background: #fff4f0;
|
||||
}
|
||||
.mode {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: #9a8f8b;
|
||||
}
|
||||
.mode.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 10px rgba(229, 77, 66, 0.12);
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #8a807c;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.lbl em {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.inp {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1.5px solid #f0e6e2;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 13px 14px;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-input-bg);
|
||||
outline: none;
|
||||
transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease;
|
||||
}
|
||||
.inp:focus {
|
||||
border-color: #f0b8b0;
|
||||
box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.inp::placeholder {
|
||||
color: #c4bbb6;
|
||||
}
|
||||
.cta {
|
||||
margin-top: var(--spacing-xs);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 10px 22px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
animation: pulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.err {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.foot {
|
||||
margin: var(--spacing-md) 0 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,10 @@
|
||||
<p class="lead">开通后可查看画像与关系理解的完整分析,并获得更多 AI 问答次数。</p>
|
||||
</div>
|
||||
|
||||
<p class="sec-title">问答额度包</p>
|
||||
<AskQuotaBuy lead="免费次数不够用时,可单独加购问答次数(不含深度报告)" @purchased="load" />
|
||||
|
||||
<p class="sec-title">成长会员</p>
|
||||
<div class="plans">
|
||||
<button
|
||||
v-for="p in plans"
|
||||
@@ -53,6 +57,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { MembershipMe } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
@@ -148,6 +153,12 @@ onMounted(load)
|
||||
.ok-title { font-size: 16px; font-weight: 700; color: #222; }
|
||||
.meta { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.lead { font-size: 14px; color: #555; line-height: 1.6; }
|
||||
.sec-title {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: #6a615c;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
.plans { display: flex; flex-direction: column; gap: 10px; }
|
||||
.plan {
|
||||
position: relative;
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
<BrandLogo size="card" class="av-logo" />
|
||||
</span>
|
||||
<div class="who">
|
||||
<p class="name">愈心谷用户</p>
|
||||
<p class="name">{{ accountLabel }}</p>
|
||||
<p class="meta">档案 {{ profileCount }} 份</p>
|
||||
</div>
|
||||
<router-link class="edit" to="/profile">编辑 ›</router-link>
|
||||
<router-link v-if="!loggedIn" class="edit" to="/login">登录 ›</router-link>
|
||||
<button v-else type="button" class="edit linkish" @click="logout">退出</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,12 +71,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { MembershipMe } from '@yuxingu/types'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { AuthUser, MembershipMe } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import BrandLogo from '../components/BrandLogo.vue'
|
||||
import ListRow from '../components/ListRow.vue'
|
||||
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' },
|
||||
@@ -98,6 +103,10 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
const membership = ref<MembershipMe | null>(null)
|
||||
const profileCount = ref(0)
|
||||
const me = ref<AuthUser | null>(null)
|
||||
|
||||
const loggedIn = computed(() => !!me.value)
|
||||
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
|
||||
|
||||
const planLabel = computed(() => {
|
||||
const p = membership.value?.plan
|
||||
@@ -111,16 +120,31 @@ async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
me.value = await api.authMe()
|
||||
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
|
||||
membership.value = m
|
||||
profileCount.value = (profiles.items || []).length
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
me.value = null
|
||||
membership.value = null
|
||||
profileCount.value = 0
|
||||
error.value = e instanceof Error ? e.message : '请先登录'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.authLogout()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearAuthToken()
|
||||
me.value = null
|
||||
await router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -164,6 +188,12 @@ onMounted(load)
|
||||
color: #bbb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.assets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
||||
@@ -18,6 +18,7 @@ const router = createRouter({
|
||||
{ path: '/ask', name: 'ask', component: () => import('../pages/AskPage.vue') },
|
||||
{ path: '/companion', name: 'companion', component: () => import('../pages/CompanionPage.vue') },
|
||||
{ path: '/mine', name: 'mine', component: () => import('../pages/MinePage.vue') },
|
||||
{ path: '/login', name: 'login', component: () => import('../pages/LoginPage.vue') },
|
||||
{ path: '/profile', name: 'profile', component: () => import('../pages/ProfilePage.vue') },
|
||||
{ path: '/portrait', name: 'portrait', component: () => import('../pages/PortraitPage.vue') },
|
||||
{ path: '/star', name: 'star', component: () => import('../pages/StarProfilePage.vue') },
|
||||
|
||||
Reference in New Issue
Block a user