feat(profile): split username and display nickname
This commit is contained in:
@@ -44,12 +44,12 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
|
||||
| Method | Path | Auth | Source | Request | Response |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/auth/login` | Public | `src/app/api/auth/login/route.ts` | `account` or `email` or `phone`, `password`, optional `adminOnly` | User profile data and session token. |
|
||||
| POST | `/api/auth/register` | Public | `src/app/api/auth/register/route.ts` | `email`, `password`, `nickname`, `phone`, `inviteCode`, `emailCode`, `acceptedTerms` | Created profile/session flow. |
|
||||
| POST | `/api/auth/register` | Public | `src/app/api/auth/register/route.ts` | `email`, `password`, `nickname` as login username, `phone`, `inviteCode`, `emailCode`, `acceptedTerms` | Created profile/session flow. New normal users receive a random Chinese display nickname and default 3D cartoon avatar. |
|
||||
| GET | `/api/auth/admin-exists` | Public | `src/app/api/auth/admin-exists/route.ts` | None | Whether an admin profile exists. |
|
||||
| POST | `/api/auth/test-api` | Public/auth context depends caller | `src/app/api/auth/test-api/route.ts` | Provider/API config | Tests upstream API. |
|
||||
| POST | `/api/auth/fetch-models` | Public/auth context depends caller | `src/app/api/auth/fetch-models/route.ts` | Endpoint/API key | Fetch model list from provider. |
|
||||
| GET | `/api/profile` | User | `src/app/api/profile/route.ts` | None | `{ profile }`. |
|
||||
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `nickname`, `phone`, `avatarUrl`, password fields | Updated profile. |
|
||||
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `username`, `displayNickname`/`nickname`, `phone`, `avatarUrl`, password fields | Updated profile. `username` remains usable for login; display nickname is returned as `nickname` for UI and gallery display. |
|
||||
| PUT | `/api/profile/theme` | User | `src/app/api/profile/theme/route.ts` | `theme` | `{ success, preferred_theme }`. |
|
||||
| GET | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | None | `{ keys }`, with previews only. |
|
||||
| POST | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Single key or `{ keys: [...] }`; fields `provider`, `supplierName`, `apiUrl`, `modelName`, `apiKey`, `type`, `isActive` | Saved keys. |
|
||||
@@ -154,3 +154,5 @@ Primary SQL tables touched directly in API routes include:
|
||||
- `platform_logs`
|
||||
|
||||
`src/storage/database/shared/schema.ts` contains a Drizzle snapshot of core tables, while several runtime APIs add compatibility columns/tables with `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
|
||||
|
||||
Profile naming convention: `profiles.nickname` is the stable login username; `profiles.display_nickname` is the public nickname shown in navbar/gallery/profile UI. APIs return `username` plus `nickname`/`display_nickname` so older clients can keep reading `nickname` as the display name.
|
||||
|
||||
@@ -189,6 +189,8 @@ Core data areas:
|
||||
|
||||
Because several routes self-migrate compatibility columns, DB bugs often require checking both SQL scripts and route-level `ensure...Schema` functions.
|
||||
|
||||
User display identity: `profiles.nickname` is retained as the login username so existing username/phone/email login and `works.user_id` ownership remain stable. Public display uses `profiles.display_nickname`, surfaced to clients as `nickname`; `src/lib/user-profile-defaults.ts` owns runtime schema creation plus random Chinese nickname/default 3D cartoon avatar generation. Existing users without `display_nickname` or `avatar_url` can be backfilled with `scripts/backfill-user-display-profile.mjs`.
|
||||
|
||||
## Admin Console Architecture
|
||||
|
||||
Admin console UI is split across:
|
||||
|
||||
@@ -20,6 +20,8 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Admin console opens without login or redirects incorrectly | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx`, `src/app/api/auth/login/route.ts` | `adminOnly` behavior, admin role check, route redirect logic. |
|
||||
| Registration fails or registration verification email arrives blank | `src/app/auth/register/page.tsx`, `src/app/api/auth/register/route.ts`, `src/app/api/email/send-register-code/route.ts`, `src/lib/email-service.ts` | `acceptedTerms`, email code, password strength, invite code, duplicate profile, SMTP settings, send logs, and MIME body encoding. Verification emails use HTML plus plain-text multipart content; base64 bodies must be folded to 76-character lines and multipart blank separators must be preserved. Do not use `.filter(Boolean)` on MIME message arrays because it removes required empty separator lines and can make mailbox clients render a blank email despite SMTP accepting it. |
|
||||
| Profile changes disappear after refresh | `src/app/profile/page.tsx`, `src/app/api/profile/route.ts`, `src/lib/auth-store.ts` | PUT writes both `profiles` and `auth.users` where needed; client refreshes returned profile. |
|
||||
| Navbar or gallery shows login username instead of public nickname | `src/lib/auth-store.ts`, `src/app/api/profile/route.ts`, `src/app/api/gallery/route.ts`, `src/lib/user-profile-defaults.ts` | `profiles.nickname` is login username; public UI should use returned `nickname` from `profiles.display_nickname`. Gallery SQL should select `display_nickname` first. |
|
||||
| Existing users have blank/default avatar after display-profile migration | `src/lib/user-profile-defaults.ts`, `scripts/backfill-user-display-profile.mjs`, `src/app/api/auth/login/route.ts` | Run the backfill script with `LOCAL_DB_URL`; login also lazily fills missing `avatar_url` with a generated SVG data URL. |
|
||||
| Theme does not persist | `src/components/account-theme-sync.tsx`, `src/app/api/profile/theme/route.ts`, `src/lib/profile-preferences.ts` | `preferred_theme` schema, token auth, theme normalization. |
|
||||
|
||||
## Site Config, Footer, Policies, Announcements
|
||||
|
||||
@@ -38,10 +38,10 @@ Use this document to jump directly to code before broad searching.
|
||||
| --- | --- | --- |
|
||||
| Login UI | `src/app/auth/login/page.tsx` | Calls `/api/auth/login`. |
|
||||
| Register UI | `src/app/auth/register/page.tsx`, `src/components/auth/registration-agreement-dialog.tsx` | Requires accepted terms and email code except admin invite path. |
|
||||
| Auth store | `src/lib/auth-store.ts` | Client auth state and token persistence. |
|
||||
| Auth store | `src/lib/auth-store.ts` | Client auth state and token persistence. `AuthUser.username` is the login username from `profiles.nickname`; `AuthUser.nickname` is the public display nickname from `profiles.display_nickname`. |
|
||||
| Session tokens | `src/lib/session-auth.ts` | HMAC token format, bearer parsing, admin checks. |
|
||||
| Login API | `src/app/api/auth/login/route.ts` | Handles admin fallback and normal users. |
|
||||
| Register API | `src/app/api/auth/register/route.ts` | Creates `auth.users`, `profiles`, initial credits. |
|
||||
| Register API | `src/app/api/auth/register/route.ts` | Creates `auth.users`, `profiles`, initial credits, random Chinese display nickname, and default 3D cartoon avatar. The submitted register `nickname` is treated as login username for compatibility. |
|
||||
| Admin exists | `src/app/api/auth/admin-exists/route.ts` | Admin setup checks. |
|
||||
| API test/model fetch | `src/app/api/auth/test-api/route.ts`, `src/app/api/auth/fetch-models/route.ts` | Used by provider/API configuration UI. |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts` | Profile edits, password/email/theme. |
|
||||
@@ -94,7 +94,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Feature | Files |
|
||||
| --- | --- |
|
||||
| Profile page | `src/app/profile/page.tsx` |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts` |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts`, `src/lib/user-profile-defaults.ts` |
|
||||
| Creation history tab | `src/components/profile/creation-history-tab.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts` |
|
||||
| Credits tab/store | `src/components/profile/credits-tab.tsx`, `src/lib/credit-records-store.ts` |
|
||||
| Orders tab/store | `src/components/profile/orders-tab.tsx`, `src/lib/order-store.ts`, `src/app/api/admin/orders/route.ts` |
|
||||
@@ -105,7 +105,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies media into gallery folders and inserts public work. |
|
||||
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works and published state. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
@@ -172,3 +172,4 @@ Use this document to jump directly to code before broad searching.
|
||||
| Admin upgrade API/UI | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
|
||||
| Admin upgrade runner | `scripts/admin-upgrade-runner.mjs`. Use this when deciding whether a change can be packaged as a hot update or must be a cold update. Source/API/server/dependency/schema/env/runtime/script changes should be treated as cold-update candidates; static/public asset-only packages are hot-update candidates only if runner preflight accepts them without restart. |
|
||||
| Boundary checks | `scripts/check-boundaries.sh` |
|
||||
| User display profile backfill | `scripts/backfill-user-display-profile.mjs` |
|
||||
|
||||
116
scripts/backfill-user-display-profile.mjs
Normal file
116
scripts/backfill-user-display-profile.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
import pg from 'pg';
|
||||
|
||||
const { Client } = pg;
|
||||
|
||||
const adjectives = ['云朵', '星河', '松风', '月白', '晴川', '青柚', '琥珀', '小满', '竹影', '橘光', '海盐', '霁蓝'];
|
||||
const nouns = ['画师', '旅人', '造梦家', '观察员', '收藏家', '调色师', '冒险家', '灵感师', '策展人', '星愿者', '小导演', '光影客'];
|
||||
const kinds = ['person', 'cat', 'bear', 'bunny', 'fox'];
|
||||
const palettes = [
|
||||
['#7dd3fc', '#c084fc', '#fdf2f8', '#0f172a'],
|
||||
['#fbbf24', '#fb7185', '#fff7ed', '#3b1d0f'],
|
||||
['#86efac', '#38bdf8', '#f0fdf4', '#052e2b'],
|
||||
['#f9a8d4', '#a78bfa', '#fdf4ff', '#312e81'],
|
||||
['#fdba74', '#60a5fa', '#eff6ff', '#1e3a8a'],
|
||||
];
|
||||
|
||||
function hashString(value) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function pick(items, seed, offset = 0) {
|
||||
return items[(seed + offset) % items.length];
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return value.replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[char] || char);
|
||||
}
|
||||
|
||||
function generateChineseNickname(seedValue) {
|
||||
const seed = hashString(seedValue);
|
||||
return `${pick(adjectives, seed)}${pick(nouns, seed >>> 5)}${String(seed % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function generateDefaultAvatarDataUrl(seedValue, labelValue) {
|
||||
const seed = hashString(seedValue);
|
||||
const [primary, secondary, surface, ink] = pick(palettes, seed);
|
||||
const kind = pick(kinds, seed >>> 3);
|
||||
const label = escapeXml(String(labelValue || '').trim().slice(0, 1) || '妙');
|
||||
const blush = seed % 2 === 0 ? '#fb7185' : '#f472b6';
|
||||
const earLeft = kind === 'cat'
|
||||
? '<path d="M76 92 L110 44 L126 108 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
|
||||
: kind === 'bunny'
|
||||
? '<ellipse cx="105" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(-16 105 54)"/>'
|
||||
: kind === 'bear' || kind === 'fox'
|
||||
? '<circle cx="103" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
|
||||
: '';
|
||||
const earRight = kind === 'cat'
|
||||
? '<path d="M180 108 L196 44 L232 92 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
|
||||
: kind === 'bunny'
|
||||
? '<ellipse cx="205" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(16 205 54)"/>'
|
||||
: kind === 'bear' || kind === 'fox'
|
||||
? '<circle cx="213" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
|
||||
: '';
|
||||
const nose = kind === 'person'
|
||||
? `<path d="M160 147 c-7 10 -1 18 10 16" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round" opacity=".44"/>`
|
||||
: `<path d="M151 151 q9 -8 18 0 q-9 11 -18 0Z" fill="${ink}" opacity=".72"/>`;
|
||||
const hair = kind === 'person'
|
||||
? `<path d="M90 133 c16 -58 58 -83 112 -57 c29 14 40 44 35 70 c-23 -20 -42 -17 -66 -36 c-26 26 -52 28 -81 23Z" fill="${secondary}" opacity=".92"/>`
|
||||
: '';
|
||||
const muzzle = kind === 'person' ? '' : '<ellipse cx="160" cy="165" rx="39" ry="26" fill="rgba(255,255,255,.54)"/>';
|
||||
const whiskers = kind === 'cat' || kind === 'fox'
|
||||
? `<path d="M101 155 h36 M101 174 h36 M183 155 h36 M183 174 h36" stroke="${ink}" stroke-width="4" stroke-linecap="round" opacity=".38"/>`
|
||||
: '';
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><defs><radialGradient id="bg" cx="34%" cy="25%" r="78%"><stop offset="0%" stop-color="${surface}"/><stop offset="48%" stop-color="${primary}"/><stop offset="100%" stop-color="${secondary}"/></radialGradient><linearGradient id="face" x1="72" y1="64" x2="236" y2="246" gradientUnits="userSpaceOnUse"><stop stop-color="#fff8f0"/><stop offset=".58" stop-color="#ffd7b5"/><stop offset="1" stop-color="#f8a978"/></linearGradient><filter id="soft" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#111827" flood-opacity=".22"/></filter></defs><rect width="320" height="320" rx="80" fill="url(#bg)"/><circle cx="254" cy="58" r="34" fill="rgba(255,255,255,.34)"/><circle cx="68" cy="250" r="44" fill="rgba(255,255,255,.20)"/><g filter="url(#soft)">${earLeft}${earRight}<circle cx="160" cy="153" r="83" fill="url(#face)" stroke="rgba(255,255,255,.68)" stroke-width="6"/>${hair}<circle cx="128" cy="144" r="9" fill="${ink}"/><circle cx="192" cy="144" r="9" fill="${ink}"/><circle cx="125" cy="142" r="3" fill="#fff"/><circle cx="189" cy="142" r="3" fill="#fff"/>${muzzle}${nose}${whiskers}<path d="M137 184 q23 18 46 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round" opacity=".62"/><circle cx="105" cy="169" r="13" fill="${blush}" opacity=".30"/><circle cx="215" cy="169" r="13" fill="${blush}" opacity=".30"/></g><g transform="translate(218 222)"><circle cx="34" cy="34" r="30" fill="rgba(255,255,255,.78)" stroke="rgba(255,255,255,.86)" stroke-width="3"/><text x="34" y="45" text-anchor="middle" font-size="30" font-weight="800" font-family="Arial, sans-serif" fill="${ink}">${label}</text></g></svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const connectionString = process.env.LOCAL_DB_URL || process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error('LOCAL_DB_URL or DATABASE_URL is required');
|
||||
|
||||
const client = new Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query('ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128)');
|
||||
const result = await client.query(`
|
||||
SELECT id, email, nickname, display_nickname, avatar_url
|
||||
FROM profiles
|
||||
WHERE display_nickname IS NULL OR display_nickname = ''
|
||||
OR avatar_url IS NULL OR avatar_url = ''
|
||||
ORDER BY created_at ASC
|
||||
`);
|
||||
|
||||
let nicknameCount = 0;
|
||||
let avatarCount = 0;
|
||||
for (const row of result.rows) {
|
||||
const displayNickname = row.display_nickname || row.nickname || generateChineseNickname(`${row.id}:${row.email}`);
|
||||
const avatarUrl = row.avatar_url || generateDefaultAvatarDataUrl(`${row.id}:${row.email}`, displayNickname);
|
||||
if (!row.display_nickname) nicknameCount += 1;
|
||||
if (!row.avatar_url) avatarCount += 1;
|
||||
await client.query(
|
||||
`UPDATE profiles
|
||||
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), $2),
|
||||
avatar_url = COALESCE(NULLIF(avatar_url, ''), $3),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[row.id, displayNickname, avatarUrl],
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify({ scanned: result.rowCount, displayNicknamesBackfilled: nicknameCount, avatarsBackfilled: avatarCount }));
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -785,12 +785,17 @@ BEGIN
|
||||
END $$;
|
||||
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark';
|
||||
|
||||
UPDATE profiles
|
||||
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
|
||||
WHERE display_nickname IS NULL OR display_nickname = '';
|
||||
|
||||
UPDATE profiles
|
||||
SET preferred_theme = 'dark'
|
||||
WHERE preferred_theme IS NULL
|
||||
@@ -1009,17 +1014,18 @@ BEGIN
|
||||
SELECT id INTO v_admin_id FROM auth.users WHERE lower(email) = lower(r.email) LIMIT 1;
|
||||
|
||||
INSERT INTO profiles (
|
||||
id, email, nickname, role, membership_tier, credits_balance,
|
||||
id, email, nickname, display_nickname, role, membership_tier, credits_balance,
|
||||
daily_quota_limit, daily_quota_used, is_active,
|
||||
email_verified, email_verified_at, email_bound_at, email_sender_domain
|
||||
)
|
||||
VALUES (
|
||||
v_admin_id, r.email, r.account, 'admin', 'enterprise',
|
||||
v_admin_id, r.email, r.account, r.account, 'admin', 'enterprise',
|
||||
9999, 999, 0, true, true, NOW(), NOW(), split_part(r.email, '@', 2)
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
nickname = EXCLUDED.nickname,
|
||||
display_nickname = COALESCE(NULLIF(profiles.display_nickname, ''), EXCLUDED.display_nickname),
|
||||
role = 'admin',
|
||||
membership_tier = 'enterprise',
|
||||
credits_balance = GREATEST(profiles.credits_balance, 9999),
|
||||
|
||||
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
nickname VARCHAR(128),
|
||||
display_nickname VARCHAR(128),
|
||||
avatar_url TEXT,
|
||||
phone VARCHAR(20),
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'user', -- guest, user, vip, enterprise_admin, enterprise_member, admin
|
||||
@@ -375,12 +376,17 @@ WHERE NOT EXISTS (
|
||||
-- 兼容旧版本库结构的幂等补丁
|
||||
-- ============================================================
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark';
|
||||
|
||||
UPDATE profiles
|
||||
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
|
||||
WHERE display_nickname IS NULL OR display_nickname = '';
|
||||
|
||||
UPDATE profiles
|
||||
SET preferred_theme = 'dark'
|
||||
WHERE preferred_theme IS NULL
|
||||
@@ -625,11 +631,13 @@ CREATE TRIGGER announcements_updated_at BEFORE UPDATE ON announcements FOR EACH
|
||||
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit)
|
||||
INSERT INTO profiles (id, email, nickname, display_nickname, avatar_url, role, membership_tier, credits_balance, daily_quota_limit)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.email,
|
||||
COALESCE(NEW.raw_user_meta_data->>'nickname', split_part(NEW.email, '@', 1)),
|
||||
COALESCE(NEW.raw_user_meta_data->>'display_nickname', NEW.raw_user_meta_data->>'nickname', split_part(NEW.email, '@', 1)),
|
||||
NEW.raw_user_meta_data->>'avatar_url',
|
||||
'user',
|
||||
'free',
|
||||
10, -- 新用户赠送 10 积分
|
||||
|
||||
@@ -51,7 +51,7 @@ const UUID_ID_TABLES = new Set([
|
||||
]);
|
||||
|
||||
const TABLE_COLUMNS: Record<string, string[]> = {
|
||||
profiles: ['id', 'email', 'nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'created_at', 'updated_at'],
|
||||
profiles: ['id', 'email', 'nickname', 'display_nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'created_at', 'updated_at'],
|
||||
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'updated_at'],
|
||||
site_stats: ['id', 'total_visits', 'total_users', 'total_generations', 'updated_at'],
|
||||
announcements: ['id', 'title', 'content', 'type', 'is_active', 'starts_at', 'expires_at', 'created_at', 'updated_at'],
|
||||
@@ -543,6 +543,7 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
if (table === 'profiles') {
|
||||
if (has('email')) assignments.push(`email = COALESCE(NULLIF(target.email, ''), EXCLUDED.email)`);
|
||||
if (has('nickname')) assignments.push(`nickname = COALESCE(NULLIF(target.nickname, ''), EXCLUDED.nickname)`);
|
||||
if (has('display_nickname')) assignments.push(`display_nickname = COALESCE(NULLIF(target.display_nickname, ''), EXCLUDED.display_nickname)`);
|
||||
if (has('avatar_url')) assignments.push(`avatar_url = COALESCE(NULLIF(target.avatar_url, ''), EXCLUDED.avatar_url)`);
|
||||
if (has('phone')) assignments.push(`phone = COALESCE(NULLIF(target.phone, ''), EXCLUDED.phone)`);
|
||||
if (has('role')) assignments.push(`role = CASE WHEN target.role = 'admin' THEN target.role ELSE COALESCE(NULLIF(target.role, ''), EXCLUDED.role) END`);
|
||||
|
||||
@@ -11,7 +11,9 @@ function mapRecipient(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
email,
|
||||
nickname: typeof row.nickname === 'string' && row.nickname.trim() ? row.nickname.trim() : email.split('@')[0],
|
||||
nickname: typeof row.display_nickname === 'string' && row.display_nickname.trim()
|
||||
? row.display_nickname.trim()
|
||||
: typeof row.nickname === 'string' && row.nickname.trim() ? row.nickname.trim() : email.split('@')[0],
|
||||
phone: typeof row.phone === 'string' ? row.phone : null,
|
||||
avatarUrl: typeof row.avatar_url === 'string' ? row.avatar_url : null,
|
||||
emailVerified: row.email_verified === true,
|
||||
@@ -40,6 +42,7 @@ export async function GET(request: NextRequest) {
|
||||
filter += `
|
||||
AND (
|
||||
LOWER(email) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(display_nickname, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(nickname, '')) LIKE $${params.length}
|
||||
OR COALESCE(phone, '') LIKE $${params.length}
|
||||
)
|
||||
@@ -47,7 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`SELECT id, email, nickname, phone, avatar_url, email_verified
|
||||
`SELECT id, email, nickname, display_nickname, phone, avatar_url, email_verified
|
||||
FROM profiles
|
||||
${filter}
|
||||
ORDER BY created_at DESC
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
whereClauses.push(`(
|
||||
j.user_id::text LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.email, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.display_nickname, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $${params.length}
|
||||
)`);
|
||||
}
|
||||
@@ -57,7 +58,7 @@ export async function GET(request: NextRequest) {
|
||||
params,
|
||||
);
|
||||
const rowsResult = await client.query(
|
||||
`SELECT j.id, j.user_id, p.email AS user_email, p.nickname AS user_nickname,
|
||||
`SELECT j.id, j.user_id, p.email AS user_email, COALESCE(NULLIF(p.display_nickname, ''), p.nickname) AS user_nickname,
|
||||
j.type, j.status, j.error, j.created_at, j.started_at, j.finished_at, j.updated_at
|
||||
FROM generation_jobs j
|
||||
LEFT JOIN profiles p ON p.id = j.user_id
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createSessionToken } from '@/lib/session-auth';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { ensureProfilePreferenceSchema, normalizePreferredTheme } from '@/lib/profile-preferences';
|
||||
import { ensureUserDisplayProfileSchema, generateChineseNickname, generateDefaultAvatarDataUrl } from '@/lib/user-profile-defaults';
|
||||
|
||||
function normalizeRoleForTier(role: string | null | undefined, tier: string | null | undefined): string {
|
||||
const currentRole = role || 'user';
|
||||
@@ -35,9 +36,11 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
let loginEmail = identifier;
|
||||
let userId = '';
|
||||
let userRole = 'user';
|
||||
let username = '';
|
||||
let userNickname = '';
|
||||
let userMembershipTier = 'free';
|
||||
let userCreditsBalance = 0;
|
||||
@@ -56,38 +59,41 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!isEmailFormat) {
|
||||
const adminLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE (nickname = $1 OR phone = $1) AND role = 'admin' LIMIT 1",
|
||||
"SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname, role FROM profiles WHERE (nickname = $1 OR phone = $1) AND role = 'admin' LIMIT 1",
|
||||
[identifier]
|
||||
);
|
||||
if (adminLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = adminLookup.rows[0].id;
|
||||
loginEmail = adminLookup.rows[0].email;
|
||||
userNickname = adminLookup.rows[0].nickname || '';
|
||||
username = adminLookup.rows[0].nickname || '';
|
||||
userNickname = adminLookup.rows[0].display_nickname || username;
|
||||
} else {
|
||||
const nicknameLower = String(identifier).toLowerCase();
|
||||
if (nicknameLower === 'admin' || nicknameLower.startsWith('admin')) {
|
||||
const anyLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||
"SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname, role FROM profiles WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||
);
|
||||
if (anyLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = anyLookup.rows[0].id;
|
||||
loginEmail = anyLookup.rows[0].email;
|
||||
userNickname = anyLookup.rows[0].nickname || '';
|
||||
username = anyLookup.rows[0].nickname || '';
|
||||
userNickname = anyLookup.rows[0].display_nickname || username;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const adminLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE email = $1 AND role = 'admin' LIMIT 1",
|
||||
"SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname, role FROM profiles WHERE email = $1 AND role = 'admin' LIMIT 1",
|
||||
[identifier]
|
||||
);
|
||||
if (adminLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = adminLookup.rows[0].id;
|
||||
loginEmail = identifier;
|
||||
userNickname = adminLookup.rows[0].nickname || '';
|
||||
username = adminLookup.rows[0].nickname || '';
|
||||
userNickname = adminLookup.rows[0].display_nickname || username;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +116,8 @@ export async function POST(request: NextRequest) {
|
||||
userMembershipTier = 'enterprise';
|
||||
userCreditsBalance = 9999;
|
||||
userDailyQuotaLimit = 999;
|
||||
userNickname = userNickname || '管理员';
|
||||
username = username || 'admin';
|
||||
userNickname = userNickname || username || '管理员';
|
||||
userEmailVerified = true;
|
||||
userEmailVerifiedAt = new Date().toISOString();
|
||||
|
||||
@@ -134,19 +141,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit, daily_quota_used, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`INSERT INTO profiles (id, email, nickname, display_nickname, role, membership_tier, credits_balance, daily_quota_limit, daily_quota_used, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = $4,
|
||||
membership_tier = $5,
|
||||
credits_balance = $6,
|
||||
daily_quota_limit = $7,
|
||||
role = $5,
|
||||
membership_tier = $6,
|
||||
credits_balance = $7,
|
||||
daily_quota_limit = $8,
|
||||
nickname = $3,
|
||||
display_nickname = COALESCE(NULLIF(profiles.display_nickname, ''), $4),
|
||||
is_active = true,
|
||||
email_verified = true,
|
||||
email_verified_at = COALESCE(profiles.email_verified_at, NOW()),
|
||||
email_bound_at = COALESCE(profiles.email_bound_at, NOW())`,
|
||||
[userId, loginEmail, userNickname, 'admin', 'enterprise', 9999, 999, 0, true]
|
||||
[userId, loginEmail, username, userNickname, 'admin', 'enterprise', 9999, 999, 0, true]
|
||||
);
|
||||
|
||||
const adminThemeResult = await client.query(
|
||||
@@ -164,7 +172,7 @@ export async function POST(request: NextRequest) {
|
||||
} else {
|
||||
if (!isEmailFormat) {
|
||||
const profileResult = await client.query(
|
||||
'SELECT id, email, nickname, phone, role FROM profiles WHERE nickname = $1 OR phone = $1 LIMIT 1',
|
||||
'SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, \'\'), nickname) AS display_nickname, phone, role FROM profiles WHERE nickname = $1 OR phone = $1 LIMIT 1',
|
||||
[identifier]
|
||||
);
|
||||
|
||||
@@ -173,7 +181,8 @@ export async function POST(request: NextRequest) {
|
||||
loginEmail = profile.email;
|
||||
userId = profile.id;
|
||||
userRole = profile.role || 'user';
|
||||
userNickname = profile.nickname;
|
||||
username = profile.nickname || '';
|
||||
userNickname = profile.display_nickname || profile.nickname;
|
||||
userPhone = profile.phone;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Account does not exist' }, { status: 401 });
|
||||
@@ -203,19 +212,24 @@ export async function POST(request: NextRequest) {
|
||||
userCreatedAt = authUser.created_at;
|
||||
|
||||
const profileResult = await client.query(
|
||||
'SELECT nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, phone, email_verified, email_verified_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
'SELECT nickname, COALESCE(NULLIF(display_nickname, \'\'), nickname) AS display_nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, phone, email_verified, email_verified_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (profileResult.rows.length > 0) {
|
||||
const profile = profileResult.rows[0];
|
||||
userNickname = profile.nickname || loginEmail.split('@')[0];
|
||||
username = profile.nickname || loginEmail.split('@')[0];
|
||||
userNickname = profile.display_nickname || username;
|
||||
userMembershipTier = profile.membership_tier || 'free';
|
||||
userRole = normalizeRoleForTier(profile.role, userMembershipTier);
|
||||
userCreditsBalance = profile.credits_balance || 0;
|
||||
userDailyQuotaUsed = profile.daily_quota_used || 0;
|
||||
userDailyQuotaLimit = profile.daily_quota_limit || 5;
|
||||
userAvatarUrl = profile.avatar_url || null;
|
||||
if (!userAvatarUrl) {
|
||||
userAvatarUrl = generateDefaultAvatarDataUrl(`${userId}:${loginEmail}`, userNickname);
|
||||
await client.query('UPDATE profiles SET avatar_url = $1, updated_at = NOW() WHERE id = $2', [userAvatarUrl, userId]);
|
||||
}
|
||||
userPhone = profile.phone || null;
|
||||
userEmailVerified = profile.email_verified === true;
|
||||
userEmailVerifiedAt = profile.email_verified_at || null;
|
||||
@@ -224,12 +238,14 @@ export async function POST(request: NextRequest) {
|
||||
await client.query('UPDATE profiles SET role = $1, updated_at = NOW() WHERE id = $2', [userRole, userId]);
|
||||
}
|
||||
} else {
|
||||
userNickname = loginEmail.split('@')[0];
|
||||
username = loginEmail.split('@')[0];
|
||||
userNickname = generateChineseNickname(`${userId}:${loginEmail}`);
|
||||
userAvatarUrl = generateDefaultAvatarDataUrl(`${userId}:${loginEmail}`, userNickname);
|
||||
await client.query(
|
||||
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET email = $2, nickname = $3, email_verified = false, email_verified_at = NULL`,
|
||||
[userId, loginEmail, userNickname, userRole, userMembershipTier, userCreditsBalance, userDailyQuotaUsed, userDailyQuotaLimit]
|
||||
`INSERT INTO profiles (id, email, nickname, display_nickname, avatar_url, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET email = $2, nickname = $3, display_nickname = COALESCE(NULLIF(profiles.display_nickname, ''), $4), avatar_url = COALESCE(NULLIF(profiles.avatar_url, ''), $5), email_verified = false, email_verified_at = NULL`,
|
||||
[userId, loginEmail, username, userNickname, userAvatarUrl, userRole, userMembershipTier, userCreditsBalance, userDailyQuotaUsed, userDailyQuotaLimit]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -264,7 +280,9 @@ export async function POST(request: NextRequest) {
|
||||
user: {
|
||||
id: userId,
|
||||
email: loginEmail,
|
||||
username,
|
||||
nickname: userNickname,
|
||||
display_nickname: userNickname,
|
||||
role: userRole,
|
||||
membership_tier: userMembershipTier,
|
||||
credits_balance: userCreditsBalance,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureEmailSchema, getRequestBaseUrl, normalizeEmail, sendTemplatedEmail, verifyEmailCode } from '@/lib/email-service';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
import { ensureUserDisplayProfileSchema, generateChineseNickname, generateDefaultAvatarDataUrl, normalizeUsername } from '@/lib/user-profile-defaults';
|
||||
import { createSessionToken } from '@/lib/session-auth';
|
||||
|
||||
function isStrongPassword(password: string): boolean {
|
||||
return password.length >= 8 && /[A-Za-z]/.test(password) && /\d/.test(password);
|
||||
@@ -32,6 +34,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
if (isAdminRegistration) {
|
||||
const existingAdminResult = await client.query(
|
||||
'SELECT id FROM profiles WHERE role = $1',
|
||||
@@ -59,6 +62,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const username = normalizeUsername(nickname, normalizedEmail.split('@')[0]);
|
||||
|
||||
const existingUsernameResult = await client.query(
|
||||
'SELECT id FROM profiles WHERE LOWER(nickname) = LOWER($1) LIMIT 1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (existingUsernameResult.rows.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username is already registered' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdminRegistration) {
|
||||
if (typeof emailCode !== 'string' || !/^[a-z0-9]{4,10}$/i.test(emailCode)) {
|
||||
@@ -88,18 +104,21 @@ export async function POST(request: NextRequest) {
|
||||
const membershipTier = isAdminRegistration ? 'enterprise' : 'free';
|
||||
const creditsBalance = isAdminRegistration ? 9999 : 10;
|
||||
const dailyQuotaLimit = isAdminRegistration ? 999 : 5;
|
||||
const displayName = nickname || normalizedEmail.split('@')[0];
|
||||
const displayNickname = isAdminRegistration ? username : generateChineseNickname(`${userId}:${normalizedEmail}`);
|
||||
const avatarUrl = generateDefaultAvatarDataUrl(`${userId}:${normalizedEmail}`, displayNickname);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (
|
||||
id, email, nickname, phone, role, membership_tier, credits_balance,
|
||||
id, email, nickname, display_nickname, avatar_url, phone, role, membership_tier, credits_balance,
|
||||
daily_quota_limit, daily_quota_used, is_active, email_verified,
|
||||
email_verified_at, email_bound_at, email_sender_domain
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CASE WHEN $11 THEN NOW() ELSE NULL END, CASE WHEN $11 THEN NOW() ELSE NULL END, $12)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CASE WHEN $13 THEN NOW() ELSE NULL END, CASE WHEN $13 THEN NOW() ELSE NULL END, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
nickname = EXCLUDED.nickname,
|
||||
display_nickname = EXCLUDED.display_nickname,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
phone = EXCLUDED.phone,
|
||||
role = EXCLUDED.role,
|
||||
membership_tier = EXCLUDED.membership_tier,
|
||||
@@ -114,7 +133,9 @@ export async function POST(request: NextRequest) {
|
||||
[
|
||||
userId,
|
||||
normalizedEmail,
|
||||
displayName,
|
||||
username,
|
||||
displayNickname,
|
||||
avatarUrl,
|
||||
phone || null,
|
||||
role,
|
||||
membershipTier,
|
||||
@@ -146,22 +167,26 @@ export async function POST(request: NextRequest) {
|
||||
assetBaseUrl: getRequestBaseUrl(request) || undefined,
|
||||
}).catch(() => undefined);
|
||||
|
||||
const accessToken = createSessionToken(userId, role);
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: userId,
|
||||
email: normalizedEmail,
|
||||
nickname: displayName,
|
||||
username,
|
||||
nickname: displayNickname,
|
||||
display_nickname: displayNickname,
|
||||
role,
|
||||
membership_tier: membershipTier,
|
||||
credits_balance: creditsBalance,
|
||||
daily_quota_used: 0,
|
||||
daily_quota_limit: dailyQuotaLimit,
|
||||
avatar_url: null,
|
||||
avatar_url: avatarUrl,
|
||||
phone: phone || null,
|
||||
email_verified: true,
|
||||
email_verified_at: new Date().toISOString(),
|
||||
preferred_theme: 'dark',
|
||||
},
|
||||
session: { access_token: accessToken },
|
||||
message: isAdminRegistration ? 'Admin account registered' : 'Registration successful',
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function GET(request: NextRequest) {
|
||||
LOWER(COALESCE(w.title, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.prompt, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.negative_prompt, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.display_nickname, p.nickname, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.email, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.params::text, '')) LIKE $${idx}
|
||||
@@ -52,7 +53,7 @@ export async function GET(request: NextRequest) {
|
||||
SELECT w.id, w.type, w.title, w.prompt, w.negative_prompt, w.result_url, w.thumbnail_url,
|
||||
w.width, w.height, w.duration, w.is_public, w.likes_count, w.credits_cost,
|
||||
w.status, w.created_at, w.user_id, w.params,
|
||||
p.nickname, p.email, p.avatar_url
|
||||
p.nickname, p.display_nickname, p.email, p.avatar_url
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
@@ -95,7 +96,7 @@ export async function GET(request: NextRequest) {
|
||||
referenceImage: references.referenceImage,
|
||||
referenceImages: references.referenceImages,
|
||||
publisherId: w.user_id,
|
||||
publisherNickname: (w.nickname as string) || ((w.email as string) || '').split('@')[0] || '匿名用户',
|
||||
publisherNickname: (w.display_nickname as string) || (w.nickname as string) || ((w.email as string) || '').split('@')[0] || '匿名用户',
|
||||
publisherAvatarUrl: (w.avatar_url as string | null) || null,
|
||||
publishedAt: w.created_at,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ensureEmailSchema } from '@/lib/email-service';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
import { ensureUserDisplayProfileSchema, generateDefaultAvatarDataUrl } from '@/lib/user-profile-defaults';
|
||||
|
||||
function normalizeRoleForTier(role: string | null | undefined, tier: string | null | undefined): string {
|
||||
const currentRole = role || 'user';
|
||||
@@ -19,6 +20,7 @@ function isSafeAvatarUrl(value: string): boolean {
|
||||
if (!value) return true;
|
||||
if (value.length > 1_000_000) return false;
|
||||
if (/^data:image\/(png|jpe?g|webp|gif);base64,[a-z0-9+/=]+$/i.test(value)) return true;
|
||||
if (/^data:image\/svg\+xml;charset=utf-8,/i.test(value)) return true;
|
||||
if (/^https?:\/\/[^\s"'<>]+$/i.test(value)) return true;
|
||||
if (/^\/api\/local-storage\/[^\s"'<>]+$/i.test(value)) return true;
|
||||
return false;
|
||||
@@ -49,8 +51,13 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
const result = await client.query(
|
||||
'SELECT id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
`SELECT id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
|
||||
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
|
||||
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
|
||||
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme
|
||||
FROM profiles WHERE id = $1`,
|
||||
[tokenUserId],
|
||||
);
|
||||
|
||||
@@ -120,10 +127,15 @@ export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
await client.query('BEGIN');
|
||||
|
||||
const profileResult = await client.query(
|
||||
'SELECT id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme FROM profiles WHERE id = $1 FOR UPDATE',
|
||||
`SELECT id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
|
||||
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
|
||||
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
|
||||
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme
|
||||
FROM profiles WHERE id = $1 FOR UPDATE`,
|
||||
[tokenUserId]
|
||||
);
|
||||
|
||||
@@ -139,6 +151,44 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
const authUser = authResult.rows[0] || null;
|
||||
|
||||
const hasUsername = Object.prototype.hasOwnProperty.call(body, 'username');
|
||||
const username = hasUsername && typeof body.username === 'string' ? body.username.trim() : undefined;
|
||||
const hasDisplayNickname = Object.prototype.hasOwnProperty.call(body, 'displayNickname') || hasNickname;
|
||||
const displayNickname = Object.prototype.hasOwnProperty.call(body, 'displayNickname') && typeof body.displayNickname === 'string'
|
||||
? body.displayNickname.trim()
|
||||
: nickname;
|
||||
|
||||
if (!isSafeProfileText(username, 50)) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Username is too long or contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (username !== undefined && !username) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Username cannot be empty' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (displayNickname !== undefined && !displayNickname) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Display nickname cannot be empty' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isSafeProfileText(displayNickname, 50)) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Display nickname is too long or contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (username !== undefined && username !== currentProfile.username) {
|
||||
const duplicateUsername = await client.query(
|
||||
'SELECT id FROM profiles WHERE LOWER(nickname) = LOWER($1) AND id <> $2 LIMIT 1',
|
||||
[username, tokenUserId]
|
||||
);
|
||||
if (duplicateUsername.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Username is already in use' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (email !== undefined && email !== currentProfile.email) {
|
||||
const duplicateProfile = await client.query(
|
||||
'SELECT id FROM profiles WHERE email = $1 AND id <> $2 LIMIT 1',
|
||||
@@ -188,26 +238,40 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const nextDisplayNickname = displayNickname !== undefined
|
||||
? displayNickname
|
||||
: currentProfile.display_nickname || currentProfile.nickname || currentProfile.username || currentProfile.email.split('@')[0];
|
||||
const nextAvatarUrl = avatarUrl !== undefined
|
||||
? avatarUrl
|
||||
: currentProfile.avatar_url || generateDefaultAvatarDataUrl(`${tokenUserId}:${currentProfile.email}`, nextDisplayNickname);
|
||||
|
||||
const updateResult = await client.query(
|
||||
`UPDATE profiles
|
||||
SET email = CASE WHEN $1::boolean THEN $2 ELSE email END,
|
||||
email_verified = CASE WHEN $1::boolean AND LOWER($2) <> LOWER(email) THEN false ELSE email_verified END,
|
||||
email_verified_at = CASE WHEN $1::boolean AND LOWER($2) <> LOWER(email) THEN NULL ELSE email_verified_at END,
|
||||
nickname = CASE WHEN $3::boolean THEN NULLIF($4, '') ELSE nickname END,
|
||||
phone = CASE WHEN $5::boolean THEN NULLIF($6, '') ELSE phone END,
|
||||
avatar_url = CASE WHEN $7::boolean THEN NULLIF($8, '') ELSE avatar_url END,
|
||||
display_nickname = CASE WHEN $5::boolean THEN NULLIF($6, '') ELSE COALESCE(NULLIF(display_nickname, ''), nickname) END,
|
||||
phone = CASE WHEN $7::boolean THEN NULLIF($8, '') ELSE phone END,
|
||||
avatar_url = CASE WHEN $9::boolean THEN NULLIF($10, '') ELSE COALESCE(NULLIF(avatar_url, ''), $11) END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $9
|
||||
RETURNING id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme`,
|
||||
WHERE id = $12
|
||||
RETURNING id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
|
||||
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
|
||||
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
|
||||
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme`,
|
||||
[
|
||||
email !== undefined,
|
||||
email || null,
|
||||
nickname !== undefined,
|
||||
nickname || '',
|
||||
username !== undefined,
|
||||
username || '',
|
||||
hasDisplayNickname,
|
||||
nextDisplayNickname,
|
||||
phone !== undefined,
|
||||
phone || '',
|
||||
avatarUrl !== undefined,
|
||||
avatarUrl || '',
|
||||
nextAvatarUrl,
|
||||
tokenUserId,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -358,17 +358,18 @@ export default function AuthPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-nickname">昵称</Label>
|
||||
<Label htmlFor="reg-nickname">用户名</Label>
|
||||
<div className="relative">
|
||||
<User className={authInputIconClass} />
|
||||
<Input
|
||||
id="reg-nickname"
|
||||
placeholder="你的昵称"
|
||||
placeholder="用于登录的用户名"
|
||||
value={regNickname}
|
||||
onChange={(e) => setRegNickname(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">系统会自动生成一个中文昵称和默认头像,注册后可在个人资料中修改。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-phone">手机号 (选填)</Label>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function RegisterPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [emailCode, setEmailCode] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
@@ -70,7 +70,7 @@ export default function RegisterPage() {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, nickname, phone, emailCode, acceptedTerms: true }),
|
||||
body: JSON.stringify({ email, password, nickname: username, phone, emailCode, acceptedTerms: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '注册失败');
|
||||
@@ -167,11 +167,12 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">昵称</Label>
|
||||
<Label htmlFor="nickname">用户名</Label>
|
||||
<div className="relative">
|
||||
<User className={authInputIconClass} />
|
||||
<Input id="nickname" placeholder="你的昵称" value={nickname} onChange={(e) => setNickname(e.target.value)} className="pl-10" />
|
||||
<Input id="nickname" placeholder="用于登录的用户名" value={username} onChange={(e) => setUsername(e.target.value)} className="pl-10" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">系统会自动生成一个中文昵称和默认头像,注册后可在个人资料中修改。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
|
||||
@@ -535,7 +535,7 @@ export default function GalleryPage() {
|
||||
referenceImage: r.referenceImage,
|
||||
referenceImages: r.referenceImages,
|
||||
publisherId: user?.id || 'anonymous',
|
||||
publisherNickname: user?.nickname || user?.email?.split('@')[0] || '匿名用户',
|
||||
publisherNickname: user?.nickname || user?.username || user?.email?.split('@')[0] || '匿名用户',
|
||||
publisherAvatarUrl: user?.avatarUrl || null,
|
||||
publishedAt: r.createdAt,
|
||||
}));
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState('account');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [accountForm, setAccountForm] = useState({ nickname: '', email: '', phone: '', avatarUrl: '' });
|
||||
const [accountForm, setAccountForm] = useState({ username: '', nickname: '', email: '', phone: '', avatarUrl: '' });
|
||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
const [savingAccount, setSavingAccount] = useState(false);
|
||||
const [processingAvatar, setProcessingAvatar] = useState(false);
|
||||
@@ -130,12 +130,13 @@ export default function ProfilePage() {
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setAccountForm({
|
||||
username: user.username || user.email?.split('@')[0] || '',
|
||||
nickname: user.nickname || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
});
|
||||
}, [user?.id, user?.nickname, user?.email, user?.phone, user?.avatarUrl]);
|
||||
}, [user?.id, user?.username, user?.nickname, user?.email, user?.phone, user?.avatarUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailVerifyCooldown <= 0) return;
|
||||
@@ -145,6 +146,7 @@ export default function ProfilePage() {
|
||||
|
||||
// Use auth store data directly
|
||||
const profile = {
|
||||
username: user?.username || user?.email?.split('@')[0] || '',
|
||||
nickname: user?.nickname || '游客',
|
||||
email: user?.email || '',
|
||||
phone: user?.phone || '',
|
||||
@@ -257,8 +259,9 @@ export default function ProfilePage() {
|
||||
|
||||
try {
|
||||
const payload: Record<string, string> = {
|
||||
username: accountForm.username,
|
||||
displayNickname: accountForm.nickname,
|
||||
email: accountForm.email,
|
||||
nickname: accountForm.nickname,
|
||||
phone: accountForm.phone,
|
||||
avatarUrl: accountForm.avatarUrl,
|
||||
};
|
||||
@@ -285,6 +288,7 @@ export default function ProfilePage() {
|
||||
if (data.profile) {
|
||||
updateProfile({
|
||||
email: data.profile.email,
|
||||
username: data.profile.username || authUser.username,
|
||||
nickname: data.profile.nickname,
|
||||
phone: data.profile.phone || null,
|
||||
membershipTier: data.profile.membership_tier || authUser.membershipTier,
|
||||
@@ -363,6 +367,7 @@ export default function ProfilePage() {
|
||||
if (data.profile) {
|
||||
updateProfile({
|
||||
email: data.profile.email,
|
||||
username: data.profile.username || user?.username || '',
|
||||
nickname: data.profile.nickname,
|
||||
phone: data.profile.phone || null,
|
||||
membershipTier: data.profile.membership_tier || user?.membershipTier || 'free',
|
||||
@@ -584,12 +589,21 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input
|
||||
value={accountForm.username}
|
||||
onChange={(event) => setAccountForm(prev => ({ ...prev, username: event.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">用户名可继续用于登录,不会在画廊公开显示。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>昵称</Label>
|
||||
<Input
|
||||
value={accountForm.nickname}
|
||||
onChange={(event) => setAccountForm(prev => ({ ...prev, nickname: event.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">昵称用于右上角、个人资料和画廊作者展示。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
|
||||
@@ -232,7 +232,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
model: record.model,
|
||||
modelLabel: record.modelLabel,
|
||||
publisherId: user?.id,
|
||||
publisherNickname: user?.nickname || user?.email?.split('@')[0] || '匿名用户',
|
||||
publisherNickname: user?.nickname || user?.username || user?.email?.split('@')[0] || '匿名用户',
|
||||
negativePrompt: record.negativePrompt,
|
||||
referenceImage: record.referenceImage,
|
||||
referenceImages: record.referenceImages,
|
||||
|
||||
@@ -29,7 +29,8 @@ function toAdminUser(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email || '',
|
||||
nickname: row.nickname || '',
|
||||
username: row.nickname || '',
|
||||
nickname: row.display_nickname || row.nickname || '',
|
||||
role,
|
||||
membership_tier: membershipTier,
|
||||
credits_balance: row.credits_balance ?? 0,
|
||||
@@ -67,6 +68,7 @@ export async function listAdminUsers(client: PoolClient, query: AdminUsersQuery
|
||||
whereClause = `
|
||||
WHERE LOWER(p.email) LIKE $1
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $1
|
||||
OR LOWER(COALESCE(p.display_nickname, '')) LIKE $1
|
||||
OR LOWER(COALESCE(p.phone, '')) LIKE $1
|
||||
OR p.id::text LIKE $1
|
||||
OR LOWER(COALESCE(p.role, '')) LIKE $1
|
||||
@@ -80,7 +82,7 @@ export async function listAdminUsers(client: PoolClient, query: AdminUsersQuery
|
||||
);
|
||||
|
||||
const result = await client.query(
|
||||
`SELECT p.id, p.email, p.nickname, p.role, p.membership_tier,
|
||||
`SELECT p.id, p.email, p.nickname, p.display_nickname, p.role, p.membership_tier,
|
||||
p.credits_balance, p.daily_quota_limit, p.daily_quota_used,
|
||||
p.is_active, p.avatar_url, p.phone, p.created_at,
|
||||
u.created_at as auth_created_at
|
||||
@@ -150,7 +152,8 @@ export async function updateAdminUser(client: PoolClient, body: Record<string, u
|
||||
if (dailyQuotaLimit !== undefined) { setClauses.push(`daily_quota_limit = $${paramIdx++}`); params.push(dailyQuotaLimit); }
|
||||
if (dailyQuotaUsed !== undefined) { setClauses.push(`daily_quota_used = $${paramIdx++}`); params.push(dailyQuotaUsed); }
|
||||
if (isActive !== undefined) { setClauses.push(`is_active = $${paramIdx++}`); params.push(isActive); }
|
||||
if (updates.nickname !== undefined) { setClauses.push(`nickname = $${paramIdx++}`); params.push(updates.nickname); }
|
||||
if (updates.username !== undefined) { setClauses.push(`nickname = $${paramIdx++}`); params.push(updates.username); }
|
||||
if (updates.nickname !== undefined) { setClauses.push(`display_nickname = $${paramIdx++}`); params.push(updates.nickname); }
|
||||
if (updates.phone !== undefined) { setClauses.push(`phone = $${paramIdx++}`); params.push(updates.phone); }
|
||||
if (updates.email !== undefined) { setClauses.push(`email = $${paramIdx++}`); params.push(updates.email); }
|
||||
setClauses.push('updated_at = NOW()');
|
||||
@@ -181,7 +184,7 @@ export async function updateAdminUser(client: PoolClient, body: Record<string, u
|
||||
}
|
||||
|
||||
const updated = await client.query(
|
||||
`SELECT p.id, p.email, p.nickname, p.role, p.membership_tier,
|
||||
`SELECT p.id, p.email, p.nickname, p.display_nickname, p.role, p.membership_tier,
|
||||
p.credits_balance, p.daily_quota_limit, p.daily_quota_used,
|
||||
p.is_active, p.avatar_url, p.phone, p.created_at,
|
||||
u.created_at as auth_created_at
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useCallback, useRef } from 'react';
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
role: 'guest' | 'user' | 'vip' | 'admin' | 'enterprise_admin' | 'enterprise_member';
|
||||
@@ -72,7 +73,8 @@ export function parseApiUser(apiUser: Record<string, unknown>): AuthUser {
|
||||
return {
|
||||
id: (apiUser.id as string) || '',
|
||||
email: (apiUser.email as string) || '',
|
||||
nickname: (apiUser.nickname as string) || ((apiUser.email as string) || '').split('@')[0],
|
||||
username: (apiUser.username as string) || (apiUser.user_name as string) || (apiUser.nickname as string) || ((apiUser.email as string) || '').split('@')[0],
|
||||
nickname: (apiUser.display_nickname as string) || (apiUser.nickname as string) || (apiUser.username as string) || ((apiUser.email as string) || '').split('@')[0],
|
||||
avatarUrl: (apiUser.avatar_url as string | null) ?? null,
|
||||
role: (apiUser.role as AuthUser['role']) || 'user',
|
||||
membershipTier: (apiUser.membership_tier as AuthUser['membershipTier']) || 'free',
|
||||
|
||||
132
src/lib/user-profile-defaults.ts
Normal file
132
src/lib/user-profile-defaults.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
|
||||
const ADJECTIVES = ['云朵', '星河', '松风', '月白', '晴川', '青柚', '琥珀', '小满', '竹影', '橘光', '海盐', '霁蓝'];
|
||||
const NOUNS = ['画师', '旅人', '造梦家', '观察员', '收藏家', '调色师', '冒险家', '灵感师', '策展人', '星愿者', '小导演', '光影客'];
|
||||
const AVATAR_KINDS = ['person', 'cat', 'bear', 'bunny', 'fox'] as const;
|
||||
const PALETTES = [
|
||||
['#7dd3fc', '#c084fc', '#fdf2f8', '#0f172a'],
|
||||
['#fbbf24', '#fb7185', '#fff7ed', '#3b1d0f'],
|
||||
['#86efac', '#38bdf8', '#f0fdf4', '#052e2b'],
|
||||
['#f9a8d4', '#a78bfa', '#fdf4ff', '#312e81'],
|
||||
['#fdba74', '#60a5fa', '#eff6ff', '#1e3a8a'],
|
||||
];
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function pick<T>(items: readonly T[], seed: number, offset = 0): T {
|
||||
return items[(seed + offset) % items.length];
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[char] || char));
|
||||
}
|
||||
|
||||
export function normalizeUsername(value: string | null | undefined, fallback: string): string {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function generateChineseNickname(seedValue: string): string {
|
||||
const seed = hashString(seedValue || crypto.randomUUID());
|
||||
return `${pick(ADJECTIVES, seed)}${pick(NOUNS, seed >>> 5)}${String(seed % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export function generateDefaultAvatarDataUrl(seedValue: string, labelValue?: string | null): string {
|
||||
const seed = hashString(seedValue || crypto.randomUUID());
|
||||
const [primary, secondary, surface, ink] = pick(PALETTES, seed);
|
||||
const kind = pick(AVATAR_KINDS, seed >>> 3);
|
||||
const label = escapeXml((labelValue || '').trim().slice(0, 1) || '妙');
|
||||
const blush = seed % 2 === 0 ? '#fb7185' : '#f472b6';
|
||||
const earLeft = kind === 'cat'
|
||||
? '<path d="M76 92 L110 44 L126 108 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
|
||||
: kind === 'bunny'
|
||||
? '<ellipse cx="105" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(-16 105 54)"/>'
|
||||
: kind === 'bear' || kind === 'fox'
|
||||
? '<circle cx="103" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
|
||||
: '';
|
||||
const earRight = kind === 'cat'
|
||||
? '<path d="M180 108 L196 44 L232 92 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
|
||||
: kind === 'bunny'
|
||||
? '<ellipse cx="205" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(16 205 54)"/>'
|
||||
: kind === 'bear' || kind === 'fox'
|
||||
? '<circle cx="213" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
|
||||
: '';
|
||||
const nose = kind === 'person'
|
||||
? `<path d="M160 147 c-7 10 -1 18 10 16" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round" opacity=".44"/>`
|
||||
: `<path d="M151 151 q9 -8 18 0 q-9 11 -18 0Z" fill="${ink}" opacity=".72"/>`;
|
||||
const hair = kind === 'person'
|
||||
? `<path d="M90 133 c16 -58 58 -83 112 -57 c29 14 40 44 35 70 c-23 -20 -42 -17 -66 -36 c-26 26 -52 28 -81 23Z" fill="${secondary}" opacity=".92"/>`
|
||||
: '';
|
||||
const muzzle = kind === 'person' ? '' : '<ellipse cx="160" cy="165" rx="39" ry="26" fill="rgba(255,255,255,.54)"/>';
|
||||
const whiskers = kind === 'cat' || kind === 'fox'
|
||||
? `<path d="M101 155 h36 M101 174 h36 M183 155 h36 M183 174 h36" stroke="${ink}" stroke-width="4" stroke-linecap="round" opacity=".38"/>`
|
||||
: '';
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="34%" cy="25%" r="78%">
|
||||
<stop offset="0%" stop-color="${surface}"/>
|
||||
<stop offset="48%" stop-color="${primary}"/>
|
||||
<stop offset="100%" stop-color="${secondary}"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="face" x1="72" y1="64" x2="236" y2="246" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fff8f0"/>
|
||||
<stop offset=".58" stop-color="#ffd7b5"/>
|
||||
<stop offset="1" stop-color="#f8a978"/>
|
||||
</linearGradient>
|
||||
<filter id="soft" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#111827" flood-opacity=".22"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="320" height="320" rx="80" fill="url(#bg)"/>
|
||||
<circle cx="254" cy="58" r="34" fill="rgba(255,255,255,.34)"/>
|
||||
<circle cx="68" cy="250" r="44" fill="rgba(255,255,255,.20)"/>
|
||||
<g filter="url(#soft)">
|
||||
${earLeft}${earRight}
|
||||
<circle cx="160" cy="153" r="83" fill="url(#face)" stroke="rgba(255,255,255,.68)" stroke-width="6"/>
|
||||
${hair}
|
||||
<circle cx="128" cy="144" r="9" fill="${ink}"/>
|
||||
<circle cx="192" cy="144" r="9" fill="${ink}"/>
|
||||
<circle cx="125" cy="142" r="3" fill="#fff"/>
|
||||
<circle cx="189" cy="142" r="3" fill="#fff"/>
|
||||
${muzzle}
|
||||
${nose}
|
||||
${whiskers}
|
||||
<path d="M137 184 q23 18 46 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round" opacity=".62"/>
|
||||
<circle cx="105" cy="169" r="13" fill="${blush}" opacity=".30"/>
|
||||
<circle cx="215" cy="169" r="13" fill="${blush}" opacity=".30"/>
|
||||
</g>
|
||||
<g transform="translate(218 222)">
|
||||
<circle cx="34" cy="34" r="30" fill="rgba(255,255,255,.78)" stroke="rgba(255,255,255,.86)" stroke-width="3"/>
|
||||
<text x="34" y="45" text-anchor="middle" font-size="30" font-weight="800" font-family="Arial, sans-serif" fill="${ink}">${label}</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export async function ensureUserDisplayProfileSchema(client: PoolClient): Promise<void> {
|
||||
await client.query(`
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128)
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
UPDATE profiles
|
||||
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
|
||||
WHERE display_nickname IS NULL OR display_nickname = ''
|
||||
`);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const profiles = pgTable(
|
||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
nickname: varchar("nickname", { length: 128 }),
|
||||
display_nickname: varchar("display_nickname", { length: 128 }),
|
||||
avatar_url: text("avatar_url"),
|
||||
phone: varchar("phone", { length: 20 }),
|
||||
role: varchar("role", { length: 32 }).notNull().default("user"), // guest, user, vip, enterprise_admin, enterprise_member, admin
|
||||
|
||||
Reference in New Issue
Block a user