feat: 用户软删除+30天自动清理,操作日志共享配方统一显示
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 17s
Test / e2e-test (push) Has been cancelled
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 17s
Test / e2e-test (push) Has been cancelled
用户删除改为软删除(deleted标记),撤销时数据完整恢复(配方/收藏/库存等)。 30天后自动硬删除清理。操作日志中共享配方和采纳配方统一显示为"共享配方"。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -245,6 +245,12 @@ def init_db():
|
|||||||
if "en_name" not in cols:
|
if "en_name" not in cols:
|
||||||
c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''")
|
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
|
# Seed admin user if no users exist
|
||||||
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
if count == 0:
|
if count == 0:
|
||||||
|
|||||||
@@ -57,7 +57,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 FROM users WHERE token = ? AND NOT deleted", (token,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not user:
|
if not user:
|
||||||
return ANON_USER
|
return ANON_USER
|
||||||
@@ -373,7 +373,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 FROM users WHERE username = ? AND NOT deleted", (username,)).fetchone()
|
||||||
if not user:
|
if not user:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(401, "用户名不存在")
|
raise HTTPException(401, "用户名不存在")
|
||||||
@@ -747,7 +747,7 @@ def list_recipes(user=Depends(get_current_user)):
|
|||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
|
rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall()
|
||||||
else:
|
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
|
admin_id = admin["id"] if admin else 1
|
||||||
user_id = user.get("id")
|
user_id = user.get("id")
|
||||||
if user_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
|
# Senior editors adding directly to public library: set owner to admin so everyone can see
|
||||||
owner_id = user["id"]
|
owner_id = user["id"]
|
||||||
if user["role"] in ("senior_editor",):
|
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:
|
if admin:
|
||||||
owner_id = admin["id"]
|
owner_id = admin["id"]
|
||||||
c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)",
|
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:
|
for tag in recipe.tags:
|
||||||
c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
|
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))
|
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"]
|
who = user.get("display_name") or user["username"]
|
||||||
if user["role"] == "senior_editor":
|
if user["role"] == "senior_editor":
|
||||||
# Senior editor adds directly — just inform admin
|
# 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")
|
@app.get("/api/users")
|
||||||
def list_users(user=Depends(require_role("admin"))):
|
def list_users(user=Depends(require_role("admin"))):
|
||||||
conn = get_db()
|
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()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
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"]:
|
if user_id == user["id"]:
|
||||||
raise HTTPException(400, "不能删除自己")
|
raise HTTPException(400, "不能删除自己")
|
||||||
conn = get_db()
|
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:
|
if not target:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, "User not found")
|
raise HTTPException(404, "User not found")
|
||||||
snapshot = dict(target)
|
conn.execute("UPDATE users SET deleted = 1, deleted_at = datetime('now') WHERE id = ?", (user_id,))
|
||||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
log_audit(conn, user["id"], "delete_user", "user", user_id, target["username"])
|
||||||
log_audit(conn, user["id"], "delete_user", "user", user_id, target["username"],
|
|
||||||
json.dumps(snapshot, ensure_ascii=False))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -1141,11 +1141,15 @@ def undo_action(log_id: int, user=Depends(require_role("admin"))):
|
|||||||
|
|
||||||
action = entry["action"]
|
action = entry["action"]
|
||||||
detail = entry["detail"]
|
detail = entry["detail"]
|
||||||
if not detail:
|
|
||||||
|
if action == "delete_user":
|
||||||
|
# Soft-delete undo — no snapshot needed
|
||||||
|
pass
|
||||||
|
elif not detail:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(400, "此操作无法撤销(无快照数据)")
|
raise HTTPException(400, "此操作无法撤销(无快照数据)")
|
||||||
|
|
||||||
snapshot = json.loads(detail)
|
snapshot = json.loads(detail) if detail else {}
|
||||||
|
|
||||||
if action == "delete_recipe":
|
if action == "delete_recipe":
|
||||||
# Restore recipe from snapshot
|
# Restore recipe from snapshot
|
||||||
@@ -1176,16 +1180,15 @@ def undo_action(log_id: int, user=Depends(require_role("admin"))):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
elif action == "delete_user":
|
elif action == "delete_user":
|
||||||
try:
|
target_id = entry["target_id"]
|
||||||
conn.execute(
|
target_user = conn.execute("SELECT id, username, display_name, deleted FROM users WHERE id = ?", (target_id,)).fetchone()
|
||||||
"INSERT INTO users (username, token, role, display_name) VALUES (?, ?, ?, ?)",
|
if not target_user or not target_user["deleted"]:
|
||||||
(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:
|
|
||||||
conn.close()
|
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()
|
conn.close()
|
||||||
return {"ok": True}
|
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()
|
requester_name = body_text.split(" 搜索了")[0].strip()
|
||||||
# Find the user
|
# Find the user
|
||||||
requester = conn.execute(
|
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)
|
(requester_name, requester_name)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if requester:
|
if requester:
|
||||||
@@ -1731,7 +1734,7 @@ def weekly_review(user=Depends(require_role("admin"))):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|
||||||
# 1. Pending recipes for admin review
|
# 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
|
admin_id = admin["id"] if admin else 1
|
||||||
pending = conn.execute(
|
pending = conn.execute(
|
||||||
"SELECT COUNT(*) as cnt FROM recipes WHERE owner_id != ?", (admin_id,)
|
"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")
|
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")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
init_db()
|
init_db()
|
||||||
|
purge_deleted_users()
|
||||||
defaults_path = os.path.join(os.path.dirname(__file__), "defaults.json")
|
defaults_path = os.path.join(os.path.dirname(__file__), "defaults.json")
|
||||||
if os.path.exists(defaults_path):
|
if os.path.exists(defaults_path):
|
||||||
with open(defaults_path) as f:
|
with open(defaults_path) as f:
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ const selectedUser = ref('')
|
|||||||
const selectedTarget = ref('')
|
const selectedTarget = ref('')
|
||||||
|
|
||||||
const ACTION_MAP = {
|
const ACTION_MAP = {
|
||||||
create_recipe: '新增配方',
|
share_recipe: '共享配方',
|
||||||
|
adopt_recipe: '共享配方',
|
||||||
update_recipe: '编辑配方',
|
update_recipe: '编辑配方',
|
||||||
delete_recipe: '删除配方',
|
delete_recipe: '删除配方',
|
||||||
adopt_recipe: '采纳配方',
|
|
||||||
reject_recipe: '拒绝配方',
|
reject_recipe: '拒绝配方',
|
||||||
undo_delete_recipe: '恢复配方',
|
undo_delete_recipe: '恢复配方',
|
||||||
upsert_oil: '编辑精油',
|
upsert_oil: '编辑精油',
|
||||||
@@ -105,8 +105,8 @@ const ACTION_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionGroups = {
|
const actionGroups = {
|
||||||
'配方': ['create_recipe', 'update_recipe', 'delete_recipe', 'undo_delete_recipe'],
|
'配方': ['share_recipe', 'adopt_recipe', 'update_recipe', 'delete_recipe', 'undo_delete_recipe'],
|
||||||
'审核': ['adopt_recipe', 'reject_recipe'],
|
'审核': ['reject_recipe'],
|
||||||
'精油': ['upsert_oil', 'delete_oil', 'undo_delete_oil'],
|
'精油': ['upsert_oil', 'delete_oil', 'undo_delete_oil'],
|
||||||
'标签': ['create_tag', 'delete_tag'],
|
'标签': ['create_tag', 'delete_tag'],
|
||||||
'用户': ['create_user', 'update_user', 'delete_user', 'undo_delete_user', 'register'],
|
'用户': ['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('create') || action.includes('upsert')) return 'color-create'
|
||||||
if (action.includes('update')) return 'color-update'
|
if (action.includes('update')) return 'color-update'
|
||||||
if (action.includes('delete') || action.includes('reject')) return 'color-delete'
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,13 @@ function parsedDetail(log) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canUndo(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) {
|
async function undoAction(log) {
|
||||||
|
|||||||
Reference in New Issue
Block a user