diff --git a/backend/database.py b/backend/database.py index bda3ef6..f7be834 100644 --- a/backend/database.py +++ b/backend/database.py @@ -245,6 +245,12 @@ def init_db(): if "en_name" not in cols: c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''") + # Migration: soft-delete for users + if "deleted" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN deleted INTEGER DEFAULT 0") + if "deleted_at" not in user_cols: + c.execute("ALTER TABLE users ADD COLUMN deleted_at TEXT") + # Seed admin user if no users exist count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] if count == 0: diff --git a/backend/main.py b/backend/main.py index ea7b826..9bda5b1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -57,7 +57,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 FROM users WHERE token = ? AND NOT deleted", (token,)).fetchone() conn.close() if not user: return ANON_USER @@ -373,7 +373,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 FROM users WHERE username = ? AND NOT deleted", (username,)).fetchone() if not user: conn.close() raise HTTPException(401, "用户名不存在") @@ -747,7 +747,7 @@ def list_recipes(user=Depends(get_current_user)): if user["role"] == "admin": rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall() else: - admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + admin = conn.execute("SELECT id FROM users WHERE role = 'admin' AND NOT deleted LIMIT 1").fetchone() admin_id = admin["id"] if admin else 1 user_id = user.get("id") if user_id: @@ -786,7 +786,7 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)): # Senior editors adding directly to public library: set owner to admin so everyone can see owner_id = user["id"] if user["role"] in ("senior_editor",): - admin = c.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + admin = c.execute("SELECT id FROM users WHERE role = 'admin' AND NOT deleted LIMIT 1").fetchone() if admin: owner_id = admin["id"] c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)", @@ -800,7 +800,9 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)): for tag in recipe.tags: c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) c.execute("INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", (rid, tag)) - log_audit(conn, user["id"], "create_recipe", "recipe", rid, recipe.name) + # Only log for admin/senior_editor direct adds (share); others wait for adopt + if user["role"] in ("admin", "senior_editor"): + log_audit(conn, user["id"], "share_recipe", "recipe", rid, recipe.name) who = user.get("display_name") or user["username"] if user["role"] == "senior_editor": # Senior editor adds directly — just inform admin @@ -1061,7 +1063,7 @@ def delete_tag(name: str, user=Depends(require_role("admin"))): @app.get("/api/users") def list_users(user=Depends(require_role("admin"))): conn = get_db() - rows = conn.execute("SELECT id, username, token, role, display_name, created_at, business_verified FROM users ORDER BY id").fetchall() + rows = conn.execute("SELECT id, username, token, role, display_name, created_at, business_verified FROM users WHERE NOT deleted ORDER BY id").fetchall() conn.close() return [dict(r) for r in rows] @@ -1090,14 +1092,12 @@ def delete_user(user_id: int, user=Depends(require_role("admin"))): if user_id == user["id"]: raise HTTPException(400, "不能删除自己") conn = get_db() - target = conn.execute("SELECT id, username, token, role, display_name FROM users WHERE id = ?", (user_id,)).fetchone() + target = conn.execute("SELECT id, username, display_name FROM users WHERE id = ? AND NOT deleted", (user_id,)).fetchone() if not target: conn.close() raise HTTPException(404, "User not found") - snapshot = dict(target) - conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) - log_audit(conn, user["id"], "delete_user", "user", user_id, target["username"], - json.dumps(snapshot, ensure_ascii=False)) + conn.execute("UPDATE users SET deleted = 1, deleted_at = datetime('now') WHERE id = ?", (user_id,)) + log_audit(conn, user["id"], "delete_user", "user", user_id, target["username"]) conn.commit() conn.close() return {"ok": True} @@ -1141,11 +1141,15 @@ def undo_action(log_id: int, user=Depends(require_role("admin"))): action = entry["action"] detail = entry["detail"] - if not detail: + + if action == "delete_user": + # Soft-delete undo — no snapshot needed + pass + elif not detail: conn.close() raise HTTPException(400, "此操作无法撤销(无快照数据)") - snapshot = json.loads(detail) + snapshot = json.loads(detail) if detail else {} if action == "delete_recipe": # Restore recipe from snapshot @@ -1176,16 +1180,15 @@ def undo_action(log_id: int, user=Depends(require_role("admin"))): return {"ok": True} elif action == "delete_user": - try: - conn.execute( - "INSERT INTO users (username, token, role, display_name) VALUES (?, ?, ?, ?)", - (snapshot["username"], snapshot["token"], snapshot["role"], snapshot.get("display_name", ""))) - log_audit(conn, user["id"], "undo_delete_user", "user", snapshot["username"], snapshot.get("display_name"), - json.dumps({"original_log_id": log_id})) - conn.commit() - except Exception: + target_id = entry["target_id"] + target_user = conn.execute("SELECT id, username, display_name, deleted FROM users WHERE id = ?", (target_id,)).fetchone() + if not target_user or not target_user["deleted"]: conn.close() - raise HTTPException(400, "恢复失败(用户名可能已被占用)") + raise HTTPException(400, "该用户未被删除或不存在") + conn.execute("UPDATE users SET deleted = 0, deleted_at = NULL WHERE id = ?", (target_id,)) + log_audit(conn, user["id"], "undo_delete_user", "user", target_user["username"], target_user["display_name"], + json.dumps({"original_log_id": log_id})) + conn.commit() conn.close() return {"ok": True} @@ -1687,7 +1690,7 @@ def mark_notification_added(nid: int, user=Depends(get_current_user)): requester_name = body_text.split(" 搜索了")[0].strip() # Find the user requester = conn.execute( - "SELECT id, role FROM users WHERE display_name = ? OR username = ?", + "SELECT id, role FROM users WHERE (display_name = ? OR username = ?) AND NOT deleted", (requester_name, requester_name) ).fetchone() if requester: @@ -1731,7 +1734,7 @@ def weekly_review(user=Depends(require_role("admin"))): conn = get_db() # 1. Pending recipes for admin review - admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + admin = conn.execute("SELECT id FROM users WHERE role = 'admin' AND NOT deleted LIMIT 1").fetchone() admin_id = admin["id"] if admin else 1 pending = conn.execute( "SELECT COUNT(*) as cnt FROM recipes WHERE owner_id != ?", (admin_id,) @@ -1800,9 +1803,30 @@ def delete_category(cat_id: int, user=Depends(require_role("admin"))): FRONTEND_DIR = os.environ.get("FRONTEND_DIR", "/app/frontend") +def purge_deleted_users(): + """Hard-delete users that were soft-deleted more than 30 days ago, along with their related data.""" + conn = get_db() + expired = conn.execute( + "SELECT id FROM users WHERE deleted AND deleted_at < datetime('now', '-30 days')" + ).fetchall() + for row in expired: + uid = row["id"] + conn.execute("DELETE FROM user_diary WHERE user_id = ?", (uid,)) + conn.execute("DELETE FROM user_favorites WHERE user_id = ?", (uid,)) + conn.execute("DELETE FROM user_inventory WHERE user_id = ?", (uid,)) + conn.execute("DELETE FROM business_applications WHERE user_id = ?", (uid,)) + conn.execute("DELETE FROM notifications WHERE target_user_id = ?", (uid,)) + conn.execute("DELETE FROM users WHERE id = ?", (uid,)) + if expired: + conn.commit() + print(f"[CLEANUP] Purged {len(expired)} users deleted >30 days ago") + conn.close() + + @app.on_event("startup") def startup(): init_db() + purge_deleted_users() defaults_path = os.path.join(os.path.dirname(__file__), "defaults.json") if os.path.exists(defaults_path): with open(defaults_path) as f: diff --git a/frontend/src/views/AuditLog.vue b/frontend/src/views/AuditLog.vue index 1dcd140..8287c8d 100644 --- a/frontend/src/views/AuditLog.vue +++ b/frontend/src/views/AuditLog.vue @@ -82,10 +82,10 @@ const selectedUser = ref('') const selectedTarget = ref('') const ACTION_MAP = { - create_recipe: '新增配方', + share_recipe: '共享配方', + adopt_recipe: '共享配方', update_recipe: '编辑配方', delete_recipe: '删除配方', - adopt_recipe: '采纳配方', reject_recipe: '拒绝配方', undo_delete_recipe: '恢复配方', upsert_oil: '编辑精油', @@ -105,8 +105,8 @@ const ACTION_MAP = { } const actionGroups = { - '配方': ['create_recipe', 'update_recipe', 'delete_recipe', 'undo_delete_recipe'], - '审核': ['adopt_recipe', 'reject_recipe'], + '配方': ['share_recipe', 'adopt_recipe', 'update_recipe', 'delete_recipe', 'undo_delete_recipe'], + '审核': ['reject_recipe'], '精油': ['upsert_oil', 'delete_oil', 'undo_delete_oil'], '标签': ['create_tag', 'delete_tag'], '用户': ['create_user', 'update_user', 'delete_user', 'undo_delete_user', 'register'], @@ -153,7 +153,7 @@ function actionColorClass(action) { if (action.includes('create') || action.includes('upsert')) return 'color-create' if (action.includes('update')) return 'color-update' if (action.includes('delete') || action.includes('reject')) return 'color-delete' - if (action.includes('adopt') || action.includes('undo')) return 'color-approve' + if (action.includes('adopt') || action.includes('undo') || action.includes('share')) return 'color-approve' return '' } @@ -179,7 +179,13 @@ function parsedDetail(log) { } function canUndo(log) { - return ['delete_recipe', 'delete_user', 'delete_oil'].includes(log.action) + if (!['delete_recipe', 'delete_user', 'delete_oil'].includes(log.action)) return false + if (log.action === 'delete_user' && log.created_at) { + const deleted = new Date(log.created_at + 'Z') + const days = (Date.now() - deleted.getTime()) / (1000 * 60 * 60 * 24) + if (days > 30) return false + } + return true } async function undoAction(log) {