회원 탈퇴와 덱 일괄 삭제 추가
This commit is contained in:
@@ -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)'}`);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
33
api/src/services/accountDeletion.js
Normal file
33
api/src/services/accountDeletion.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user