feat: add group recipe sharing and translation

This commit is contained in:
2026-08-14 16:46:01 +09:00
parent 146e78cddf
commit 4491c91455
13 changed files with 170 additions and 19 deletions
+19 -8
View File
@@ -5,11 +5,12 @@ Instagram 게시물과 YouTube 영상의 텍스트를 레시피로 구조화해
## 현재 구현 범위
- Google Authorization Code/OIDC 로그인, PKCE `S256`, 이메일 allowlist
- HttpOnly 자체 인증 cookie와 소유자별 API 접근 제어
- HttpOnly 자체 인증 cookie와 그룹 멤버 간 recipe 공유
- Instagram caption 추출 및 YouTube 설명·자막·timestamp 추출
- MiniMax OpenAI 호환 Chat Completions API를 이용한 레시피 구조화
- 영어 등 외국어 원문의 recipe 필드를 자연스러운 한국어로 번역
- AI 결과 미리보기/편집 후 저장
- Recipe CRUD, 소유자·원본별 중복 방지
- Recipe CRUD, 그룹 멤버 조회와 소유자·원본별 중복 방지
- 외부 이미지를 최대 1280px WebP로 변환해 `IMAGE_ROOT`에 저장
- 반응형 Recipe 목록/상세 화면과 YouTube timestamp 링크
- nginx, systemd 운영 예제
@@ -158,12 +159,22 @@ ID와 비밀번호로 서버에서 자동 로그인하지 않습니다. cookie
MongoDB는 `our_recipe_atlas` 하나만 사용하고 collection으로 환경을 분리합니다.
- 공통: `allowed_google_emails`
- 개발: `users_dev`, `recipes_dev`
- 운영: `users`, `recipes`
- 개발: `users_dev`, `groups_dev`, `recipes_dev`
- 운영: `users`, `groups`, `recipes`
-`users*.googleSub`: unique index
-`groups*.memberGoogleSubs`: 조회 index
-`recipes*`: `ownerGoogleSub + source.platform + source.sourceId` unique index
- 원본 text와 YouTube transcript를 recipe source에 보존해 재분석에 사용할 수 있습니다.
그룹 문서는 recipe를 함께 볼 Google 계정의 `sub`를 보관합니다. 그룹이 없는 사용자는 자기 recipe만 볼 수 있고, 같은 그룹의 recipe는 함께 조회하되 수정과 삭제는 작성자만 할 수 있습니다.
```json
{
"name": "우리 가족",
"memberGoogleSubs": ["google-sub-1", "google-sub-2"]
}
```
이미지의 DB 값은 다음과 같은 상대 경로뿐입니다.
```text
@@ -174,13 +185,13 @@ recipes/<recipe-id>/cover.webp
## API
모든 `/api/**` endpoint는 인증 cookie가 필요하며 로그인한 사용자의 데이터만 반환합니다.
모든 `/api/**` endpoint는 인증 cookie가 필요합니다. Recipe 목록과 상세는 같은 그룹 멤버에게 공유되고, 생성·수정·삭제 권한은 작성자에게 유지됩니다.
| Method | Path | 역할 |
| --- | --- | --- |
| `POST` | `/api/import/preview` | URL 추출 및 AI recipe 미리보기 |
| `GET` | `/api/recipes` | 내 recipe 목록 |
| `GET` | `/api/recipes/:id` | 내 recipe 상세 |
| `GET` | `/api/recipes` | 내 그룹의 recipe 목록 |
| `GET` | `/api/recipes/:id` | 내 그룹의 recipe 상세 |
| `POST` | `/api/recipes` | 미리보기 확인 후 저장 |
| `PATCH` | `/api/recipes/:id` | recipe 필드 수정 |
| `DELETE` | `/api/recipes/:id` | recipe와 로컬 이미지 삭제 |
@@ -199,7 +210,7 @@ npm test
npm run lint
```
테스트 범위에는 URL/ID 판별, AI schema, timestamp 정규화, allowlist, 인증 middleware, 소유자 격리, CRUD, 중복 source 처리, 이미지 상대경로와 SSRF 차단이 포함됩니다.
테스트 범위에는 URL/ID 판별, AI schema, timestamp 정규화, allowlist, 인증 middleware, 그룹 공유와 소유자 쓰기 권한, CRUD, 중복 source 처리, 이미지 상대경로와 SSRF 차단이 포함됩니다.
실제 계정과 네트워크가 준비된 뒤에는 별도 smoke test로 다음을 확인합니다.
+8 -4
View File
@@ -8,6 +8,7 @@ const detail = document.querySelector('#recipe-detail');
const toast = document.querySelector('#toast');
const scrollToTop = document.querySelector('#scroll-to-top');
let currentRecipe = null;
let currentUser = null;
let toastTimer;
bindScrollToTop(scrollToTop);
@@ -166,6 +167,7 @@ function renderRecipeEditor(recipe) {
function renderRecipe(recipe) {
document.title = `${recipe.title} · Our Recipe Atlas`;
const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null;
const canEdit = recipe.ownerGoogleSub === currentUser.googleSub;
detail.innerHTML = `
<a class="back-link" href="/">← Recipe Library</a>
<article class="recipe-detail">
@@ -177,7 +179,7 @@ function renderRecipe(recipe) {
${recipe.summary ? `<p>${escapeHtml(recipe.summary)}</p>` : ''}
${recipe.servings ? `<p class="servings">분량 · ${escapeHtml(recipe.servings)}</p>` : ''}
<div class="detail-actions">
<button id="edit-recipe" class="button button-primary" type="button">레시피 수정</button>
${canEdit ? '<button id="edit-recipe" class="button button-primary" type="button">레시피 수정</button>' : ''}
<a class="button button-secondary" href="${escapeHtml(recipe.source.url)}" target="_blank" rel="noreferrer">원본 보기 ↗</a>
</div>
</div>
@@ -205,12 +207,13 @@ function renderRecipe(recipe) {
</div>
<footer class="detail-footer">
<div>${recipe.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>
<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button>
${canEdit ? '<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button>' : ''}
</footer>
</article>`;
document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe));
if (!canEdit) return;
document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe));
document.querySelector('#delete-recipe').addEventListener('click', async () => {
if (!window.confirm(`'${recipe.title}' 레시피를 삭제할까요?`)) return;
try {
@@ -223,7 +226,8 @@ function renderRecipe(recipe) {
}
bindLogout();
appBar.setUser(await loadSession());
currentUser = await loadSession();
appBar.setUser(currentUser);
try {
const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop());
+2
View File
@@ -55,10 +55,12 @@ export async function buildApp({ config = loadConfig(), dependencies = {} } = {}
if (
dependencies.allowedEmailRepository
&& dependencies.groupRepository
&& dependencies.recipeRepository
&& dependencies.userRepository
) {
app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository);
app.decorate('groupRepository', dependencies.groupRepository);
app.decorate('recipeRepository', dependencies.recipeRepository);
app.decorate('userRepository', dependencies.userRepository);
} else {
+1
View File
@@ -37,6 +37,7 @@ export function mongoCollectionNames(nodeEnv) {
const suffix = nodeEnv === 'development' ? '_dev' : nodeEnv === 'test' ? '_test' : '';
return Object.freeze({
allowedEmails: 'allowed_google_emails',
groups: `groups${suffix}`,
recipes: `recipes${suffix}`,
users: `users${suffix}`,
});
+4
View File
@@ -1,6 +1,7 @@
import { MongoClient } from 'mongodb';
import fastifyPlugin from 'fastify-plugin';
import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js';
import { GroupRepository } from '../repositories/group.repository.js';
import { RecipeRepository } from '../repositories/recipe.repository.js';
import { UserRepository } from '../repositories/user.repository.js';
@@ -49,11 +50,13 @@ async function mongoPlugin(fastify, { config }) {
const db = client.db();
const collections = config.mongoCollections;
const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails);
const groupRepository = new GroupRepository(db, collections.groups);
const recipeRepository = new RecipeRepository(db, collections.recipes);
const userRepository = new UserRepository(db, collections.users);
await Promise.all([
allowedEmailRepository.ensureIndexes(),
groupRepository.ensureIndexes(),
recipeRepository.ensureIndexes(),
userRepository.ensureIndexes(),
]);
@@ -65,6 +68,7 @@ async function mongoPlugin(fastify, { config }) {
fastify.decorate('mongoClient', client);
fastify.decorate('db', db);
fastify.decorate('allowedEmailRepository', allowedEmailRepository);
fastify.decorate('groupRepository', groupRepository);
fastify.decorate('recipeRepository', recipeRepository);
fastify.decorate('userRepository', userRepository);
+24
View File
@@ -0,0 +1,24 @@
export class GroupRepository {
constructor(db, collectionName = 'groups') {
this.collection = db.collection(collectionName);
}
async ensureIndexes() {
await this.collection.createIndex({ memberGoogleSubs: 1 });
}
async listMemberGoogleSubs(googleSub) {
const groups = await this.collection
.find(
{ memberGoogleSubs: googleSub },
{ projection: { _id: 0, memberGoogleSubs: 1 } },
)
.toArray();
return [...new Set(
groups
.flatMap((group) => group.memberGoogleSubs ?? [])
.filter((memberGoogleSub) => typeof memberGoogleSub === 'string'),
)];
}
}
+12 -1
View File
@@ -16,9 +16,13 @@ export class RecipeRepository {
}
async listByOwner(ownerGoogleSub) {
return this.listByOwners([ownerGoogleSub]);
}
async listByOwners(ownerGoogleSubs) {
return this.collection
.find(
{ ownerGoogleSub },
{ ownerGoogleSub: { $in: ownerGoogleSubs } },
{
projection: {
'source.rawText': 0,
@@ -34,6 +38,13 @@ export class RecipeRepository {
return this.collection.findOne({ _id: recipeId, ownerGoogleSub });
}
async findByIdForOwners(ownerGoogleSubs, recipeId) {
return this.collection.findOne({
_id: recipeId,
ownerGoogleSub: { $in: ownerGoogleSubs },
});
}
async findBySource(ownerGoogleSub, platform, sourceId) {
return this.collection.findOne({
ownerGoogleSub,
+14 -4
View File
@@ -29,13 +29,23 @@ function verifiedSource(source) {
return source;
}
async function visibleOwnerGoogleSubs(fastify, googleSub) {
const groupMembers = await fastify.groupRepository.listMemberGoogleSubs(googleSub);
return [...new Set([googleSub, ...groupMembers])];
}
export default async function recipeRoutes(fastify) {
fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => ({
recipes: await fastify.recipeRepository.listByOwner(request.user.sub),
}));
fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => {
const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub);
return { recipes: await fastify.recipeRepository.listByOwners(ownerGoogleSubs) };
});
fastify.get('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => {
const recipe = await fastify.recipeRepository.findById(request.user.sub, request.params.id);
const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub);
const recipe = await fastify.recipeRepository.findByIdForOwners(
ownerGoogleSubs,
request.params.id,
);
if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.');
return { recipe };
});
+3
View File
@@ -13,6 +13,9 @@ const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데
- 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다.
- g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다.
- 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다.
- 원문이 주로 영어 또는 다른 외국어이면 title, summary, servings, ingredientGroups, steps, tips를 자연스러운 한국어로 번역합니다.
- 번역할 때 재료와 요리의 고유명사는 의미가 달라지지 않게 유지하고, 수량, 온도, 시간 값은 변환하지 않습니다.
- 원문이 이미 한국어이면 불필요하게 문체나 표현을 바꾸지 않습니다.
- YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다.
- Instagram 단계의 timestampSec은 null입니다.
- 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다.
+56 -1
View File
@@ -6,15 +6,17 @@ import { SESSION_COOKIE_NAME } from '../src/plugins/auth.js';
import {
createTestConfig,
FakeAllowedEmailRepository,
FakeGroupRepository,
FakeRecipeRepository,
fakeUserRepository,
recipeDraft,
source,
} from './helpers/fakes.js';
function createDependencies(recipeRepository) {
function createDependencies(recipeRepository, groupRepository = new FakeGroupRepository()) {
return {
allowedEmailRepository: new FakeAllowedEmailRepository(),
groupRepository,
recipeRepository,
userRepository: fakeUserRepository,
sourceExtractor: { extract: async () => source },
@@ -336,3 +338,56 @@ test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) =>
});
assert.equal(deleteResponse.statusCode, 204);
});
test('같은 그룹 사용자는 레시피를 함께 조회하지만 작성자만 수정하고 삭제한다', async (t) => {
const recipeRepository = new FakeRecipeRepository();
const groupRepository = new FakeGroupRepository([
{ memberGoogleSubs: ['google-user-1', 'google-user-2'] },
]);
const app = await buildApp({
config: createTestConfig(),
dependencies: createDependencies(recipeRepository, groupRepository),
});
t.after(() => app.close());
const createResponse = await app.inject({
method: 'POST',
url: '/api/recipes',
headers: { cookie: authCookie(app, 'google-user-1') },
payload: { recipe: recipeDraft, source },
});
assert.equal(createResponse.statusCode, 201);
const created = createResponse.json().recipe;
const sharedListResponse = await app.inject({
method: 'GET',
url: '/api/recipes',
headers: { cookie: authCookie(app, 'google-user-2') },
});
assert.deepEqual(
sharedListResponse.json().recipes.map((recipe) => recipe._id),
[created._id],
);
const sharedDetailResponse = await app.inject({
method: 'GET',
url: `/api/recipes/${created._id}`,
headers: { cookie: authCookie(app, 'google-user-2') },
});
assert.equal(sharedDetailResponse.statusCode, 200);
const updateResponse = await app.inject({
method: 'PATCH',
url: `/api/recipes/${created._id}`,
headers: { cookie: authCookie(app, 'google-user-2') },
payload: { title: '다른 사용자의 수정' },
});
assert.equal(updateResponse.statusCode, 404);
const deleteResponse = await app.inject({
method: 'DELETE',
url: `/api/recipes/${created._id}`,
headers: { cookie: authCookie(app, 'google-user-2') },
});
assert.equal(deleteResponse.statusCode, 404);
});
+23 -1
View File
@@ -4,7 +4,12 @@ export class FakeRecipeRepository {
}
async listByOwner(ownerGoogleSub) {
return [...this.recipes.values()].filter((recipe) => recipe.ownerGoogleSub === ownerGoogleSub);
return this.listByOwners([ownerGoogleSub]);
}
async listByOwners(ownerGoogleSubs) {
return [...this.recipes.values()]
.filter((recipe) => ownerGoogleSubs.includes(recipe.ownerGoogleSub));
}
async findById(ownerGoogleSub, recipeId) {
@@ -12,6 +17,11 @@ export class FakeRecipeRepository {
return recipe?.ownerGoogleSub === ownerGoogleSub ? recipe : null;
}
async findByIdForOwners(ownerGoogleSubs, recipeId) {
const recipe = this.recipes.get(recipeId);
return ownerGoogleSubs.includes(recipe?.ownerGoogleSub) ? recipe : null;
}
async findBySource(ownerGoogleSub, platform, sourceId) {
return [...this.recipes.values()].find((recipe) => (
recipe.ownerGoogleSub === ownerGoogleSub
@@ -50,6 +60,18 @@ export class FakeRecipeRepository {
}
}
export class FakeGroupRepository {
constructor(groups = []) {
this.groups = groups;
}
async listMemberGoogleSubs(googleSub) {
return [...new Set(this.groups
.filter((group) => group.memberGoogleSubs.includes(googleSub))
.flatMap((group) => group.memberGoogleSubs))];
}
}
export class FakeAllowedEmailRepository {
constructor(accounts = [
{ email: 'google-user-1@example.com', canManageAccess: true },
+2
View File
@@ -23,6 +23,7 @@ test('Windows에서 개발용 MongoDB와 로컬 이미지 프로필을 자동
assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas');
assert.deepEqual(config.mongoCollections, {
allowedEmails: 'allowed_google_emails',
groups: 'groups_dev',
recipes: 'recipes_dev',
users: 'users_dev',
});
@@ -43,6 +44,7 @@ test('OS 프로필이 환경파일의 개발·운영 선택값보다 우선한
assert.equal(linuxConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas');
assert.deepEqual(linuxConfig.mongoCollections, {
allowedEmails: 'allowed_google_emails',
groups: 'groups',
recipes: 'recipes',
users: 'users',
});
+2
View File
@@ -71,5 +71,7 @@ test('MiniMax 사고 과정 분리와 충분한 출력 한도를 요청한다',
assert.equal(request.max_completion_tokens, 8192);
assert.match(request.messages[0].content, /최대 3개/);
assert.match(request.messages[0].content, /한식, 중식/);
assert.match(request.messages[0].content, /외국어.*자연스러운 한국어로 번역/);
assert.match(request.messages[0].content, /수량, 온도, 시간 값은 변환하지 않습니다/);
assert.equal(result.ingredientGroups[0].name, '재료');
});