feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点

落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+57 -3
View File
@@ -92,6 +92,11 @@ export function createClient(opts: CreateClientOptions) {
authMe: () => call<AuthUser>('/api/v1/auth/me'),
authUpdateMe: (body: { nickname: string }) =>
call<AuthUser>('/api/v1/auth/me', { method: 'PATCH', body }),
authUploadAvatar: (file: Blob, filename = 'avatar.jpg') => {
const fd = new FormData()
fd.append('file', file, filename)
return call<AuthUser>('/api/v1/auth/me/avatar', { method: 'POST', body: fd })
},
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
createProfile: (body: CreateProfileBody) =>
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
@@ -142,7 +147,7 @@ export function createClient(opts: CreateClientOptions) {
listGrowthPlans: () => call<{ items: GrowthPlan[] }>('/api/v1/growth/plans'),
createGrowthPlan: (body: { title: string; focus?: string }) =>
call<GrowthPlan>('/api/v1/growth/plans', { method: 'POST', body }),
growthCheckin: (planId: string, body?: { note?: string }) =>
createGrowthCheckin: (planId: string, body?: { note?: string }) =>
call<{ id: string; day: string }>(`/api/v1/growth/plans/${planId}/checkin`, {
method: 'POST',
body: body || {},
@@ -162,7 +167,55 @@ export function createClient(opts: CreateClientOptions) {
body: { profile_a_id, profile_b_id },
}),
listScales: () => call<{ items: ScaleSummary[] }>('/api/v1/scales'),
getScaleBankCatalog: () =>
call<{
featured: {
slug: string
title: string
description: string
category_key: string
icon: string
path: string
}[]
categories: {
key: string
title: string
description: string
icon: string
count: number
featured: {
slug: string
title: string
description: string
category_key: string
icon: string
path: string
}[]
path: string
}[]
}>('/api/v1/scale-bank/catalog'),
getScaleBankCategory: (key: string) =>
call<{
category: {
key: string
title: string
description: string
icon: string
count: number
path: string
}
items: {
slug: string
title: string
description: string
icon?: string
question_count: number
path: string
}[]
}>(`/api/v1/scale-bank/categories/${key}`),
getScale: (slug: string) => call<ScaleDetail>(`/api/v1/scales/${slug}`),
getScaleResult: (slug: string) =>
call<ScaleSubmitResult>(`/api/v1/scales/${slug}/result`),
submitScale: (slug: string, profile_id: string, answers: Record<string, string>) =>
call<ScaleSubmitResult>(`/api/v1/scales/${slug}/result`, {
method: 'POST',
@@ -334,13 +387,14 @@ export function createBrowserAdapters(): ClientAdapters {
const deviceKeyStorage = 'yxg_device_key'
return {
request: async <T>({ method = 'GET', path, body, headers, keepalive }: RequestOptions) => {
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
const res = await fetch(path, {
method,
headers: {
'Content-Type': 'application/json',
...(isForm ? {} : { 'Content-Type': 'application/json' }),
...(headers || {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body),
keepalive: keepalive === true,
})
const text = await res.text()
+22 -6
View File
@@ -58,12 +58,15 @@ export interface AuthUser {
id: string
phone: string
nickname: string
avatar_url?: string
}
export interface AuthSession {
token: string
expires_at: string
user: AuthUser
/** true when OpenLogin created a new account (auto-register) */
is_new?: boolean
}
/** 探索测试列表项 — 对齐 Go ScaleListItem / listScales 响应 */
@@ -78,19 +81,28 @@ export interface ScaleQuestionOption {
text: string
}
export interface ScaleQuestion {
id: string
sort: number
body: { prompt: string; options: ScaleQuestionOption[] }
}
export interface ScaleDetail {
slug: string
title: string
description: string
access?: 'free' | 'membership' | string
locked?: boolean
questions: ScaleQuestion[]
}
export interface ScaleQuestion {
id: string
sort: number
body: {
prompt: string
format?: string
left?: string
right?: string
dimension?: string
options: ScaleQuestionOption[]
}
}
export interface ScaleSubmitResult {
id: string
result: Record<string, unknown>
@@ -197,6 +209,10 @@ export interface HomeDailyTips {
source: 'llm' | 'fallback' | string
gua_name?: string
day_part?: string
as_of?: string
shichen?: number
shichen_name?: string
valid_until?: string
need_birth?: boolean
}
+1 -1
View File
@@ -62,7 +62,7 @@
--shadow-sheet: 0 -4px 24px rgba(180, 100, 80, 0.06);
--shadow-nav: 0 -2px 8px rgba(0, 0, 0, 0.03);
--layout-max-user: 430px;
--layout-nav-h: 56px;
--layout-nav-h: 64px;
/* ── Motion ── */
--duration-fast: 150ms;
+7
View File
@@ -18,3 +18,10 @@ export function validateBirth(year: number, month: number, day: number): string
if (day < 1 || day > 31) return '日期无效'
return null
}
export {
normalizeUserText,
validateUserText,
type UserTextKind,
type ValidateUserTextResult,
} from './textsafe'
+121
View File
@@ -0,0 +1,121 @@
/** User free-text kinds (mirror apps/api/internal/textsafe). */
export type UserTextKind =
| 'nickname'
| 'display_name'
| 'ask_content'
| 'note'
| 'title'
| 'focus'
| 'scene'
const limits: Record<UserTextKind, { min: number; max: number; allowNL: boolean }> = {
nickname: { min: 1, max: 16, allowNL: false },
display_name: { min: 1, max: 24, allowNL: false },
ask_content: { min: 1, max: 2000, allowNL: true },
note: { min: 0, max: 200, allowNL: true },
title: { min: 1, max: 40, allowNL: false },
focus: { min: 0, max: 40, allowNL: false },
scene: { min: 1, max: 80, allowNL: false },
}
const banned = [
'占卜',
'算命',
'改命',
'测测',
'疗效',
'包治',
'根治',
'治疗疾病',
'治愈癌症',
'不测就有灾',
'大师算卦',
'神棍',
]
/** Normalize like backend textsafe.Normalize. */
export function normalizeUserText(raw: string, allowNL: boolean): string {
let s = raw.trim()
if (!allowNL) {
s = s.replace(/[\n\r\t]/g, ' ').replace(/\s+/g, ' ').trim()
}
return s
}
function rejectControls(s: string, allowNL: boolean): string | null {
for (const ch of s) {
const code = ch.codePointAt(0) ?? 0
if (code === 0) return '含非法字符'
if (ch === '\n' || ch === '\r' || ch === '\t') {
if (!allowNL) return '含非法空白'
continue
}
if (code < 0x20 || code === 0x7f) return '含非法字符'
}
return null
}
function rejectMarkup(s: string): string | null {
const low = s.toLowerCase()
if (low.includes('<script') || low.includes('javascript:')) return '含不安全内容'
if (low.includes('onerror=') || low.includes('onload=')) return '含不安全内容'
for (let i = 0; i < s.length; i++) {
if (s[i] !== '<') continue
const c = s[i + 1]
if (!c) continue
if (c === '/' || c === '!' || /[a-zA-Z]/.test(c)) return '含不安全内容'
}
return null
}
function rejectBanned(s: string): string | null {
for (const b of banned) {
if (b && s.includes(b)) return '含不允许的表述,请换一种说法'
}
return null
}
function rejectSpam(s: string): string | null {
const runes = [...s]
if (runes.length >= 6) {
if (runes.every((r) => r === runes[0]) && runes[0]?.trim()) return '请勿重复刷屏'
}
let streak = 1
for (let i = 1; i < runes.length; i++) {
if (runes[i] === runes[i - 1] && runes[i]?.trim()) {
streak++
if (streak >= 8) return '请勿重复刷屏'
} else {
streak = 1
}
}
return null
}
export type ValidateUserTextResult =
| { ok: true; value: string }
| { ok: false; message: string }
/**
* Frontend mirror of API textsafe.Check.
* Backend remains authoritative (HTTP 400 / code 40060).
*/
export function validateUserText(kind: UserTextKind, raw: string): ValidateUserTextResult {
const lim = limits[kind]
const s = normalizeUserText(raw, lim.allowNL)
const n = [...s].length
if (n < lim.min) {
if (lim.min === 0) return { ok: true, value: '' }
return { ok: false, message: '文案不合规:请填写内容' }
}
if (n > lim.max) return { ok: false, message: '文案不合规:内容过长' }
const ctrl = rejectControls(s, lim.allowNL)
if (ctrl) return { ok: false, message: `文案不合规:${ctrl}` }
const markup = rejectMarkup(s)
if (markup) return { ok: false, message: `文案不合规:${markup}` }
const ban = rejectBanned(s)
if (ban) return { ok: false, message: `文案不合规:${ban}` }
const spam = rejectSpam(s)
if (spam) return { ok: false, message: `文案不合规:${spam}` }
return { ok: true, value: s }
}