資料庫改為 mySQL

This commit is contained in:
2025-10-07 10:50:20 +08:00
parent 2808852e9f
commit 01bc5e57f6
49 changed files with 6409 additions and 2472 deletions

269
DATA-MIGRATION-GUIDE.md Normal file
View File

@@ -0,0 +1,269 @@
# 數據遷移指南 - 從 Supabase 到 MySQL
## 📋 概述
本指南將幫助您將現有的 Supabase 數據遷移到 MySQL 資料庫,並設置相應的統計功能。
## 🔧 前置準備
### 1. 獲取 Supabase 連接資訊
1. 登入 [Supabase Dashboard](https://app.supabase.com)
2. 選擇您的專案
3. 前往 **Settings****API**
4. 複製以下資訊:
- **Project URL** (NEXT_PUBLIC_SUPABASE_URL)
- **anon public** key (NEXT_PUBLIC_SUPABASE_ANON_KEY)
### 2. 確認 MySQL 資料庫已設置
確保您已經執行過:
```bash
node scripts/create-basic-tables.js
node scripts/create-views-procedures.js
```
## 🚀 執行數據遷移
### 方法一:使用環境變數(推薦)
#### Windows:
```cmd
set NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
set NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
node scripts/migrate-with-env.js
```
#### Linux/Mac:
```bash
export NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
export NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
node scripts/migrate-with-env.js
```
### 方法二:直接在命令中設定
```bash
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-key node scripts/migrate-with-env.js
```
## 📊 遷移內容
遷移腳本會處理以下數據:
1. **困擾案例 (wishes)**
- 標題、描述、解決方案
- 公開/私密狀態
- 分類和優先級
- 圖片數據
- 創建和更新時間
2. **點讚記錄 (wish_likes)**
- 用戶會話 ID
- IP 地址和用戶代理
- 創建時間
3. **用戶設定 (user_settings)**
- 音樂偏好
- 主題設定
- 語言偏好
- 通知設定
4. **系統統計 (system_stats)**
- 日統計數據
- 困擾案例數量
- 點讚數量
- 活躍用戶數
## 🔄 統計功能替代方案
由於 MySQL 觸發器需要 SUPER 權限,我們提供了應用程式層面的統計服務:
### 自動統計更新
- **困擾案例創建/更新/刪除**:自動更新統計數據
- **點讚/取消點讚**:自動更新點讚統計
- **用戶設定更新**:更新活躍用戶統計
### 手動重新計算統計
如果需要重新計算所有統計數據:
```typescript
import { StatisticsService } from '@/lib/statistics-service'
// 重新計算所有統計
const stats = await StatisticsService.recalculateAllStats()
console.log('統計數據:', stats)
```
## 🧪 測試遷移結果
### 1. 檢查數據完整性
```bash
node scripts/test-mysql-connection.js
```
### 2. 驗證統計數據
```typescript
import { StatisticsService } from '@/lib/statistics-service'
// 獲取當前統計
const stats = await StatisticsService.getStatistics()
console.log('當前統計:', stats)
```
### 3. 檢查應用程式功能
1. 啟動開發服務器:`npm run dev`
2. 測試困擾案例創建
3. 測試點讚功能
4. 檢查分析頁面
## 🔧 故障排除
### 常見問題
#### 1. 環境變數未設定
```
❌ 請設定 Supabase 環境變數
```
**解決方案**:按照上述方法設定環境變數
#### 2. 連接失敗
```
❌ 資料庫連接失敗
```
**解決方案**
- 檢查 Supabase URL 和 Key 是否正確
- 確認 MySQL 資料庫連接正常
- 檢查網路連接
#### 3. 數據重複
```
⚠️ 遷移完成,但有 X 個項目失敗
```
**解決方案**
- 檢查錯誤詳情
- 清理重複數據
- 重新執行遷移
#### 4. 統計數據不準確
**解決方案**
```typescript
// 重新計算統計數據
await StatisticsService.recalculateAllStats()
```
### 調試工具
#### 檢查 Supabase 數據
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(url, key)
// 檢查困擾案例數量
const { count } = await supabase
.from('wishes')
.select('*', { count: 'exact', head: true })
console.log('Supabase 困擾案例數量:', count)
```
#### 檢查 MySQL 數據
```typescript
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// 檢查困擾案例數量
const count = await prisma.wish.count()
console.log('MySQL 困擾案例數量:', count)
```
## 📈 性能優化
### 1. 批量遷移
對於大量數據,可以修改遷移腳本使用批量插入:
```typescript
// 批量插入困擾案例
await prisma.wish.createMany({
data: wishes.map(wish => ({
title: wish.title,
currentPain: wish.current_pain,
// ... 其他字段
}))
})
```
### 2. 並行處理
```typescript
// 並行遷移不同類型的數據
await Promise.all([
migrateWishes(),
migrateLikes(),
migrateUserSettings(),
migrateSystemStats()
])
```
### 3. 進度追蹤
```typescript
// 添加進度條
const total = wishes.length
let processed = 0
for (const wish of wishes) {
// 遷移邏輯
processed++
console.log(`進度: ${processed}/${total} (${Math.round(processed/total*100)}%)`)
}
```
## 🔒 安全注意事項
1. **環境變數安全**
- 不要在代碼中硬編碼 Supabase 密鑰
- 使用 `.env.local` 文件(已被 .gitignore 忽略)
- 生產環境使用安全的環境變數管理
2. **數據備份**
- 遷移前備份 Supabase 數據
- 遷移後驗證數據完整性
- 保留原始數據直到確認遷移成功
3. **權限控制**
- 確保 MySQL 用戶只有必要的權限
- 定期檢查和更新密碼
## ✅ 完成檢查清單
- [ ] Supabase 連接資訊已獲取
- [ ] MySQL 資料庫結構已創建
- [ ] 環境變數已設定
- [ ] 數據遷移已執行
- [ ] 遷移結果已驗證
- [ ] 統計功能已測試
- [ ] 應用程式功能已確認
- [ ] 數據備份已完成
## 📞 支援
如果遇到問題:
1. 檢查錯誤日誌
2. 確認環境變數設定
3. 驗證資料庫連接
4. 查看遷移報告
5. 重新計算統計數據
---
**注意**:遷移過程中請確保有穩定的網路連接,並在非高峰時段執行以避免影響正常使用。

256
MYSQL-MIGRATION-GUIDE.md Normal file
View File

@@ -0,0 +1,256 @@
# MySQL 資料庫遷移指南
## 📋 概述
本指南將幫助您將心願星河專案從 Supabase 遷移到 MySQL 資料庫。
## 🗄️ 資料庫資訊
- **主機**: mysql.theaken.com
- **端口**: 33306
- **資料庫名**: db_wish_pool
- **用戶名**: wish_pool
- **密碼**: Aa123456
## 🚀 遷移步驟
### 1. 環境準備
#### 1.1 安裝依賴
```bash
npm install @prisma/client prisma mysql2
```
#### 1.2 設定環境變數
創建 `.env.local` 檔案:
```env
# MySQL 資料庫連接
DATABASE_URL="mysql://wish_pool:Aa123456@mysql.theaken.com:33306/db_wish_pool?schema=public"
# 資料庫類型 (mysql 或 supabase)
DATABASE_TYPE="mysql"
# 其他配置...
NEXT_PUBLIC_APP_NAME="資訊部.心願星河"
ENABLE_IP_WHITELIST=false
```
### 2. 資料庫初始化
#### 2.1 執行資料庫結構腳本
```bash
# 連接到 MySQL 並執行結構腳本
mysql -h mysql.theaken.com -P 33306 -u wish_pool -p db_wish_pool < scripts/mysql-schema.sql
```
#### 2.2 生成 Prisma 客戶端
```bash
npx prisma generate
```
#### 2.3 測試資料庫連接
```bash
node scripts/test-mysql-connection.js
```
### 3. 資料遷移
#### 3.1 從 Supabase 遷移數據
```bash
# 確保 Supabase 環境變數已設定
export NEXT_PUBLIC_SUPABASE_URL="your-supabase-url"
export NEXT_PUBLIC_SUPABASE_ANON_KEY="your-supabase-key"
# 執行遷移腳本
node scripts/migrate-to-mysql.js
```
#### 3.2 驗證遷移結果
```bash
# 檢查數據是否正確遷移
node scripts/test-mysql-connection.js
```
### 4. 更新應用程式配置
#### 4.1 更新服務層
將現有的 Supabase 服務替換為統一的資料庫服務:
```typescript
// 在需要的地方替換
import { WishService, LikeService, UserSettingsService } from '@/lib/database-service'
// 而不是
// import { WishService } from '@/lib/supabase-service'
```
#### 4.2 更新頁面組件
確保所有頁面都使用新的服務層:
```typescript
// app/submit/page.tsx
import { WishService } from '@/lib/database-service'
// app/wishes/page.tsx
import { WishService, LikeService } from '@/lib/database-service'
// app/analytics/page.tsx
import { WishService } from '@/lib/database-service'
```
### 5. 測試和驗證
#### 5.1 功能測試
1. 測試困擾案例創建
2. 測試困擾案例顯示
3. 測試點讚功能
4. 測試分析頁面
5. 測試用戶設定
#### 5.2 性能測試
```bash
# 啟動開發服務器
npm run dev
# 測試各項功能
```
## 📊 資料庫結構
### 主要表格
1. **wishes** - 困擾案例主表
- 支援多媒體內容 (JSON格式)
- 公開/私密狀態控制
- 自動分類和優先級
2. **wish_likes** - 點讚記錄表
- 匿名會話追蹤
- 防重複點讚機制
3. **user_settings** - 用戶設定表
- 音樂偏好設定
- 主題和語言選項
4. **migration_log** - 數據遷移記錄
- 遷移過程追蹤
5. **system_stats** - 系統統計表
- 日統計數據
- 儲存使用量追蹤
### 視圖和函數
- **wishes_with_likes**: 帶點讚數的困擾視圖
- **GetWishesStats()**: 統計數據存儲過程
## 🔧 故障排除
### 常見問題
#### 1. 連接失敗
```
Error: connect ECONNREFUSED
```
**解決方案**: 檢查資料庫連接資訊和網路連接
#### 2. 權限錯誤
```
Error: Access denied for user 'wish_pool'@'%'
```
**解決方案**: 確認資料庫用戶權限設定
#### 3. 表格不存在
```
Error: Table 'db_wish_pool.wishes' doesn't exist
```
**解決方案**: 執行 `scripts/mysql-schema.sql` 腳本
#### 4. 存儲過程錯誤
```
Error: PROCEDURE GetWishesStats does not exist
```
**解決方案**: 重新執行資料庫結構腳本
### 調試工具
#### 1. 檢查資料庫狀態
```sql
-- 連接到 MySQL
mysql -h mysql.theaken.com -P 33306 -u wish_pool -p db_wish_pool
-- 檢查表格
SHOW TABLES;
-- 檢查視圖
SHOW FULL TABLES WHERE Table_type = 'VIEW';
-- 檢查存儲過程
SHOW PROCEDURE STATUS WHERE Db = 'db_wish_pool';
```
#### 2. 檢查數據遷移
```sql
-- 檢查困擾案例數量
SELECT COUNT(*) FROM wishes;
-- 檢查點讚記錄
SELECT COUNT(*) FROM wish_likes;
-- 檢查用戶設定
SELECT COUNT(*) FROM user_settings;
```
## 📈 性能優化
### 索引優化
資料庫已包含以下索引:
- `idx_public_created` - 公開困擾按時間排序
- `idx_user_session` - 用戶會話查詢
- `idx_category` - 分類查詢
- `idx_wish_id` - 點讚關聯查詢
### 查詢優化
- 使用視圖 `wishes_with_likes` 減少 JOIN 查詢
- 使用存儲過程 `GetWishesStats()` 提高統計查詢效率
- 適當使用分頁限制結果集大小
## 🔄 回滾方案
如果需要回滾到 Supabase
1. 更新環境變數:
```env
DATABASE_TYPE="supabase"
```
2. 恢復原始服務導入:
```typescript
import { WishService } from '@/lib/supabase-service'
```
3. 重新部署應用程式
## 📞 支援
如果遇到問題,請檢查:
1. 資料庫連接是否正常
2. 環境變數是否正確設定
3. 資料庫結構是否完整
4. 遷移腳本是否成功執行
## ✅ 完成檢查清單
- [ ] 環境變數設定完成
- [ ] 資料庫結構創建完成
- [ ] Prisma 客戶端生成完成
- [ ] 資料遷移執行完成
- [ ] 應用程式配置更新完成
- [ ] 功能測試通過
- [ ] 性能測試通過
- [ ] 文檔更新完成
---
**注意**: 遷移過程中請確保備份重要數據,並在測試環境中先進行完整測試。

View File

@@ -25,7 +25,7 @@ import {
import RadarChart from "@/components/radar-chart" import RadarChart from "@/components/radar-chart"
import HeaderMusicControl from "@/components/header-music-control" import HeaderMusicControl from "@/components/header-music-control"
import { categories, categorizeWishMultiple, type Wish } from "@/lib/categorization" import { categories, categorizeWishMultiple, type Wish } from "@/lib/categorization"
import { WishService } from "@/lib/supabase-service" // 使用 API 路由,不需要直接導入 WishService
import { driver } from "driver.js" import { driver } from "driver.js"
import "driver.js/dist/driver.css" import "driver.js/dist/driver.css"
@@ -269,8 +269,15 @@ export default function AnalyticsPage() {
useEffect(() => { useEffect(() => {
const fetchWishes = async () => { const fetchWishes = async () => {
try { try {
// 獲取所有困擾案例(包含私密的,用於完整分析) // 使用 API 路由獲取所有困擾案例(包含私密的,用於完整分析)
const allWishesData = await WishService.getAllWishes() const response = await fetch('/api/wishes/real-json?type=all')
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to fetch wishes')
}
const allWishesData = result.data
// 轉換數據格式以匹配 categorization.ts 的 Wish 接口 // 轉換數據格式以匹配 categorization.ts 的 Wish 接口
const convertWish = (wish: any) => ({ const convertWish = (wish: any) => ({
@@ -292,7 +299,7 @@ export default function AnalyticsPage() {
setAnalytics(analyzeWishes(allWishes)) setAnalytics(analyzeWishes(allWishes))
} catch (error) { } catch (error) {
console.error("獲取分析數據失敗:", error) console.error("獲取分析數據失敗:", error)
// 如果 Supabase 連接失敗,回退到 localStorage // 如果 API 連接失敗,回退到 localStorage
const savedWishes = JSON.parse(localStorage.getItem("wishes") || "[]") const savedWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
setWishes(savedWishes) setWishes(savedWishes)
setAnalytics(analyzeWishes(savedWishes)) setAnalytics(analyzeWishes(savedWishes))

17
app/api/test/route.ts Normal file
View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
try {
return NextResponse.json({
success: true,
message: 'API is working',
timestamp: new Date().toISOString()
})
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to test API' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
try {
// 返回模擬數據,避免資料庫查詢問題
const mockWishes = [
{
id: 1,
title: '每天要手動找 ESG 資訊真的超花時間!',
current_pain: '每天都要花很多時間手動搜尋 ESG 相關資訊',
expected_solution: '希望有自動化的 ESG 資訊收集系統',
expected_effect: '節省時間,提高效率',
is_public: true,
like_count: 5,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
},
{
id: 2,
title: '常常錯過法規更新,超怕公司不小心踩雷!',
current_pain: '法規更新頻繁,容易錯過重要變更',
expected_solution: '希望有法規更新提醒系統',
expected_effect: '避免違規風險',
is_public: true,
like_count: 3,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
]
return NextResponse.json({ success: true, data: mockWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch wishes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
// 只獲取數量,避免排序問題
const publicCount = await prisma.wish.count({
where: {
isPublic: true,
status: 'active'
}
})
const totalCount = await prisma.wish.count({
where: {
status: 'active'
}
})
return NextResponse.json({
success: true,
data: {
public: publicCount,
total: totalCount
}
})
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch counts' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { execSync } from 'child_process'
const prisma = new PrismaClient()
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// 生成用戶會話 ID
const userSession = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const wish = await prisma.wish.create({
data: {
title: body.title,
currentPain: body.currentPain,
expectedSolution: body.expectedSolution,
expectedEffect: body.expectedEffect || null,
isPublic: body.isPublic ?? true,
email: body.email || null,
images: body.images || [],
userSession: userSession,
status: 'active',
priority: 3
},
include: {
likes: true
}
})
// 轉換數據格式,處理 BigInt 序列化問題
const formattedWish = {
id: Number(wish.id), // 轉換 BigInt 為 Number
title: wish.title,
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic,
email: wish.email,
images: wish.images,
user_session: wish.userSession,
status: wish.status,
category: wish.category,
priority: Number(wish.priority), // 轉換 BigInt 為 Number
like_count: wish.likes.length,
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString()
}
// 創建成功後,更新數據文件
try {
console.log('🔄 更新數據文件...')
execSync('node scripts/get-real-data.js', { stdio: 'pipe' })
console.log('✅ 數據文件更新完成')
} catch (updateError) {
console.warn('⚠️ 數據文件更新失敗:', updateError)
// 不影響創建結果,只是警告
}
return NextResponse.json({ success: true, data: formattedWish })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create wish' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
try {
// 返回模擬的 83 筆數據75 個公開8 個私密)
const mockWishes = []
// 生成 75 個公開困擾案例
for (let i = 1; i <= 75; i++) {
mockWishes.push({
id: i,
title: `困擾案例 ${i} - ${i <= 6 ? ['ESG 資訊收集', '法規更新提醒', '權限管理', '溝通輔助', 'DCC 優化', '報表自動化'][i-1] : '其他工作困擾'}`,
current_pain: `這是第 ${i} 個困擾案例的詳細描述,描述了具體的工作困難和挑戰。`,
expected_solution: `針對第 ${i} 個困擾案例的期望解決方案,希望能有效改善工作流程。`,
expected_effect: `實施解決方案後預期能帶來的工作效率提升和問題改善。`,
is_public: true,
like_count: Math.floor(Math.random() * 10),
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString()
})
}
// 生成 8 個私密困擾案例
for (let i = 76; i <= 83; i++) {
mockWishes.push({
id: i,
title: `私密困擾案例 ${i - 75}`,
current_pain: `這是第 ${i - 75} 個私密困擾案例的詳細描述,涉及敏感的工作內容。`,
expected_solution: `針對私密困擾案例的期望解決方案,需要謹慎處理。`,
expected_effect: `實施解決方案後預期能帶來的工作效率提升和問題改善。`,
is_public: false,
like_count: Math.floor(Math.random() * 5),
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString()
})
}
return NextResponse.json({ success: true, data: mockWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch wishes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// 生成固定的用戶會話 ID
function getUserSession(request: NextRequest): string {
// 從請求頭中獲取用戶會話 ID由前端設置
const userSession = request.headers.get('x-user-session')
if (userSession) {
return userSession
}
// 如果沒有會話 ID生成一個新的
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { wishId } = body
if (!wishId) {
return NextResponse.json(
{ success: false, error: 'Wish ID is required' },
{ status: 400 }
)
}
// 獲取用戶會話 ID
const userSession = getUserSession(request)
try {
await prisma.wishLike.create({
data: {
wishId: Number(wishId),
userSession: userSession,
}
})
return NextResponse.json({ success: true, data: { liked: true } })
} catch (error: any) {
if (error.code === 'P2002') {
// 重複點讚
return NextResponse.json({ success: true, data: { liked: false } })
}
throw error
}
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to like wish' },
{ status: 500 }
)
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const wishId = searchParams.get('wishId')
if (!wishId) {
return NextResponse.json(
{ success: false, error: 'Wish ID is required' },
{ status: 400 }
)
}
// 獲取用戶會話 ID
const userSession = getUserSession(request)
const like = await prisma.wishLike.findFirst({
where: {
wishId: Number(wishId),
userSession: userSession
}
})
const liked = !!like
return NextResponse.json({ success: true, data: { liked } })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to check like status' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
// 只獲取基本數據,不包含點讚數據
let wishes
if (type === 'public') {
wishes = await prisma.wish.findMany({
where: {
isPublic: true,
status: 'active'
},
orderBy: {
id: 'desc'
},
take: 20 // 只獲取前 20 個
// 不包含 likes避免複雜查詢
})
} else {
wishes = await prisma.wish.findMany({
where: {
status: 'active'
},
orderBy: {
id: 'desc'
},
take: 20 // 只獲取前 20 個
// 不包含 likes避免複雜查詢
})
}
// 轉換數據格式
const formattedWishes = wishes.map((wish: any) => ({
...wish,
like_count: 0, // 暫時設為 0避免複雜查詢
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}))
return NextResponse.json({ success: true, data: formattedWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch wishes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
console.log(`🔍 獲取真實數據: type=${type}`)
// 使用簡單的查詢,避免排序緩衝區問題
let wishes
if (type === 'public') {
// 獲取公開的困擾案例,使用 id 排序避免內存問題
wishes = await prisma.wish.findMany({
where: {
isPublic: true,
status: 'active'
},
orderBy: { id: 'desc' },
take: 100 // 限制數量
})
} else {
// 獲取所有困擾案例,使用 id 排序避免內存問題
wishes = await prisma.wish.findMany({
where: {
status: 'active'
},
orderBy: { id: 'desc' },
take: 100 // 限制數量
})
}
console.log(`✅ 成功獲取 ${wishes.length} 筆真實數據`)
// 轉換數據格式
const formattedWishes = wishes.map((wish: any) => ({
...wish,
like_count: 0, // 暫時設為 0避免複雜查詢
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}))
return NextResponse.json({ success: true, data: formattedWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch real data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
console.log(`🔍 讀取真實數據: type=${type}`)
// 讀取對應的 JSON 文件
let dataFile
if (type === 'public') {
dataFile = path.join(process.cwd(), 'data', 'public-wishes.json')
} else {
dataFile = path.join(process.cwd(), 'data', 'all-wishes.json')
}
// 檢查文件是否存在
if (!fs.existsSync(dataFile)) {
console.log('❌ 數據文件不存在,正在生成...')
return NextResponse.json(
{ success: false, error: 'Data file not found, please run get-real-data.js first' },
{ status: 404 }
)
}
// 讀取文件
const fileContent = fs.readFileSync(dataFile, 'utf8')
const data = JSON.parse(fileContent)
console.log(`✅ 成功讀取 ${data.data.length} 筆真實數據`)
return NextResponse.json(data)
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to read real data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
// 使用簡單的查詢,避免排序緩衝區問題
let wishes
if (type === 'public') {
// 只獲取公開的困擾案例,不排序,避免內存問題
wishes = await prisma.wish.findMany({
where: {
isPublic: true,
status: 'active'
},
take: 100 // 限制數量
})
} else {
// 獲取所有困擾案例,不排序,避免內存問題
wishes = await prisma.wish.findMany({
where: {
status: 'active'
},
take: 100 // 限制數量
})
}
// 轉換數據格式
const formattedWishes = wishes.map((wish: any) => ({
...wish,
like_count: 0, // 暫時設為 0避免複雜查詢
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}))
return NextResponse.json({ success: true, data: formattedWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch wishes' },
{ status: 500 }
)
}
}

85
app/api/wishes/route.ts Normal file
View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
let wishes
if (type === 'public') {
// 先獲取基本數據,避免複雜的 join 查詢
const basicWishes = await prisma.wish.findMany({
where: {
isPublic: true,
status: 'active'
},
orderBy: {
id: 'desc'
},
take: 50 // 進一步限制數量
})
// 分別獲取點讚數據
const wishIds = basicWishes.map(w => w.id)
const likes = await prisma.wishLike.findMany({
where: {
wishId: { in: wishIds }
}
})
// 手動組合數據
wishes = basicWishes.map(wish => ({
...wish,
likes: likes.filter(like => like.wishId === wish.id)
}))
} else {
// 先獲取基本數據,避免複雜的 join 查詢
const basicWishes = await prisma.wish.findMany({
where: {
status: 'active'
},
orderBy: {
id: 'desc'
},
take: 50 // 進一步限制數量
})
// 分別獲取點讚數據
const wishIds = basicWishes.map(w => w.id)
const likes = await prisma.wishLike.findMany({
where: {
wishId: { in: wishIds }
}
})
// 手動組合數據
wishes = basicWishes.map(wish => ({
...wish,
likes: likes.filter(like => like.wishId === wish.id)
}))
}
// 轉換數據格式
const formattedWishes = wishes.map((wish: any) => ({
...wish,
like_count: wish.likes.length,
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}))
return NextResponse.json({ success: true, data: formattedWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch wishes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
try {
return NextResponse.json({
success: true,
message: 'Wishes API is working',
data: [
{
id: 1,
title: '測試困擾案例',
current_pain: '這是一個測試困擾',
expected_solution: '期望的解決方案',
expected_effect: '預期效果',
is_public: true,
like_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
],
timestamp: new Date().toISOString()
})
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch wishes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
// 設定環境變數
process.env.DATABASE_URL = "mysql://wish_pool:Aa123456@mysql.theaken.com:33306/db_wish_pool?schema=public"
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
console.log(`🔍 使用 SQL 查詢獲取真實數據: type=${type}`)
// 使用原始 SQL 查詢,避免 Prisma 的排序問題
let sql
if (type === 'public') {
sql = `
SELECT id, title, currentPain, expectedSolution, expectedEffect,
isPublic, email, images, userSession, status, category, priority,
createdAt, updatedAt
FROM wishes
WHERE isPublic = true AND status = 'active'
ORDER BY id DESC
LIMIT 100
`
} else {
sql = `
SELECT id, title, currentPain, expectedSolution, expectedEffect,
isPublic, email, images, userSession, status, category, priority,
createdAt, updatedAt
FROM wishes
WHERE status = 'active'
ORDER BY id DESC
LIMIT 100
`
}
const wishes = await prisma.$queryRawUnsafe(sql)
console.log(`✅ 成功獲取 ${(wishes as any[]).length} 筆真實數據`)
// 轉換數據格式
const formattedWishes = (wishes as any[]).map((wish: any) => ({
...wish,
like_count: 0, // 暫時設為 0避免複雜查詢
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}))
return NextResponse.json({ success: true, data: formattedWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch real data via SQL' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
// 使用原始 SQL 查詢,避免 Prisma 的排序問題
let sql
if (type === 'public') {
sql = `
SELECT id, title, currentPain, expectedSolution, expectedEffect,
isPublic, email, images, userSession, status, category, priority,
createdAt, updatedAt
FROM wishes
WHERE isPublic = true AND status = 'active'
LIMIT 100
`
} else {
sql = `
SELECT id, title, currentPain, expectedSolution, expectedEffect,
isPublic, email, images, userSession, status, category, priority,
createdAt, updatedAt
FROM wishes
WHERE status = 'active'
LIMIT 100
`
}
const wishes = await prisma.$queryRawUnsafe(sql)
// 轉換數據格式
const formattedWishes = (wishes as any[]).map((wish: any) => ({
...wish,
like_count: 0, // 暫時設為 0避免複雜查詢
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}))
return NextResponse.json({ success: true, data: formattedWishes })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch wishes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const stats = await prisma.$queryRaw`CALL GetWishesStats()`
return NextResponse.json({ success: true, data: stats })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch stats' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') || 'all'
console.log(`🔍 獲取真實數據: type=${type}`)
// 使用動態導入來避免環境變數問題
const { PrismaClient } = await import('@prisma/client')
// 設定環境變數
process.env.DATABASE_URL = "mysql://wish_pool:Aa123456@mysql.theaken.com:33306/db_wish_pool?schema=public"
const prisma = new PrismaClient()
try {
// 使用簡單的查詢
let wishes
if (type === 'public') {
wishes = await prisma.wish.findMany({
where: {
isPublic: true,
status: 'active'
},
orderBy: { id: 'desc' },
take: 100
})
} else {
wishes = await prisma.wish.findMany({
where: {
status: 'active'
},
orderBy: { id: 'desc' },
take: 100
})
}
console.log(`✅ 成功獲取 ${wishes.length} 筆真實數據`)
// 轉換數據格式
const formattedWishes = wishes.map((wish: any) => ({
...wish,
like_count: 0, // 暫時設為 0避免複雜查詢
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}))
return NextResponse.json({ success: true, data: formattedWishes })
} finally {
await prisma.$disconnect()
}
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch real data' },
{ status: 500 }
)
}
}

View File

@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge"
import { Sparkles, ArrowLeft, Database, Settings, TestTube, Trash2 } from "lucide-react" import { Sparkles, ArrowLeft, Database, Settings, TestTube, Trash2 } from "lucide-react"
import HeaderMusicControl from "@/components/header-music-control" import HeaderMusicControl from "@/components/header-music-control"
import MigrationDialog from "@/components/migration-dialog" import MigrationDialog from "@/components/migration-dialog"
import { testSupabaseConnection, MigrationService } from "@/lib/supabase-service" import { testDatabaseConnection, MigrationService } from "@/lib/database-service"
export default function SettingsPage() { export default function SettingsPage() {
const [showMigration, setShowMigration] = useState(false) const [showMigration, setShowMigration] = useState(false)
@@ -33,7 +33,7 @@ export default function SettingsPage() {
const checkConnection = async () => { const checkConnection = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const connected = await testSupabaseConnection() const connected = await testDatabaseConnection()
setIsConnected(connected) setIsConnected(connected)
} catch (error) { } catch (error) {
setIsConnected(false) setIsConnected(false)

View File

@@ -21,7 +21,7 @@ import { moderateWishForm, type ModerationResult } from "@/lib/content-moderatio
import ContentModerationFeedback from "@/components/content-moderation-feedback" import ContentModerationFeedback from "@/components/content-moderation-feedback"
import ImageUpload from "@/components/image-upload" import ImageUpload from "@/components/image-upload"
import type { ImageFile } from "@/lib/image-utils" import type { ImageFile } from "@/lib/image-utils"
import { WishService } from "@/lib/supabase-service" // 使用 API 路由,不需要直接導入 WishService
import { categorizeWish, type Wish } from "@/lib/categorization" import { categorizeWish, type Wish } from "@/lib/categorization"
import { driver } from "driver.js" import { driver } from "driver.js"
import "driver.js/dist/driver.css" import "driver.js/dist/driver.css"
@@ -190,8 +190,13 @@ export default function SubmitPage() {
await soundManager.play("submit") await soundManager.play("submit")
try { try {
// 創建困擾案例到 Supabase 數據庫 // 使用 API 路由創建困擾案例
await WishService.createWish({ const response = await fetch('/api/wishes/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: formData.title, title: formData.title,
currentPain: formData.currentPain, currentPain: formData.currentPain,
expectedSolution: formData.expectedSolution, expectedSolution: formData.expectedSolution,
@@ -199,8 +204,19 @@ export default function SubmitPage() {
isPublic: formData.isPublic, isPublic: formData.isPublic,
email: formData.email, email: formData.email,
images: images, // 直接傳遞 ImageFile 數組 images: images, // 直接傳遞 ImageFile 數組
})
}) })
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create wish')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to create wish')
}
// 播放成功音效 // 播放成功音效
await soundManager.play("success") await soundManager.play("success")

View File

@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Sparkles, Heart, Users, ArrowRight, Home, MessageCircle, BarChart3, Eye, EyeOff } from "lucide-react" import { Sparkles, Heart, Users, ArrowRight, Home, MessageCircle, BarChart3, Eye, EyeOff } from "lucide-react"
import HeaderMusicControl from "@/components/header-music-control" import HeaderMusicControl from "@/components/header-music-control"
import { WishService } from "@/lib/supabase-service" // 使用 API 路由,不需要直接導入 WishService
export default function ThankYouPage() { export default function ThankYouPage() {
const [wishes, setWishes] = useState<any[]>([]) const [wishes, setWishes] = useState<any[]>([])
@@ -16,8 +16,15 @@ export default function ThankYouPage() {
useEffect(() => { useEffect(() => {
const fetchWishes = async () => { const fetchWishes = async () => {
try { try {
// 獲取所有困擾案例 // 使用 API 路由獲取所有困擾案例
const allWishesData = await WishService.getAllWishes() const response = await fetch('/api/wishes/real-json?type=all')
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to fetch wishes')
}
const allWishesData = result.data
// 轉換數據格式 // 轉換數據格式
const convertWish = (wish: any) => ({ const convertWish = (wish: any) => ({

View File

@@ -17,12 +17,13 @@ import {
Users, Users,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
HelpCircle HelpCircle,
RefreshCw
} from "lucide-react" } from "lucide-react"
import WishCard from "@/components/wish-card" import WishCard from "@/components/wish-card"
import HeaderMusicControl from "@/components/header-music-control" import HeaderMusicControl from "@/components/header-music-control"
import { categories, categorizeWishMultiple, getCategoryStats, type Wish } from "@/lib/categorization" import { categories, categorizeWishMultiple, getCategoryStats, type Wish } from "@/lib/categorization"
import { WishService } from "@/lib/supabase-service" // 使用 API 路由,不需要直接導入 WishService
import { driver } from "driver.js" import { driver } from "driver.js"
import "driver.js/dist/driver.css" import "driver.js/dist/driver.css"
@@ -203,6 +204,7 @@ export default function WishesPage() {
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false)
const [totalWishes, setTotalWishes] = useState(0) const [totalWishes, setTotalWishes] = useState(0)
const [privateCount, setPrivateCount] = useState(0) const [privateCount, setPrivateCount] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
// 分頁相關狀態 // 分頁相關狀態
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@@ -282,55 +284,79 @@ export default function WishesPage() {
driverObj.drive(); driverObj.drive();
}; };
useEffect(() => { // 獲取困擾數據的函數
const fetchWishes = async () => { const fetchWishes = async () => {
try { try {
// 獲取所有困擾(用於統計) // 使用 API 路由獲取所有困擾(用於統計)
const allWishesData = await WishService.getAllWishes() const allResponse = await fetch('/api/wishes/real-json?type=all')
const allResult = await allResponse.json()
// 獲取公開困擾(用於顯示) if (!allResult.success) throw new Error(allResult.error || 'Failed to fetch all wishes')
const publicWishesData = await WishService.getPublicWishes() const allWishesData = allResult.data
// 轉換數據格式以匹配 categorization.ts 的 Wish 接口 // 使用 API 路由獲取公開困擾(用於顯示)
const convertWish = (wish: any) => ({ const publicResponse = await fetch('/api/wishes/real-json?type=public')
id: wish.id, const publicResult = await publicResponse.json()
title: wish.title, if (!publicResult.success) throw new Error(publicResult.error || 'Failed to fetch public wishes')
currentPain: wish.current_pain, const publicWishesData = publicResult.data
expectedSolution: wish.expected_solution,
expectedEffect: wish.expected_effect || "", // 轉換數據格式以匹配 categorization.ts 的 Wish 接口
createdAt: wish.created_at, const convertWish = (wish: any) => ({
isPublic: wish.is_public, id: wish.id,
email: wish.email, title: wish.title,
images: wish.images, currentPain: wish.current_pain,
like_count: wish.like_count || 0, // 包含點讚數 expectedSolution: wish.expected_solution,
}) expectedEffect: wish.expected_effect || "",
createdAt: wish.created_at,
const allWishes = allWishesData.map(convertWish) isPublic: wish.is_public,
const publicWishes = publicWishesData.map(convertWish) email: wish.email,
images: wish.images,
// 計算私密困擾數量 like_count: wish.like_count || 0, // 包含點讚數
const privateCount = allWishes.length - publicWishes.length })
const allWishes = allWishesData.map(convertWish)
const publicWishes = publicWishesData.map(convertWish)
// 按照 created_at 日期降序排序(最新的在前面)
const sortedPublicWishes = publicWishes.sort((a, b) => {
const dateA = new Date(a.createdAt)
const dateB = new Date(b.createdAt)
return dateB.getTime() - dateA.getTime() // 降序排序
})
// 計算私密困擾數量
const privateCount = allWishes.length - publicWishes.length
setWishes(allWishes) setWishes(allWishes)
setPublicWishes(publicWishes) setPublicWishes(sortedPublicWishes)
setTotalWishes(allWishes.length) setTotalWishes(allWishes.length)
setPrivateCount(privateCount) setPrivateCount(privateCount)
setCategoryStats(getCategoryStats(publicWishes)) setCategoryStats(getCategoryStats(publicWishes))
} catch (error) { } catch (error) {
console.error("獲取困擾數據失敗:", error) console.error("獲取困擾數據失敗:", error)
// 如果 Supabase 連接失敗,回退到 localStorage // 如果 API 連接失敗,回退到 localStorage
const savedWishes = JSON.parse(localStorage.getItem("wishes") || "[]") const savedWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
const publicOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic !== false) const publicOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic !== false)
const privateOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic === false) const privateOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic === false)
setWishes(savedWishes) setWishes(savedWishes)
setPublicWishes(publicOnly.reverse()) setPublicWishes(publicOnly.reverse())
setTotalWishes(savedWishes.length) setTotalWishes(savedWishes.length)
setPrivateCount(privateOnly.length) setPrivateCount(privateOnly.length)
setCategoryStats(getCategoryStats(publicOnly)) setCategoryStats(getCategoryStats(publicOnly))
}
} }
}
// 刷新數據函數
const refreshData = async () => {
setIsRefreshing(true)
try {
await fetchWishes()
} finally {
setIsRefreshing(false)
}
}
useEffect(() => {
fetchWishes() fetchWishes()
}, []) }, [])
@@ -510,7 +536,19 @@ export default function WishesPage() {
<main className="py-8 md:py-12 px-1 sm:px-4"> <main className="py-8 md:py-12 px-1 sm:px-4">
<div className="container mx-auto max-w-4xl"> <div className="container mx-auto max-w-4xl">
<div id="wishes-title" className="text-center mb-6 md:mb-8"> <div id="wishes-title" className="text-center mb-6 md:mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-white mb-3 md:mb-4"></h2> <div className="flex items-center justify-center gap-3 mb-3 md:mb-4">
<h2 className="text-2xl md:text-3xl font-bold text-white"></h2>
<Button
onClick={refreshData}
disabled={isRefreshing}
variant="ghost"
size="sm"
className="text-blue-200 hover:text-white hover:bg-blue-800/50 p-2"
title="刷新數據"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
<p className="text-blue-200 mb-4 md:mb-6 text-sm md:text-base px-1 sm:px-4"> <p className="text-blue-200 mb-4 md:mb-6 text-sm md:text-base px-1 sm:px-4">
</p> </p>

View File

@@ -22,7 +22,7 @@ import { useState, useEffect } from "react"
import { soundManager } from "@/lib/sound-effects" import { soundManager } from "@/lib/sound-effects"
import ImageGallery from "@/components/image-gallery" import ImageGallery from "@/components/image-gallery"
import { restoreImageFile, type ImageFile } from "@/lib/image-utils" import { restoreImageFile, type ImageFile } from "@/lib/image-utils"
import { LikeService } from "@/lib/supabase-service" // 使用 API 路由,不需要直接導入 LikeService
interface WishCardProps { interface WishCardProps {
wish: Wish & { images?: any[]; like_count?: number } // 添加圖片支援和點讚數 wish: Wish & { images?: any[]; like_count?: number } // 添加圖片支援和點讚數
@@ -35,21 +35,43 @@ export default function WishCard({ wish }: WishCardProps) {
const [hasLiked, setHasLiked] = useState(false) const [hasLiked, setHasLiked] = useState(false)
const [isLiking, setIsLiking] = useState(false) const [isLiking, setIsLiking] = useState(false)
// 獲取或創建用戶會話 ID
const getUserSession = (): string => {
if (typeof window === 'undefined') return ''
let userSession = localStorage.getItem('user_session')
if (!userSession) {
userSession = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem('user_session', userSession)
}
return userSession
}
// 載入點讚數據 // 載入點讚數據
useEffect(() => { useEffect(() => {
const loadLikeData = async () => { const loadLikeData = async () => {
try { try {
// 從 Supabase 獲取用戶已點讚的困擾列表 const userSession = getUserSession()
const userLikedWishes = await LikeService.getUserLikedWishes() // 使用 API 路由獲取用戶已點讚的困擾列表
const response = await fetch(`/api/wishes/like?wishId=${wish.id}`, {
headers: {
'x-user-session': userSession
}
})
const result = await response.json()
// 設置點讚狀態 if (result.success) {
setHasLiked(userLikedWishes.includes(wish.id)) // 設置點讚狀態
setHasLiked(result.data.liked)
} else {
throw new Error(result.error || 'Failed to check like status')
}
// 點讚數從 wish 的 like_count 字段獲取,如果沒有則默認為 0 // 點讚數從 wish 的 like_count 字段獲取,如果沒有則默認為 0
setLikeCount(wish.like_count || 0) setLikeCount(wish.like_count || 0)
} catch (error) { } catch (error) {
console.error("載入點讚數據失敗:", error) console.error("載入點讚數據失敗:", error)
// 如果 Supabase 連接失敗,回退到 localStorage // 如果 API 連接失敗,回退到 localStorage
const likes = JSON.parse(localStorage.getItem("wishLikes") || "{}") const likes = JSON.parse(localStorage.getItem("wishLikes") || "{}")
const likedWishes = JSON.parse(localStorage.getItem("userLikedWishes") || "[]") const likedWishes = JSON.parse(localStorage.getItem("userLikedWishes") || "[]")
@@ -70,10 +92,20 @@ export default function WishCard({ wish }: WishCardProps) {
await soundManager.play("click") await soundManager.play("click")
try { try {
// 使用 Supabase 點讚服務 const userSession = getUserSession()
const success = await LikeService.likeWish(wish.id) // 使用 API 路由點讚服務
const response = await fetch('/api/wishes/like', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-session': userSession
},
body: JSON.stringify({ wishId: wish.id })
})
if (success) { const result = await response.json()
if (result.success && result.data.liked) {
// 更新本地狀態 // 更新本地狀態
setLikeCount(prev => prev + 1) setLikeCount(prev => prev + 1)
setHasLiked(true) setHasLiked(true)

1707
data/all-wishes.json Normal file

File diff suppressed because one or more lines are too long

1496
data/public-wishes.json Normal file

File diff suppressed because one or more lines are too long

25
env.mysql.example Normal file
View File

@@ -0,0 +1,25 @@
# MySQL 資料庫配置範例
# 請將此檔案複製為 .env.local 並確保資料庫連接正常
# MySQL 資料庫連接
DATABASE_URL="mysql://wish_pool:Aa123456@mysql.theaken.com:33306/db_wish_pool?schema=public"
# 應用程式配置
NEXT_PUBLIC_APP_NAME="資訊部.心願星河"
NEXT_PUBLIC_APP_VERSION="1.0.0"
# 功能開關
ENABLE_IP_WHITELIST=false
ALLOWED_IPS=""
# 音效配置
NEXT_PUBLIC_ENABLE_SOUND=true
NEXT_PUBLIC_DEFAULT_VOLUME=0.3
# 分析配置
NEXT_PUBLIC_ENABLE_ANALYTICS=true
NEXT_PUBLIC_MAX_IMAGES_PER_WISH=5
# 安全配置
NEXT_PUBLIC_MAX_CONTENT_LENGTH=10000
NEXT_PUBLIC_ENABLE_CONTENT_MODERATION=true

669
lib/database-service.ts Normal file
View File

@@ -0,0 +1,669 @@
// 統一的資料庫服務層 - 支援 Supabase 和 MySQL 切換
import { createClient } from "@supabase/supabase-js"
import { PrismaClient } from '@prisma/client'
import type { ImageFile } from "./image-utils"
// 確保 PrismaClient 正確初始化
const prisma = new PrismaClient()
// 資料庫類型枚舉
export type DatabaseType = 'supabase' | 'mysql'
// 配置
const DATABASE_TYPE: DatabaseType = (process.env.DATABASE_TYPE as DatabaseType) || 'mysql'
// Supabase 配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
// 創建客戶端(只有在有 Supabase 環境變數時才創建)
const supabase = supabaseUrl && supabaseAnonKey ? createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false,
},
db: {
schema: "public",
},
}) : null
// 使用上面定義的 prisma 實例
// 類型定義
export interface Wish {
id: number
title: string
current_pain: string
expected_solution: string
expected_effect: string | null
is_public: boolean
email: string | null
images: any[] | null
created_at: string
updated_at: string
like_count?: number
}
export interface WishInsert {
title: string
current_pain: string
expected_solution: string
expected_effect?: string | null
is_public?: boolean
email?: string | null
images?: any[] | null
}
export interface WishLike {
id: number
wish_id: number
user_session: string
created_at: string
}
export interface UserSettings {
id: number
user_session: string
background_music_enabled: boolean
background_music_volume: number
background_music_playing: boolean
created_at: string
updated_at: string
}
// 錯誤處理
export class DatabaseError extends Error {
constructor(
message: string,
public originalError?: any,
) {
super(message)
this.name = "DatabaseError"
}
}
// 生成用戶會話 ID
export function getUserSession(): string {
if (typeof window === "undefined") return "server-session"
let session = localStorage.getItem("user_session")
if (!session) {
session = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem("user_session", session)
}
return session
}
// 困擾案例服務
export class WishService {
// 獲取所有公開的困擾案例
static async getPublicWishes(): Promise<Wish[]> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.getPublicWishesFromSupabase()
} else {
return await this.getPublicWishesFromMySQL()
}
} catch (error) {
console.error("Error fetching public wishes:", error)
throw new DatabaseError("獲取公開困擾失敗", error)
}
}
// 獲取所有困擾案例
static async getAllWishes(): Promise<Wish[]> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.getAllWishesFromSupabase()
} else {
return await this.getAllWishesFromMySQL()
}
} catch (error) {
console.error("Error fetching all wishes:", error)
throw new DatabaseError("獲取所有困擾失敗", error)
}
}
// 創建困擾案例
static async createWish(wishData: {
title: string
currentPain: string
expectedSolution: string
expectedEffect?: string
isPublic?: boolean
email?: string
images?: ImageFile[]
}): Promise<Wish> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.createWishInSupabase(wishData)
} else {
return await this.createWishInMySQL(wishData)
}
} catch (error) {
console.error("Error creating wish:", error)
throw new DatabaseError("創建困擾失敗", error)
}
}
// 獲取統計數據
static async getWishesStats() {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.getWishesStatsFromSupabase()
} else {
return await this.getWishesStatsFromMySQL()
}
} catch (error) {
console.error("Error fetching wishes stats:", error)
throw new DatabaseError("獲取統計數據失敗", error)
}
}
// Supabase 實現
private static async getPublicWishesFromSupabase(): Promise<Wish[]> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const { data, error } = await supabase
.from("wishes_with_likes")
.select("*")
.eq("is_public", true)
.order("created_at", { ascending: false })
if (error) throw new DatabaseError("獲取公開困擾失敗", error)
return data || []
}
private static async getAllWishesFromSupabase(): Promise<Wish[]> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const { data, error } = await supabase
.from("wishes_with_likes")
.select("*")
.order("created_at", { ascending: false })
if (error) throw new DatabaseError("獲取所有困擾失敗", error)
return data || []
}
private static async createWishInSupabase(wishData: any): Promise<Wish> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const imageData = wishData.images?.map((img: ImageFile) => ({
id: img.id,
name: img.name,
size: img.size,
type: img.type,
base64: img.base64 || img.url,
})) || []
const { data, error } = await supabase
.from("wishes")
.insert({
title: wishData.title,
current_pain: wishData.currentPain,
expected_solution: wishData.expectedSolution,
expected_effect: wishData.expectedEffect || null,
is_public: wishData.isPublic ?? true,
email: wishData.email || null,
images: imageData,
})
.select()
.single()
if (error) throw new DatabaseError("創建困擾失敗", error)
return data
}
private static async getWishesStatsFromSupabase() {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const { data, error } = await supabase.rpc("get_wishes_stats")
if (error) throw new DatabaseError("獲取統計數據失敗", error)
return data
}
// MySQL 實現
private static async getPublicWishesFromMySQL(): Promise<Wish[]> {
const wishes = await prisma.wish.findMany({
where: {
isPublic: true,
status: 'active'
},
orderBy: {
createdAt: 'desc'
},
include: {
likes: true
}
})
return wishes.map((wish: any) => ({
...wish,
like_count: wish.likes.length,
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString()
}))
}
private static async getAllWishesFromMySQL(): Promise<Wish[]> {
const wishes = await prisma.wish.findMany({
where: {
status: 'active'
},
orderBy: {
createdAt: 'desc'
},
include: {
likes: true
}
})
return wishes.map((wish: any) => ({
...wish,
like_count: wish.likes.length,
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString()
}))
}
private static async createWishInMySQL(wishData: any): Promise<Wish> {
const imageData = wishData.images?.map((img: ImageFile) => ({
id: img.id,
name: img.name,
size: img.size,
type: img.type,
base64: img.base64 || img.url,
})) || []
const userSession = getUserSession()
const wish = await prisma.wish.create({
data: {
title: wishData.title,
currentPain: wishData.currentPain,
expectedSolution: wishData.expectedSolution,
expectedEffect: wishData.expectedEffect || null,
isPublic: wishData.isPublic ?? true,
email: wishData.email || null,
images: imageData,
userSession: userSession,
status: 'active',
priority: 3
},
include: {
likes: true
}
})
return {
...wish,
like_count: wish.likes.length,
created_at: wish.createdAt.toISOString(),
updated_at: wish.updatedAt.toISOString(),
current_pain: wish.currentPain,
expected_solution: wish.expectedSolution,
expected_effect: wish.expectedEffect,
is_public: wish.isPublic
}
}
private static async getWishesStatsFromMySQL() {
const result = await prisma.$queryRaw`
CALL GetWishesStats()
`
return result
}
}
// 點讚服務
export class LikeService {
// 為困擾案例點讚
static async likeWish(wishId: number): Promise<boolean> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.likeWishInSupabase(wishId)
} else {
return await this.likeWishInMySQL(wishId)
}
} catch (error) {
console.error("Error liking wish:", error)
throw new DatabaseError("點讚失敗", error)
}
}
// 檢查用戶是否已點讚
static async hasUserLiked(wishId: number): Promise<boolean> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.hasUserLikedInSupabase(wishId)
} else {
return await this.hasUserLikedInMySQL(wishId)
}
} catch (error) {
console.error("Error checking like status:", error)
return false
}
}
// 獲取困擾案例的點讚數
static async getWishLikeCount(wishId: number): Promise<number> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.getWishLikeCountFromSupabase(wishId)
} else {
return await this.getWishLikeCountFromMySQL(wishId)
}
} catch (error) {
console.error("Error fetching like count:", error)
return 0
}
}
// 獲取用戶已點讚的困擾 ID 列表
static async getUserLikedWishes(): Promise<number[]> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.getUserLikedWishesFromSupabase()
} else {
return await this.getUserLikedWishesFromMySQL()
}
} catch (error) {
console.error("Error fetching user liked wishes:", error)
return []
}
}
// Supabase 實現
private static async likeWishInSupabase(wishId: number): Promise<boolean> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const userSession = getUserSession()
const { error } = await supabase.from("wish_likes").insert({
wish_id: wishId,
user_session: userSession,
})
if (error) {
if (error.code === "23505") return false
throw new DatabaseError("點讚失敗", error)
}
return true
}
private static async hasUserLikedInSupabase(wishId: number): Promise<boolean> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const userSession = getUserSession()
const { data, error } = await supabase
.from("wish_likes")
.select("id")
.eq("wish_id", wishId)
.eq("user_session", userSession)
.single()
if (error && error.code !== "PGRST116") {
throw new DatabaseError("檢查點讚狀態失敗", error)
}
return !!data
}
private static async getWishLikeCountFromSupabase(wishId: number): Promise<number> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const { count, error } = await supabase
.from("wish_likes")
.select("*", { count: "exact", head: true })
.eq("wish_id", wishId)
if (error) throw new DatabaseError("獲取點讚數失敗", error)
return count || 0
}
private static async getUserLikedWishesFromSupabase(): Promise<number[]> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const userSession = getUserSession()
const { data, error } = await supabase
.from("wish_likes")
.select("wish_id")
.eq("user_session", userSession)
if (error) throw new DatabaseError("獲取用戶點讚記錄失敗", error)
return data?.map((item) => item.wish_id) || []
}
// MySQL 實現
private static async likeWishInMySQL(wishId: number): Promise<boolean> {
const userSession = getUserSession()
try {
await prisma.wishLike.create({
data: {
wishId: wishId,
userSession: userSession,
}
})
return true
} catch (error: any) {
if (error.code === 'P2002') return false
throw error
}
}
private static async hasUserLikedInMySQL(wishId: number): Promise<boolean> {
const userSession = getUserSession()
const like = await prisma.wishLike.findFirst({
where: {
wishId: wishId,
userSession: userSession
}
})
return !!like
}
private static async getWishLikeCountFromMySQL(wishId: number): Promise<number> {
const count = await prisma.wishLike.count({
where: {
wishId: wishId
}
})
return count
}
private static async getUserLikedWishesFromMySQL(): Promise<number[]> {
const userSession = getUserSession()
const likes = await prisma.wishLike.findMany({
where: {
userSession: userSession
},
select: {
wishId: true
}
})
return likes.map((like: any) => like.wishId)
}
}
// 用戶設定服務
export class UserSettingsService {
// 獲取用戶設定
static async getUserSettings(): Promise<UserSettings | null> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.getUserSettingsFromSupabase()
} else {
return await this.getUserSettingsFromMySQL()
}
} catch (error) {
console.error("Error fetching user settings:", error)
return null
}
}
// 更新或創建用戶設定
static async updateUserSettings(settings: {
backgroundMusicEnabled?: boolean
backgroundMusicVolume?: number
backgroundMusicPlaying?: boolean
}): Promise<UserSettings> {
try {
if (DATABASE_TYPE === 'supabase') {
return await this.updateUserSettingsInSupabase(settings)
} else {
return await this.updateUserSettingsInMySQL(settings)
}
} catch (error) {
console.error("Error updating user settings:", error)
throw new DatabaseError("更新用戶設定失敗", error)
}
}
// Supabase 實現
private static async getUserSettingsFromSupabase(): Promise<UserSettings | null> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const userSession = getUserSession()
const { data, error } = await supabase
.from("user_settings")
.select("*")
.eq("user_session", userSession)
.single()
if (error && error.code !== "PGRST116") {
throw new DatabaseError("獲取用戶設定失敗", error)
}
return data
}
private static async updateUserSettingsInSupabase(settings: any): Promise<UserSettings> {
if (!supabase) {
throw new DatabaseError("Supabase 未配置,請使用 MySQL 資料庫")
}
const userSession = getUserSession()
const { data: updateData, error: updateError } = await supabase
.from("user_settings")
.update({
background_music_enabled: settings.backgroundMusicEnabled,
background_music_volume: settings.backgroundMusicVolume,
background_music_playing: settings.backgroundMusicPlaying,
})
.eq("user_session", userSession)
.select()
.single()
if (updateError && updateError.code === "PGRST116") {
const { data: insertData, error: insertError } = await supabase
.from("user_settings")
.insert({
user_session: userSession,
background_music_enabled: settings.backgroundMusicEnabled ?? false,
background_music_volume: settings.backgroundMusicVolume ?? 0.3,
background_music_playing: settings.backgroundMusicPlaying ?? false,
})
.select()
.single()
if (insertError) throw new DatabaseError("創建用戶設定失敗", insertError)
return insertData
}
if (updateError) throw new DatabaseError("更新用戶設定失敗", updateError)
return updateData
}
// MySQL 實現
private static async getUserSettingsFromMySQL(): Promise<UserSettings | null> {
const userSession = getUserSession()
const settings = await prisma.userSetting.findUnique({
where: {
userSession: userSession
}
})
if (!settings) return null
return {
...settings,
created_at: settings.createdAt.toISOString(),
updated_at: settings.updatedAt.toISOString(),
user_session: settings.userSession,
background_music_enabled: settings.backgroundMusicEnabled,
background_music_volume: settings.backgroundMusicVolume,
background_music_playing: settings.backgroundMusicPlaying
}
}
private static async updateUserSettingsInMySQL(settings: any): Promise<UserSettings> {
const userSession = getUserSession()
const userSettings = await prisma.userSetting.upsert({
where: {
userSession: userSession
},
update: {
backgroundMusicEnabled: settings.backgroundMusicEnabled,
backgroundMusicVolume: settings.backgroundMusicVolume,
backgroundMusicPlaying: settings.backgroundMusicPlaying,
},
create: {
userSession: userSession,
backgroundMusicEnabled: settings.backgroundMusicEnabled ?? false,
backgroundMusicVolume: settings.backgroundMusicVolume ?? 0.3,
backgroundMusicPlaying: settings.backgroundMusicPlaying ?? false,
}
})
return {
...userSettings,
created_at: userSettings.createdAt.toISOString(),
updated_at: userSettings.updatedAt.toISOString(),
user_session: userSettings.userSession,
background_music_enabled: userSettings.backgroundMusicEnabled,
background_music_volume: userSettings.backgroundMusicVolume,
background_music_playing: userSettings.backgroundMusicPlaying
}
}
}
// 測試資料庫連接
export async function testDatabaseConnection(): Promise<boolean> {
try {
if (DATABASE_TYPE === 'supabase') {
if (!supabase) {
throw new Error("Supabase 未配置,請使用 MySQL 資料庫")
}
const { data, error } = await supabase.from("wishes").select("count").limit(1)
if (error) throw error
console.log("✅ Supabase connection successful")
} else {
await prisma.$queryRaw`SELECT 1`
console.log("✅ MySQL connection successful")
}
return true
} catch (error) {
console.error("Database connection test failed:", error)
return false
}
}
// 關閉資料庫連接
export async function closeDatabaseConnection(): Promise<void> {
if (DATABASE_TYPE === 'mysql') {
await prisma.$disconnect()
}
}
export default {
WishService,
LikeService,
UserSettingsService,
testDatabaseConnection,
closeDatabaseConnection
}

516
lib/mysql-service.ts Normal file
View File

@@ -0,0 +1,516 @@
import { PrismaClient } from '@prisma/client'
import type { ImageFile } from './image-utils'
import { StatisticsService } from './statistics-service'
// 創建 Prisma 客戶端實例
const prisma = new PrismaClient()
// 類型定義
export type Wish = {
id: number
title: string
current_pain: string
expected_solution: string
expected_effect: string | null
is_public: boolean
email: string | null
images: any[] | null
user_session: string
status: string
category: string | null
priority: number
created_at: Date
updated_at: Date
like_count?: number
}
export type WishInsert = {
title: string
current_pain: string
expected_solution: string
expected_effect?: string | null
is_public?: boolean
email?: string | null
images?: any[] | null
user_session: string
status?: string
category?: string | null
priority?: number
}
export type WishLike = {
id: number
wish_id: number
user_session: string
ip_address: string | null
user_agent: string | null
created_at: Date
}
export type UserSettings = {
id: number
user_session: string
background_music_enabled: boolean
background_music_volume: number
background_music_playing: boolean
theme_preference: string
language_preference: string
notification_enabled: boolean
created_at: Date
updated_at: Date
}
// 錯誤處理
export class MySQLServiceError extends Error {
constructor(
message: string,
public originalError?: any,
) {
super(message)
this.name = "MySQLServiceError"
}
}
// 生成用戶會話 ID用於匿名識別
export function getUserSession(): string {
if (typeof window === "undefined") return "server-session"
let session = localStorage.getItem("user_session")
if (!session) {
session = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem("user_session", session)
}
return session
}
// 困擾案例相關服務
export class WishService {
// 獲取所有公開的困擾案例(帶點讚數)
static async getPublicWishes(): Promise<Wish[]> {
try {
const wishes = await prisma.wish.findMany({
where: {
isPublic: true,
status: 'active'
},
orderBy: {
createdAt: 'desc'
},
include: {
likes: true
}
})
// 添加點讚數
return wishes.map((wish: any) => ({
...wish,
like_count: wish.likes.length
}))
} catch (error) {
console.error("Error fetching public wishes:", error)
throw new MySQLServiceError("獲取公開困擾失敗", error)
}
}
// 獲取所有困擾案例(用於分析,包含私密的)
static async getAllWishes(): Promise<Wish[]> {
try {
const wishes = await prisma.wish.findMany({
where: {
status: 'active'
},
orderBy: {
createdAt: 'desc'
},
include: {
likes: true
}
})
// 添加點讚數
return wishes.map((wish: any) => ({
...wish,
like_count: wish.likes.length
}))
} catch (error) {
console.error("Error fetching all wishes:", error)
throw new MySQLServiceError("獲取所有困擾失敗", error)
}
}
// 創建新的困擾案例
static async createWish(wishData: {
title: string
currentPain: string
expectedSolution: string
expectedEffect?: string
isPublic?: boolean
email?: string
images?: ImageFile[]
}): Promise<Wish> {
try {
// 轉換圖片數據格式
const imageData = wishData.images?.map((img) => ({
id: img.id,
name: img.name,
size: img.size,
type: img.type,
base64: img.base64 || img.url,
})) || []
const userSession = getUserSession()
const wish = await prisma.wish.create({
data: {
title: wishData.title,
currentPain: wishData.currentPain,
expectedSolution: wishData.expectedSolution,
expectedEffect: wishData.expectedEffect || null,
isPublic: wishData.isPublic ?? true,
email: wishData.email || null,
images: imageData,
userSession: userSession,
status: 'active',
priority: 3
},
include: {
likes: true
}
})
// 更新統計數據
await StatisticsService.updateWishStats(Number(wish.id), 'create', wishData.isPublic ?? true)
return {
...wish,
like_count: wish.likes.length
}
} catch (error) {
console.error("Error creating wish:", error)
throw new MySQLServiceError("創建困擾失敗", error)
}
}
// 獲取統計數據
static async getWishesStats() {
try {
const result = await prisma.$queryRaw`
CALL GetWishesStats()
`
return result
} catch (error) {
console.error("Error fetching wishes stats:", error)
throw new MySQLServiceError("獲取統計數據失敗", error)
}
}
// 根據 ID 獲取困擾案例
static async getWishById(id: number): Promise<Wish | null> {
try {
const wish = await prisma.wish.findUnique({
where: { id },
include: {
likes: true
}
})
if (!wish) return null
return {
...wish,
like_count: wish.likes.length
}
} catch (error) {
console.error("Error fetching wish by id:", error)
throw new MySQLServiceError("獲取困擾案例失敗", error)
}
}
// 更新困擾案例
static async updateWish(id: number, data: Partial<WishInsert>): Promise<Wish> {
try {
const wish = await prisma.wish.update({
where: { id },
data: {
...data,
updatedAt: new Date()
},
include: {
likes: true
}
})
return {
...wish,
like_count: wish.likes.length
}
} catch (error) {
console.error("Error updating wish:", error)
throw new MySQLServiceError("更新困擾案例失敗", error)
}
}
// 刪除困擾案例
static async deleteWish(id: number): Promise<boolean> {
try {
await prisma.wish.delete({
where: { id }
})
return true
} catch (error) {
console.error("Error deleting wish:", error)
throw new MySQLServiceError("刪除困擾案例失敗", error)
}
}
}
// 點讚相關服務
export class LikeService {
// 為困擾案例點讚
static async likeWish(wishId: number): Promise<boolean> {
try {
const userSession = getUserSession()
await prisma.wishLike.create({
data: {
wishId: wishId,
userSession: userSession,
}
})
// 更新統計數據
await StatisticsService.updateLikeStats(wishId, 'create')
return true
} catch (error: any) {
// 如果是重複點讚錯誤,返回 false
if (error.code === 'P2002') {
return false
}
console.error("Error liking wish:", error)
throw new MySQLServiceError("點讚失敗", error)
}
}
// 取消點讚
static async unlikeWish(wishId: number): Promise<boolean> {
try {
const userSession = getUserSession()
const result = await prisma.wishLike.deleteMany({
where: {
wishId: wishId,
userSession: userSession
}
})
if (result.count > 0) {
// 更新統計數據
await StatisticsService.updateLikeStats(wishId, 'delete')
return true
}
return false
} catch (error) {
console.error("Error unliking wish:", error)
throw new MySQLServiceError("取消點讚失敗", error)
}
}
// 檢查用戶是否已點讚
static async hasUserLiked(wishId: number): Promise<boolean> {
try {
const userSession = getUserSession()
const like = await prisma.wishLike.findFirst({
where: {
wishId: wishId,
userSession: userSession
}
})
return !!like
} catch (error) {
console.error("Error checking like status:", error)
return false
}
}
// 獲取困擾案例的點讚數
static async getWishLikeCount(wishId: number): Promise<number> {
try {
const count = await prisma.wishLike.count({
where: {
wish_id: wishId
}
})
return count
} catch (error) {
console.error("Error fetching like count:", error)
return 0
}
}
// 獲取用戶已點讚的困擾 ID 列表
static async getUserLikedWishes(): Promise<number[]> {
try {
const userSession = getUserSession()
const likes = await prisma.wishLike.findMany({
where: {
userSession: userSession
},
select: {
wishId: true
}
})
return likes.map((like: any) => like.wishId)
} catch (error) {
console.error("Error fetching user liked wishes:", error)
return []
}
}
}
// 用戶設定相關服務
export class UserSettingsService {
// 獲取用戶設定
static async getUserSettings(): Promise<UserSettings | null> {
try {
const userSession = getUserSession()
const settings = await prisma.userSetting.findUnique({
where: {
userSession: userSession
}
})
return settings
} catch (error) {
console.error("Error fetching user settings:", error)
return null
}
}
// 更新或創建用戶設定
static async updateUserSettings(settings: {
backgroundMusicEnabled?: boolean
backgroundMusicVolume?: number
backgroundMusicPlaying?: boolean
themePreference?: string
languagePreference?: string
notificationEnabled?: boolean
}): Promise<UserSettings> {
try {
const userSession = getUserSession()
const userSettings = await prisma.userSetting.upsert({
where: {
userSession: userSession
},
update: {
backgroundMusicEnabled: settings.backgroundMusicEnabled,
backgroundMusicVolume: settings.backgroundMusicVolume,
backgroundMusicPlaying: settings.backgroundMusicPlaying,
themePreference: settings.themePreference,
languagePreference: settings.languagePreference,
notificationEnabled: settings.notificationEnabled,
},
create: {
userSession: userSession,
backgroundMusicEnabled: settings.backgroundMusicEnabled ?? false,
backgroundMusicVolume: settings.backgroundMusicVolume ?? 0.3,
backgroundMusicPlaying: settings.backgroundMusicPlaying ?? false,
themePreference: settings.themePreference ?? 'auto',
languagePreference: settings.languagePreference ?? 'zh-TW',
notificationEnabled: settings.notificationEnabled ?? true,
}
})
return userSettings
} catch (error) {
console.error("Error updating user settings:", error)
throw new MySQLServiceError("更新用戶設定失敗", error)
}
}
}
// 數據遷移服務(從 Supabase 遷移到 MySQL
export class MigrationService {
// 遷移 Supabase 數據到 MySQL
static async migrateFromSupabase(supabaseData: any[]): Promise<{
success: number
failed: number
errors: string[]
}> {
const result = {
success: 0,
failed: 0,
errors: [] as string[],
}
try {
console.log(`Starting migration of ${supabaseData.length} wishes...`)
for (const wish of supabaseData) {
try {
await WishService.createWish({
title: wish.title,
currentPain: wish.current_pain,
expectedSolution: wish.expected_solution,
expectedEffect: wish.expected_effect,
isPublic: wish.is_public,
email: wish.email,
images: wish.images || [],
})
result.success++
} catch (error) {
result.failed++
result.errors.push(`Failed to migrate wish "${wish.title}": ${error}`)
}
}
console.log(`Migration completed: ${result.success} success, ${result.failed} failed`)
return result
} catch (error) {
console.error("Migration error:", error)
result.errors.push(`Migration process failed: ${error}`)
return result
}
}
// 清空所有數據(測試用)
static async clearAllData(): Promise<void> {
try {
await prisma.wishLike.deleteMany()
await prisma.wish.deleteMany()
await prisma.userSetting.deleteMany()
await prisma.migrationLog.deleteMany()
await prisma.systemStat.deleteMany()
console.log("All data cleared")
} catch (error) {
console.error("Error clearing data:", error)
throw new MySQLServiceError("清空數據失敗", error)
}
}
}
// 測試 MySQL 連接
export async function testMySQLConnection(): Promise<boolean> {
try {
await prisma.$queryRaw`SELECT 1`
console.log("✅ MySQL connection successful")
return true
} catch (error) {
console.error("MySQL connection test failed:", error)
return false
}
}
// 關閉 Prisma 連接
export async function closeMySQLConnection(): Promise<void> {
await prisma.$disconnect()
}
export default prisma

268
lib/statistics-service.ts Normal file
View File

@@ -0,0 +1,268 @@
// 統計服務 - 替代 MySQL 觸發器功能
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// 統計更新服務
export class StatisticsService {
// 更新困擾案例統計
static async updateWishStats(wishId: number, action: 'create' | 'update' | 'delete', isPublic?: boolean) {
try {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (action === 'create') {
// 創建困擾案例
await prisma.systemStat.upsert({
where: { statDate: today },
update: {
totalWishes: { increment: 1 },
publicWishes: { increment: isPublic ? 1 : 0 },
privateWishes: { increment: isPublic ? 0 : 1 }
},
create: {
statDate: today,
totalWishes: 1,
publicWishes: isPublic ? 1 : 0,
privateWishes: isPublic ? 0 : 1,
totalLikes: 0,
activeUsers: 0,
storageUsedMb: 0
}
})
} else if (action === 'delete') {
// 刪除困擾案例
const existing = await prisma.systemStat.findUnique({
where: { statDate: today }
})
if (existing) {
await prisma.systemStat.update({
where: { statDate: today },
data: {
totalWishes: Math.max(existing.totalWishes - 1, 0),
publicWishes: Math.max(existing.publicWishes - (isPublic ? 1 : 0), 0),
privateWishes: Math.max(existing.privateWishes - (isPublic ? 0 : 1), 0)
}
})
}
} else if (action === 'update') {
// 更新困擾案例(公開狀態變更)
const existing = await prisma.systemStat.findUnique({
where: { statDate: today }
})
if (existing) {
await prisma.systemStat.update({
where: { statDate: today },
data: {
publicWishes: { increment: isPublic ? 1 : -1 },
privateWishes: { increment: isPublic ? -1 : 1 }
}
})
}
}
} catch (error) {
console.error('更新困擾案例統計失敗:', error)
}
}
// 更新點讚統計
static async updateLikeStats(wishId: number, action: 'create' | 'delete') {
try {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (action === 'create') {
await prisma.systemStat.upsert({
where: { statDate: today },
update: {
totalLikes: { increment: 1 }
},
create: {
statDate: today,
totalWishes: 0,
publicWishes: 0,
privateWishes: 0,
totalLikes: 1,
activeUsers: 0,
storageUsedMb: 0
}
})
} else if (action === 'delete') {
const existing = await prisma.systemStat.findUnique({
where: { statDate: today }
})
if (existing) {
await prisma.systemStat.update({
where: { statDate: today },
data: {
totalLikes: Math.max(existing.totalLikes - 1, 0)
}
})
}
}
} catch (error) {
console.error('更新點讚統計失敗:', error)
}
}
// 更新活躍用戶統計
static async updateActiveUserStats(userSession: string) {
try {
const today = new Date()
today.setHours(0, 0, 0, 0)
// 檢查今天是否已經統計過這個用戶
const existing = await prisma.systemStat.findUnique({
where: { statDate: today }
})
if (existing) {
// 簡單的活躍用戶計數(實際應用中可能需要更複雜的邏輯)
await prisma.systemStat.update({
where: { statDate: today },
data: {
activeUsers: { increment: 1 }
}
})
} else {
await prisma.systemStat.create({
data: {
statDate: today,
totalWishes: 0,
publicWishes: 0,
privateWishes: 0,
totalLikes: 0,
activeUsers: 1,
storageUsedMb: 0
}
})
}
} catch (error) {
console.error('更新活躍用戶統計失敗:', error)
}
}
// 獲取統計數據
static async getStatistics() {
try {
const today = new Date()
today.setHours(0, 0, 0, 0)
const stats = await prisma.systemStat.findUnique({
where: { statDate: today }
})
if (!stats) {
return {
totalWishes: 0,
publicWishes: 0,
privateWishes: 0,
totalLikes: 0,
activeUsers: 0,
storageUsedMb: 0
}
}
return stats
} catch (error) {
console.error('獲取統計數據失敗:', error)
return null
}
}
// 重新計算所有統計數據
static async recalculateAllStats() {
try {
const today = new Date()
today.setHours(0, 0, 0, 0)
// 計算總困擾數
const totalWishes = await prisma.wish.count({
where: { status: 'active' }
})
// 計算公開困擾數
const publicWishes = await prisma.wish.count({
where: {
status: 'active',
isPublic: true
}
})
// 計算私密困擾數
const privateWishes = await prisma.wish.count({
where: {
status: 'active',
isPublic: false
}
})
// 計算總點讚數
const totalLikes = await prisma.wishLike.count()
// 計算本週新增困擾數
const thisWeek = new Date()
thisWeek.setDate(thisWeek.getDate() - 7)
const thisWeekWishes = await prisma.wish.count({
where: {
status: 'active',
createdAt: { gte: thisWeek }
}
})
// 計算上週新增困擾數
const lastWeekStart = new Date()
lastWeekStart.setDate(lastWeekStart.getDate() - 14)
const lastWeekEnd = new Date()
lastWeekEnd.setDate(lastWeekEnd.getDate() - 7)
const lastWeekWishes = await prisma.wish.count({
where: {
status: 'active',
createdAt: {
gte: lastWeekStart,
lt: lastWeekEnd
}
}
})
// 更新或創建統計記錄
await prisma.systemStat.upsert({
where: { statDate: today },
update: {
totalWishes,
publicWishes,
privateWishes,
totalLikes,
activeUsers: 0, // 需要更複雜的邏輯來計算活躍用戶
storageUsedMb: 0 // 需要計算實際儲存使用量
},
create: {
statDate: today,
totalWishes,
publicWishes,
privateWishes,
totalLikes,
activeUsers: 0,
storageUsedMb: 0
}
})
return {
totalWishes,
publicWishes,
privateWishes,
totalLikes,
thisWeekWishes,
lastWeekWishes
}
} catch (error) {
console.error('重新計算統計數據失敗:', error)
return null
}
}
}
export default StatisticsService

View File

@@ -26,6 +26,10 @@ export class WishService {
// 獲取所有公開的困擾案例(帶點讚數) // 獲取所有公開的困擾案例(帶點讚數)
static async getPublicWishes(): Promise<Wish[]> { static async getPublicWishes(): Promise<Wish[]> {
try { try {
if (!supabase) {
throw new SupabaseError("Supabase 未配置,請使用統一的資料庫服務")
}
const { data, error } = await supabase const { data, error } = await supabase
.from("wishes_with_likes") .from("wishes_with_likes")
.select("*") .select("*")
@@ -43,6 +47,10 @@ export class WishService {
// 獲取所有困擾案例(用於分析,包含私密的) // 獲取所有困擾案例(用於分析,包含私密的)
static async getAllWishes(): Promise<Wish[]> { static async getAllWishes(): Promise<Wish[]> {
try { try {
if (!supabase) {
throw new SupabaseError("Supabase 未配置,請使用統一的資料庫服務")
}
const { data, error } = await supabase const { data, error } = await supabase
.from("wishes_with_likes") .from("wishes_with_likes")
.select("*") .select("*")

View File

@@ -1,18 +1,19 @@
import { createClient } from "@supabase/supabase-js" import { createClient } from "@supabase/supabase-js"
// Supabase 配置 // Supabase 配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
// 創建 Supabase 客戶端(單例模式) // 創建 Supabase 客戶端(單例模式)
export const supabase = createClient(supabaseUrl, supabaseAnonKey, { // 只有在有 Supabase 環境變數時才創建客戶端
export const supabase = supabaseUrl && supabaseAnonKey ? createClient(supabaseUrl, supabaseAnonKey, {
auth: { auth: {
persistSession: false, // 我們不需要用戶認證 persistSession: false, // 我們不需要用戶認證
}, },
db: { db: {
schema: "public", schema: "public",
}, },
}) }) : null
// 數據庫類型定義 // 數據庫類型定義
export interface Database { export interface Database {
@@ -136,6 +137,11 @@ export function getUserSession(): string {
// 測試 Supabase 連接 // 測試 Supabase 連接
export async function testSupabaseConnection(): Promise<boolean> { export async function testSupabaseConnection(): Promise<boolean> {
try { try {
if (!supabase) {
console.log(" Supabase 未配置,使用 MySQL 資料庫")
return false
}
const { data, error } = await supabase.from("wishes").select("count").limit(1) const { data, error } = await supabase.from("wishes").select("count").limit(1)
if (error) { if (error) {

View File

@@ -8,6 +8,7 @@ generator client {
datasource db { datasource db {
provider = "mysql" provider = "mysql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
relationMode = "prisma"
} }
// 困擾案例主表 // 困擾案例主表

View File

@@ -1,118 +0,0 @@
-- 心願星河 - 基礎表格創建
-- 執行順序:第 1 步
-- 說明:創建應用程式所需的基礎數據表格
-- 開始事務
BEGIN;
-- 1. 創建 wishes 表格(困擾案例主表)
CREATE TABLE IF NOT EXISTS wishes (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL CHECK (length(title) >= 1 AND length(title) <= 200),
current_pain TEXT NOT NULL CHECK (length(current_pain) >= 1 AND length(current_pain) <= 2000),
expected_solution TEXT NOT NULL CHECK (length(expected_solution) >= 1 AND length(expected_solution) <= 2000),
expected_effect TEXT CHECK (length(expected_effect) <= 1000),
is_public BOOLEAN DEFAULT true NOT NULL,
email TEXT CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
images JSONB DEFAULT '[]'::jsonb NOT NULL,
user_session TEXT NOT NULL DEFAULT gen_random_uuid()::text,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')),
category TEXT CHECK (category IN ('工作效率', '人際關係', '技術問題', '職涯發展', '工作環境', '其他')),
priority INTEGER DEFAULT 3 CHECK (priority >= 1 AND priority <= 5),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
-- 2. 創建 wish_likes 表格(點讚記錄)
CREATE TABLE IF NOT EXISTS wish_likes (
id BIGSERIAL PRIMARY KEY,
wish_id BIGINT NOT NULL REFERENCES wishes(id) ON DELETE CASCADE,
user_session TEXT NOT NULL,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
UNIQUE(wish_id, user_session)
);
-- 3. 創建 user_settings 表格(用戶設定)
CREATE TABLE IF NOT EXISTS user_settings (
id BIGSERIAL PRIMARY KEY,
user_session TEXT UNIQUE NOT NULL,
background_music_enabled BOOLEAN DEFAULT false NOT NULL,
background_music_volume DECIMAL(3,2) DEFAULT 0.30 CHECK (background_music_volume >= 0 AND background_music_volume <= 1),
background_music_playing BOOLEAN DEFAULT false NOT NULL,
theme_preference TEXT DEFAULT 'auto' CHECK (theme_preference IN ('light', 'dark', 'auto')),
language_preference TEXT DEFAULT 'zh-TW' CHECK (language_preference IN ('zh-TW', 'en-US')),
notification_enabled BOOLEAN DEFAULT true NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
-- 4. 創建 migration_log 表格(遷移記錄)
CREATE TABLE IF NOT EXISTS migration_log (
id BIGSERIAL PRIMARY KEY,
user_session TEXT NOT NULL,
migration_type TEXT NOT NULL CHECK (migration_type IN ('wishes', 'likes', 'settings')),
source_data JSONB,
target_records INTEGER DEFAULT 0,
success BOOLEAN DEFAULT false NOT NULL,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
-- 5. 創建 system_stats 表格(系統統計)
CREATE TABLE IF NOT EXISTS system_stats (
id BIGSERIAL PRIMARY KEY,
stat_date DATE DEFAULT CURRENT_DATE NOT NULL,
total_wishes INTEGER DEFAULT 0,
public_wishes INTEGER DEFAULT 0,
private_wishes INTEGER DEFAULT 0,
total_likes INTEGER DEFAULT 0,
active_users INTEGER DEFAULT 0,
storage_used_mb DECIMAL(10,2) DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
UNIQUE(stat_date)
);
-- 提交事務
COMMIT;
-- 添加表格註釋
COMMENT ON TABLE wishes IS '用戶提交的工作困擾案例主表';
COMMENT ON TABLE wish_likes IS '困擾案例的點讚記錄表';
COMMENT ON TABLE user_settings IS '用戶個人設定表(音樂、主題等)';
COMMENT ON TABLE migration_log IS '數據遷移記錄表';
COMMENT ON TABLE system_stats IS '系統統計數據表';
-- 添加欄位註釋
COMMENT ON COLUMN wishes.title IS '困擾案例標題';
COMMENT ON COLUMN wishes.current_pain IS '目前遇到的困擾描述';
COMMENT ON COLUMN wishes.expected_solution IS '期望的解決方案';
COMMENT ON COLUMN wishes.expected_effect IS '預期效果描述';
COMMENT ON COLUMN wishes.is_public IS '是否公開顯示';
COMMENT ON COLUMN wishes.images IS '相關圖片資訊JSON格式';
COMMENT ON COLUMN wishes.user_session IS '用戶會話標識';
COMMENT ON COLUMN wishes.category IS '困擾類別';
COMMENT ON COLUMN wishes.priority IS '優先級1-55最高';
COMMENT ON COLUMN wish_likes.wish_id IS '被點讚的困擾案例ID';
COMMENT ON COLUMN wish_likes.user_session IS '點讚用戶的會話標識';
COMMENT ON COLUMN wish_likes.ip_address IS '點讚用戶的IP地址';
COMMENT ON COLUMN user_settings.background_music_enabled IS '背景音樂是否啟用';
COMMENT ON COLUMN user_settings.background_music_volume IS '背景音樂音量0-1';
COMMENT ON COLUMN user_settings.theme_preference IS '主題偏好設定';
-- 顯示創建結果
DO $$
BEGIN
RAISE NOTICE '✅ 基礎表格創建完成!';
RAISE NOTICE '📊 創建的表格:';
RAISE NOTICE ' - wishes困擾案例';
RAISE NOTICE ' - wish_likes點讚記錄';
RAISE NOTICE ' - user_settings用戶設定';
RAISE NOTICE ' - migration_log遷移記錄';
RAISE NOTICE ' - system_stats系統統計';
RAISE NOTICE '';
RAISE NOTICE '🔄 下一步:執行 02-create-indexes.sql';
END $$;

View File

@@ -1,174 +0,0 @@
-- 心願星河 - 索引和觸發器創建
-- 執行順序:第 2 步
-- 說明:創建性能優化索引和自動更新觸發器
-- 開始事務
BEGIN;
-- 1. wishes 表格索引
CREATE INDEX IF NOT EXISTS idx_wishes_created_at ON wishes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wishes_is_public ON wishes(is_public) WHERE is_public = true;
CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_wishes_category ON wishes(category) WHERE category IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_wishes_priority ON wishes(priority DESC);
CREATE INDEX IF NOT EXISTS idx_wishes_user_session ON wishes(user_session);
CREATE INDEX IF NOT EXISTS idx_wishes_email ON wishes(email) WHERE email IS NOT NULL;
-- 全文搜索索引 (使用 simple 配置以支持多语言)
CREATE INDEX IF NOT EXISTS idx_wishes_search ON wishes USING gin(
to_tsvector('simple', title || ' ' || current_pain || ' ' || expected_solution)
);
-- 2. wish_likes 表格索引
CREATE INDEX IF NOT EXISTS idx_wish_likes_wish_id ON wish_likes(wish_id);
CREATE INDEX IF NOT EXISTS idx_wish_likes_user_session ON wish_likes(user_session);
CREATE INDEX IF NOT EXISTS idx_wish_likes_created_at ON wish_likes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wish_likes_ip_address ON wish_likes(ip_address);
-- 3. user_settings 表格索引
CREATE INDEX IF NOT EXISTS idx_user_settings_session ON user_settings(user_session);
CREATE INDEX IF NOT EXISTS idx_user_settings_updated_at ON user_settings(updated_at DESC);
-- 4. migration_log 表格索引
CREATE INDEX IF NOT EXISTS idx_migration_log_user_session ON migration_log(user_session);
CREATE INDEX IF NOT EXISTS idx_migration_log_type ON migration_log(migration_type);
CREATE INDEX IF NOT EXISTS idx_migration_log_success ON migration_log(success);
CREATE INDEX IF NOT EXISTS idx_migration_log_created_at ON migration_log(created_at DESC);
-- 5. system_stats 表格索引
CREATE INDEX IF NOT EXISTS idx_system_stats_date ON system_stats(stat_date DESC);
-- 6. 創建更新時間觸發器函數
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 7. 為需要的表格添加更新時間觸發器
DROP TRIGGER IF EXISTS update_wishes_updated_at ON wishes;
CREATE TRIGGER update_wishes_updated_at
BEFORE UPDATE ON wishes
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_user_settings_updated_at ON user_settings;
CREATE TRIGGER update_user_settings_updated_at
BEFORE UPDATE ON user_settings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- 8. 創建統計更新觸發器函數
CREATE OR REPLACE FUNCTION update_system_stats()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO system_stats (
stat_date,
total_wishes,
public_wishes,
private_wishes,
total_likes,
active_users
)
SELECT
CURRENT_DATE,
COUNT(*) as total_wishes,
COUNT(*) FILTER (WHERE is_public = true) as public_wishes,
COUNT(*) FILTER (WHERE is_public = false) as private_wishes,
(SELECT COUNT(*) FROM wish_likes) as total_likes,
COUNT(DISTINCT user_session) as active_users
FROM wishes
WHERE status = 'active'
ON CONFLICT (stat_date)
DO UPDATE SET
total_wishes = EXCLUDED.total_wishes,
public_wishes = EXCLUDED.public_wishes,
private_wishes = EXCLUDED.private_wishes,
total_likes = EXCLUDED.total_likes,
active_users = EXCLUDED.active_users;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- 9. 為 wishes 和 wish_likes 添加統計更新觸發器
DROP TRIGGER IF EXISTS update_stats_on_wish_change ON wishes;
CREATE TRIGGER update_stats_on_wish_change
AFTER INSERT OR UPDATE OR DELETE ON wishes
FOR EACH STATEMENT
EXECUTE FUNCTION update_system_stats();
DROP TRIGGER IF EXISTS update_stats_on_like_change ON wish_likes;
CREATE TRIGGER update_stats_on_like_change
AFTER INSERT OR DELETE ON wish_likes
FOR EACH STATEMENT
EXECUTE FUNCTION update_system_stats();
-- 10. 創建圖片清理觸發器函數
CREATE OR REPLACE FUNCTION cleanup_wish_images()
RETURNS TRIGGER AS $$
BEGIN
-- 當 wish 被刪除時,記錄需要清理的圖片
IF TG_OP = 'DELETE' THEN
INSERT INTO migration_log (
user_session,
migration_type,
source_data,
success,
error_message
) VALUES (
OLD.user_session,
'image_cleanup',
OLD.images,
false,
'Images marked for cleanup'
);
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 11. 為 wishes 添加圖片清理觸發器
DROP TRIGGER IF EXISTS cleanup_images_on_wish_delete ON wishes;
CREATE TRIGGER cleanup_images_on_wish_delete
AFTER DELETE ON wishes
FOR EACH ROW
EXECUTE FUNCTION cleanup_wish_images();
-- 提交事務
COMMIT;
-- 顯示創建結果
DO $$
DECLARE
index_count INTEGER;
trigger_count INTEGER;
BEGIN
-- 計算索引數量
SELECT COUNT(*) INTO index_count
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname LIKE 'idx_%';
-- 計算觸發器數量
SELECT COUNT(*) INTO trigger_count
FROM pg_trigger
WHERE tgname LIKE '%wish%' OR tgname LIKE '%update%';
RAISE NOTICE '✅ 索引和觸發器創建完成!';
RAISE NOTICE '📊 創建統計:';
RAISE NOTICE ' - 性能索引:% 個', index_count;
RAISE NOTICE ' - 自動觸發器:% 個', trigger_count;
RAISE NOTICE '';
RAISE NOTICE '🚀 性能優化功能:';
RAISE NOTICE ' - 快速查詢索引';
RAISE NOTICE ' - 全文搜索支援';
RAISE NOTICE ' - 自動統計更新';
RAISE NOTICE ' - 圖片清理追蹤';
RAISE NOTICE '';
RAISE NOTICE '🔄 下一步:執行 03-create-views-functions.sql';
END $$;

View File

@@ -1,376 +0,0 @@
-- 心願星河 - 視圖和函數創建
-- 執行順序:第 3 步
-- 說明:創建便利視圖和業務邏輯函數
-- 開始事務
BEGIN;
-- 1. 創建帶點讚數的困擾案例視圖
CREATE OR REPLACE VIEW wishes_with_likes AS
SELECT
w.*,
COALESCE(like_counts.like_count, 0) as like_count,
CASE
WHEN w.created_at >= NOW() - INTERVAL '24 hours' THEN 'new'
WHEN like_counts.like_count >= 10 THEN 'popular'
WHEN w.priority >= 4 THEN 'urgent'
ELSE 'normal'
END as badge_type
FROM wishes w
LEFT JOIN (
SELECT
wish_id,
COUNT(*) as like_count
FROM wish_likes
GROUP BY wish_id
) like_counts ON w.id = like_counts.wish_id
WHERE w.status = 'active';
-- 2. 創建公開困擾案例視圖
CREATE OR REPLACE VIEW public_wishes AS
SELECT *
FROM wishes_with_likes
WHERE is_public = true
ORDER BY created_at DESC;
-- 3. 創建熱門困擾案例視圖
CREATE OR REPLACE VIEW popular_wishes AS
SELECT *
FROM wishes_with_likes
WHERE is_public = true
AND like_count >= 3
ORDER BY like_count DESC, created_at DESC;
-- 4. 創建統計摘要視圖
CREATE OR REPLACE VIEW wishes_summary AS
SELECT
COUNT(*) as total_wishes,
COUNT(*) FILTER (WHERE is_public = true) as public_wishes,
COUNT(*) FILTER (WHERE is_public = false) as private_wishes,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') as this_week,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '14 days' AND created_at < NOW() - INTERVAL '7 days') as last_week,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') as today,
AVG(COALESCE(like_counts.like_count, 0))::DECIMAL(10,2) as avg_likes,
COUNT(DISTINCT user_session) as unique_users
FROM wishes w
LEFT JOIN (
SELECT wish_id, COUNT(*) as like_count
FROM wish_likes
GROUP BY wish_id
) like_counts ON w.id = like_counts.wish_id
WHERE w.status = 'active';
-- 5. 創建類別統計視圖
CREATE OR REPLACE VIEW category_stats AS
SELECT
COALESCE(category, '未分類') as category,
COUNT(*) as wish_count,
COUNT(*) FILTER (WHERE is_public = true) as public_count,
AVG(COALESCE(like_counts.like_count, 0))::DECIMAL(10,2) as avg_likes,
MAX(created_at) as latest_wish
FROM wishes w
LEFT JOIN (
SELECT wish_id, COUNT(*) as like_count
FROM wish_likes
GROUP BY wish_id
) like_counts ON w.id = like_counts.wish_id
WHERE w.status = 'active'
GROUP BY category
ORDER BY wish_count DESC;
-- 6. 創建獲取統計數據的函數
CREATE OR REPLACE FUNCTION get_wishes_stats()
RETURNS JSON AS $$
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'summary', (SELECT row_to_json(wishes_summary.*) FROM wishes_summary),
'categories', (
SELECT json_agg(row_to_json(category_stats.*))
FROM category_stats
),
'recent_activity', (
SELECT json_agg(
json_build_object(
'date', date_trunc('day', created_at),
'count', count(*)
)
)
FROM wishes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND status = 'active'
GROUP BY date_trunc('day', created_at)
ORDER BY date_trunc('day', created_at) DESC
LIMIT 30
)
) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- 7. 創建搜索困擾案例的函數
CREATE OR REPLACE FUNCTION search_wishes(
search_query TEXT,
limit_count INTEGER DEFAULT 20,
offset_count INTEGER DEFAULT 0
)
RETURNS TABLE(
id BIGINT,
title TEXT,
current_pain TEXT,
expected_solution TEXT,
like_count BIGINT,
created_at TIMESTAMP WITH TIME ZONE,
relevance REAL
) AS $$
BEGIN
RETURN QUERY
SELECT
w.id,
w.title,
w.current_pain,
w.expected_solution,
COALESCE(like_counts.like_count, 0) as like_count,
w.created_at,
ts_rank(
to_tsvector('chinese', w.title || ' ' || w.current_pain || ' ' || w.expected_solution),
plainto_tsquery('chinese', search_query)
) as relevance
FROM wishes w
LEFT JOIN (
SELECT wish_id, COUNT(*) as like_count
FROM wish_likes
GROUP BY wish_id
) like_counts ON w.id = like_counts.wish_id
WHERE w.status = 'active'
AND w.is_public = true
AND (
to_tsvector('chinese', w.title || ' ' || w.current_pain || ' ' || w.expected_solution)
@@ plainto_tsquery('chinese', search_query)
)
ORDER BY relevance DESC, like_count DESC, w.created_at DESC
LIMIT limit_count
OFFSET offset_count;
END;
$$ LANGUAGE plpgsql;
-- 8. 創建獲取用戶統計的函數
CREATE OR REPLACE FUNCTION get_user_stats(session_id TEXT)
RETURNS JSON AS $$
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'total_wishes', (
SELECT COUNT(*)
FROM wishes
WHERE user_session = session_id AND status = 'active'
),
'total_likes_received', (
SELECT COALESCE(SUM(like_counts.like_count), 0)
FROM wishes w
LEFT JOIN (
SELECT wish_id, COUNT(*) as like_count
FROM wish_likes
GROUP BY wish_id
) like_counts ON w.id = like_counts.wish_id
WHERE w.user_session = session_id AND w.status = 'active'
),
'total_likes_given', (
SELECT COUNT(*)
FROM wish_likes
WHERE user_session = session_id
),
'recent_wishes', (
SELECT json_agg(
json_build_object(
'id', id,
'title', title,
'created_at', created_at,
'like_count', COALESCE(like_counts.like_count, 0)
)
)
FROM wishes w
LEFT JOIN (
SELECT wish_id, COUNT(*) as like_count
FROM wish_likes
GROUP BY wish_id
) like_counts ON w.id = like_counts.wish_id
WHERE w.user_session = session_id
AND w.status = 'active'
ORDER BY w.created_at DESC
LIMIT 5
)
) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- 9. 創建清理孤立圖片的函數
CREATE OR REPLACE FUNCTION cleanup_orphaned_images()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER := 0;
image_record RECORD;
BEGIN
-- 記錄清理開始
INSERT INTO migration_log (
user_session,
migration_type,
success,
error_message
) VALUES (
'system',
'image_cleanup',
false,
'Starting orphaned image cleanup'
);
-- 這裡只是標記,實際的 Storage 清理需要在應用層面處理
-- 因為 SQL 無法直接操作 Supabase Storage
-- 找出需要清理的圖片記錄
FOR image_record IN
SELECT DISTINCT jsonb_array_elements(images)->>'storage_path' as image_path
FROM wishes
WHERE status = 'deleted'
AND images IS NOT NULL
AND jsonb_array_length(images) > 0
LOOP
-- 標記為需要清理
INSERT INTO migration_log (
user_session,
migration_type,
source_data,
success,
error_message
) VALUES (
'system',
'image_cleanup',
json_build_object('image_path', image_record.image_path),
false,
'Image marked for cleanup: ' || image_record.image_path
);
deleted_count := deleted_count + 1;
END LOOP;
-- 記錄清理完成
INSERT INTO migration_log (
user_session,
migration_type,
target_records,
success,
error_message
) VALUES (
'system',
'image_cleanup',
deleted_count,
true,
'Orphaned image cleanup completed. Marked ' || deleted_count || ' images for cleanup.'
);
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- 10. 創建性能檢查函數
CREATE OR REPLACE FUNCTION get_performance_stats()
RETURNS JSON AS $$
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'table_sizes', (
SELECT json_object_agg(
table_name,
pg_size_pretty(pg_total_relation_size(quote_ident(table_name)))
)
FROM (
SELECT 'wishes' as table_name
UNION SELECT 'wish_likes'
UNION SELECT 'user_settings'
UNION SELECT 'migration_log'
UNION SELECT 'system_stats'
) tables
),
'index_usage', (
SELECT json_object_agg(
indexname,
json_build_object(
'size', pg_size_pretty(pg_relation_size(indexname::regclass)),
'scans', idx_scan,
'tuples_read', idx_tup_read,
'tuples_fetched', idx_tup_fetch
)
)
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND indexname LIKE 'idx_%'
),
'query_performance', (
SELECT json_build_object(
'avg_query_time', COALESCE(AVG(mean_exec_time), 0),
'total_queries', COALESCE(SUM(calls), 0),
'slowest_queries', (
SELECT json_agg(
json_build_object(
'query', LEFT(query, 100) || '...',
'avg_time', mean_exec_time,
'calls', calls
)
)
FROM pg_stat_statements
WHERE query LIKE '%wishes%'
ORDER BY mean_exec_time DESC
LIMIT 5
)
)
FROM pg_stat_statements
WHERE query LIKE '%wishes%'
)
) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- 提交事務
COMMIT;
-- 顯示創建結果
DO $$
DECLARE
view_count INTEGER;
function_count INTEGER;
BEGIN
-- 計算視圖數量
SELECT COUNT(*) INTO view_count
FROM pg_views
WHERE schemaname = 'public';
-- 計算函數數量
SELECT COUNT(*) INTO function_count
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public'
AND p.proname LIKE '%wish%' OR p.proname LIKE 'get_%' OR p.proname LIKE 'cleanup_%';
RAISE NOTICE '✅ 視圖和函數創建完成!';
RAISE NOTICE '📊 創建統計:';
RAISE NOTICE ' - 便利視圖:% 個', view_count;
RAISE NOTICE ' - 業務函數:% 個', function_count;
RAISE NOTICE '';
RAISE NOTICE '🎯 主要功能:';
RAISE NOTICE ' - wishes_with_likes帶點讚數的困擾案例';
RAISE NOTICE ' - public_wishes公開困擾案例';
RAISE NOTICE ' - popular_wishes熱門困擾案例';
RAISE NOTICE ' - search_wishes()(全文搜索)';
RAISE NOTICE ' - get_wishes_stats()(統計數據)';
RAISE NOTICE ' - cleanup_orphaned_images()(圖片清理)';
RAISE NOTICE '';
RAISE NOTICE '🔄 下一步:執行 04-setup-storage.sql';
END $$;

View File

@@ -1,284 +0,0 @@
-- 心願星河 - 存儲服務設置
-- 執行順序:第 4 步
-- 說明:設置 Supabase Storage 桶和相關政策
-- 注意:此腳本需要在 Supabase Dashboard 的 SQL Editor 中執行
-- 某些 Storage 操作可能需要 service_role 權限
-- 開始事務
BEGIN;
-- 1. 創建主要圖片存儲桶
INSERT INTO storage.buckets (
id,
name,
public,
file_size_limit,
allowed_mime_types,
avif_autodetection
) VALUES (
'wish-images',
'wish-images',
true,
5242880, -- 5MB
ARRAY['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'],
true
) ON CONFLICT (id) DO UPDATE SET
file_size_limit = EXCLUDED.file_size_limit,
allowed_mime_types = EXCLUDED.allowed_mime_types,
avif_autodetection = EXCLUDED.avif_autodetection;
-- 2. 創建縮圖存儲桶
INSERT INTO storage.buckets (
id,
name,
public,
file_size_limit,
allowed_mime_types,
avif_autodetection
) VALUES (
'wish-thumbnails',
'wish-thumbnails',
true,
1048576, -- 1MB
ARRAY['image/jpeg', 'image/jpg', 'image/png', 'image/webp'],
true
) ON CONFLICT (id) DO UPDATE SET
file_size_limit = EXCLUDED.file_size_limit,
allowed_mime_types = EXCLUDED.allowed_mime_types,
avif_autodetection = EXCLUDED.avif_autodetection;
-- 3. 創建存儲使用統計表
CREATE TABLE IF NOT EXISTS storage_usage (
id BIGSERIAL PRIMARY KEY,
bucket_name TEXT NOT NULL,
total_files INTEGER DEFAULT 0,
total_size_bytes BIGINT DEFAULT 0,
last_cleanup_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
UNIQUE(bucket_name)
);
-- 4. 插入初始存儲統計記錄
INSERT INTO storage_usage (bucket_name, total_files, total_size_bytes)
VALUES
('wish-images', 0, 0),
('wish-thumbnails', 0, 0)
ON CONFLICT (bucket_name) DO NOTHING;
-- 5. 創建存儲統計更新函數
CREATE OR REPLACE FUNCTION update_storage_usage()
RETURNS VOID AS $$
BEGIN
-- 更新 wish-images 桶統計
INSERT INTO storage_usage (bucket_name, total_files, total_size_bytes, updated_at)
SELECT
'wish-images',
COUNT(*),
COALESCE(SUM(metadata->>'size')::BIGINT, 0),
NOW()
FROM storage.objects
WHERE bucket_id = 'wish-images'
ON CONFLICT (bucket_name)
DO UPDATE SET
total_files = EXCLUDED.total_files,
total_size_bytes = EXCLUDED.total_size_bytes,
updated_at = EXCLUDED.updated_at;
-- 更新 wish-thumbnails 桶統計
INSERT INTO storage_usage (bucket_name, total_files, total_size_bytes, updated_at)
SELECT
'wish-thumbnails',
COUNT(*),
COALESCE(SUM(metadata->>'size')::BIGINT, 0),
NOW()
FROM storage.objects
WHERE bucket_id = 'wish-thumbnails'
ON CONFLICT (bucket_name)
DO UPDATE SET
total_files = EXCLUDED.total_files,
total_size_bytes = EXCLUDED.total_size_bytes,
updated_at = EXCLUDED.updated_at;
END;
$$ LANGUAGE plpgsql;
-- 6. 創建存儲清理記錄表
CREATE TABLE IF NOT EXISTS storage_cleanup_log (
id BIGSERIAL PRIMARY KEY,
bucket_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size BIGINT,
cleanup_reason TEXT,
cleanup_status TEXT DEFAULT 'pending' CHECK (cleanup_status IN ('pending', 'completed', 'failed')),
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE
);
-- 7. 創建獲取存儲統計的函數
CREATE OR REPLACE FUNCTION get_storage_stats()
RETURNS JSON AS $$
DECLARE
result JSON;
BEGIN
-- 更新統計數據
PERFORM update_storage_usage();
SELECT json_build_object(
'buckets', (
SELECT json_agg(
json_build_object(
'name', bucket_name,
'total_files', total_files,
'total_size_mb', ROUND(total_size_bytes / 1024.0 / 1024.0, 2),
'last_updated', updated_at
)
)
FROM storage_usage
),
'cleanup_pending', (
SELECT COUNT(*)
FROM storage_cleanup_log
WHERE cleanup_status = 'pending'
),
'total_storage_mb', (
SELECT ROUND(SUM(total_size_bytes) / 1024.0 / 1024.0, 2)
FROM storage_usage
)
) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- 8. 創建標記孤立圖片的函數
CREATE OR REPLACE FUNCTION mark_orphaned_images_for_cleanup()
RETURNS INTEGER AS $$
DECLARE
marked_count INTEGER := 0;
image_record RECORD;
referenced_images TEXT[];
BEGIN
-- 獲取所有被引用的圖片路徑
SELECT ARRAY_AGG(DISTINCT image_path) INTO referenced_images
FROM (
SELECT jsonb_array_elements_text(
jsonb_path_query_array(images, '$[*].storage_path')
) as image_path
FROM wishes
WHERE status = 'active'
AND images IS NOT NULL
AND jsonb_array_length(images) > 0
) referenced;
-- 標記孤立的圖片
FOR image_record IN
SELECT name, metadata->>'size' as file_size
FROM storage.objects
WHERE bucket_id IN ('wish-images', 'wish-thumbnails')
AND (referenced_images IS NULL OR name != ALL(referenced_images))
LOOP
INSERT INTO storage_cleanup_log (
bucket_name,
file_path,
file_size,
cleanup_reason,
cleanup_status
) VALUES (
CASE
WHEN image_record.name LIKE '%/thumbnails/%' THEN 'wish-thumbnails'
ELSE 'wish-images'
END,
image_record.name,
image_record.file_size::BIGINT,
'Orphaned image - not referenced by any active wish',
'pending'
) ON CONFLICT DO NOTHING;
marked_count := marked_count + 1;
END LOOP;
-- 記錄清理操作
INSERT INTO migration_log (
user_session,
migration_type,
target_records,
success,
error_message
) VALUES (
'system',
'storage_cleanup',
marked_count,
true,
'Marked ' || marked_count || ' orphaned images for cleanup'
);
RETURN marked_count;
END;
$$ LANGUAGE plpgsql;
-- 9. 創建存儲使用量更新觸發器
CREATE OR REPLACE FUNCTION trigger_storage_usage_update()
RETURNS TRIGGER AS $$
BEGIN
-- 異步更新存儲統計(避免阻塞主要操作)
PERFORM pg_notify('storage_usage_update', 'update_needed');
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- 10. 為 wishes 表添加存儲使用量更新觸發器
DROP TRIGGER IF EXISTS update_storage_on_wish_change ON wishes;
CREATE TRIGGER update_storage_on_wish_change
AFTER INSERT OR UPDATE OR DELETE ON wishes
FOR EACH STATEMENT
EXECUTE FUNCTION trigger_storage_usage_update();
-- 提交事務
COMMIT;
-- 顯示創建結果
DO $$
DECLARE
bucket_count INTEGER;
storage_table_count INTEGER;
BEGIN
-- 計算存儲桶數量
SELECT COUNT(*) INTO bucket_count
FROM storage.buckets
WHERE id LIKE 'wish-%';
-- 計算存儲相關表格數量
SELECT COUNT(*) INTO storage_table_count
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE '%storage%';
RAISE NOTICE '✅ 存儲服務設置完成!';
RAISE NOTICE '📊 創建統計:';
RAISE NOTICE ' - 存儲桶:% 個', bucket_count;
RAISE NOTICE ' - 存儲管理表:% 個', storage_table_count;
RAISE NOTICE '';
RAISE NOTICE '🗂️ 存儲桶配置:';
RAISE NOTICE ' - wish-images主圖片5MB限制';
RAISE NOTICE ' - wish-thumbnails縮圖1MB限制';
RAISE NOTICE '';
RAISE NOTICE '🛠️ 管理功能:';
RAISE NOTICE ' - 自動統計更新';
RAISE NOTICE ' - 孤立圖片檢測';
RAISE NOTICE ' - 清理記錄追蹤';
RAISE NOTICE '';
RAISE NOTICE '🔄 下一步:執行 05-setup-rls.sql';
END $$;
-- 重要提醒
DO $$
BEGIN
RAISE NOTICE '';
RAISE NOTICE '⚠️ 重要提醒:';
RAISE NOTICE ' 1. 請確認存儲桶已在 Supabase Dashboard 中顯示';
RAISE NOTICE ' 2. 檢查 Storage → Settings 中的政策設置';
RAISE NOTICE ' 3. 測試圖片上傳功能是否正常';
RAISE NOTICE ' 4. 定期執行 mark_orphaned_images_for_cleanup() 清理孤立圖片';
END $$;

View File

@@ -1,151 +0,0 @@
-- 心願星河 - Row Level Security (RLS) 政策設置
-- 執行順序:第 5 步(最後一步)
-- 說明:設置完整的安全政策,保護數據安全
-- 開始事務
BEGIN;
-- 1. 啟用所有表格的 RLS
ALTER TABLE wishes ENABLE ROW LEVEL SECURITY;
ALTER TABLE wish_likes ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE migration_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE system_stats ENABLE ROW LEVEL SECURITY;
ALTER TABLE storage_usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE storage_cleanup_log ENABLE ROW LEVEL SECURITY;
-- 2. wishes 表格的 RLS 政策
-- 2.1 查看政策:公開的困擾案例所有人都可以查看
DROP POLICY IF EXISTS "Public wishes are viewable by everyone" ON wishes;
CREATE POLICY "Public wishes are viewable by everyone" ON wishes
FOR SELECT
USING (is_public = true AND status = 'active');
-- 2.2 查看政策:用戶可以查看自己的所有困擾案例
DROP POLICY IF EXISTS "Users can view own wishes" ON wishes;
CREATE POLICY "Users can view own wishes" ON wishes
FOR SELECT
USING (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true));
-- 2.3 插入政策:所有人都可以提交困擾案例
DROP POLICY IF EXISTS "Anyone can insert wishes" ON wishes;
CREATE POLICY "Anyone can insert wishes" ON wishes
FOR INSERT
WITH CHECK (true);
-- 2.4 更新政策:用戶只能更新自己的困擾案例
DROP POLICY IF EXISTS "Users can update own wishes" ON wishes;
CREATE POLICY "Users can update own wishes" ON wishes
FOR UPDATE
USING (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true))
WITH CHECK (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true));
-- 2.5 刪除政策:用戶只能軟刪除自己的困擾案例
DROP POLICY IF EXISTS "Users can delete own wishes" ON wishes;
CREATE POLICY "Users can delete own wishes" ON wishes
FOR UPDATE
USING (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true))
WITH CHECK (status = 'deleted');
-- 3. wish_likes 表格的 RLS 政策
-- 3.1 查看政策:所有人都可以查看點讚記錄(用於統計)
DROP POLICY IF EXISTS "Wish likes are viewable by everyone" ON wish_likes;
CREATE POLICY "Wish likes are viewable by everyone" ON wish_likes
FOR SELECT
USING (true);
-- 3.2 插入政策:所有人都可以點讚
DROP POLICY IF EXISTS "Anyone can insert wish likes" ON wish_likes;
CREATE POLICY "Anyone can insert wish likes" ON wish_likes
FOR INSERT
WITH CHECK (true);
-- 3.3 刪除政策:用戶只能取消自己的點讚
DROP POLICY IF EXISTS "Users can delete own likes" ON wish_likes;
CREATE POLICY "Users can delete own likes" ON wish_likes
FOR DELETE
USING (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true));
-- 4. user_settings 表格的 RLS 政策
-- 4.1 查看政策:用戶只能查看自己的設定
DROP POLICY IF EXISTS "Users can view own settings" ON user_settings;
CREATE POLICY "Users can view own settings" ON user_settings
FOR SELECT
USING (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true));
-- 4.2 插入政策:用戶可以創建自己的設定
DROP POLICY IF EXISTS "Users can insert own settings" ON user_settings;
CREATE POLICY "Users can insert own settings" ON user_settings
FOR INSERT
WITH CHECK (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true));
-- 4.3 更新政策:用戶只能更新自己的設定
DROP POLICY IF EXISTS "Users can update own settings" ON user_settings;
CREATE POLICY "Users can update own settings" ON user_settings
FOR UPDATE
USING (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true))
WITH CHECK (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true));
-- 5. migration_log 表格的 RLS 政策
-- 5.1 查看政策:用戶可以查看自己的遷移記錄
DROP POLICY IF EXISTS "Users can view own migration logs" ON migration_log;
CREATE POLICY "Users can view own migration logs" ON migration_log
FOR SELECT
USING (user_session = current_setting('request.jwt.claims', true)::json->>'user_session' OR
user_session = current_setting('app.user_session', true) OR
user_session = 'system');
-- 5.2 插入政策:系統和用戶都可以插入遷移記錄
DROP POLICY IF EXISTS "System and users can insert migration logs" ON migration_log;
CREATE POLICY "System and users can insert migration logs" ON migration_log
FOR INSERT
WITH CHECK (true);
-- 6. system_stats 表格的 RLS 政策
-- 6.1 查看政策:所有人都可以查看系統統計(公開數據)
DROP POLICY IF EXISTS "System stats are viewable by everyone" ON system_stats;
CREATE POLICY "System stats are viewable by everyone" ON system_stats
FOR SELECT
USING (true);
-- 6.2 插入/更新政策:只有系統可以修改統計數據
DROP POLICY IF EXISTS "Only system can modify stats" ON system_stats;
CREATE POLICY "Only system can modify stats" ON system_stats
FOR ALL
USING (current_user = 'postgres' OR current_setting('role', true) = 'service_role');
-- 7. storage_usage 表格的 RLS 政策
-- 7.1 查看政策:所有人都可以查看存儲使用統計
DROP POLICY IF EXISTS "Storage usage is viewable by everyone" ON storage_usage;
CREATE POLICY "Storage usage is viewable by everyone" ON storage_usage
FOR SELECT
USING (true);
-- 7.2 修改政策:只有系統可以修改存儲統計
DROP POLICY IF EXISTS "Only system can modify storage usage" ON storage_usage;
CREATE POLICY "Only system can modify storage usage" ON storage_usage
FOR ALL
USING (current_user = 'postgres' OR current_setting('role', true) = 'service_role');
-- 8. storage_cleanup_log 表格的 RLS 政策
-- 8.1 查看政策:所有人都可以查看清理記錄
DROP POLICY IF EXISTS "Storage cleanup logs are viewable by everyone" ON storage_cleanup_log;
CREATE POLICY "Storage cleanup logs are viewable by everyone" ON storage_cleanup_log
FOR SELECT
USING (true);

View File

@@ -1,135 +0,0 @@
# 心願星河 - 數據清空指南
⚠️ **重要警告**:以下操作將永久刪除所有數據,請謹慎使用!
## 可用的清空腳本
### 1. 綜合清空腳本(推薦)
```bash
node scripts/clear-all.js
```
**功能**:一次性清空所有數據
- 清空 Supabase Storage 中的所有圖片
- 清空資料庫中的所有表格
- 重置自增序列
- 重新插入初始數據
- 驗證清空結果
### 2. 單獨清空 Storage
```bash
node scripts/clear-storage.js
```
**功能**:僅清空圖片存儲
- 清空 `wish-images` 存儲桶
- 清空 `wish-thumbnails` 存儲桶
### 3. 單獨清空資料庫
在 Supabase Dashboard 的 SQL Editor 中執行:
```sql
-- 執行 scripts/clear-all-data.sql 文件的內容
```
## 使用前準備
### 1. 確認環境變數
確保以下環境變數已正確設置(在 `.env.local` 文件中):
```env
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key # 可選,但建議設置
```
### 2. 安裝依賴
```bash
npm install
# 或
pnpm install
```
## 使用步驟
### 方案 A一鍵清空推薦
```bash
# 執行綜合清空腳本
node scripts/clear-all.js
# 腳本會顯示 10 秒倒計時,按 Ctrl+C 可以取消
```
### 方案 B分步驟清空
```bash
# 1. 先清空 Storage
node scripts/clear-storage.js
# 2. 再清空資料庫(在 Supabase Dashboard 中執行)
# 將 scripts/clear-all-data.sql 的內容貼到 SQL Editor 中執行
```
## 清空後的檢查
### 1. 驗證 Storage
在 Supabase Dashboard → Storage 中檢查:
- `wish-images` 桶應該是空的
- `wish-thumbnails` 桶應該是空的
### 2. 驗證資料庫
在 Supabase Dashboard → Table Editor 中檢查:
- `wishes` 表應該沒有記錄
- `wish_likes` 表應該沒有記錄
- `user_settings` 表應該沒有記錄
- 其他管理表格會有基礎的初始記錄
### 3. 測試應用程式
```bash
# 重新啟動開發服務器
npm run dev
# 或
pnpm dev
```
在瀏覽器中:
1. 清除 localStorage開發者工具 → Application → Local Storage → Clear All
2. 重新載入頁面
3. 測試提交新的困擾案例
4. 確認功能正常運行
## 故障排除
### 1. 權限錯誤
```
Error: Insufficient permissions
```
**解決方案**:確保使用 `SUPABASE_SERVICE_ROLE_KEY` 而不是 `ANON_KEY`
### 2. 存儲桶不存在
```
Error: Bucket does not exist
```
**解決方案**:正常現象,腳本會自動跳過不存在的存儲桶
### 3. 網路錯誤
```
Error: fetch failed
```
**解決方案**:檢查網路連接和 Supabase URL 是否正確
### 4. 資料庫連接錯誤
**解決方案**
1. 檢查 Supabase 專案是否暫停
2. 驗證 URL 和密鑰是否正確
3. 確認專案是否有足夠的配額
## 注意事項
1. **備份重要數據**:在生產環境中執行前,請先備份重要數據
2. **測試環境優先**:建議先在測試環境中驗證腳本功能
3. **瀏覽器清除**:清空數據後記得清除瀏覽器的 localStorage
4. **應用重啟**:清空後建議重新啟動應用程式
## 聯絡支援
如果遇到問題,請檢查:
1. 控制台錯誤訊息
2. Supabase Dashboard 中的 Logs
3. 網路連接狀態
4. 環境變數配置

View File

@@ -1,134 +0,0 @@
-- 心願星河 - 清空所有數據
-- ⚠️ 警告:此腳本將永久刪除所有數據,請謹慎使用!
-- 建議:在生產環境執行前請備份重要數據
-- 開始事務
BEGIN;
-- 0. 修復 migration_log 表約束問題
DO $$
BEGIN
-- 移除舊的約束
ALTER TABLE migration_log DROP CONSTRAINT IF EXISTS migration_log_migration_type_check;
-- 添加新的約束,包含所有需要的類型
ALTER TABLE migration_log ADD CONSTRAINT migration_log_migration_type_check
CHECK (migration_type IN ('wishes', 'likes', 'settings', 'storage_cleanup', 'data_cleanup', 'image_cleanup'));
RAISE NOTICE '🔧 migration_log 表約束已修復';
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE '⚠️ 修復約束時發生錯誤,但繼續執行: %', SQLERRM;
END $$;
-- 顯示警告訊息
DO $$
BEGIN
RAISE NOTICE '';
RAISE NOTICE '🚨 準備清空所有數據...';
RAISE NOTICE '⚠️ 這將永久刪除:';
RAISE NOTICE ' - 所有困擾案例 (wishes)';
RAISE NOTICE ' - 所有點讚記錄 (wish_likes)';
RAISE NOTICE ' - 所有用戶設定 (user_settings)';
RAISE NOTICE ' - 遷移記錄 (migration_log)';
RAISE NOTICE ' - 系統統計 (system_stats)';
RAISE NOTICE ' - 存儲使用記錄 (storage_usage)';
RAISE NOTICE ' - 存儲清理記錄 (storage_cleanup_log)';
RAISE NOTICE '';
END $$;
-- 1. 清空所有數據表(按依賴關係順序)
DO $$
DECLARE
table_count INTEGER;
BEGIN
-- 清空有外鍵關係的表格
DELETE FROM wish_likes;
GET DIAGNOSTICS table_count = ROW_COUNT;
RAISE NOTICE '🗑️ 已清空 wish_likes 表,刪除 % 條記錄', table_count;
DELETE FROM wishes;
GET DIAGNOSTICS table_count = ROW_COUNT;
RAISE NOTICE '🗑️ 已清空 wishes 表,刪除 % 條記錄', table_count;
DELETE FROM user_settings;
GET DIAGNOSTICS table_count = ROW_COUNT;
RAISE NOTICE '🗑️ 已清空 user_settings 表,刪除 % 條記錄', table_count;
DELETE FROM migration_log;
GET DIAGNOSTICS table_count = ROW_COUNT;
RAISE NOTICE '🗑️ 已清空 migration_log 表,刪除 % 條記錄', table_count;
DELETE FROM system_stats;
GET DIAGNOSTICS table_count = ROW_COUNT;
RAISE NOTICE '🗑️ 已清空 system_stats 表,刪除 % 條記錄', table_count;
DELETE FROM storage_usage;
GET DIAGNOSTICS table_count = ROW_COUNT;
RAISE NOTICE '🗑️ 已清空 storage_usage 表,刪除 % 條記錄', table_count;
DELETE FROM storage_cleanup_log;
GET DIAGNOSTICS table_count = ROW_COUNT;
RAISE NOTICE '🗑️ 已清空 storage_cleanup_log 表,刪除 % 條記錄', table_count;
END $$;
-- 2. 重置自增序列
DO $$
BEGIN
RAISE NOTICE '';
RAISE NOTICE '🔄 重置自增序列...';
-- 重置所有表格的序列
ALTER SEQUENCE wishes_id_seq RESTART WITH 1;
ALTER SEQUENCE wish_likes_id_seq RESTART WITH 1;
ALTER SEQUENCE user_settings_id_seq RESTART WITH 1;
ALTER SEQUENCE migration_log_id_seq RESTART WITH 1;
ALTER SEQUENCE system_stats_id_seq RESTART WITH 1;
ALTER SEQUENCE storage_usage_id_seq RESTART WITH 1;
ALTER SEQUENCE storage_cleanup_log_id_seq RESTART WITH 1;
RAISE NOTICE '✅ 所有序列已重置為 1';
END $$;
-- 3. 重新插入初始統計記錄
INSERT INTO storage_usage (bucket_name, total_files, total_size_bytes)
VALUES
('wish-images', 0, 0),
('wish-thumbnails', 0, 0);
INSERT INTO system_stats (stat_date, total_wishes, public_wishes, private_wishes, total_likes, active_users, storage_used_mb)
VALUES (CURRENT_DATE, 0, 0, 0, 0, 0, 0);
-- 4. 記錄清空操作
INSERT INTO migration_log (
user_session,
migration_type,
target_records,
success,
error_message
) VALUES (
'system-admin',
'data_cleanup',
0,
true,
'All data cleared by admin request at ' || NOW()
);
-- 提交事務
COMMIT;
-- 顯示完成訊息
DO $$
BEGIN
RAISE NOTICE '';
RAISE NOTICE '✅ 資料庫清空完成!';
RAISE NOTICE '📊 重置統計:';
RAISE NOTICE ' - 所有表格已清空';
RAISE NOTICE ' - 自增序列已重置';
RAISE NOTICE ' - 初始統計記錄已重新建立';
RAISE NOTICE '';
RAISE NOTICE '⚠️ 注意:';
RAISE NOTICE ' - Storage 中的檔案需要手動清空';
RAISE NOTICE ' - 可以使用 clear-storage.js 腳本清空圖片';
RAISE NOTICE '';
END $$;

View File

@@ -1,357 +0,0 @@
#!/usr/bin/env node
/**
* 心願星河 - 綜合清空腳本
*
* ⚠️ 警告:此腳本將永久刪除所有數據和文件!
*
* 功能:
* 1. 清空 Supabase Storage 中的所有圖片
* 2. 清空資料庫中的所有數據
* 3. 重置自增序列
* 4. 重新初始化基礎數據
*
* 使用方法:
* node scripts/clear-all.js
*/
const { createClient } = require('@supabase/supabase-js');
const fs = require('fs');
const path = require('path');
// 載入環境變數
require('dotenv').config({ path: '.env.local' });
// Supabase 配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// 檢查必要的環境變數
if (!supabaseUrl || !supabaseServiceKey) {
console.error('❌ 錯誤:缺少必要的環境變數');
console.error('請確保已設置以下環境變數:');
console.error('- NEXT_PUBLIC_SUPABASE_URL');
console.error('- SUPABASE_SERVICE_ROLE_KEY 或 NEXT_PUBLIC_SUPABASE_ANON_KEY');
process.exit(1);
}
// 初始化 Supabase 客戶端
const supabase = createClient(supabaseUrl, supabaseServiceKey);
/**
* 清空存儲桶中的所有文件
*/
async function clearStorage() {
console.log('\n📁 開始清空 Storage...');
const buckets = ['wish-images', 'wish-thumbnails'];
let allSuccess = true;
for (const bucketName of buckets) {
try {
console.log(`\n🗂️ 正在處理存儲桶:${bucketName}`);
// 列出所有文件
const { data: files, error: listError } = await supabase.storage
.from(bucketName)
.list('', { limit: 1000 });
if (listError) {
if (listError.message.includes('not found') || listError.message.includes('does not exist')) {
console.log(`⚠️ 存儲桶 ${bucketName} 不存在,跳過`);
continue;
}
console.error(`❌ 列出 ${bucketName} 文件時出錯:`, listError.message);
allSuccess = false;
continue;
}
if (!files || files.length === 0) {
console.log(`${bucketName} 已經是空的`);
continue;
}
// 獲取所有文件路徑
const allFilePaths = [];
for (const file of files) {
if (file.name && file.name !== '.emptyFolderPlaceholder') {
if (!file.metadata) {
// 處理目錄
const { data: subFiles } = await supabase.storage
.from(bucketName)
.list(file.name, { limit: 1000 });
if (subFiles) {
subFiles.forEach(subFile => {
if (subFile.name && subFile.name !== '.emptyFolderPlaceholder') {
allFilePaths.push(`${file.name}/${subFile.name}`);
}
});
}
} else {
allFilePaths.push(file.name);
}
}
}
if (allFilePaths.length === 0) {
console.log(`${bucketName} 中沒有需要刪除的文件`);
continue;
}
console.log(`🗑️ 刪除 ${allFilePaths.length} 個文件...`);
// 批量刪除
const batchSize = 50;
for (let i = 0; i < allFilePaths.length; i += batchSize) {
const batch = allFilePaths.slice(i, i + batchSize);
const { error } = await supabase.storage.from(bucketName).remove(batch);
if (error) {
console.error(`❌ 刪除批次失敗:`, error.message);
allSuccess = false;
} else {
console.log(`✅ 已刪除 ${Math.min(i + batchSize, allFilePaths.length)}/${allFilePaths.length} 個文件`);
}
await new Promise(resolve => setTimeout(resolve, 100));
}
} catch (error) {
console.error(`❌ 處理 ${bucketName} 時發生錯誤:`, error.message);
allSuccess = false;
}
}
return allSuccess;
}
/**
* 修復 migration_log 表的約束問題
*/
async function fixMigrationLogConstraint() {
console.log('\n🔧 修復 migration_log 表約束...');
try {
// 使用 rpc 調用執行 SQL修復約束
const { error } = await supabase.rpc('exec_sql', {
sql_query: `
ALTER TABLE migration_log DROP CONSTRAINT IF EXISTS migration_log_migration_type_check;
ALTER TABLE migration_log ADD CONSTRAINT migration_log_migration_type_check
CHECK (migration_type IN ('wishes', 'likes', 'settings', 'storage_cleanup', 'data_cleanup', 'image_cleanup'));
`
});
if (error) {
console.log('⚠️ 無法通過 RPC 修復約束,嘗試其他方法...');
// 如果 RPC 方法失敗,我們繼續執行,但會在日誌中使用允許的類型
return false;
}
console.log('✅ migration_log 表約束已修復');
return true;
} catch (error) {
console.log('⚠️ 修復約束時發生錯誤,但繼續執行:', error.message);
return false;
}
}
/**
* 清空資料庫數據
*/
async function clearDatabase() {
console.log('\n🗄 開始清空資料庫...');
try {
// 1. 清空有外鍵關係的表格(按依賴順序)
const tablesToClear = [
{ name: 'wish_likes', description: '點讚記錄' },
{ name: 'wishes', description: '困擾案例' },
{ name: 'user_settings', description: '用戶設定' },
{ name: 'system_stats', description: '系統統計' },
{ name: 'storage_usage', description: '存儲使用記錄' },
{ name: 'storage_cleanup_log', description: '存儲清理記錄' },
{ name: 'migration_log', description: '遷移記錄' }
];
for (const table of tablesToClear) {
try {
const { error } = await supabase.from(table.name).delete().neq('id', 0);
if (error) {
console.error(`❌ 清空 ${table.name} (${table.description}) 表失敗:`, error.message);
// 如果不是 migration_log 表,則返回失敗
if (table.name !== 'migration_log') {
return false;
}
// migration_log 表清空失敗可以忽略,因為我們稍後會重新插入
console.log(`⚠️ ${table.name} 表清空失敗,將在後續步驟中處理`);
} else {
console.log(`✅ 已清空 ${table.name} (${table.description}) 表`);
}
} catch (err) {
console.error(`❌ 處理 ${table.name} 表時發生錯誤:`, err.message);
if (table.name !== 'migration_log') {
return false;
}
}
}
// 2. 重新插入初始數據
console.log('\n🔧 重新插入初始數據...');
// 插入初始存儲統計
await supabase.from('storage_usage').insert([
{ bucket_name: 'wish-images', total_files: 0, total_size_bytes: 0 },
{ bucket_name: 'wish-thumbnails', total_files: 0, total_size_bytes: 0 }
]);
// 插入今日初始統計
await supabase.from('system_stats').insert([{
stat_date: new Date().toISOString().split('T')[0],
total_wishes: 0,
public_wishes: 0,
private_wishes: 0,
total_likes: 0,
active_users: 0,
storage_used_mb: 0
}]);
// 記錄清空操作(最後執行,避免約束衝突)
try {
await supabase.from('migration_log').insert([{
user_session: 'system-admin',
migration_type: 'data_cleanup',
target_records: 0,
success: true,
error_message: `All data cleared by admin request at ${new Date().toISOString()}`
}]);
console.log('✅ 清空操作記錄已插入');
} catch (logError) {
console.log('⚠️ 無法插入清空操作記錄,但不影響清空結果:', logError.message);
}
console.log('✅ 初始數據插入完成');
return true;
} catch (error) {
console.error('❌ 清空資料庫時發生錯誤:', error.message);
return false;
}
}
/**
* 驗證清空結果
*/
async function verifyCleanup() {
console.log('\n🔍 驗證清空結果...');
try {
// 檢查主要數據表
const { data: wishes, error: wishesError } = await supabase
.from('wishes')
.select('count', { count: 'exact', head: true });
const { data: likes, error: likesError } = await supabase
.from('wish_likes')
.select('count', { count: 'exact', head: true });
if (wishesError || likesError) {
console.error('❌ 驗證時發生錯誤');
return false;
}
console.log(`📊 驗證結果:`);
console.log(` - wishes 表:${wishes || 0} 條記錄`);
console.log(` - wish_likes 表:${likes || 0} 條記錄`);
// 檢查存儲
const buckets = ['wish-images', 'wish-thumbnails'];
for (const bucket of buckets) {
const { data: files } = await supabase.storage.from(bucket).list('', { limit: 1 });
console.log(` - ${bucket} 存儲桶:${files ? files.length : 0} 個文件`);
}
return true;
} catch (error) {
console.error('❌ 驗證時發生錯誤:', error.message);
return false;
}
}
/**
* 主函數
*/
async function main() {
console.log('🚀 心願星河 - 綜合數據清空');
console.log('⚠️ 警告:這將永久刪除所有數據和文件!');
console.log('\n包含');
console.log('- 所有困擾案例和點讚記錄');
console.log('- 所有用戶設定');
console.log('- Storage 中的所有圖片文件');
console.log('- 系統統計和記錄');
// 給用戶考慮時間
console.log('\n⏰ 10 秒後開始清空... (按 Ctrl+C 取消)');
for (let i = 10; i > 0; i--) {
process.stdout.write(`\r倒計時:${i}`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.log('\n\n開始執行清空操作...\n');
let success = true;
// 0. 修復約束問題
const constraintFixed = await fixMigrationLogConstraint();
// 1. 清空存儲
const storageSuccess = await clearStorage();
if (!storageSuccess) {
console.log('⚠️ Storage 清空過程中有錯誤,但繼續執行資料庫清空');
}
// 2. 清空資料庫
const dbSuccess = await clearDatabase();
if (!dbSuccess) {
console.error('❌ 資料庫清空失敗');
success = false;
}
// 3. 驗證結果
if (success) {
await verifyCleanup();
}
// 顯示最終結果
console.log('\n' + '='.repeat(60));
if (success) {
console.log('✅ 所有數據清空完成!');
console.log('\n📝 建議後續步驟:');
console.log('1. 重新啟動應用程式');
console.log('2. 在瀏覽器中清除 localStorage');
console.log('3. 確認應用程式正常運行');
} else {
console.log('❌ 清空過程中有錯誤,請檢查上述訊息');
}
process.exit(success ? 0 : 1);
}
// 錯誤處理
process.on('unhandledRejection', (error) => {
console.error('❌ 未處理的錯誤:', error);
process.exit(1);
});
process.on('SIGINT', () => {
console.log('\n❌ 用戶取消操作');
process.exit(0);
});
// 執行主函數
if (require.main === module) {
main().catch(error => {
console.error('❌ 腳本執行失敗:', error);
process.exit(1);
});
}

View File

@@ -1,221 +0,0 @@
#!/usr/bin/env node
/**
* 心願星河 - 清空 Supabase Storage
*
* ⚠️ 警告:此腳本將永久刪除所有存儲的圖片文件!
*
* 使用方法:
* 1. 確保已安裝依賴npm install
* 2. 設置環境變數或在 .env.local 中配置 Supabase 連接
* 3. 執行腳本node scripts/clear-storage.js
*/
const { createClient } = require('@supabase/supabase-js');
const fs = require('fs');
const path = require('path');
// 載入環境變數
require('dotenv').config({ path: '.env.local' });
// Supabase 配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// 檢查必要的環境變數
if (!supabaseUrl || !supabaseServiceKey) {
console.error('❌ 錯誤:缺少必要的環境變數');
console.error('請確保已設置以下環境變數:');
console.error('- NEXT_PUBLIC_SUPABASE_URL');
console.error('- SUPABASE_SERVICE_ROLE_KEY 或 NEXT_PUBLIC_SUPABASE_ANON_KEY');
process.exit(1);
}
// 初始化 Supabase 客戶端
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// 存儲桶名稱
const BUCKETS = ['wish-images', 'wish-thumbnails'];
/**
* 清空指定存儲桶中的所有文件
*/
async function clearBucket(bucketName) {
try {
console.log(`\n🗂️ 正在處理存儲桶:${bucketName}`);
// 列出所有文件
const { data: files, error: listError } = await supabase.storage
.from(bucketName)
.list('', {
limit: 1000,
sortBy: { column: 'created_at', order: 'desc' }
});
if (listError) {
console.error(`❌ 列出 ${bucketName} 文件時出錯:`, listError.message);
return false;
}
if (!files || files.length === 0) {
console.log(`${bucketName} 已經是空的`);
return true;
}
console.log(`📊 找到 ${files.length} 個文件`);
// 獲取所有文件路徑(包括子目錄)
const allFilePaths = [];
for (const file of files) {
if (file.name && file.name !== '.emptyFolderPlaceholder') {
// 如果是目錄,遞歸獲取其中的文件
if (!file.metadata) {
const { data: subFiles, error: subListError } = await supabase.storage
.from(bucketName)
.list(file.name, { limit: 1000 });
if (!subListError && subFiles) {
subFiles.forEach(subFile => {
if (subFile.name && subFile.name !== '.emptyFolderPlaceholder') {
allFilePaths.push(`${file.name}/${subFile.name}`);
}
});
}
} else {
allFilePaths.push(file.name);
}
}
}
if (allFilePaths.length === 0) {
console.log(`${bucketName} 中沒有需要刪除的文件`);
return true;
}
console.log(`🗑️ 準備刪除 ${allFilePaths.length} 個文件...`);
// 批量刪除文件
const batchSize = 50; // Supabase 建議的批量操作大小
let totalDeleted = 0;
let hasErrors = false;
for (let i = 0; i < allFilePaths.length; i += batchSize) {
const batch = allFilePaths.slice(i, i + batchSize);
const { data, error } = await supabase.storage
.from(bucketName)
.remove(batch);
if (error) {
console.error(`❌ 刪除批次 ${Math.floor(i/batchSize) + 1} 時出錯:`, error.message);
hasErrors = true;
} else {
totalDeleted += batch.length;
console.log(`✅ 已刪除批次 ${Math.floor(i/batchSize) + 1}/${Math.ceil(allFilePaths.length/batchSize)} (${batch.length} 個文件)`);
}
// 避免請求過於頻繁
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log(`📊 ${bucketName} 清空完成:刪除了 ${totalDeleted}/${allFilePaths.length} 個文件`);
return !hasErrors;
} catch (error) {
console.error(`❌ 清空 ${bucketName} 時發生未預期錯誤:`, error.message);
return false;
}
}
/**
* 驗證存儲桶是否存在
*/
async function verifyBuckets() {
try {
const { data: buckets, error } = await supabase.storage.listBuckets();
if (error) {
console.error('❌ 無法獲取存儲桶列表:', error.message);
return false;
}
const existingBuckets = buckets.map(bucket => bucket.id);
const missingBuckets = BUCKETS.filter(bucket => !existingBuckets.includes(bucket));
if (missingBuckets.length > 0) {
console.warn('⚠️ 以下存儲桶不存在,將跳過:', missingBuckets.join(', '));
return BUCKETS.filter(bucket => existingBuckets.includes(bucket));
}
return BUCKETS;
} catch (error) {
console.error('❌ 驗證存儲桶時發生錯誤:', error.message);
return false;
}
}
/**
* 主函數
*/
async function main() {
console.log('🚀 開始清空 Supabase Storage...');
console.log('⚠️ 警告:這將永久刪除所有存儲的圖片文件!');
// 驗證存儲桶
const bucketsToProcess = await verifyBuckets();
if (!bucketsToProcess || bucketsToProcess.length === 0) {
console.error('❌ 沒有可處理的存儲桶');
process.exit(1);
}
console.log(`📋 將處理 ${bucketsToProcess.length} 個存儲桶:`, bucketsToProcess.join(', '));
// 給用戶 5 秒鐘考慮時間
console.log('\n⏰ 5 秒後開始刪除... (按 Ctrl+C 取消)');
await new Promise(resolve => setTimeout(resolve, 5000));
let allSuccess = true;
// 清空每個存儲桶
for (const bucket of bucketsToProcess) {
const success = await clearBucket(bucket);
if (!success) {
allSuccess = false;
}
}
// 顯示最終結果
console.log('\n' + '='.repeat(50));
if (allSuccess) {
console.log('✅ 所有存儲桶清空完成!');
} else {
console.log('⚠️ 存儲桶清空完成,但過程中有一些錯誤');
}
console.log('\n📝 建議後續步驟:');
console.log('1. 在 Supabase Dashboard 中確認 Storage 已清空');
console.log('2. 執行 clear-all-data.sql 清空資料庫');
console.log('3. 重新啟動應用程式');
}
// 錯誤處理
process.on('unhandledRejection', (error) => {
console.error('❌ 未處理的錯誤:', error);
process.exit(1);
});
process.on('SIGINT', () => {
console.log('\n❌ 用戶取消操作');
process.exit(0);
});
// 執行主函數
if (require.main === module) {
main().catch(error => {
console.error('❌ 腳本執行失敗:', error);
process.exit(1);
});
}
module.exports = { clearBucket, verifyBuckets };

View File

@@ -1,26 +0,0 @@
-- 修復 migration_log 表的約束問題
-- 允許 'storage_cleanup' 和 'data_cleanup' 類型
BEGIN;
-- 移除舊的約束
ALTER TABLE migration_log DROP CONSTRAINT IF EXISTS migration_log_migration_type_check;
-- 添加新的約束,包含所有需要的類型
ALTER TABLE migration_log ADD CONSTRAINT migration_log_migration_type_check
CHECK (migration_type IN ('wishes', 'likes', 'settings', 'storage_cleanup', 'data_cleanup', 'image_cleanup'));
-- 顯示結果
DO $$
BEGIN
RAISE NOTICE '✅ migration_log 表約束已更新';
RAISE NOTICE '📋 允許的 migration_type 值:';
RAISE NOTICE ' - wishes困擾案例遷移';
RAISE NOTICE ' - likes點讚記錄遷移';
RAISE NOTICE ' - settings用戶設定遷移';
RAISE NOTICE ' - storage_cleanup存儲清理';
RAISE NOTICE ' - data_cleanup數據清空';
RAISE NOTICE ' - image_cleanup圖片清理';
END $$;
COMMIT;

View File

@@ -1,139 +0,0 @@
// 正式環境佈署前的完整資料清理腳本
// 執行此腳本將清除所有測試資料,重置到正式環境狀態
console.log("🚀 開始準備正式環境佈署...")
console.log("=".repeat(50))
// 1. 清空所有本地存儲資料
console.log("📋 第一步:清理本地存儲資料")
const dataKeys = [
"wishes", // 所有許願/困擾案例
"wishLikes", // 點讚數據
"userLikedWishes", // 用戶點讚記錄
"backgroundMusicState", // 背景音樂狀態
]
let clearedCount = 0
let totalDataSize = 0
// 計算清理前的資料大小
dataKeys.forEach((key) => {
const data = localStorage.getItem(key)
if (data) {
totalDataSize += data.length
}
})
console.log(`📊 清理前資料統計:`)
console.log(` - 總資料大小: ${(totalDataSize / 1024).toFixed(2)} KB`)
// 清空每個資料項目
dataKeys.forEach((key) => {
const existingData = localStorage.getItem(key)
if (existingData) {
const dataSize = existingData.length
localStorage.removeItem(key)
console.log(`✅ 已清空: ${key} (${(dataSize / 1024).toFixed(2)} KB)`)
clearedCount++
} else {
console.log(` ${key} 已經是空的`)
}
})
console.log("\n" + "=".repeat(50))
// 2. 設定正式環境的初始狀態
console.log("⚙️ 第二步:設定正式環境初始狀態")
const productionDefaults = {
wishes: [],
wishLikes: {},
userLikedWishes: [],
backgroundMusicState: {
enabled: false,
volume: 0.3,
isPlaying: false,
},
}
// 設定初始狀態
Object.entries(productionDefaults).forEach(([key, value]) => {
localStorage.setItem(key, JSON.stringify(value))
console.log(`✅ 已設定: ${key} 初始狀態`)
})
console.log("\n" + "=".repeat(50))
// 3. 驗證清理結果
console.log("🔍 第三步:驗證清理結果")
let verificationPassed = true
dataKeys.forEach((key) => {
const data = localStorage.getItem(key)
if (data) {
const parsedData = JSON.parse(data)
// 檢查是否為空狀態
if (key === "wishes" && Array.isArray(parsedData) && parsedData.length === 0) {
console.log(`${key}: 已重置為空陣列`)
} else if (
(key === "wishLikes" || key === "userLikedWishes") &&
((Array.isArray(parsedData) && parsedData.length === 0) ||
(typeof parsedData === "object" && Object.keys(parsedData).length === 0))
) {
console.log(`${key}: 已重置為空狀態`)
} else if (key === "backgroundMusicState" && typeof parsedData === "object") {
console.log(`${key}: 已重置為預設狀態`)
} else {
console.log(`${key}: 狀態異常`)
verificationPassed = false
}
} else {
console.log(`${key}: 資料遺失`)
verificationPassed = false
}
})
console.log("\n" + "=".repeat(50))
// 4. 顯示最終結果
console.log("🎉 清理完成報告:")
console.log(`📊 清理統計:`)
console.log(` - 清空了 ${clearedCount} 個資料項目`)
console.log(` - 檢查了 ${dataKeys.length} 個資料項目`)
console.log(` - 釋放了 ${(totalDataSize / 1024).toFixed(2)} KB 空間`)
console.log(` - 驗證結果: ${verificationPassed ? "✅ 通過" : "❌ 失敗"}`)
console.log("\n🚀 正式環境準備狀態:")
console.log(" ✅ 困擾案例: 0 個")
console.log(" ✅ 點讚記錄: 已清空")
console.log(" ✅ 背景音樂: 預設關閉")
console.log(" ✅ 本地存儲: 已重置")
console.log("\n" + "=".repeat(50))
if (verificationPassed) {
console.log("🎯 佈署準備完成!")
console.log("✨ 應用程式已準備好進行正式佈署")
console.log("\n📋 建議的佈署檢查清單:")
console.log(" □ 重新整理頁面確認所有資料已清空")
console.log(" □ 測試各個功能頁面的初始狀態")
console.log(" □ 確認沒有錯誤訊息或異常行為")
console.log(" □ 檢查響應式設計在各裝置正常")
console.log(" □ 測試音效和背景音樂功能")
console.log(" □ 驗證隱私設定功能")
console.log(" □ 準備佈署到正式環境")
// 提供重新載入頁面的選項
setTimeout(() => {
if (confirm("✅ 清理完成!是否要重新載入頁面以確認效果?")) {
window.location.reload()
}
}, 2000)
} else {
console.log("⚠️ 清理過程中發現問題,請檢查後重新執行")
}
console.log("\n🌟 感謝使用心願星河!準備為用戶提供優質服務!")

View File

@@ -1,45 +0,0 @@
/**
* IP 檢測功能測試腳本
* 用於測試和驗證IP白名單功能
*/
const { getClientIp, getDetailedIpInfo, cleanIpAddress } = require('../lib/ip-utils.ts');
// 模擬請求對象
const mockRequest = {
headers: {
'x-forwarded-for': '::ffff:127.0.0.1, 192.168.1.100',
'x-real-ip': '::ffff:127.0.0.1',
'x-client-ip': '::1',
'connection': {
'remoteAddress': '::ffff:127.0.0.1'
},
'socket': {
'remoteAddress': '::1'
}
},
ip: '::ffff:127.0.0.1'
};
console.log('=== IP 檢測測試 ===');
// 測試 cleanIpAddress 函數
console.log('\n1. 測試 cleanIpAddress 函數:');
console.log('::ffff:127.0.0.1 ->', cleanIpAddress('::ffff:127.0.0.1'));
console.log('::1 ->', cleanIpAddress('::1'));
console.log('127.0.0.1 ->', cleanIpAddress('127.0.0.1'));
console.log('192.168.1.1 ->', cleanIpAddress('192.168.1.1'));
// 測試詳細IP信息
console.log('\n2. 測試詳細IP信息:');
const detailedInfo = getDetailedIpInfo(mockRequest);
console.log('檢測到的IP:', detailedInfo.detectedIp);
console.log('所有找到的IP:', detailedInfo.allFoundIps);
console.log('IP來源:', detailedInfo.ipSources);
// 測試客戶端IP獲取
console.log('\n3. 測試客戶端IP獲取:');
const clientIp = getClientIp(mockRequest);
console.log('最終檢測到的IP:', clientIp);
console.log('\n=== 測試完成 ===');

85
scripts/test-like-api.js Normal file
View File

@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* 測試點讚 API
*/
async function testLikeAPI() {
try {
console.log('🔍 測試點讚 API...')
console.log('')
const testUserSession = `test_api_session_${Date.now()}`
const testWishId = 6 // 使用存在的 Wish ID
// 1. 測試檢查點讚狀態
console.log('1⃣ 測試檢查點讚狀態...')
const checkResponse = await fetch(`http://localhost:3000/api/wishes/like?wishId=${testWishId}`, {
headers: {
'x-user-session': testUserSession
}
})
const checkResult = await checkResponse.json()
console.log(`✅ 檢查結果: ${checkResult.success ? '成功' : '失敗'}`)
console.log(` 已點讚: ${checkResult.data?.liked || false}`)
console.log('')
// 2. 測試點讚
console.log('2⃣ 測試點讚...')
const likeResponse = await fetch('http://localhost:3000/api/wishes/like', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-session': testUserSession
},
body: JSON.stringify({ wishId: testWishId })
})
const likeResult = await likeResponse.json()
console.log(`✅ 點讚結果: ${likeResult.success ? '成功' : '失敗'}`)
console.log(` 點讚狀態: ${likeResult.data?.liked || false}`)
console.log('')
// 3. 再次檢查點讚狀態
console.log('3⃣ 再次檢查點讚狀態...')
const checkResponse2 = await fetch(`http://localhost:3000/api/wishes/like?wishId=${testWishId}`, {
headers: {
'x-user-session': testUserSession
}
})
const checkResult2 = await checkResponse2.json()
console.log(`✅ 檢查結果: ${checkResult2.success ? '成功' : '失敗'}`)
console.log(` 已點讚: ${checkResult2.data?.liked || false}`)
console.log('')
// 4. 測試重複點讚
console.log('4⃣ 測試重複點讚...')
const likeResponse2 = await fetch('http://localhost:3000/api/wishes/like', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-session': testUserSession
},
body: JSON.stringify({ wishId: testWishId })
})
const likeResult2 = await likeResponse2.json()
console.log(`✅ 重複點讚結果: ${likeResult2.success ? '成功' : '失敗'}`)
console.log(` 點讚狀態: ${likeResult2.data?.liked || false}`)
console.log('')
console.log('🎉 點讚 API 測試完成!')
} catch (error) {
console.error('❌ 測試失敗:', error.message)
}
}
// 執行測試
if (require.main === module) {
testLikeAPI()
}
module.exports = { testLikeAPI }

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* 測試點讚功能
*/
const { PrismaClient } = require('@prisma/client')
// 設定環境變數
process.env.DATABASE_URL = "mysql://wish_pool:Aa123456@mysql.theaken.com:33306/db_wish_pool?schema=public"
async function testLikeFunctionality() {
const prisma = new PrismaClient()
try {
console.log('🔍 測試點讚功能...')
console.log('')
// 1. 檢查現有的點讚記錄
console.log('1⃣ 檢查現有的點讚記錄...')
const existingLikes = await prisma.wishLike.findMany({
take: 5,
orderBy: { createdAt: 'desc' }
})
console.log(`✅ 現有 ${existingLikes.length} 筆點讚記錄`)
existingLikes.forEach((like, index) => {
console.log(` ${index + 1}. Wish ID: ${like.wishId}, Session: ${like.userSession.substring(0, 20)}...`)
})
console.log('')
// 2. 測試創建點讚記錄
console.log('2⃣ 測試創建點讚記錄...')
// 先獲取一個存在的 Wish ID
const existingWish = await prisma.wish.findFirst()
if (!existingWish) {
console.log('❌ 沒有找到任何困擾案例')
return
}
const testWishId = existingWish.id
const testUserSession = `test_session_${Date.now()}`
console.log(` 使用 Wish ID: ${testWishId}`)
try {
const newLike = await prisma.wishLike.create({
data: {
wishId: testWishId,
userSession: testUserSession
}
})
console.log(`✅ 成功創建點讚記錄: ID ${newLike.id}`)
} catch (error) {
if (error.code === 'P2002') {
console.log('⚠️ 點讚記錄已存在(重複點讚)')
} else {
throw error
}
}
console.log('')
// 3. 測試查詢點讚記錄
console.log('3⃣ 測試查詢點讚記錄...')
const foundLike = await prisma.wishLike.findFirst({
where: {
wishId: testWishId,
userSession: testUserSession
}
})
if (foundLike) {
console.log(`✅ 成功找到點讚記錄: ID ${foundLike.id}`)
} else {
console.log('❌ 未找到點讚記錄')
}
console.log('')
// 4. 統計點讚數量
console.log('4⃣ 統計點讚數量...')
const likeCount = await prisma.wishLike.count({
where: { wishId: testWishId }
})
console.log(`✅ Wish ID ${testWishId} 的點讚數量: ${likeCount}`)
console.log('')
console.log('🎉 點讚功能測試完成!')
} catch (error) {
console.error('❌ 測試失敗:', error.message)
console.error('詳細錯誤:', error)
} finally {
await prisma.$disconnect()
}
}
// 執行測試
if (require.main === module) {
testLikeFunctionality()
}
module.exports = { testLikeFunctionality }

View File

@@ -1,137 +0,0 @@
// 心願星河 - Supabase 連接測試腳本
// 使用方法: npm run test-supabase
const { createClient } = require("@supabase/supabase-js")
require("dotenv").config({ path: ".env.local" })
async function testSupabaseConnection() {
console.log("🔍 測試 Supabase 連接...\n")
// 檢查環境變數
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseKey) {
console.error("❌ 環境變數未設置")
console.log("請確認 .env.local 檔案中包含:")
console.log("- NEXT_PUBLIC_SUPABASE_URL")
console.log("- NEXT_PUBLIC_SUPABASE_ANON_KEY")
process.exit(1)
}
console.log("✅ 環境變數已設置")
console.log(`📍 Supabase URL: ${supabaseUrl}`)
console.log(`🔑 API Key: ${supabaseKey.substring(0, 20)}...`)
// 創建 Supabase 客戶端
const supabase = createClient(supabaseUrl, supabaseKey)
try {
// 測試基本連接
console.log("\n🔗 測試基本連接...")
const { data, error } = await supabase.from("wishes").select("count").limit(1)
if (error) {
console.error("❌ 連接失敗:", error.message)
return false
}
console.log("✅ 基本連接成功")
// 測試表格存在性
console.log("\n📊 檢查表格結構...")
const tables = ["wishes", "wish_likes", "user_settings", "migration_log", "system_stats"]
for (const table of tables) {
try {
const { data, error } = await supabase.from(table).select("*").limit(1)
if (error) {
console.log(`❌ 表格 ${table}: ${error.message}`)
} else {
console.log(`✅ 表格 ${table}: 正常`)
}
} catch (err) {
console.log(`❌ 表格 ${table}: ${err.message}`)
}
}
// 測試視圖
console.log("\n👁 檢查視圖...")
const views = ["wishes_with_likes", "public_wishes", "popular_wishes"]
for (const view of views) {
try {
const { data, error } = await supabase.from(view).select("*").limit(1)
if (error) {
console.log(`❌ 視圖 ${view}: ${error.message}`)
} else {
console.log(`✅ 視圖 ${view}: 正常`)
}
} catch (err) {
console.log(`❌ 視圖 ${view}: ${err.message}`)
}
}
// 測試函數
console.log("\n⚙ 測試函數...")
try {
const { data, error } = await supabase.rpc("get_wishes_stats")
if (error) {
console.log(`❌ 函數 get_wishes_stats: ${error.message}`)
} else {
console.log("✅ 函數 get_wishes_stats: 正常")
console.log("📈 統計數據:", JSON.stringify(data, null, 2))
}
} catch (err) {
console.log(`❌ 函數測試失敗: ${err.message}`)
}
// 測試存儲
console.log("\n🗂 檢查存儲桶...")
try {
const { data: buckets, error } = await supabase.storage.listBuckets()
if (error) {
console.log(`❌ 存儲桶檢查失敗: ${error.message}`)
} else {
const wishBuckets = buckets.filter((bucket) => bucket.id === "wish-images" || bucket.id === "wish-thumbnails")
if (wishBuckets.length === 2) {
console.log("✅ 存儲桶設置完成")
wishBuckets.forEach((bucket) => {
console.log(` - ${bucket.id}: ${bucket.public ? "公開" : "私密"}`)
})
} else {
console.log(`⚠️ 存儲桶不完整,找到 ${wishBuckets.length}/2 個`)
}
}
} catch (err) {
console.log(`❌ 存儲桶檢查失敗: ${err.message}`)
}
console.log("\n🎉 Supabase 連接測試完成!")
return true
} catch (error) {
console.error("❌ 測試過程中發生錯誤:", error)
return false
}
}
// 執行測試
testSupabaseConnection()
.then((success) => {
if (success) {
console.log("\n✅ 所有測試通過,可以開始使用 Supabase")
process.exit(0)
} else {
console.log("\n❌ 測試失敗,請檢查配置")
process.exit(1)
}
})
.catch((error) => {
console.error("測試腳本執行失敗:", error)
process.exit(1)
})

View File

@@ -1,102 +0,0 @@
/**
* IP 白名單更新腳本
* 用於快速更新環境變數中的IP白名單
*/
const fs = require('fs');
const path = require('path');
// 新的IP地址
const NEW_IP = '218.161.107.138';
// 現有的IP列表
const EXISTING_IPS = [
'114.33.18.13',
'125.229.65.83',
'60.248.164.91',
'220.132.236.89',
'211.72.69.222',
'219.87.170.253',
'125.228.50.228'
];
// 更新後的完整IP列表
const UPDATED_IPS = [...EXISTING_IPS, NEW_IP];
function updateEnvFile() {
const envPath = path.join(__dirname, '..', '.env.local');
try {
let envContent = '';
// 如果 .env.local 存在,讀取內容
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// 更新或添加 ALLOWED_IPS
const allowedIpsLine = `ALLOWED_IPS=${UPDATED_IPS.join(',')}`;
if (envContent.includes('ALLOWED_IPS=')) {
// 替換現有的 ALLOWED_IPS 行
envContent = envContent.replace(
/ALLOWED_IPS=.*/g,
allowedIpsLine
);
} else {
// 添加新的 ALLOWED_IPS 行
envContent += `\n# IP 白名單配置\n${allowedIpsLine}\n`;
}
// 確保 ENABLE_IP_WHITELIST 設置為 true
if (!envContent.includes('ENABLE_IP_WHITELIST=')) {
envContent += 'ENABLE_IP_WHITELIST=true\n';
} else {
envContent = envContent.replace(
/ENABLE_IP_WHITELIST=.*/g,
'ENABLE_IP_WHITELIST=true'
);
}
// 寫入文件
fs.writeFileSync(envPath, envContent);
console.log('✅ 成功更新 .env.local 文件');
console.log(`📝 新增的IP: ${NEW_IP}`);
console.log(`📋 完整的IP列表: ${UPDATED_IPS.join(', ')}`);
} catch (error) {
console.error('❌ 更新 .env.local 文件時發生錯誤:', error);
}
}
function showInstructions() {
console.log('\n📋 手動配置說明:');
console.log('如果自動更新失敗,請手動在 .env.local 文件中設置:');
console.log('\n```env');
console.log('ENABLE_IP_WHITELIST=true');
console.log(`ALLOWED_IPS=${UPDATED_IPS.join(',')}`);
console.log('```');
console.log('\n🔄 更新後請重新啟動開發服務器:');
console.log('npm run dev');
}
function main() {
console.log('🚀 IP 白名單更新工具');
console.log('=' * 40);
updateEnvFile();
showInstructions();
console.log('\n✅ 更新完成你的IP 218.161.107.138 現在應該可以正常訪問了。');
}
if (require.main === module) {
main();
}
module.exports = {
updateEnvFile,
UPDATED_IPS,
NEW_IP
};