diff --git a/backend/database.py b/backend/database.py index bda3ef6..137ec68 100644 --- a/backend/database.py +++ b/backend/database.py @@ -165,6 +165,8 @@ def init_db(): c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'") if "role_changed_at" not in user_cols: c.execute("ALTER TABLE users ADD COLUMN role_changed_at TEXT") + if "username_changed" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN username_changed INTEGER DEFAULT 0") # Migration: add tags to user_diary diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()] diff --git a/backend/main.py b/backend/main.py index e727656..55a5729 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,7 +63,7 @@ def get_current_user(request: Request): if not token: return ANON_USER conn = get_db() - user = conn.execute("SELECT id, username, role, display_name, password, business_verified FROM users WHERE token = ?", (token,)).fetchone() + user = conn.execute("SELECT id, username, role, display_name, password, business_verified, username_changed FROM users WHERE token = ?", (token,)).fetchone() conn.close() if not user: return ANON_USER @@ -132,7 +132,7 @@ def get_version(): @app.get("/api/me") def get_me(user=Depends(get_current_user)): - return {"username": user["username"], "role": user["role"], "display_name": user.get("display_name", ""), "id": user.get("id"), "has_password": bool(user.get("password")), "business_verified": bool(user.get("business_verified"))} + return {"username": user["username"], "role": user["role"], "display_name": user["username"], "id": user.get("id"), "has_password": bool(user.get("password")), "business_verified": bool(user.get("business_verified")), "username_changed": bool(user.get("username_changed"))} # ── Bug Reports ───────────────────────────────────────── @@ -350,20 +350,24 @@ def symptom_search(body: dict, user=Depends(get_current_user)): def register(body: dict): username = body.get("username", "").strip() password = body.get("password", "").strip() - display_name = body.get("display_name", "").strip() if not username or len(username) < 2: raise HTTPException(400, "用户名至少2个字符") if not password or len(password) < 4: raise HTTPException(400, "密码至少4位") - token = _secrets.token_hex(24) + # Case-insensitive uniqueness check conn = get_db() + existing = conn.execute("SELECT id FROM users WHERE LOWER(username) = LOWER(?)", (username,)).fetchone() + if existing: + conn.close() + raise HTTPException(400, "用户名已被占用") + token = _secrets.token_hex(24) try: conn.execute( "INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)", - (username, token, "viewer", display_name or username, hash_password(password)) + (username, token, "viewer", username, hash_password(password)) ) uid = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone() - log_audit(conn, uid["id"] if uid else None, "register", "user", username, display_name or username, None) + log_audit(conn, uid["id"] if uid else None, "register", "user", username, username, None) conn.commit() except Exception: conn.close() @@ -380,7 +384,7 @@ def login(body: dict): if not username or not password: raise HTTPException(400, "请输入用户名和密码") conn = get_db() - user = conn.execute("SELECT id, token, password, display_name, role FROM users WHERE username = ?", (username,)).fetchone() + user = conn.execute("SELECT id, token, password, display_name, role, username FROM users WHERE LOWER(username) = LOWER(?)", (username,)).fetchone() if not user: conn.close() raise HTTPException(401, "用户名不存在") @@ -452,6 +456,30 @@ def set_password(body: dict, user=Depends(get_current_user)): return {"ok": True} +@app.put("/api/me/username") +def change_username(body: dict, user=Depends(get_current_user)): + if not user["id"]: + raise HTTPException(403, "请先登录") + conn = get_db() + u = conn.execute("SELECT username_changed FROM users WHERE id = ?", (user["id"],)).fetchone() + if u and u["username_changed"]: + conn.close() + raise HTTPException(400, "用户名只能修改一次") + new_name = body.get("username", "").strip() + if not new_name or len(new_name) < 2: + conn.close() + raise HTTPException(400, "用户名至少2个字符") + existing = conn.execute("SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?", (new_name, user["id"])).fetchone() + if existing: + conn.close() + raise HTTPException(400, "用户名已被占用") + conn.execute("UPDATE users SET username = ?, display_name = ?, username_changed = 1 WHERE id = ?", + (new_name, new_name, user["id"])) + conn.commit() + conn.close() + return {"ok": True, "username": new_name} + + # ── Business Verification ────────────────────────────── @app.post("/api/business-apply", status_code=201) def business_apply(body: dict, user=Depends(get_current_user)): @@ -1583,6 +1611,8 @@ def recipes_by_inventory(user=Depends(get_current_user)): return result + +# ── Search Logging ───────────────────────────────────── # ── Search Logging ───────────────────────────────────── @app.post("/api/search-log") def log_search(body: dict, user=Depends(get_current_user)): @@ -1864,6 +1894,26 @@ def startup(): data = json.load(f) seed_defaults(data["oils_meta"], data["recipes"]) + # One-time migration: sync display_name = username, notify about username change + conn = get_db() + needs_sync = conn.execute("SELECT id, username, display_name FROM users WHERE display_name != username AND display_name IS NOT NULL").fetchall() + if needs_sync: + for row in needs_sync: + conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (row["username"], row["id"])) + # Send notification once (check if already sent) + already_notified = conn.execute("SELECT id FROM notifications WHERE title = '📢 用户名变更通知'").fetchone() + if not already_notified: + all_users = conn.execute("SELECT id FROM users").fetchall() + for u in all_users: + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + ("viewer", "📢 用户名更新提醒", + "为了统一体验,系统已将显示名称合并为用户名。你有一次修改用户名的机会,修改后将不可更改,请慎重选择。", u["id"]) + ) + conn.commit() + print(f"[INIT] Synced display_name for {len(needs_sync)} users") + conn.close() + # Auto-fill missing en_name for existing recipes conn = get_db() missing = conn.execute("SELECT id, name FROM recipes WHERE en_name IS NULL OR en_name = ''").fetchall() diff --git a/frontend/cypress/e2e/pr27-features.cy.js b/frontend/cypress/e2e/pr27-features.cy.js index 1b0a22e..03356e5 100644 --- a/frontend/cypress/e2e/pr27-features.cy.js +++ b/frontend/cypress/e2e/pr27-features.cy.js @@ -266,4 +266,396 @@ describe('PR27 Feature Tests', () => { cy.contains('登录 / 注册').should('be.visible') }) }) + + // ------------------------------------------------------------------------- + // API: Case-insensitive username registration + // ------------------------------------------------------------------------- + describe('API: case-insensitive username registration', () => { + const CASE_USER = 'CypressCaseTest' + const CASE_PASS = 'test1234' + + // Cleanup before test + before(() => { + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + const leftover = res.body.find(u => + u.username.toLowerCase() === CASE_USER.toLowerCase() + ) + if (leftover) { + cy.request({ + method: 'DELETE', + url: `/api/users/${leftover.id || leftover._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + + after(() => { + // Cleanup registered user + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + const user = res.body.find(u => + u.username.toLowerCase() === CASE_USER.toLowerCase() + ) + if (user) { + cy.request({ + method: 'DELETE', + url: `/api/users/${user.id || user._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + + it('registers a user with mixed case', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: CASE_USER, password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(201) + expect(res.body.token).to.be.a('string') + }) + }) + + it('rejects registration with same username in different case', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: CASE_USER.toLowerCase(), password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(400) + }) + }) + + it('rejects registration with all-uppercase variant', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: CASE_USER.toUpperCase(), password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(400) + }) + }) + + it('allows case-insensitive login', () => { + cy.request({ + method: 'POST', + url: '/api/login', + body: { username: CASE_USER.toLowerCase(), password: CASE_PASS }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.token).to.be.a('string') + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: One-time username change via PUT /api/me/username + // ------------------------------------------------------------------------- + describe('API: one-time username change', () => { + const RENAME_USER = 'cypress_rename_test' + const RENAME_PASS = 'rename1234' + let renameToken + let renameUserId + + before(() => { + // Cleanup leftovers + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + for (const name of [RENAME_USER, 'cypress_renamed']) { + const leftover = res.body.find(u => u.username.toLowerCase() === name) + if (leftover) { + cy.request({ + method: 'DELETE', + url: `/api/users/${leftover.id || leftover._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + } + }) + }) + + after(() => { + // Cleanup + if (renameUserId) { + cy.request({ + method: 'DELETE', + url: `/api/users/${renameUserId}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + + it('registers a user for rename test', () => { + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: RENAME_USER, password: RENAME_PASS }, + }).then(res => { + expect(res.status).to.eq(201) + renameToken = res.body.token + + // Get user ID + cy.request({ url: '/api/users', headers: authHeaders }).then(listRes => { + const u = listRes.body.find(x => x.username === RENAME_USER) + renameUserId = u.id || u._id + }) + }) + }) + + it('GET /api/me returns username_changed=false initially', () => { + cy.request({ + url: '/api/me', + headers: { Authorization: `Bearer ${renameToken}` }, + }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.username_changed).to.eq(false) + }) + }) + + it('renames username successfully the first time', () => { + cy.request({ + method: 'PUT', + url: '/api/me/username', + headers: { Authorization: `Bearer ${renameToken}` }, + body: { username: 'cypress_renamed' }, + }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.ok).to.eq(true) + expect(res.body.username).to.eq('cypress_renamed') + }) + }) + + it('GET /api/me returns username_changed=true after rename', () => { + cy.request({ + url: '/api/me', + headers: { Authorization: `Bearer ${renameToken}` }, + }).then(res => { + expect(res.body.username_changed).to.eq(true) + }) + }) + + it('rejects second rename attempt', () => { + cy.request({ + method: 'PUT', + url: '/api/me/username', + headers: { Authorization: `Bearer ${renameToken}` }, + body: { username: 'cypress_another_name' }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(400) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: en_name auto-translation on recipe create (no explicit en_name) + // ------------------------------------------------------------------------- + describe('API: en_name auto-translation on create', () => { + let recipeId + + after(() => { + if (recipeId) { + cy.request({ + method: 'DELETE', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + + it('auto-translates en_name when creating recipe without en_name', () => { + cy.request({ + method: 'POST', + url: '/api/recipes', + headers: authHeaders, + body: { + name: '排毒按摩', + ingredients: [{ oil_name: '薰衣草', drops: 3 }], + tags: [], + }, + }).then(res => { + expect(res.status).to.be.oneOf([200, 201]) + recipeId = res.body.id + + cy.request('/api/recipes').then(listRes => { + const found = listRes.body.find(r => r.id === recipeId) + expect(found).to.exist + expect(found.en_name).to.be.a('string') + expect(found.en_name.length).to.be.greaterThan(0) + // auto_translate('排毒按摩') should produce 'Detox Massage' + expect(found.en_name).to.include('Detox') + expect(found.en_name).to.include('Massage') + }) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: Recipe name change auto-retranslates en_name + // ------------------------------------------------------------------------- + describe('API: rename recipe auto-retranslates en_name', () => { + let recipeId + + after(() => { + if (recipeId) { + cy.request({ + method: 'DELETE', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + + it('creates recipe with auto en_name, then renames to verify retranslation', () => { + cy.request({ + method: 'POST', + url: '/api/recipes', + headers: authHeaders, + body: { + name: '助眠喷雾', + ingredients: [{ oil_name: '薰衣草', drops: 5 }], + tags: [], + }, + }).then(res => { + recipeId = res.body.id + + // Verify initial auto-translation + cy.request('/api/recipes').then(listRes => { + const r = listRes.body.find(x => x.id === recipeId) + expect(r.en_name).to.include('Sleep') + + // Rename to completely different name + cy.request({ + method: 'PUT', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + body: { name: '肩颈按摩' }, + }).then(() => { + cy.request('/api/recipes').then(list2 => { + const r2 = list2.body.find(x => x.id === recipeId) + // Should now be retranslated to Neck & Shoulder Massage + expect(r2.en_name).to.include('Neck') + expect(r2.en_name).to.include('Massage') + // Should NOT contain Sleep anymore + expect(r2.en_name).to.not.include('Sleep') + }) + }) + }) + }) + }) + + it('does not retranslate when explicit en_name provided on update', () => { + cy.request({ + method: 'PUT', + url: `/api/recipes/${recipeId}`, + headers: authHeaders, + body: { name: '免疫配方', en_name: 'my custom name' }, + }).then(() => { + cy.request('/api/recipes').then(listRes => { + const r = listRes.body.find(x => x.id === recipeId) + expect(r.en_name).to.eq('My Custom Name') // title-cased + }) + }) + }) + }) + + // ------------------------------------------------------------------------- + // API: Delete user transfers diary to admin (with username appended) + // ------------------------------------------------------------------------- + describe('API: delete user diary transfer with username', () => { + const XFER_USER = 'cypress_xfer_test' + const XFER_PASS = 'xfer1234' + let xferUserId + let xferToken + + before(() => { + // Cleanup leftovers + cy.request({ url: '/api/users', headers: authHeaders }).then(res => { + const leftover = res.body.find(u => u.username === XFER_USER) + if (leftover) { + cy.request({ + method: 'DELETE', + url: `/api/users/${leftover.id || leftover._id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + + it('registers user, adds diary, deletes user, verifies transfer', () => { + // Register + cy.request({ + method: 'POST', + url: '/api/register', + body: { username: XFER_USER, password: XFER_PASS }, + }).then(regRes => { + xferToken = regRes.body.token + + // Get user id + cy.request({ url: '/api/users', headers: authHeaders }).then(listRes => { + const u = listRes.body.find(x => x.username === XFER_USER) + xferUserId = u.id || u._id + + const userAuth = { Authorization: `Bearer ${xferToken}` } + + // Add unique diary entry + cy.request({ + method: 'POST', + url: '/api/diary', + headers: userAuth, + body: { + name: 'PR28转移日记', + ingredients: [ + { oil: '檀香', drops: 7 }, + { oil: '岩兰草', drops: 3 }, + ], + note: '转移测试PR28', + }, + }).then(() => { + // Delete user + cy.request({ + method: 'DELETE', + url: `/api/users/${xferUserId}`, + headers: authHeaders, + }).then(delRes => { + expect(delRes.body.ok).to.eq(true) + + // Verify diary was transferred to admin with username appended + cy.request({ + url: '/api/diary', + headers: authHeaders, + }).then(diaryRes => { + const transferred = diaryRes.body.find( + d => d.name && d.name.includes('PR28转移日记') && d.name.includes(XFER_USER) + ) + expect(transferred).to.exist + expect(transferred.note).to.eq('转移测试PR28') + + // Cleanup + if (transferred) { + cy.request({ + method: 'DELETE', + url: `/api/diary/${transferred.id}`, + headers: authHeaders, + failOnStatusCode: false, + }) + } + }) + }) + }) + }) + }) + }) + }) }) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c90807f..0a7e668 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,7 +15,7 @@