feat: 购油方案功能 #28
@@ -165,6 +165,8 @@ def init_db():
|
|||||||
c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'")
|
c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'")
|
||||||
if "role_changed_at" not in user_cols:
|
if "role_changed_at" not in user_cols:
|
||||||
c.execute("ALTER TABLE users ADD COLUMN role_changed_at TEXT")
|
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
|
# Migration: add tags to user_diary
|
||||||
diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()]
|
diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()]
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def get_current_user(request: Request):
|
|||||||
if not token:
|
if not token:
|
||||||
return ANON_USER
|
return ANON_USER
|
||||||
conn = get_db()
|
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()
|
conn.close()
|
||||||
if not user:
|
if not user:
|
||||||
return ANON_USER
|
return ANON_USER
|
||||||
@@ -132,7 +132,7 @@ def get_version():
|
|||||||
|
|
||||||
@app.get("/api/me")
|
@app.get("/api/me")
|
||||||
def get_me(user=Depends(get_current_user)):
|
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 ─────────────────────────────────────────
|
# ── Bug Reports ─────────────────────────────────────────
|
||||||
@@ -350,20 +350,24 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
|
|||||||
def register(body: dict):
|
def register(body: dict):
|
||||||
username = body.get("username", "").strip()
|
username = body.get("username", "").strip()
|
||||||
password = body.get("password", "").strip()
|
password = body.get("password", "").strip()
|
||||||
display_name = body.get("display_name", "").strip()
|
|
||||||
if not username or len(username) < 2:
|
if not username or len(username) < 2:
|
||||||
raise HTTPException(400, "用户名至少2个字符")
|
raise HTTPException(400, "用户名至少2个字符")
|
||||||
if not password or len(password) < 4:
|
if not password or len(password) < 4:
|
||||||
raise HTTPException(400, "密码至少4位")
|
raise HTTPException(400, "密码至少4位")
|
||||||
token = _secrets.token_hex(24)
|
# Case-insensitive uniqueness check
|
||||||
conn = get_db()
|
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:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)",
|
"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()
|
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()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -380,7 +384,7 @@ def login(body: dict):
|
|||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise HTTPException(400, "请输入用户名和密码")
|
raise HTTPException(400, "请输入用户名和密码")
|
||||||
conn = get_db()
|
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:
|
if not user:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(401, "用户名不存在")
|
raise HTTPException(401, "用户名不存在")
|
||||||
@@ -452,6 +456,30 @@ def set_password(body: dict, user=Depends(get_current_user)):
|
|||||||
return {"ok": True}
|
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 ──────────────────────────────
|
# ── Business Verification ──────────────────────────────
|
||||||
@app.post("/api/business-apply", status_code=201)
|
@app.post("/api/business-apply", status_code=201)
|
||||||
def business_apply(body: dict, user=Depends(get_current_user)):
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Search Logging ─────────────────────────────────────
|
||||||
# ── Search Logging ─────────────────────────────────────
|
# ── Search Logging ─────────────────────────────────────
|
||||||
@app.post("/api/search-log")
|
@app.post("/api/search-log")
|
||||||
def log_search(body: dict, user=Depends(get_current_user)):
|
def log_search(body: dict, user=Depends(get_current_user)):
|
||||||
@@ -1864,6 +1894,26 @@ def startup():
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
seed_defaults(data["oils_meta"], data["recipes"])
|
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
|
# Auto-fill missing en_name for existing recipes
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
missing = conn.execute("SELECT id, name FROM recipes WHERE en_name IS NULL OR en_name = ''").fetchall()
|
missing = conn.execute("SELECT id, name FROM recipes WHERE en_name IS NULL OR en_name = ''").fetchall()
|
||||||
|
|||||||
@@ -266,4 +266,396 @@ describe('PR27 Feature Tests', () => {
|
|||||||
cy.contains('登录 / 注册').should('be.visible')
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="header-right" @click="toggleUserMenu">
|
<div class="header-right" @click="toggleUserMenu">
|
||||||
<template v-if="auth.isLoggedIn">
|
<template v-if="auth.isLoggedIn">
|
||||||
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
||||||
<span class="user-name">{{ auth.user.display_name || auth.user.username }} ▾</span>
|
<span class="user-name">{{ auth.user.username }} ▾</span>
|
||||||
<span v-if="unreadNotifCount > 0" class="notif-badge">{{ unreadNotifCount }}</span>
|
<span v-if="unreadNotifCount > 0" class="notif-badge">{{ unreadNotifCount }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { recipeNameEn } from '../composables/useOilTranslation'
|
import { recipeNameEn, oilEn } from '../composables/useOilTranslation'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// EDITOR_ONLY_TAGS includes '已下架'
|
// EDITOR_ONLY_TAGS includes '已下架'
|
||||||
@@ -169,4 +169,117 @@ describe('duplicate oil prevention', () => {
|
|||||||
const isDup = ings.some(i => i !== ing && i.oil === '薰衣草')
|
const isDup = ings.some(i => i !== ing && i.oil === '薰衣草')
|
||||||
expect(isDup).toBe(false)
|
expect(isDup).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('handles empty ingredient list (no duplicates)', () => {
|
||||||
|
const ings = []
|
||||||
|
const isDup = ings.some(i => i.oil === '薰衣草')
|
||||||
|
expect(isDup).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// recipeNameEn — additional edge cases for PR28
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('recipeNameEn — PR28 additional cases', () => {
|
||||||
|
it('translates 排毒配方 → Detox Blend', () => {
|
||||||
|
expect(recipeNameEn('排毒配方')).toBe('Detox Blend')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates 呼吸系统护理 → Respiratory System Care', () => {
|
||||||
|
expect(recipeNameEn('呼吸系统护理')).toBe('Respiratory System Care')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates 儿童助眠 → Children\'s Sleep Aid', () => {
|
||||||
|
expect(recipeNameEn('儿童助眠')).toBe("Children's Sleep Aid")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates 美容按摩 → Beauty Massage', () => {
|
||||||
|
expect(recipeNameEn('美容按摩')).toBe('Beauty Massage')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles mixed Chinese and ASCII text', () => {
|
||||||
|
// Unknown Chinese chars are skipped; if ASCII appears, it's kept
|
||||||
|
const result = recipeNameEn('testBlend')
|
||||||
|
// No Chinese keyword matches, falls back to original
|
||||||
|
expect(result).toBe('testBlend')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles single-keyword name', () => {
|
||||||
|
expect(recipeNameEn('免疫')).toBe('Immunity')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates compound: 肩颈按摩配方 → Neck & Shoulder Massage Blend', () => {
|
||||||
|
expect(recipeNameEn('肩颈按摩配方')).toBe('Neck & Shoulder Massage Blend')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// oilEn — English oil name translation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('oilEn', () => {
|
||||||
|
it('translates known oils', () => {
|
||||||
|
expect(oilEn('薰衣草')).toBe('Lavender')
|
||||||
|
expect(oilEn('茶树')).toBe('Tea Tree')
|
||||||
|
expect(oilEn('乳香')).toBe('Frankincense')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles 复方 suffix removal', () => {
|
||||||
|
expect(oilEn('舒缓复方')).toBe('Past Tense')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles 复方 suffix addition', () => {
|
||||||
|
// '呼吸' maps via '呼吸复方' → 'Breathe'
|
||||||
|
expect(oilEn('呼吸')).toBe('Breathe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for unknown oil', () => {
|
||||||
|
expect(oilEn('不存在的油')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Case-insensitive username logic (pure function)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('case-insensitive username matching', () => {
|
||||||
|
const matchCaseInsensitive = (input, existing) =>
|
||||||
|
existing.some(u => u.toLowerCase() === input.toLowerCase())
|
||||||
|
|
||||||
|
it('detects duplicate usernames case-insensitively', () => {
|
||||||
|
const existing = ['TestUser', 'Alice', 'Bob']
|
||||||
|
expect(matchCaseInsensitive('testuser', existing)).toBe(true)
|
||||||
|
expect(matchCaseInsensitive('TESTUSER', existing)).toBe(true)
|
||||||
|
expect(matchCaseInsensitive('TestUser', existing)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows unique username', () => {
|
||||||
|
const existing = ['TestUser', 'Alice']
|
||||||
|
expect(matchCaseInsensitive('Charlie', existing)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is case-insensitive for mixed-case inputs', () => {
|
||||||
|
const existing = ['alice']
|
||||||
|
expect(matchCaseInsensitive('Alice', existing)).toBe(true)
|
||||||
|
expect(matchCaseInsensitive('ALICE', existing)).toBe(true)
|
||||||
|
expect(matchCaseInsensitive('aLiCe', existing)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// One-time username change logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('one-time username change guard', () => {
|
||||||
|
it('blocks rename when username_changed is truthy', () => {
|
||||||
|
const user = { username_changed: 1 }
|
||||||
|
expect(!!user.username_changed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows rename when username_changed is falsy', () => {
|
||||||
|
const user = { username_changed: 0 }
|
||||||
|
expect(!!user.username_changed).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows rename when username_changed is undefined', () => {
|
||||||
|
const user = {}
|
||||||
|
expect(!!user.username_changed).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,14 +37,6 @@
|
|||||||
class="login-input"
|
class="login-input"
|
||||||
@keydown.enter="submit"
|
@keydown.enter="submit"
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
v-if="mode === 'register'"
|
|
||||||
v-model="displayName"
|
|
||||||
type="text"
|
|
||||||
placeholder="显示名称(可选)"
|
|
||||||
class="login-input"
|
|
||||||
@keydown.enter="submit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
|
||||||
|
|
||||||
@@ -80,7 +72,6 @@ const mode = ref('login')
|
|||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const displayName = ref('')
|
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showFeedback = ref(false)
|
const showFeedback = ref(false)
|
||||||
@@ -109,11 +100,7 @@ async function submit() {
|
|||||||
await auth.login(username.value.trim(), password.value)
|
await auth.login(username.value.trim(), password.value)
|
||||||
ui.showToast('登录成功')
|
ui.showToast('登录成功')
|
||||||
} else {
|
} else {
|
||||||
await auth.register(
|
await auth.register(username.value.trim(), password.value)
|
||||||
username.value.trim(),
|
|
||||||
password.value,
|
|
||||||
displayName.value.trim() || username.value.trim()
|
|
||||||
)
|
|
||||||
ui.showToast('注册成功')
|
ui.showToast('注册成功')
|
||||||
}
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="usermenu-overlay" @mousedown.self="$emit('close')">
|
<div class="usermenu-overlay" @mousedown.self="$emit('close')">
|
||||||
<div class="usermenu-card">
|
<div class="usermenu-card">
|
||||||
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div>
|
<div class="usermenu-name">
|
||||||
|
{{ auth.user.username }}
|
||||||
|
<button v-if="!auth.user.username_changed" class="rename-btn" @click="changeUsername">✏️ 改名</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="usermenu-actions">
|
<div class="usermenu-actions">
|
||||||
<button class="usermenu-btn" @click="goMyDiary">
|
<button class="usermenu-btn" @click="goMyDiary">
|
||||||
@@ -38,6 +41,9 @@
|
|||||||
<div v-if="!n.is_read" class="notif-actions">
|
<div v-if="!n.is_read" class="notif-actions">
|
||||||
<!-- 搜索未收录通知:已添加按钮 -->
|
<!-- 搜索未收录通知:已添加按钮 -->
|
||||||
<button v-if="isSearchMissing(n)" class="notif-action-btn notif-btn-added" @click="markAdded(n)">已添加</button>
|
<button v-if="isSearchMissing(n)" class="notif-action-btn notif-btn-added" @click="markAdded(n)">已添加</button>
|
||||||
|
<!-- 用户名更新通知:去修改按钮(已改过则显示已读) -->
|
||||||
|
<button v-else-if="isUsernameNotice(n) && !auth.user.username_changed" class="notif-action-btn notif-btn-plan" @click="goRename(n)">去修改</button>
|
||||||
|
<button v-else-if="isUsernameNotice(n) && auth.user.username_changed" class="notif-mark-one" @click="markOneRead(n)">已读</button>
|
||||||
<!-- 审核类通知:去审核按钮 -->
|
<!-- 审核类通知:去审核按钮 -->
|
||||||
<button v-else-if="isReviewable(n)" class="notif-action-btn notif-btn-review" @click="goReview(n)">去审核</button>
|
<button v-else-if="isReviewable(n)" class="notif-action-btn notif-btn-review" @click="goReview(n)">去审核</button>
|
||||||
<!-- 默认:已读按钮 -->
|
<!-- 默认:已读按钮 -->
|
||||||
@@ -129,6 +135,36 @@ function isSearchMissing(n) {
|
|||||||
return n.title && n.title.includes('用户需求')
|
return n.title && n.title.includes('用户需求')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeUsername() {
|
||||||
|
const { showPrompt } = await import('../composables/useDialog')
|
||||||
|
const newName = await showPrompt('输入新用户名(只能修改一次):', auth.user.username)
|
||||||
|
if (!newName || !newName.trim() || newName.trim() === auth.user.username) return
|
||||||
|
try {
|
||||||
|
const res = await api('/api/me/username', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ username: newName.trim() }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
await auth.loadMe()
|
||||||
|
ui.showToast('用户名已修改')
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
ui.showToast(err.detail || '修改失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ui.showToast('修改失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsernameNotice(n) {
|
||||||
|
return n.title && n.title.includes('用户名更新')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goRename(n) {
|
||||||
|
markOneRead(n)
|
||||||
|
changeUsername()
|
||||||
|
}
|
||||||
|
|
||||||
function isReviewable(n) {
|
function isReviewable(n) {
|
||||||
if (!n.title) return false
|
if (!n.title) return false
|
||||||
// Admin: review recipe/business/applications
|
// Admin: review recipe/business/applications
|
||||||
@@ -215,7 +251,9 @@ onMounted(loadNotifications)
|
|||||||
z-index: 4001;
|
z-index: 4001;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 14px; }
|
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.rename-btn { background: none; border: none; font-size: 12px; color: #b0aab5; cursor: pointer; padding: 0; }
|
||||||
|
.rename-btn:hover { color: #4a9d7e; }
|
||||||
|
|
||||||
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
|
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.usermenu-btn {
|
.usermenu-btn {
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
display_name: data.display_name,
|
display_name: data.username,
|
||||||
has_password: data.has_password ?? false,
|
has_password: data.has_password ?? false,
|
||||||
business_verified: data.business_verified ?? false,
|
business_verified: data.business_verified ?? false,
|
||||||
|
username_changed: data.username_changed ?? false,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logout()
|
logout()
|
||||||
@@ -56,11 +57,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
await loadMe()
|
await loadMe()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(username, password, displayName) {
|
async function register(username, password) {
|
||||||
const data = await api.post('/api/register', {
|
const data = await api.post('/api/register', {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
display_name: displayName,
|
|
||||||
})
|
})
|
||||||
token.value = data.token
|
token.value = data.token
|
||||||
localStorage.setItem('oil_auth_token', data.token)
|
localStorage.setItem('oil_auth_token', data.token)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="inventory-page">
|
<div class="inventory-page">
|
||||||
|
<!-- Login prompt -->
|
||||||
|
<div v-if="!auth.isLoggedIn" class="login-prompt">
|
||||||
|
<p>登录后可管理个人库存</p>
|
||||||
|
<button class="btn-primary" @click="ui.openLogin()">登录 / 注册</button>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
<!-- Search + direct add -->
|
<!-- Search + direct add -->
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input
|
<input
|
||||||
@@ -84,6 +90,7 @@
|
|||||||
<div v-else class="empty-hint">
|
<div v-else class="empty-hint">
|
||||||
{{ ownedOils.length ? '暂无完全匹配的配方' : '添加精油后自动推荐配方' }}
|
{{ ownedOils.length ? '暂无完全匹配的配方' : '添加精油后自动推荐配方' }}
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -473,4 +480,7 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-prompt { text-align: center; padding: 60px 20px; color: #6b6375; }
|
||||||
|
.login-prompt p { margin-bottom: 16px; font-size: 15px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -208,22 +208,6 @@
|
|||||||
|
|
||||||
<!-- Account Tab -->
|
<!-- Account Tab -->
|
||||||
<div v-if="activeTab === 'account'" class="tab-content">
|
<div v-if="activeTab === 'account'" class="tab-content">
|
||||||
<div class="section-card">
|
|
||||||
<h4>👤 账号设置</h4>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>显示名称</label>
|
|
||||||
<input v-model="displayName" class="form-input" />
|
|
||||||
<button class="btn-primary btn-sm" style="margin-top:6px" @click="updateDisplayName">保存</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>用户名</label>
|
|
||||||
<div class="form-static">{{ auth.user.username }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h4>🔑 修改密码</h4>
|
<h4>🔑 修改密码</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -82,10 +82,7 @@
|
|||||||
<div class="user-list">
|
<div class="user-list">
|
||||||
<div v-for="u in filteredUsers" :key="u._id || u.id" class="user-card">
|
<div v-for="u in filteredUsers" :key="u._id || u.id" class="user-card">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name">
|
<div class="user-name">{{ u.username }}</div>
|
||||||
{{ u.display_name || u.username }}
|
|
||||||
<span class="user-username" v-if="u.display_name">@{{ u.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-meta">
|
<div class="user-meta">
|
||||||
<span class="user-role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
<span class="user-role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
||||||
<span v-if="u.business_verified" class="biz-badge">💼 商业认证</span>
|
<span v-if="u.business_verified" class="biz-badge">💼 商业认证</span>
|
||||||
@@ -757,4 +754,5 @@ onMounted(() => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user