회원 탈퇴와 덱 일괄 삭제 추가

This commit is contained in:
2026-06-25 17:11:31 +09:00
parent a5b7d720d6
commit 2c4f622408
27 changed files with 613 additions and 51 deletions

View File

@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: '4.0.15' });
res.json({ ok: true, version: '4.0.16' });
});
app.use('/api/v1/auth', createAuthRouter(db));
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
});
app.listen(PORT, () => {
console.log(`UNIARE API v4.0.15 listening on :${PORT}`);
console.log(`UNIARE API v4.0.16 listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
});

View File

@@ -10,6 +10,7 @@ import {
deleteOrphanPreviewImages,
} from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
import { deleteUserAccount } from '../services/accountDeletion.js';
/**
* 회원 목록에 저장 덱 수(공개/전체) 부여
@@ -272,6 +273,33 @@ export function createAdminRouter(db) {
res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' });
});
router.delete('/users/:id', (req, res) => {
const targetId = Number(req.params.id);
if (req.user.id === targetId) {
return res.status(400).json({
error: '현재 로그인한 관리자 계정은 직접 탈퇴시킬 수 없습니다.',
});
}
if (String(req.body?.confirmation || '') !== 'DELETE') {
return res.status(400).json({
error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.',
code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED',
});
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const result = deleteUserAccount(db, targetId);
res.json({
ok: true,
...result,
message: '회원 탈퇴 처리가 완료되었습니다.',
});
});
router.post('/decks/preset', async (req, res, next) => {
try {
const { series, seriesName, name, cards, isPublic } = req.body || {};
@@ -359,6 +387,43 @@ export function createAdminRouter(db) {
res.json({ ok: true, ...deleteOrphanPreviewImages(referencedCodes) });
});
router.post('/decks/bulk-delete', (req, res) => {
const shareCodes = Array.isArray(req.body?.shareCodes)
? [...new Set(req.body.shareCodes)]
.filter((code) => typeof code === 'string' && code.length >= 1 && code.length <= 64)
.slice(0, 100)
: [];
if (!shareCodes.length) {
return res.status(400).json({ error: '삭제할 덱을 선택하세요.' });
}
const placeholders = shareCodes.map(() => '?').join(',');
const existing = db.prepare(`
SELECT share_code AS shareCode
FROM decks
WHERE share_code IN (${placeholders})
`).all(...shareCodes);
const deleteDecks = db.transaction((codes) => {
db.prepare(`
DELETE FROM decks
WHERE share_code IN (${codes.map(() => '?').join(',')})
`).run(...codes);
});
const existingCodes = existing.map((row) => row.shareCode);
if (existingCodes.length) deleteDecks(existingCodes);
let previewDeletedCount = 0;
existingCodes.forEach((shareCode) => {
if (deletePreviewImage(shareCode)) previewDeletedCount += 1;
});
res.json({
ok: true,
deletedCount: existingCodes.length,
previewDeletedCount,
});
});
router.delete('/decks/:shareCode', (req, res) => {
const row = db.prepare('SELECT id, share_code FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {

View File

@@ -6,6 +6,7 @@ import { signToken, requireAuth } from '../middleware/auth.js';
import { nowIso } from '../utils.js';
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js';
import { deleteUserAccount } from '../services/accountDeletion.js';
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u;
@@ -589,5 +590,32 @@ export function createAuthRouter(db) {
res.json({ ok: true, message: '비밀번호가 변경되었습니다.' });
});
router.delete('/me', requireAuth, (req, res) => {
const confirmation = String(req.body?.confirmation || '');
if (confirmation !== 'DELETE') {
return res.status(400).json({
error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.',
code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED',
});
}
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.user.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
if (user.role === 'admin') {
return res.status(400).json({
error: '관리자 계정은 다른 관리자가 회원 관리 화면에서 탈퇴 처리해야 합니다.',
});
}
const result = deleteUserAccount(db, user.id);
res.json({
ok: true,
...result,
message: '회원 탈퇴가 완료되었습니다.',
});
});
return router;
}

View File

@@ -0,0 +1,33 @@
import { deleteAvatarImage } from './avatarImage.js';
/**
* 사용자 계정과 개인 데이터를 영구 삭제하고 저장 덱은 익명으로 보존한다.
* @param {import('better-sqlite3').Database} db
* @param {number} userId
* @returns {{deleted: boolean, retainedDeckCount: number, avatarDeleted: boolean}}
*/
export function deleteUserAccount(db, userId) {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
if (!user) {
return {
deleted: false,
retainedDeckCount: 0,
avatarDeleted: false,
};
}
const retainedDeckCount = db.prepare(`
SELECT COUNT(*) AS count
FROM decks
WHERE user_id = ?
`).get(userId).count;
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
const avatarDeleted = deleteAvatarImage(userId);
return {
deleted: true,
retainedDeckCount,
avatarDeleted,
};
}

View File

@@ -56,10 +56,16 @@ export function saveAvatarImage(userId, dataUrl) {
/**
* 아바타 이미지 삭제
* @param {number|string} userId
* @returns {boolean}
*/
export function deleteAvatarImage(userId) {
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
if (fs.existsSync(filePath)) {
if (!fs.existsSync(filePath)) return false;
try {
fs.unlinkSync(filePath);
return true;
} catch (err) {
console.warn(`[avatar] 삭제 실패: ${filePath}`, err);
return false;
}
}

View File

@@ -66,7 +66,7 @@ async function sendEmail(message) {
* @returns {Promise<object>}
*/
export function sendVerificationEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.15&token=${encodeURIComponent(token)}`;
const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.16&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url);
return sendEmail({
@@ -91,7 +91,7 @@ export function sendVerificationEmail({ email, username, token }) {
* @returns {Promise<object>}
*/
export function sendPasswordResetEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.15&token=${encodeURIComponent(token)}`;
const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.16&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url);
return sendEmail({