feat: add group recipe sharing and translation
This commit is contained in:
@@ -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
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
)];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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, '재료');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user