⚙️ 商品登録システム実装ガイド
Step-by-Step開発手順
BFF統合・Canvas合成・Shopify API連携の完全実装ガイド
🚀 実装ロードマップ
Phase 1: データベース・API基盤構築
Week 1: D1データベースセットアップ
-- 1. スキーマファイル作成: schema/product-registration.sql
-- 時の絵作品テーブル
CREATE TABLE IF NOT EXISTS tokinoe_artworks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT NOT NULL,
technique TEXT,
description TEXT,
firebase_uid TEXT NOT NULL,
-- 画像情報(非PII)
original_image_url TEXT NOT NULL,
thumbnail_url TEXT,
image_hash TEXT UNIQUE,
image_width INTEGER,
image_height INTEGER,
-- 価格設定(円)
premium_base_price INTEGER DEFAULT 25000,
wood_base_price INTEGER DEFAULT 18000,
stand_price INTEGER DEFAULT 12000,
postcard_price INTEGER DEFAULT 500,
-- Shopify連携情報
shopify_product_id TEXT UNIQUE,
shopify_handle TEXT,
-- システム情報
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'processing', 'published', 'archived')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- バリアント管理テーブル
CREATE TABLE IF NOT EXISTS artwork_variants (
id TEXT PRIMARY KEY,
artwork_id TEXT NOT NULL,
-- バリアント構成
variant_type TEXT NOT NULL CHECK (variant_type IN ('premium', 'wood', 'stand', 'postcard')),
frame_color TEXT CHECK (frame_color IN ('black', 'brown', 'white', 'natural')),
mat_color TEXT CHECK (mat_color IN ('white', 'cream', 'gray', 'navy')),
-- 合成画像情報
composite_image_url TEXT NOT NULL,
alt_text TEXT,
-- Shopify情報
shopify_variant_id TEXT UNIQUE,
price INTEGER NOT NULL,
sku TEXT,
-- 在庫・ステータス
inventory_quantity INTEGER DEFAULT 999,
available_for_sale BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (artwork_id) REFERENCES tokinoe_artworks(id) ON DELETE CASCADE
);
-- インデックス作成
CREATE INDEX IF NOT EXISTS idx_artworks_firebase_uid ON tokinoe_artworks(firebase_uid);
CREATE INDEX IF NOT EXISTS idx_artworks_status ON tokinoe_artworks(status);
CREATE INDEX IF NOT EXISTS idx_variants_artwork_id ON artwork_variants(artwork_id);
CREATE INDEX IF NOT EXISTS idx_variants_type ON artwork_variants(variant_type);
-- 猫アプリテンプレート(シンプル版)
CREATE TABLE IF NOT EXISTS neko_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
-- 印刷仕様
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
dpi INTEGER DEFAULT 300,
-- 価格(JSON格納)
pricing_json TEXT NOT NULL, -- {"base": 300, "bulk": {"10": 250, "50": 200}}
-- Shopify連携
shopify_product_id TEXT,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
Week 1-2: BFF API基本構造
// src/api/artwork/index.ts - BFF APIルーター構造
import { Hono } from 'hono'
import { authMiddleware } from '../middleware/auth'
import { artworkController } from './controller'
const artwork = new Hono()
// 認証必須ミドルウェア
artwork.use('*', authMiddleware)
// 時の絵作品管理
artwork.post('/create', artworkController.create) // 作品登録開始
artwork.post('/:id/upload', artworkController.uploadImage) // 画像アップロード
artwork.post('/:id/generate', artworkController.generateVariants) // バリアント合成
artwork.get('/:id/status', artworkController.getStatus) // 進行状況確認
artwork.post('/:id/publish', artworkController.publishToShopify) // Shopify公開
// 作品一覧・管理
artwork.get('/list', artworkController.list) // 作品一覧
artwork.get('/:id', artworkController.get) // 作品詳細
artwork.put('/:id', artworkController.update) // 作品更新
artwork.delete('/:id', artworkController.delete) // 作品削除
export { artwork }
// src/api/artwork/controller.ts - コントローラー実装
import { Context } from 'hono'
import { ArtworkService } from './service'
export class ArtworkController {
private artworkService = new ArtworkService()
// 作品登録開始
async create(c: Context) {
try {
const user = c.get('user') // authMiddlewareから取得
const body = await c.req.json()
// バリデーション
const validatedData = this.validateArtworkData(body)
// 作品作成
const artwork = await this.artworkService.createArtwork({
...validatedData,
firebase_uid: user.uid
})
return c.json({
success: true,
artwork_id: artwork.id,
upload_token: await this.generateUploadToken(artwork.id)
})
} catch (error) {
console.error('作品作成エラー:', error)
return c.json({ error: error.message }, 400)
}
}
// 画像アップロード
async uploadImage(c: Context) {
try {
const artworkId = c.req.param('id')
const formData = await c.req.formData()
const imageFile = formData.get('image') as File
if (!imageFile) {
return c.json({ error: '画像ファイルが必要です' }, 400)
}
// 画像処理・R2アップロード
const result = await this.artworkService.processAndUploadImage(
artworkId,
imageFile
)
return c.json({
success: true,
image_url: result.url,
thumbnail_url: result.thumbnail_url,
ready_for_composition: true
})
} catch (error) {
console.error('画像アップロードエラー:', error)
return c.json({ error: error.message }, 500)
}
}
// バリアント合成開始
async generateVariants(c: Context) {
try {
const artworkId = c.req.param('id')
// バックグラウンドジョブ開始
const jobId = await this.artworkService.startVariantGeneration(artworkId)
return c.json({
success: true,
job_id: jobId,
estimated_time: '180s'
})
} catch (error) {
console.error('バリアント生成エラー:', error)
return c.json({ error: error.message }, 500)
}
}
// 進行状況確認
async getStatus(c: Context) {
try {
const artworkId = c.req.param('id')
const status = await this.artworkService.getGenerationStatus(artworkId)
return c.json(status)
} catch (error) {
return c.json({ error: error.message }, 500)
}
}
}
Phase 2: 画像処理・合成システム
Canvas合成エンジン実装
// src/services/canvas-composer.ts - Canvas合成サービス
interface CompositionRequest {
artwork_id: string
original_image_url: string
variants: VariantConfig[]
}
interface VariantConfig {
type: 'premium' | 'wood' | 'stand' | 'postcard'
frame_color?: string
mat_color?: string
}
export class CanvasComposer {
private canvas: OffscreenCanvas
private ctx: OffscreenCanvasRenderingContext2D
constructor() {
this.canvas = new OffscreenCanvas(3555, 2528) // 高解像度
this.ctx = this.canvas.getContext('2d')!
}
async composeVariants(request: CompositionRequest): Promise<VariantResult[]> {
const results: VariantResult[] = []
// 原画像読み込み
const artworkImage = await this.loadImage(request.original_image_url)
for (const variant of request.variants) {
try {
const result = await this.composeVariant(artworkImage, variant, request.artwork_id)
results.push(result)
// 進行状況更新
await this.updateProgress(request.artwork_id, results.length, request.variants.length)
} catch (error) {
console.error(`バリアント合成失敗: ${variant.type}`, error)
// エラーログ記録・スキップして続行
}
}
return results
}
private async composeVariant(
artwork: ImageBitmap,
config: VariantConfig,
artwork_id: string
): Promise<VariantResult> {
// Canvas初期化
this.ctx.clearRect(0, 0, 3555, 2528)
// 1. 背景色設定
this.ctx.fillStyle = this.getBackgroundColor(config.type)
this.ctx.fillRect(0, 0, 3555, 2528)
// 2. マット描画(プレミアムフレームのみ)
if (config.type === 'premium' && config.mat_color) {
const matImage = await this.loadTemplate('mat', config.mat_color)
this.ctx.drawImage(matImage, 0, 0, 3555, 2528)
}
// 3. 作品画像配置(中央・アスペクト比保持)
const artworkBounds = this.calculateArtworkBounds(config.type)
this.drawImageWithinBounds(artwork, artworkBounds)
// 4. フレーム描画
const frameImage = await this.loadTemplate('frame', config.type, config.frame_color)
this.ctx.drawImage(frameImage, 0, 0, 3555, 2528)
// 5. WebP変換
const blob = await this.canvas.convertToBlob({
type: 'image/webp',
quality: 0.92 // 高品質
})
// 6. R2アップロード
const filename = this.generateFilename(artwork_id, config)
const url = await this.uploadToR2(blob, filename)
return {
config,
image_url: url,
alt_text: this.generateAltText(config),
file_size: blob.size
}
}
private calculateArtworkBounds(type: string): ImageBounds {
// 商品タイプ別の作品画像配置領域
const bounds = {
premium: { x: 400, y: 300, width: 2755, height: 1928 },
wood: { x: 200, y: 150, width: 3155, height: 2228 },
stand: { x: 500, y: 400, width: 2555, height: 1728 },
postcard: { x: 0, y: 0, width: 3555, height: 2528 }
}
return bounds[type] || bounds.premium
}
private drawImageWithinBounds(image: ImageBitmap, bounds: ImageBounds) {
// アスペクト比を保持して描画
const imageAspect = image.width / image.height
const boundsAspect = bounds.width / bounds.height
let drawWidth, drawHeight, drawX, drawY
if (imageAspect > boundsAspect) {
// 画像が横長:幅を基準にリサイズ
drawWidth = bounds.width
drawHeight = bounds.width / imageAspect
drawX = bounds.x
drawY = bounds.y + (bounds.height - drawHeight) / 2
} else {
// 画像が縦長:高さを基準にリサイズ
drawHeight = bounds.height
drawWidth = bounds.height * imageAspect
drawX = bounds.x + (bounds.width - drawWidth) / 2
drawY = bounds.y
}
this.ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
}
private async loadTemplate(type: 'frame' | 'mat', subtype: string, color?: string): Promise<ImageBitmap> {
// フレーム・マットテンプレート読み込み
const templatePath = `templates/${type}/${subtype}${color ? `-${color}` : ''}.png`
const url = `${process.env.R2_PUBLIC_URL}/${templatePath}`
const response = await fetch(url)
const blob = await response.blob()
return await createImageBitmap(blob)
}
}
Phase 3: Shopify統合・商品作成
Shopify商品作成サービス
// src/services/shopify-product.service.ts
interface ShopifyProductData {
artwork: TokinoeArtwork
variants: VariantResult[]
}
export class ShopifyProductService {
private apiUrl = 'https://contents-print-dev.myshopify.com/admin/api/2024-01'
async createProduct(data: ShopifyProductData): Promise<ShopifyProduct> {
const productPayload = this.buildProductPayload(data)
// BFF経由でShopify API呼び出し
const response = await fetch(`${this.apiUrl}/products.json`, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_TOKEN!,
'Content-Type': 'application/json'
},
body: JSON.stringify({ product: productPayload })
})
if (!response.ok) {
const error = await response.json()
throw new Error(`Shopify商品作成失敗: ${error.errors}`)
}
const result = await response.json()
return result.product
}
private buildProductPayload(data: ShopifyProductData) {
const { artwork, variants } = data
return {
title: `${artwork.title} / ${artwork.artist}`,
body_html: this.formatDescription(artwork),
vendor: artwork.artist,
product_type: 'tokinoe',
tags: this.buildTags(artwork),
// 商品画像(メイン画像)
images: variants.map(variant => ({
src: variant.image_url,
alt: variant.alt_text
})),
// バリアント作成
variants: variants.map(variant => ({
title: this.formatVariantTitle(variant.config),
price: this.calculatePrice(artwork, variant.config).toString(),
sku: this.generateSKU(artwork.id, variant.config),
inventory_management: 'shopify',
inventory_quantity: 999, // 受注生産
requires_shipping: true,
weight: this.getWeight(variant.config),
// バリアントオプション(Shopifyの3つまで制限)
option1: variant.config.type, // フレームタイプ
option2: variant.config.frame_color, // フレーム色
option3: variant.config.mat_color, // マット色
// 画像関連
image_id: null // 後でimage関連付け
})),
// 商品オプション定義
options: [
{ name: 'フレームタイプ', values: ['プレミアム', '木製', 'スタンド', 'ポストカード'] },
{ name: 'フレーム色', values: ['ブラック', 'ブラウン', 'ホワイト', 'ナチュラル'] },
{ name: 'マット色', values: ['ホワイト', 'クリーム', 'グレー', 'ネイビー'] }
],
// SEO設定
seo_title: `${artwork.title} - ${artwork.artist} | 時の絵`,
seo_description: artwork.description?.substring(0, 150),
// メタフィールド
metafields: [
{
namespace: 'custom',
key: 'brand',
value: 'tokinoe',
type: 'single_line_text_field'
},
{
namespace: 'custom',
key: 'firebase_uid',
value: artwork.firebase_uid,
type: 'single_line_text_field'
},
{
namespace: 'custom',
key: 'technique',
value: artwork.technique,
type: 'single_line_text_field'
}
]
}
}
private calculatePrice(artwork: TokinoeArtwork, config: VariantConfig): number {
switch (config.type) {
case 'premium': return artwork.premium_base_price
case 'wood': return artwork.wood_base_price
case 'stand': return artwork.stand_price
case 'postcard': return artwork.postcard_price
default: return artwork.premium_base_price
}
}
private formatVariantTitle(config: VariantConfig): string {
const typeNames = {
premium: 'プレミアムフレーム',
wood: '木製フレーム',
stand: 'スタンド',
postcard: 'ポストカード'
}
const colorNames = {
black: 'ブラック', brown: 'ブラウン',
white: 'ホワイト', natural: 'ナチュラル',
cream: 'クリーム', gray: 'グレー', navy: 'ネイビー'
}
let title = typeNames[config.type]
if (config.frame_color && config.mat_color) {
title += `・${colorNames[config.frame_color]}×${colorNames[config.mat_color]}`
} else if (config.frame_color) {
title += `・${colorNames[config.frame_color]}`
}
return title
}
}
🖥️ 管理画面実装
React管理画面コンポーネント
// components/admin/ArtworkRegistration.tsx
import { useState, useCallback } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { ImageUploadZone } from '@/components/ui/ImageUploadZone'
import { ProgressBar } from '@/components/ui/ProgressBar'
interface ArtworkForm {
title: string
artist: string
technique: string
description: string
pricing: {
premium: number
wood: number
stand: number
postcard: number
}
}
export function ArtworkRegistration() {
const { user } = useAuth()
const [form, setForm] = useState<ArtworkForm>({
title: '',
artist: '',
technique: '',
description: '',
pricing: { premium: 25000, wood: 18000, stand: 12000, postcard: 500 }
})
const [step, setStep] = useState<'form' | 'upload' | 'processing' | 'complete'>('form')
const [artworkId, setArtworkId] = useState<string>('')
const [uploadedImage, setUploadedImage] = useState<string>('')
const [progress, setProgress] = useState({ completed: 0, total: 21 })
// Step 1: 基本情報入力
const handleFormSubmit = async () => {
try {
const response = await fetch('/api/tokinoe/artworks/create', {
method: 'POST',
headers: {
'Authorization': `Bearer ${await user.getIdToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
})
const result = await response.json()
if (result.success) {
setArtworkId(result.artwork_id)
setStep('upload')
}
} catch (error) {
console.error('作品登録エラー:', error)
}
}
// Step 2: 画像アップロード
const handleImageUpload = async (file: File) => {
try {
const formData = new FormData()
formData.append('image', file)
const response = await fetch(`/api/tokinoe/artworks/${artworkId}/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${await user.getIdToken()}` },
body: formData
})
const result = await response.json()
if (result.success) {
setUploadedImage(result.image_url)
setStep('processing')
startVariantGeneration()
}
} catch (error) {
console.error('画像アップロードエラー:', error)
}
}
// Step 3: バリアント合成開始・進行監視
const startVariantGeneration = async () => {
try {
// 合成開始
await fetch(`/api/tokinoe/artworks/${artworkId}/generate`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${await user.getIdToken()}` }
})
// 進行状況監視
const interval = setInterval(async () => {
const statusResponse = await fetch(`/api/tokinoe/artworks/${artworkId}/status`)
const status = await statusResponse.json()
setProgress({ completed: status.completed, total: status.total })
if (status.status === 'completed') {
clearInterval(interval)
setStep('complete')
}
}, 2000)
} catch (error) {
console.error('バリアント生成エラー:', error)
}
}
// Step 4: Shopify公開
const publishToShopify = async () => {
try {
const response = await fetch(`/api/tokinoe/artworks/${artworkId}/publish`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${await user.getIdToken()}` }
})
const result = await response.json()
if (result.success) {
alert(`商品が公開されました: ${result.url}`)
}
} catch (error) {
console.error('Shopify公開エラー:', error)
}
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<h1 className="text-3xl font-bold text-gray-900">新規作品登録</h1>
{/* ステップインジケーター */}
<div className="flex items-center justify-between">
{['基本情報', '画像アップロード', '合成処理', '完了'].map((label, index) => (
<div key={index} className={`flex items-center ${
['form', 'upload', 'processing', 'complete'][index] === step
? 'text-blue-600' : 'text-gray-400'
}`}>
<div className="w-8 h-8 rounded-full border-2 flex items-center justify-center">
{index + 1}
</div>
<span className="ml-2 font-medium">{label}</span>
</div>
))}
</div>
{/* Step 1: フォーム入力 */}
{step === 'form' && (
<Card className="p-6">
<div className="space-y-4">
<Input
label="作品名"
value={form.title}
onChange={(e) => setForm({...form, title: e.target.value})}
placeholder="例:紅葉"
required
/>
<Input
label="作家名"
value={form.artist}
onChange={(e) => setForm({...form, artist: e.target.value})}
placeholder="例:山田太郎"
required
/>
<Input
label="技法"
value={form.technique}
onChange={(e) => setForm({...form, technique: e.target.value})}
placeholder="例:水彩画"
/>
<Textarea
label="作品解説"
value={form.description}
onChange={(e) => setForm({...form, description: e.target.value})}
rows={4}
placeholder="作品の背景や技法について..."
/>
{/* 価格設定 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Input
type="number"
label="プレミアムフレーム"
value={form.pricing.premium}
onChange={(e) => setForm({
...form,
pricing: {...form.pricing, premium: Number(e.target.value)}
})}
/>
<Input
type="number"
label="木製フレーム"
value={form.pricing.wood}
onChange={(e) => setForm({
...form,
pricing: {...form.pricing, wood: Number(e.target.value)}
})}
/>
<Input
type="number"
label="スタンド"
value={form.pricing.stand}
onChange={(e) => setForm({
...form,
pricing: {...form.pricing, stand: Number(e.target.value)}
})}
/>
<Input
type="number"
label="ポストカード"
value={form.pricing.postcard}
onChange={(e) => setForm({
...form,
pricing: {...form.pricing, postcard: Number(e.target.value)}
})}
/>
</div>
<Button onClick={handleFormSubmit} className="w-full" size="lg">
次へ:画像アップロード
</Button>
</div>
</Card>
)}
{/* Step 2: 画像アップロード */}
{step === 'upload' && (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">作品画像をアップロード</h2>
<ImageUploadZone
onUpload={handleImageUpload}
acceptedTypes={['image/jpeg', 'image/png', 'image/webp']}
maxSize={50 * 1024 * 1024} // 50MB
/>
</Card>
)}
{/* Step 3: 合成処理中 */}
{step === 'processing' && (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">バリアント合成中...</h2>
<div className="space-y-4">
<img
src={uploadedImage}
alt="アップロード画像"
className="w-64 h-64 object-cover rounded-lg mx-auto"
/>
<ProgressBar
value={progress.completed}
max={progress.total}
label={`${progress.completed}/${progress.total} バリアント完了`}
/>
<p className="text-center text-gray-600">
21種類のバリアントを自動生成中です。しばらくお待ちください...
</p>
</div>
</Card>
)}
{/* Step 4: 完了 */}
{step === 'complete' && (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4 text-green-600">
✅ バリアント合成完了
</h2>
<p className="mb-6">
21種類のバリアントが正常に生成されました。Shopifyに公開しますか?
</p>
<div className="flex gap-4">
<Button onClick={publishToShopify} variant="primary" size="lg">
Shopifyに公開
</Button>
<Button variant="outline" size="lg">
プレビュー確認
</Button>
</div>
</Card>
)}
</div>
)
}
✅ テスト・品質管理
E2Eテスト実装
// tests/e2e/artwork-registration.spec.ts
import { test, expect } from '@playwright/test'
test.describe('作品登録フロー', () => {
test('時の絵作品の完全登録フロー', async ({ page }) => {
// 1. ログイン
await page.goto('/admin/login')
await page.fill('[data-testid=email]', 'artist@test.com')
await page.fill('[data-testid=password]', 'testpass')
await page.click('[data-testid=login-button]')
// 2. 作品登録ページへ
await page.goto('/admin/artworks/new')
// 3. フォーム入力
await page.fill('[data-testid=title]', 'テスト作品')
await page.fill('[data-testid=artist]', 'テスト作家')
await page.fill('[data-testid=technique]', 'テスト技法')
await page.fill('[data-testid=description]', 'テスト説明文')
// 4. 次のステップへ
await page.click('[data-testid=next-button]')
// 5. 画像アップロード
const fileInput = page.locator('[data-testid=image-upload]')
await fileInput.setInputFiles('tests/fixtures/test-artwork.jpg')
// 6. 合成処理完了まで待機
await expect(page.locator('[data-testid=progress-bar]')).toBeVisible()
await expect(page.locator('[data-testid=completion-message]')).toBeVisible({ timeout: 300000 }) // 5分
// 7. Shopify公開
await page.click('[data-testid=publish-button]')
await expect(page.locator('[data-testid=success-message]')).toBeVisible()
// 8. 結果確認
const productUrl = await page.locator('[data-testid=product-url]').textContent()
expect(productUrl).toContain('myshopify.com')
})
test('バリデーションエラーハンドリング', async ({ page }) => {
await page.goto('/admin/artworks/new')
// 必須項目未入力でsubmit
await page.click('[data-testid=next-button]')
// エラーメッセージ表示確認
await expect(page.locator('[data-testid=error-title]')).toBeVisible()
await expect(page.locator('[data-testid=error-artist]')).toBeVisible()
})
})
📍 実装優先順位・スケジュール
✅ Week 1-2: 基盤構築
- D1データベース設計・作成
- BFF APIルーター構築
- 認証・権限ミドルウェア
- R2アップロード機能
✅ Week 3-4: Canvas合成システム
- OffscreenCanvas合成エンジン
- フレーム・マットテンプレート準備
- バリアント生成ロジック
- 進行状況管理システム
✅ Week 5-6: Shopify統合
- 商品・バリアント作成API
- メタフィールド設定
- 価格・在庫管理
- 公開・非公開制御
✅ Week 7-8: 管理画面
- React管理画面構築
- 作品登録フォーム
- 画像アップロードUI
- 進行状況表示・管理
📍 関連ドキュメント: 商品登録システム設計 | BFF API設計 | Canvas合成システム
文書作成日: 2025-08-24
最終更新: 2025-08-24
バージョン: 1.0 - 実装ガイド版