Skip to content

Commit 344220a

Browse files
sscoderaticlaude
andcommitted
feat: 스프레드시트 기반 DB 시딩 및 배포 파이프라인 수정
- packages/db/src/seed-spreadsheet.ts: JSON 기반 DB 시딩 스크립트 추가 (유저/가계부/카테고리/계좌/거래) - packages/db/package.json: db:seed:spreadsheet 커맨드 추가 - .gitignore: packages/db/scripts/ 전체 제외 (개인 데이터 및 시딩 스크립트 보호) - apps/web/Dockerfile: ARG API_URL=http://api:4000 추가 (빌드 타임 proxy 타겟 주입) - .github/workflows/deploy.yml: web 이미지 빌드 시 build-args API_URL 추가 - docker-compose.yml: web 서비스 build args 설정 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 0468247 commit 344220a

6 files changed

Lines changed: 324 additions & 4 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,6 @@ jobs:
7474
platforms: linux/arm64
7575
push: true
7676
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_WEB }}:latest
77+
build-args: API_URL=http://api:4000
7778
cache-from: type=gha
7879
cache-to: type=gha,mode=max

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,7 @@ yarn-error.log*
3838
*.pem
3939

4040
# IDE
41-
.idea/
41+
.idea/
42+
43+
# Seed scripts & data (private - not for public repo)
44+
packages/db/scripts/

apps/web/Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ RUN turbo prune @marrylife/web --docker
77
FROM node:22-alpine AS installer
88
RUN npm install -g pnpm
99
WORKDIR /app
10+
11+
# vite.config.ts의 nitro routeRules proxy 타겟이 빌드 타임에 결정되므로
12+
# API_URL을 빌드 ARG로 주입해야 함
13+
ARG API_URL=http://api:4000
14+
ENV API_URL=$API_URL
15+
1016
COPY --from=builder /app/out/json/ .
1117
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
1218
RUN pnpm install

docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ services:
3232
build:
3333
context: .
3434
dockerfile: apps/web/Dockerfile
35+
args:
36+
API_URL: http://api:4000 # nitro proxy 타겟 빌드 타임 주입
3537
ports:
3638
- "3000:3000"
37-
environment:
38-
API_URL: http://api:4000
3939
depends_on:
4040
- api
4141

packages/db/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"db:migrate": "drizzle-kit migrate",
1111
"db:push": "drizzle-kit push",
1212
"db:studio": "drizzle-kit studio",
13-
"db:seed": "dotenv -e .env -- tsx src/seed.ts"
13+
"db:seed": "dotenv -e .env -- tsx src/seed.ts",
14+
"db:seed:spreadsheet": "dotenv -e .env -- tsx src/seed-spreadsheet.ts"
1415
},
1516
"keywords": [],
1617
"author": "",
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
/**
2+
* 스프레드시트 기반 시딩 스크립트
3+
*
4+
* 사전 준비:
5+
* 1. Python으로 xlsx 추출:
6+
* python3 packages/db/scripts/extract-spreadsheet.py <xlsx경로> packages/db/scripts/spreadsheet-data.json
7+
*
8+
* 2. .env에 아래 환경변수 설정:
9+
* DATABASE_URL=...
10+
* SEED_USER1_EMAIL=... (유저1 이메일)
11+
* SEED_USER1_NAME=... (유저1 이름)
12+
* SEED_USER2_EMAIL=... (유저2 이메일)
13+
* SEED_USER2_NAME=... (유저2 이름)
14+
* SEED_USER1_PASSWORD=... (유저1 비밀번호)
15+
* SEED_USER2_PASSWORD=... (유저2 비밀번호)
16+
* SPREADSHEET_DATA_PATH=packages/db/scripts/spreadsheet-data.json (선택, 기본값 사용 가능)
17+
*
18+
* 3. 실행:
19+
* pnpm --filter @marrylife/db db:seed:spreadsheet
20+
*/
21+
22+
import { and, eq } from 'drizzle-orm'
23+
import { hash } from 'bcryptjs'
24+
import { readFileSync } from 'fs'
25+
import { resolve } from 'path'
26+
import { createClient } from './index'
27+
import {
28+
accountTypeEnum,
29+
accounts,
30+
categories,
31+
categoryTypeEnum,
32+
householdLedgerMembers,
33+
householdLedgers,
34+
transactionTypeEnum,
35+
transactions,
36+
users,
37+
} from './schema'
38+
39+
// ── 타입 정의 ──────────────────────────────────────────────────────────────
40+
41+
interface SpreadsheetData {
42+
ledger: {
43+
bookName: string
44+
goal: string
45+
startYear: number
46+
fiscalStartDay: number
47+
}
48+
categories: Array<{
49+
type: string
50+
major: string
51+
minor: string
52+
defaultIsFixed: boolean
53+
sortOrder: number
54+
}>
55+
accounts: Array<{
56+
accountType: string
57+
major: string
58+
minor: string
59+
name: string
60+
amountStarting: number
61+
sortOrder: number
62+
}>
63+
transactions: Array<{
64+
date: string
65+
type: string
66+
categoryKey: string | null
67+
accountInName: string | null
68+
accountOutName: string | null
69+
amount: number
70+
memo: string | null
71+
}>
72+
}
73+
74+
// ── 메인 ───────────────────────────────────────────────────────────────────
75+
76+
async function seed() {
77+
if (process.env.NODE_ENV === 'production' && process.env.ALLOW_PROD_SEED !== 'true') {
78+
throw new Error(
79+
'production 환경에서는 ALLOW_PROD_SEED=true 설정이 없으면 seed를 실행할 수 없습니다.',
80+
)
81+
}
82+
83+
// JSON 데이터 로드
84+
const dataPath = resolve(
85+
process.env.SPREADSHEET_DATA_PATH ?? 'packages/db/scripts/spreadsheet-data.json',
86+
)
87+
let data: SpreadsheetData
88+
try {
89+
data = JSON.parse(readFileSync(dataPath, 'utf-8'))
90+
} catch {
91+
throw new Error(
92+
`JSON 파일을 읽을 수 없습니다: ${dataPath}\n` +
93+
`먼저 Python 추출 스크립트를 실행하세요:\n` +
94+
` python3 packages/db/scripts/extract-spreadsheet.py <xlsx경로> ${dataPath}`,
95+
)
96+
}
97+
98+
const db = createClient(process.env.DATABASE_URL!)
99+
const now = new Date()
100+
101+
// ── 유저 ─────────────────────────────────────────────────────────────────
102+
103+
const user1Email = process.env.SEED_USER1_EMAIL
104+
const user1Name = process.env.SEED_USER1_NAME
105+
const user2Email = process.env.SEED_USER2_EMAIL
106+
const user2Name = process.env.SEED_USER2_NAME
107+
const user1Password = process.env.SEED_USER1_PASSWORD
108+
const user2Password = process.env.SEED_USER2_PASSWORD
109+
if (!user1Email || !user1Name || !user2Email || !user2Name || !user1Password || !user2Password) {
110+
throw new Error(
111+
'SEED_USER1_EMAIL, SEED_USER1_NAME, SEED_USER2_EMAIL, SEED_USER2_NAME, ' +
112+
'SEED_USER1_PASSWORD, SEED_USER2_PASSWORD 환경변수를 모두 설정해야 합니다.',
113+
)
114+
}
115+
116+
const [hash1, hash2] = await Promise.all([hash(user1Password, 10), hash(user2Password, 10)])
117+
118+
await db
119+
.insert(users)
120+
.values({ name: user1Name, email: user1Email, passwordHash: hash1, createdAt: now, updatedAt: now })
121+
.onConflictDoUpdate({
122+
target: users.email,
123+
set: { name: user1Name, passwordHash: hash1, updatedAt: now },
124+
})
125+
126+
await db
127+
.insert(users)
128+
.values({ name: user2Name, email: user2Email, passwordHash: hash2, createdAt: now, updatedAt: now })
129+
.onConflictDoUpdate({
130+
target: users.email,
131+
set: { name: user2Name, passwordHash: hash2, updatedAt: now },
132+
})
133+
134+
const [user1] = await db.select().from(users).where(eq(users.email, user1Email)).limit(1)
135+
const [user2] = await db.select().from(users).where(eq(users.email, user2Email)).limit(1)
136+
137+
if (!user1 || !user2) throw new Error('유저 생성/조회 실패')
138+
139+
// ── 가계부 ────────────────────────────────────────────────────────────────
140+
141+
let [ledger] = await db
142+
.select()
143+
.from(householdLedgers)
144+
.where(eq(householdLedgers.bookName, data.ledger.bookName))
145+
.limit(1)
146+
147+
if (!ledger) {
148+
await db.insert(householdLedgers).values({
149+
bookName: data.ledger.bookName,
150+
goal: data.ledger.goal,
151+
startYear: data.ledger.startYear,
152+
fiscalStartDay: data.ledger.fiscalStartDay,
153+
createdAt: now,
154+
updatedAt: now,
155+
})
156+
;[ledger] = await db
157+
.select()
158+
.from(householdLedgers)
159+
.where(eq(householdLedgers.bookName, data.ledger.bookName))
160+
.limit(1)
161+
}
162+
163+
if (!ledger) throw new Error('가계부 생성/조회 실패')
164+
165+
await db
166+
.insert(householdLedgerMembers)
167+
.values([
168+
{ householdLedgerId: ledger.id, userId: user1.id, joinedAt: now },
169+
{ householdLedgerId: ledger.id, userId: user2.id, joinedAt: now },
170+
])
171+
.onConflictDoNothing()
172+
173+
// ── 카테고리 ──────────────────────────────────────────────────────────────
174+
175+
for (const cat of data.categories) {
176+
const [existing] = await db
177+
.select({ id: categories.id })
178+
.from(categories)
179+
.where(
180+
and(
181+
eq(categories.householdLedgerId, ledger.id),
182+
eq(categories.type, cat.type as (typeof categoryTypeEnum.enumValues)[number]),
183+
eq(categories.major, cat.major),
184+
eq(categories.minor, cat.minor),
185+
),
186+
)
187+
.limit(1)
188+
189+
if (existing) continue
190+
191+
await db.insert(categories).values({
192+
householdLedgerId: ledger.id,
193+
type: cat.type as (typeof categoryTypeEnum.enumValues)[number],
194+
major: cat.major,
195+
minor: cat.minor,
196+
defaultIsFixed: cat.defaultIsFixed,
197+
sortOrder: cat.sortOrder,
198+
isDeleted: false,
199+
})
200+
}
201+
202+
const seededCategories = await db
203+
.select()
204+
.from(categories)
205+
.where(eq(categories.householdLedgerId, ledger.id))
206+
207+
const categoryMap = new Map(seededCategories.map((c) => [`${c.major}:${c.minor}`, c.id]))
208+
209+
// ── 계좌 ──────────────────────────────────────────────────────────────────
210+
211+
for (const acc of data.accounts) {
212+
const [existing] = await db
213+
.select({ id: accounts.id })
214+
.from(accounts)
215+
.where(
216+
and(
217+
eq(accounts.householdLedgerId, ledger.id),
218+
eq(accounts.name, acc.name),
219+
),
220+
)
221+
.limit(1)
222+
223+
if (existing) continue
224+
225+
await db.insert(accounts).values({
226+
householdLedgerId: ledger.id,
227+
accountType: acc.accountType as (typeof accountTypeEnum.enumValues)[number],
228+
major: acc.major,
229+
minor: acc.minor,
230+
name: acc.name,
231+
amountStarting: acc.amountStarting,
232+
isHidden: false,
233+
sortOrder: acc.sortOrder,
234+
isDeleted: false,
235+
})
236+
}
237+
238+
const seededAccounts = await db
239+
.select()
240+
.from(accounts)
241+
.where(eq(accounts.householdLedgerId, ledger.id))
242+
243+
const accountMap = new Map(seededAccounts.map((a) => [a.name, a.id]))
244+
245+
// ── 거래 ──────────────────────────────────────────────────────────────────
246+
247+
// 중복 방지: 동일한 date+type+amount+memo 조합은 skip
248+
const existingTxs = await db
249+
.select({ date: transactions.transactionDate, type: transactions.type, amount: transactions.amount, memo: transactions.memo })
250+
.from(transactions)
251+
.where(eq(transactions.householdLedgerId, ledger.id))
252+
253+
const existingKeys = new Set(
254+
existingTxs.map((tx) => `${tx.date}|${tx.type}|${tx.amount}|${tx.memo ?? ''}`),
255+
)
256+
257+
const toInsert = []
258+
let skipped = 0
259+
260+
for (const tx of data.transactions) {
261+
const key = `${tx.date}|${tx.type}|${tx.amount}|${tx.memo ?? ''}`
262+
if (existingKeys.has(key)) {
263+
skipped++
264+
continue
265+
}
266+
existingKeys.add(key) // 같은 배치 내 중복 방지
267+
268+
const categoryId = tx.categoryKey ? (categoryMap.get(tx.categoryKey) ?? null) : null
269+
const accountInId = tx.accountInName ? (accountMap.get(tx.accountInName) ?? null) : null
270+
const accountOutId = tx.accountOutName ? (accountMap.get(tx.accountOutName) ?? null) : null
271+
272+
toInsert.push({
273+
householdLedgerId: ledger.id,
274+
createdBy: user1.id,
275+
transactionDate: tx.date,
276+
type: tx.type as (typeof transactionTypeEnum.enumValues)[number],
277+
categoryId,
278+
amount: tx.amount,
279+
accountInId,
280+
accountOutId,
281+
isFixed: false,
282+
memo: tx.memo ?? null,
283+
installmentId: null,
284+
createdAt: now,
285+
updatedAt: now,
286+
})
287+
}
288+
289+
// 배치 삽입 (500건씩 나눠서 insert)
290+
const BATCH = 500
291+
for (let i = 0; i < toInsert.length; i += BATCH) {
292+
await db.insert(transactions).values(toInsert.slice(i, i + BATCH))
293+
}
294+
295+
console.log(
296+
`Seed complete:\n` +
297+
` users : 2 (${user1Email}, ${user2Email})\n` +
298+
` ledger : ${data.ledger.bookName}\n` +
299+
` categories : ${seededCategories.length}\n` +
300+
` accounts : ${seededAccounts.length}\n` +
301+
` transactions: ${toInsert.length} inserted, ${skipped} skipped`,
302+
)
303+
process.exit(0)
304+
}
305+
306+
seed().catch((err) => {
307+
console.error(err)
308+
process.exit(1)
309+
})

0 commit comments

Comments
 (0)