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
+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 }
}