資料庫改為 mySQL
This commit is contained in:
269
DATA-MIGRATION-GUIDE.md
Normal file
269
DATA-MIGRATION-GUIDE.md
Normal 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
256
MYSQL-MIGRATION-GUIDE.md
Normal 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 客戶端生成完成
|
||||
- [ ] 資料遷移執行完成
|
||||
- [ ] 應用程式配置更新完成
|
||||
- [ ] 功能測試通過
|
||||
- [ ] 性能測試通過
|
||||
- [ ] 文檔更新完成
|
||||
|
||||
---
|
||||
|
||||
**注意**: 遷移過程中請確保備份重要數據,並在測試環境中先進行完整測試。
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import RadarChart from "@/components/radar-chart"
|
||||
import HeaderMusicControl from "@/components/header-music-control"
|
||||
import { categories, categorizeWishMultiple, type Wish } from "@/lib/categorization"
|
||||
import { WishService } from "@/lib/supabase-service"
|
||||
// 使用 API 路由,不需要直接導入 WishService
|
||||
import { driver } from "driver.js"
|
||||
import "driver.js/dist/driver.css"
|
||||
|
||||
@@ -269,8 +269,15 @@ export default function AnalyticsPage() {
|
||||
useEffect(() => {
|
||||
const fetchWishes = async () => {
|
||||
try {
|
||||
// 獲取所有困擾案例(包含私密的,用於完整分析)
|
||||
const allWishesData = await WishService.getAllWishes()
|
||||
// 使用 API 路由獲取所有困擾案例(包含私密的,用於完整分析)
|
||||
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 接口
|
||||
const convertWish = (wish: any) => ({
|
||||
@@ -292,7 +299,7 @@ export default function AnalyticsPage() {
|
||||
setAnalytics(analyzeWishes(allWishes))
|
||||
} catch (error) {
|
||||
console.error("獲取分析數據失敗:", error)
|
||||
// 如果 Supabase 連接失敗,回退到 localStorage
|
||||
// 如果 API 連接失敗,回退到 localStorage
|
||||
const savedWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
|
||||
setWishes(savedWishes)
|
||||
setAnalytics(analyzeWishes(savedWishes))
|
||||
|
||||
17
app/api/test/route.ts
Normal file
17
app/api/test/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/api/wishes/basic/route.ts
Normal file
39
app/api/wishes/basic/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
36
app/api/wishes/count/route.ts
Normal file
36
app/api/wishes/count/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
69
app/api/wishes/create/route.ts
Normal file
69
app/api/wishes/create/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
46
app/api/wishes/data/route.ts
Normal file
46
app/api/wishes/data/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
91
app/api/wishes/like/route.ts
Normal file
91
app/api/wishes/like/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
58
app/api/wishes/list/route.ts
Normal file
58
app/api/wishes/list/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
58
app/api/wishes/real-data/route.ts
Normal file
58
app/api/wishes/real-data/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
44
app/api/wishes/real-json/route.ts
Normal file
44
app/api/wishes/real-json/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
52
app/api/wishes/real/route.ts
Normal file
52
app/api/wishes/real/route.ts
Normal 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
85
app/api/wishes/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
30
app/api/wishes/simple/route.ts
Normal file
30
app/api/wishes/simple/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
64
app/api/wishes/sql-data/route.ts
Normal file
64
app/api/wishes/sql-data/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
55
app/api/wishes/sql/route.ts
Normal file
55
app/api/wishes/sql/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
18
app/api/wishes/stats/route.ts
Normal file
18
app/api/wishes/stats/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
67
app/api/wishes/working/route.ts
Normal file
67
app/api/wishes/working/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Sparkles, ArrowLeft, Database, Settings, TestTube, Trash2 } from "lucide-react"
|
||||
import HeaderMusicControl from "@/components/header-music-control"
|
||||
import MigrationDialog from "@/components/migration-dialog"
|
||||
import { testSupabaseConnection, MigrationService } from "@/lib/supabase-service"
|
||||
import { testDatabaseConnection, MigrationService } from "@/lib/database-service"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [showMigration, setShowMigration] = useState(false)
|
||||
@@ -33,7 +33,7 @@ export default function SettingsPage() {
|
||||
const checkConnection = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const connected = await testSupabaseConnection()
|
||||
const connected = await testDatabaseConnection()
|
||||
setIsConnected(connected)
|
||||
} catch (error) {
|
||||
setIsConnected(false)
|
||||
|
||||
@@ -21,7 +21,7 @@ import { moderateWishForm, type ModerationResult } from "@/lib/content-moderatio
|
||||
import ContentModerationFeedback from "@/components/content-moderation-feedback"
|
||||
import ImageUpload from "@/components/image-upload"
|
||||
import type { ImageFile } from "@/lib/image-utils"
|
||||
import { WishService } from "@/lib/supabase-service"
|
||||
// 使用 API 路由,不需要直接導入 WishService
|
||||
import { categorizeWish, type Wish } from "@/lib/categorization"
|
||||
import { driver } from "driver.js"
|
||||
import "driver.js/dist/driver.css"
|
||||
@@ -190,8 +190,13 @@ export default function SubmitPage() {
|
||||
await soundManager.play("submit")
|
||||
|
||||
try {
|
||||
// 創建困擾案例到 Supabase 數據庫
|
||||
await WishService.createWish({
|
||||
// 使用 API 路由創建困擾案例
|
||||
const response = await fetch('/api/wishes/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: formData.title,
|
||||
currentPain: formData.currentPain,
|
||||
expectedSolution: formData.expectedSolution,
|
||||
@@ -199,8 +204,19 @@ export default function SubmitPage() {
|
||||
isPublic: formData.isPublic,
|
||||
email: formData.email,
|
||||
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")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Sparkles, Heart, Users, ArrowRight, Home, MessageCircle, BarChart3, Eye, EyeOff } from "lucide-react"
|
||||
import HeaderMusicControl from "@/components/header-music-control"
|
||||
import { WishService } from "@/lib/supabase-service"
|
||||
// 使用 API 路由,不需要直接導入 WishService
|
||||
|
||||
export default function ThankYouPage() {
|
||||
const [wishes, setWishes] = useState<any[]>([])
|
||||
@@ -16,8 +16,15 @@ export default function ThankYouPage() {
|
||||
useEffect(() => {
|
||||
const fetchWishes = async () => {
|
||||
try {
|
||||
// 獲取所有困擾案例
|
||||
const allWishesData = await WishService.getAllWishes()
|
||||
// 使用 API 路由獲取所有困擾案例
|
||||
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) => ({
|
||||
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
Users,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
HelpCircle
|
||||
HelpCircle,
|
||||
RefreshCw
|
||||
} from "lucide-react"
|
||||
import WishCard from "@/components/wish-card"
|
||||
import HeaderMusicControl from "@/components/header-music-control"
|
||||
import { categories, categorizeWishMultiple, getCategoryStats, type Wish } from "@/lib/categorization"
|
||||
import { WishService } from "@/lib/supabase-service"
|
||||
// 使用 API 路由,不需要直接導入 WishService
|
||||
import { driver } from "driver.js"
|
||||
import "driver.js/dist/driver.css"
|
||||
|
||||
@@ -203,6 +204,7 @@ export default function WishesPage() {
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [totalWishes, setTotalWishes] = useState(0)
|
||||
const [privateCount, setPrivateCount] = useState(0)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
// 分頁相關狀態
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -282,55 +284,79 @@ export default function WishesPage() {
|
||||
driverObj.drive();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWishes = async () => {
|
||||
try {
|
||||
// 獲取所有困擾(用於統計)
|
||||
const allWishesData = await WishService.getAllWishes()
|
||||
|
||||
// 獲取公開困擾(用於顯示)
|
||||
const publicWishesData = await WishService.getPublicWishes()
|
||||
|
||||
// 轉換數據格式以匹配 categorization.ts 的 Wish 接口
|
||||
const convertWish = (wish: any) => ({
|
||||
id: wish.id,
|
||||
title: wish.title,
|
||||
currentPain: wish.current_pain,
|
||||
expectedSolution: wish.expected_solution,
|
||||
expectedEffect: wish.expected_effect || "",
|
||||
createdAt: wish.created_at,
|
||||
isPublic: wish.is_public,
|
||||
email: wish.email,
|
||||
images: wish.images,
|
||||
like_count: wish.like_count || 0, // 包含點讚數
|
||||
})
|
||||
|
||||
const allWishes = allWishesData.map(convertWish)
|
||||
const publicWishes = publicWishesData.map(convertWish)
|
||||
|
||||
// 計算私密困擾數量
|
||||
const privateCount = allWishes.length - publicWishes.length
|
||||
// 獲取困擾數據的函數
|
||||
const fetchWishes = async () => {
|
||||
try {
|
||||
// 使用 API 路由獲取所有困擾(用於統計)
|
||||
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 allWishesData = allResult.data
|
||||
|
||||
// 使用 API 路由獲取公開困擾(用於顯示)
|
||||
const publicResponse = await fetch('/api/wishes/real-json?type=public')
|
||||
const publicResult = await publicResponse.json()
|
||||
if (!publicResult.success) throw new Error(publicResult.error || 'Failed to fetch public wishes')
|
||||
const publicWishesData = publicResult.data
|
||||
|
||||
// 轉換數據格式以匹配 categorization.ts 的 Wish 接口
|
||||
const convertWish = (wish: any) => ({
|
||||
id: wish.id,
|
||||
title: wish.title,
|
||||
currentPain: wish.current_pain,
|
||||
expectedSolution: wish.expected_solution,
|
||||
expectedEffect: wish.expected_effect || "",
|
||||
createdAt: wish.created_at,
|
||||
isPublic: wish.is_public,
|
||||
email: wish.email,
|
||||
images: wish.images,
|
||||
like_count: wish.like_count || 0, // 包含點讚數
|
||||
})
|
||||
|
||||
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)
|
||||
setPublicWishes(publicWishes)
|
||||
setTotalWishes(allWishes.length)
|
||||
setPrivateCount(privateCount)
|
||||
setCategoryStats(getCategoryStats(publicWishes))
|
||||
} catch (error) {
|
||||
console.error("獲取困擾數據失敗:", error)
|
||||
// 如果 Supabase 連接失敗,回退到 localStorage
|
||||
const savedWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
|
||||
const publicOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic !== false)
|
||||
const privateOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic === false)
|
||||
setWishes(allWishes)
|
||||
setPublicWishes(sortedPublicWishes)
|
||||
setTotalWishes(allWishes.length)
|
||||
setPrivateCount(privateCount)
|
||||
setCategoryStats(getCategoryStats(publicWishes))
|
||||
} catch (error) {
|
||||
console.error("獲取困擾數據失敗:", error)
|
||||
// 如果 API 連接失敗,回退到 localStorage
|
||||
const savedWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
|
||||
const publicOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic !== false)
|
||||
const privateOnly = savedWishes.filter((wish: Wish & { isPublic?: boolean }) => wish.isPublic === false)
|
||||
|
||||
setWishes(savedWishes)
|
||||
setPublicWishes(publicOnly.reverse())
|
||||
setTotalWishes(savedWishes.length)
|
||||
setPrivateCount(privateOnly.length)
|
||||
setCategoryStats(getCategoryStats(publicOnly))
|
||||
}
|
||||
setWishes(savedWishes)
|
||||
setPublicWishes(publicOnly.reverse())
|
||||
setTotalWishes(savedWishes.length)
|
||||
setPrivateCount(privateOnly.length)
|
||||
setCategoryStats(getCategoryStats(publicOnly))
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新數據函數
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await fetchWishes()
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchWishes()
|
||||
}, [])
|
||||
|
||||
@@ -510,7 +536,19 @@ export default function WishesPage() {
|
||||
<main className="py-8 md:py-12 px-1 sm:px-4">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<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>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useState, useEffect } from "react"
|
||||
import { soundManager } from "@/lib/sound-effects"
|
||||
import ImageGallery from "@/components/image-gallery"
|
||||
import { restoreImageFile, type ImageFile } from "@/lib/image-utils"
|
||||
import { LikeService } from "@/lib/supabase-service"
|
||||
// 使用 API 路由,不需要直接導入 LikeService
|
||||
|
||||
interface WishCardProps {
|
||||
wish: Wish & { images?: any[]; like_count?: number } // 添加圖片支援和點讚數
|
||||
@@ -35,21 +35,43 @@ export default function WishCard({ wish }: WishCardProps) {
|
||||
const [hasLiked, setHasLiked] = 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(() => {
|
||||
const loadLikeData = async () => {
|
||||
try {
|
||||
// 從 Supabase 獲取用戶已點讚的困擾列表
|
||||
const userLikedWishes = await LikeService.getUserLikedWishes()
|
||||
const userSession = getUserSession()
|
||||
// 使用 API 路由獲取用戶已點讚的困擾列表
|
||||
const response = await fetch(`/api/wishes/like?wishId=${wish.id}`, {
|
||||
headers: {
|
||||
'x-user-session': userSession
|
||||
}
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
// 設置點讚狀態
|
||||
setHasLiked(userLikedWishes.includes(wish.id))
|
||||
if (result.success) {
|
||||
// 設置點讚狀態
|
||||
setHasLiked(result.data.liked)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to check like status')
|
||||
}
|
||||
|
||||
// 點讚數從 wish 的 like_count 字段獲取,如果沒有則默認為 0
|
||||
setLikeCount(wish.like_count || 0)
|
||||
} catch (error) {
|
||||
console.error("載入點讚數據失敗:", error)
|
||||
// 如果 Supabase 連接失敗,回退到 localStorage
|
||||
// 如果 API 連接失敗,回退到 localStorage
|
||||
const likes = JSON.parse(localStorage.getItem("wishLikes") || "{}")
|
||||
const likedWishes = JSON.parse(localStorage.getItem("userLikedWishes") || "[]")
|
||||
|
||||
@@ -70,10 +92,20 @@ export default function WishCard({ wish }: WishCardProps) {
|
||||
await soundManager.play("click")
|
||||
|
||||
try {
|
||||
// 使用 Supabase 點讚服務
|
||||
const success = await LikeService.likeWish(wish.id)
|
||||
const userSession = getUserSession()
|
||||
// 使用 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)
|
||||
setHasLiked(true)
|
||||
|
||||
1707
data/all-wishes.json
Normal file
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
1496
data/public-wishes.json
Normal file
File diff suppressed because one or more lines are too long
25
env.mysql.example
Normal file
25
env.mysql.example
Normal 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
669
lib/database-service.ts
Normal 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
516
lib/mysql-service.ts
Normal 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
268
lib/statistics-service.ts
Normal 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
|
||||
@@ -26,6 +26,10 @@ export class WishService {
|
||||
// 獲取所有公開的困擾案例(帶點讚數)
|
||||
static async getPublicWishes(): Promise<Wish[]> {
|
||||
try {
|
||||
if (!supabase) {
|
||||
throw new SupabaseError("Supabase 未配置,請使用統一的資料庫服務")
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("wishes_with_likes")
|
||||
.select("*")
|
||||
@@ -43,6 +47,10 @@ export class WishService {
|
||||
// 獲取所有困擾案例(用於分析,包含私密的)
|
||||
static async getAllWishes(): Promise<Wish[]> {
|
||||
try {
|
||||
if (!supabase) {
|
||||
throw new SupabaseError("Supabase 未配置,請使用統一的資料庫服務")
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("wishes_with_likes")
|
||||
.select("*")
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { createClient } from "@supabase/supabase-js"
|
||||
|
||||
// Supabase 配置
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
|
||||
// 創建 Supabase 客戶端(單例模式)
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
// 只有在有 Supabase 環境變數時才創建客戶端
|
||||
export const supabase = supabaseUrl && supabaseAnonKey ? createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: false, // 我們不需要用戶認證
|
||||
},
|
||||
db: {
|
||||
schema: "public",
|
||||
},
|
||||
})
|
||||
}) : null
|
||||
|
||||
// 數據庫類型定義
|
||||
export interface Database {
|
||||
@@ -136,6 +137,11 @@ export function getUserSession(): string {
|
||||
// 測試 Supabase 連接
|
||||
export async function testSupabaseConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!supabase) {
|
||||
console.log("ℹ️ Supabase 未配置,使用 MySQL 資料庫")
|
||||
return false
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from("wishes").select("count").limit(1)
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ generator client {
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
relationMode = "prisma"
|
||||
}
|
||||
|
||||
// 困擾案例主表
|
||||
|
||||
@@ -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-5,5最高)';
|
||||
|
||||
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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -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);
|
||||
@@ -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. 環境變數配置
|
||||
@@ -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 $$;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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🌟 感謝使用心願星河!準備為用戶提供優質服務!")
|
||||
@@ -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
85
scripts/test-like-api.js
Normal 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 }
|
||||
101
scripts/test-like-functionality.js
Normal file
101
scripts/test-like-functionality.js
Normal 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 }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user