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

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

@@ -1,6 +1,6 @@
{
"name": "uadeck-api",
"version": "4.0.15",
"version": "4.0.16",
"type": "module",
"main": "src/index.js",
"scripts": {

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({

View File

@@ -8,10 +8,12 @@ import express from 'express';
import { initDb } from '../src/db.js';
import { createAdminRouter } from '../src/routes/admin.js';
import { createAuthRouter } from '../src/routes/auth.js';
import { deleteUserAccount } from '../src/services/accountDeletion.js';
test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-'));
const dbPath = path.join(tempDir, 'test.db');
process.env.AVATARS_PATH = path.join(tempDir, 'avatars');
const db = initDb(dbPath);
try {
@@ -34,6 +36,23 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(oldUserId);
const referenceUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'reference@example.com',
'reference',
'reference-password-hash',
'reference',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(referenceUserId);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day'))
@@ -43,7 +62,7 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
VALUES (?, 'verify_email', '2026-06', 'sent')
`).run(oldUserId);
db.prepare('DELETE FROM users WHERE id = ?').run(oldUserId);
const deletionResult = deleteUserAccount(db, oldUserId);
const newUserId = Number(
db.prepare(`
@@ -58,12 +77,22 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1'
`).get();
assert.equal(deletionResult.deleted, true);
assert.equal(deletionResult.retainedDeckCount, 1);
assert.ok(newUserId > oldUserId);
assert.equal(oldDeck.userId, null);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM deck_favorites
WHERE user_id = ? AND share_code = 'OLDDECK1'
`).get(referenceUserId).count,
1,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count,
0,
@@ -85,23 +114,25 @@ test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정
}
});
test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수 있다', async () => {
test('이메일 승인·본인 탈퇴·관리자 탈퇴·덱 일괄 삭제 API가 동작한다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-'));
const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath);
process.env.JWT_SECRET = 'test-secret-for-admin-email-verification';
const adminPassword = 'admin-password';
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(
'admin@example.com',
'admin',
bcrypt.hashSync(adminPassword, 4),
'admin',
const adminUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(
'admin@example.com',
'admin',
bcrypt.hashSync(adminPassword, 4),
'admin',
).lastInsertRowid,
);
const targetUserId = Number(
db.prepare(`
@@ -120,6 +151,33 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day'))
`).run(targetUserId);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('PENDING1', 'user', ?, 'ua01', '테스트', '탈퇴 후 보존 덱', '[]', 1)
`).run(targetUserId);
const forcedUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'forced@example.com',
'forced',
bcrypt.hashSync('forced-password', 4),
'forced',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES
('BULKDEL1', 'user', ?, 'ua01', '테스트', '일괄 삭제 1', '[]', 1),
('BULKDEL2', 'user', ?, 'ua01', '테스트', '일괄 삭제 2', '[]', 1)
`).run(adminUserId, adminUserId);
const app = express();
app.use(express.json());
@@ -143,6 +201,16 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
assert.equal(loginResponse.status, 200);
const { token } = await loginResponse.json();
const adminSelfDeleteResponse = await fetch(`${baseUrl}/admin/users/${adminUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminSelfDeleteResponse.status, 400);
const approveResponse = await fetch(
`${baseUrl}/admin/users/${targetUserId}/verify-email`,
{
@@ -162,6 +230,78 @@ test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수
`).get(targetUserId);
assert.ok(target.emailVerifiedAt);
assert.ok(pendingToken.usedAt);
const pendingLoginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'pending@example.com',
password: 'pending-password',
}),
});
assert.equal(pendingLoginResponse.status, 200);
const { token: pendingUserToken } = await pendingLoginResponse.json();
const invalidSelfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'delete' }),
});
assert.equal(invalidSelfDeleteResponse.status, 400);
const selfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(selfDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(targetUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'PENDING1'
`).get().userId,
null,
);
const adminDeleteResponse = await fetch(`${baseUrl}/admin/users/${forcedUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(forcedUserId).count,
0,
);
const bulkDeleteResponse = await fetch(`${baseUrl}/admin/decks/bulk-delete`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ shareCodes: ['BULKDEL1', 'BULKDEL2'] }),
});
assert.equal(bulkDeleteResponse.status, 200);
const bulkDeleteResult = await bulkDeleteResponse.json();
assert.equal(bulkDeleteResult.deletedCount, 2);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM decks WHERE share_code IN ('BULKDEL1', 'BULKDEL2')
`).get().count,
0,
);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));