Compare commits

...

12 Commits

Author SHA1 Message Date
1d9ae792fa fix: add md & fix dateformat & add note
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 1m10s
2026-07-03 02:00:01 +08:00
077db1f7c6 config: change dockerfile
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 13s
2026-06-30 23:49:38 +08:00
8c88eb0866 fix: remove useless route
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 14s
2026-06-30 23:44:52 +08:00
5241d2c9c6 config: change docker-compose.yml
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 14s
2026-06-30 23:28:52 +08:00
758c553512 fix: change deploy,yaml
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 16s
2026-06-30 17:23:45 +08:00
0941d2bbb9 fix: change config
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 6s
2026-06-30 17:15:59 +08:00
436047c1e8 fix: dateformat
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 6s
2026-06-27 00:57:40 +08:00
7f8b17cbc5 fix: auth url
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 6s
2026-06-26 01:07:56 +08:00
c0ec662a6c fix: change config
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 4s
2026-06-25 22:17:51 +08:00
1b771167cf fix: change config
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 3s
2026-06-25 22:13:19 +08:00
60832c5722 fix: change config
All checks were successful
Laravel-Oracle-Deploy / redeploy (push) Successful in 3s
2026-06-25 21:58:15 +08:00
d8c5c541e0 fix: change config 2026-06-25 21:58:00 +08:00
11 changed files with 11968 additions and 248 deletions

View File

@@ -12,27 +12,30 @@ jobs:
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Create .env file - name: Create .env file
run: echo "${{ secrets.ENV_FILE }}" > .env run: echo "${{ secrets.ENV_FILE }}" > .env
- name: Main Deploy Backend Container - name: Main Deploy Backend Container
run: | run: |
# 1. 刪除舊容器 # 0. 📦 重新打包最新程式碼 (這是你原本漏掉的靈魂步驟!)
# 確保這裡的執行路徑有你的 Dockerfile
docker build -t finance_app-finance-backend:latest .
# 1. 🗑️ 刪除舊容器
docker rm -f finance-laravel-backend || true docker rm -f finance-laravel-backend || true
# 2. 啟動最乾淨的新容器 # 2. 🚀 啟動最乾淨的新容器
docker run -d \ docker run -d \
--name finance-laravel-backend \ --name finance-laravel-backend \
--network 1panel-network \ --network 1panel-network \
-p 8081:8000 \ -p 8081:8000 \
finance_app-finance-backend:latest finance_app-finance-backend:latest
# 3. 🎯 終極一指通:直接把整坨 ENV_FILE Secret 的文字,凌空灌進容器內變成實體檔案! # 3. 🎯 終極一指通:寫入環境變數
# 這樣管它有幾百個變數,通通一秒就定位,而且完全不留痕跡!
docker exec -i finance-laravel-backend sh -c "cat > .env" << 'EOF' docker exec -i finance-laravel-backend sh -c "cat > .env" << 'EOF'
${{ secrets.ENV_FILE }} ${{ secrets.ENV_FILE }}
EOF EOF
# 4. 刷新 Laravel 快取 # 4. 🧹 刷新 Laravel 快取
docker exec -i finance-laravel-backend php artisan optimize:clear docker exec -i finance-laravel-backend php artisan optimize:clear

30
.gitignore vendored
View File

@@ -1,29 +1,3 @@
<<<<<<< HEAD
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
=======
/vendor/ /vendor/
node_modules/ node_modules/
npm-debug.log npm-debug.log
@@ -43,10 +17,12 @@ public_html/hot
storage/*.key storage/*.key
.env .env
.env.dev
Homestead.yaml Homestead.yaml
Homestead.json Homestead.json
/.vagrant /.vagrant
.phpunit.result.cache .phpunit.result.cache
/app/public/storage
/public/build /public/build
/storage/pail /storage/pail
@@ -54,4 +30,4 @@ Homestead.json
.env.production .env.production
.phpactor.json .phpactor.json
auth.json auth.json
>>>>>>> 5123647b7180bb2fdc0760039c8e38d903dc15eb logs/laravel.log

View File

@@ -31,6 +31,6 @@ COPY . /var/www
# 5. 補上自動載入優化 # 5. 補上自動載入優化
RUN composer dump-autoload --optimize --no-dev RUN composer dump-autoload --optimize --no-dev
EXPOSE 9000 EXPOSE 8000
CMD ["php", "/var/www/artisan", "serve", "--host=0.0.0.0", "--port=9000"] CMD ["php", "/var/www/artisan", "serve", "--host=0.0.0.0", "--port=8000"]

View File

@@ -15,21 +15,42 @@ class DashboardController extends Controller
$userId = Auth::id(); $userId = Auth::id();
$now = Carbon::now(); $now = Carbon::now();
$latestMonth = $now->copy();
$latestYear = $latestMonth->year;
// 1. 本月基礎統計 (用一個查詢同時撈出「總金額」與「總筆數」) // 1. 本月基礎統計 (用一個查詢同時撈出「總金額」與「總筆數」)
$monthStats = Expense::where('user_id', $userId) $monthStats = Expense::where('user_id', $userId)
->whereYear('date', $now->year) ->whereYear('date', $latestYear)
->whereMonth('date', $now->month) ->whereMonth('date', $latestMonth)
->selectRaw('SUM(amount) as total_amount, COUNT(id) as total_count') ->selectRaw('SUM(amount) as total_amount, COUNT(id) as total_count')
->first(); ->first();
if (!$monthStats || $monthStats->total_amount == 0) {
// 改用 first() 確保能抓到單筆資料
$latestExpense = Expense::where('user_id', $userId)->latest('date')->first();
if ($latestExpense) {
$targetDate = Carbon::parse($latestExpense->date);
$latestYear = $targetDate->year;
$latestMonth = $targetDate->month;
// 重新撈取該月份的統計資料
$monthStats = Expense::where('user_id', $userId)
->whereYear('date', $latestYear)
->whereMonth('date', $latestMonth)
->selectRaw('SUM(amount) as total_amount, COUNT(id) as total_count')
->first();
}
}
$monthlyTotal = (float) ($monthStats->total_amount ?? 0); $monthlyTotal = (float) ($monthStats->total_amount ?? 0);
$monthlyCount = (int) ($monthStats->total_count ?? 0); $monthlyCount = (int) ($monthStats->total_count ?? 0);
// 2. 本月分類統計(圓餅圖)- 保持集合操作即可 // 2. 本月分類統計(圓餅圖)- 保持集合操作即可
$categoryStats = Expense::with('category') $categoryStats = Expense::with('category')
->where('user_id', $userId) ->where('user_id', $userId)
->whereYear('date', $now->year) ->whereYear('date', $latestYear)
->whereMonth('date', $now->month) ->whereMonth('date', $latestMonth)
->get() ->get()
->groupBy(fn($e) => $e->category?->name ?? '未分類') ->groupBy(fn($e) => $e->category?->name ?? '未分類')
->map(fn($group, $name) => [ ->map(fn($group, $name) => [
@@ -86,12 +107,12 @@ class DashboardController extends Controller
$monthlyShare = (float) $expense->amount / $totalMonths; $monthlyShare = (float) $expense->amount / $totalMonths;
// 跑這 6 個月的迴圈,看有沒有落在這筆費用的歸屬區間內 // 跑這 6 個月的迴圈,看有沒有落在這筆費用的歸屬區間內
foreach ($monthlyTrendData as $monthStr => $currentTotal) { foreach ($monthlyTrendData as $monthStr => &$currentTotal) {
$currentMonthObj = Carbon::createFromFormat('Y/m', $monthStr)->startOfMonth(); $currentMonthObj = Carbon::createFromFormat('Y/m', $monthStr)->startOfMonth();
// 如果圖表上的這個月份,剛好在費用的歸屬起訖之內,就把每月平均金額灌進去! // 如果圖表上的這個月份,剛好在費用的歸屬起訖之內,就把每月平均金額灌進去!
if ($currentMonthObj->betweenIncluded($start, $end)) { if ($currentMonthObj->betweenIncluded($start, $end)) {
$monthlyTrendData[$monthStr] += $monthlyShare; $currentTotal += $monthlyShare;
} }
} }
} }

View File

@@ -46,6 +46,6 @@ class GoogleAuthController extends Controller
$token = $user->createToken('auth_token')->plainTextToken; $token = $user->createToken('auth_token')->plainTextToken;
// 把 token 帶回前端 // 把 token 帶回前端
return redirect("http://localhost:5173/#/auth/callback?token={$token}&name=" . urlencode($user->name)); return redirect(config('app.url') . "/#/auth/callback?token={$token}&name=" . urlencode($user->name));
} }
} }

View File

@@ -57,7 +57,8 @@ class UpdateAssetPriceJob implements ShouldQueue
$latestPrice = floatval($info['z'] ?? $info['o'] ?? 0); $latestPrice = floatval($info['z'] ?? $info['o'] ?? 0);
$latestPriceDate = isset($info['d']) ? Carbon::parse($info['d'])->format('Y-m-d') : now()->format('Y-m-d'); $latestPriceDate = isset($info['d']) ? Carbon::parse($info['d'])->format('Y-m-d') : now()->format('Y-m-d');
$name = $info['n'] ?? $this->defaultName; $name = $info['n'] ?? $this->defaultName;
// 忘了備份時用的
// $latestPriceDate = isset($info['d']) ? Carbon::parse($info['d'], 'Asia/Taipei')->subDay()->format('Y-m-d') : now()->subDay()->format('Y-m-d');
if (isset($info['y']) && $latestPrice > 0) { if (isset($info['y']) && $latestPrice > 0) {
$changeAmount = floatval($latestPrice - $info['y']); $changeAmount = floatval($latestPrice - $info['y']);
$changeRate = ($changeAmount / $info['y']) * 100; $changeRate = ($changeAmount / $info['y']) * 100;
@@ -88,7 +89,9 @@ class UpdateAssetPriceJob implements ShouldQueue
} }
if ($headerInfo) { if ($headerInfo) {
$latestPriceDate = isset($headerInfo['navDate']) ? substr($headerInfo['navDate'], 0, 10) : now()->format('Y-m-d'); $latestPriceDate = isset($headerInfo['navDate']) ? Carbon::parse($headerInfo['navDate'], 'Asia/Taipei')->format('Y-m-d') : now()->format('Y-m-d');
// 忘了備份時用的
// $latestPriceDate = isset($headerInfo['navDate']) ? Carbon::parse($headerInfo['navDate'], 'Asia/Taipei')->subDay()->format('Y-m-d') : now()->subDay()->format('Y-m-d');
$latestPrice = floatval($headerInfo['nav'] ?? 0); $latestPrice = floatval($headerInfo['nav'] ?? 0);
if ($latestPriceDate !== now()->format('Y-m-d')) { if ($latestPriceDate !== now()->format('Y-m-d')) {
@@ -124,7 +127,7 @@ class UpdateAssetPriceJob implements ShouldQueue
SecurityPriceHistories::updateOrCreate( SecurityPriceHistories::updateOrCreate(
[ [
'security_id' => $security->id, 'security_id' => $security->id,
'price_date' => now()->format('Y-m-d'), // 記錄今天的日期 'price_date' => $latestPriceDate, // 記錄今天的日期
], ],
[ [
'price' => $latestPrice, 'price' => $latestPrice,

View File

@@ -1,38 +1,16 @@
<?php <?php
return [ return [
'paths' => ['api/*', 'v1/*', 'auth/*', 'sanctum/csrf-cookie'],
/*
|-------------------------------------------------------------------------- // 🎯 暫時改成全放行星號,排除 cors.php 這一層的陣列比對干擾
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
*/
// 🟢 修正 1確保包含你要戳的所有 API 路徑(包含上傳)
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
// 🟢 修正 2允許前端傳入的 HTTP 方法GET, POST, PUT, DELETE 都要開)
'allowed_methods' => ['*'], 'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
// 🟢 修正 3精準允許你的 Vue 前端開發網址(看你前端是在 5173 還是其他埠號)
// 你也可以直接寫 ['*'] 偷懶大開,但在本地端寫特定網址更安全:
'allowed_origins' => ['http://localhost:5173', 'http://127.0.0.1:5173'],
'allowed_origins_patterns' => [], 'allowed_origins_patterns' => [],
// 🟢 修正 4上傳 FormData 時會帶有 Content-Type 等 Header必須全開
'allowed_headers' => ['*'], 'allowed_headers' => ['*'],
'exposed_headers' => [], 'exposed_headers' => [],
'max_age' => 0, 'max_age' => 0,
// 🟢 修正 5非常關鍵因為我們用了 Sanctum 驗證狀態,必須允許傳遞 Cookie/憑證 // 🎯 保持 true 以支援登入態 Cookie
'supports_credentials' => true, 'supports_credentials' => true,
];
];

35
develop.md Normal file
View File

@@ -0,0 +1,35 @@
# Fin-Buddy 專案開發進度與規劃
## ✅ 已完成階段
- [x] **容器架構部署**Laravel 與 PostgreSQL 成功運行於 `1panel-network`
- [x] **資料庫遷移**:順利整合至 1Panel 內建資料庫容器。
- [x] **環境對齊**:修正 Port 對應 (8081:9000) 及路由快取問題,系統穩定運行。
- [x] **端到端測試**:核心 API 流程驗證通過。
---
## ⏳ 待辦與優化清單
### 1. 系統清理與維運
- [ ] 移除舊容器與無用映像檔 (釋放空間)。
- [ ] 將 `web.php` 內危險的同步腳本遷移至 Laravel Artisan Command。
- [ ] 檢查 `.env` 安全性設定 (關閉 `APP_DEBUG`)。
- [ ] 配置 1Panel 資料庫自動備份任務。
---
## 🚀 未來 Roadmap
| 階段 | 項目 | 目標 |
| :--- | :--- | :--- |
| **I** | **APP 化 (PWA)** | 將前端轉換為 PWA實現手機桌面安裝與離線訪問。 |
| **I** | **記帳 Widget** | 實作快速記帳入口,點擊即錄入,簡化操作動線。 |
| **II** | **內容細化與排程** | 擴充 DB Schema納入「信用卡結帳日」、「發票排程」邏輯。 |
| **II** | **銀行與信用卡聯動** | 實作數據同步機制 (Open Banking / 自動抓取)。 |
| **III** | **後台與權限管理** | 導入 Filament 與 Spatie建立 API 測試後台與系統監控。 |
---
## 📝 優先級建議
1. **建議先做:**`sync-all-history` 正式化為 Command減少系統安全風險。
2. **開發策略:** 建議優先採用 **Filament** 進行後台開發,這能省去 80% 開發後台介面的時間,讓你直接專注於監控系統與 API 測試。

View File

@@ -12,7 +12,7 @@ services:
- APP_ENV=production - APP_ENV=production
- APP_DEBUG=false - APP_DEBUG=false
- DB_CONNECTION=pgsql - DB_CONNECTION=pgsql
- DB_HOST=finance-laravel-backend-postgres # 🎯 關鍵:改用資料庫的服務名稱當作 Host - DB_HOST=1Panel-postgresql-PXlc # 🎯 關鍵:改用資料庫的服務名稱當作 Host
- DB_PORT=5432 - DB_PORT=5432
- DB_DATABASE=myfinance - DB_DATABASE=myfinance
- DB_USERNAME=myfinance_henry4682 - DB_USERNAME=myfinance_henry4682
@@ -23,20 +23,20 @@ services:
- finance-postgres # 🎯 確保資料庫先開,後端才開 - finance-postgres # 🎯 確保資料庫先開,後端才開
# 🐬 2. 直接在這裡把消失的 PostgreSQL 生回來! # 🐬 2. 直接在這裡把消失的 PostgreSQL 生回來!
finance-postgres: # finance-postgres:
image: postgres:15-alpine # 使用穩定的 15-alpine 版本 # image: postgres:15-alpine # 使用穩定的 15-alpine 版本
container_name: finance-laravel-backend-postgres # container_name: finance-laravel-backend-postgres
restart: always # restart: always
ports: # ports:
- "5433:5432" # 🎯 把 5432 暴露給實體主機,這樣你本機的 pgAdmin 就能連進來了! # - "5433:5432" # 🎯 把 5432 暴露給實體主機,這樣你本機的 pgAdmin 就能連進來了!
environment: # environment:
- POSTGRES_DB=myfinance # - POSTGRES_DB=myfinance
- POSTGRES_USER=myfinance_henry4682 # - POSTGRES_USER=myfinance_henry4682
- POSTGRES_PASSWORD=DwmQ78PB # - POSTGRES_PASSWORD=DwmQ78PB
volumes: # volumes:
- finance_db_data:/var/lib/postgresql/data # 🎯 資料持久化,容器重啟資料也不會掉 # - finance_db_data:/var/lib/postgresql/data # 🎯 資料持久化,容器重啟資料也不會掉
networks: # networks:
- 1panel-network # - 1panel-network
networks: networks:
1panel-network: 1panel-network:
@@ -44,4 +44,4 @@ networks:
# 🎯 定義資料庫的儲存卷 # 🎯 定義資料庫的儲存卷
volumes: volumes:
finance_db_data: finance_db_data:

View File

@@ -1,18 +1,6 @@
<?php <?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ExpenseController;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\CategoryRuleController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\InvoiceImportController;
use App\Models\InvestmentHolding;
use App\Models\Security;
use App\Models\SecurityPriceHistories;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
@@ -25,151 +13,6 @@ Route::get('/', function () {
]); ]);
}); });
// Route::get('/dashboard', function () {
// return Inertia::render('Dashboard');
// })->middleware(['auth', 'verified'])->name('dashboard');
Route::get('/sync-all-history', function () {
set_time_limit(600);
// 🟢 1. 纯粹捞出持仓,不用多余的 with 关联
$holdings = InvestmentHolding::all();
$count = 0;
foreach ($holdings as $holding) {
// 🟢 2. 直接用持仓的 symbol 去匹配字典表,拿到真正需要的自增主键 id
$security = DB::table('securities')->where('code', $holding->symbol)->first();
if (!$security) {
// 如果字典表没有,顺手现场帮它开荒一笔,拿到新 id
$securityId = DB::table('securities')->insertGetId([
'code' => $holding->symbol,
'name' => $holding->name ?? $holding->symbol,
'type' => $holding->type,
'source' => $holding->type === 'stock' ? 'twse' : 'anue',
'latest_price' => floatval($holding->avg_cost ?? 0),
'created_at' => now(),
'updated_at' => now(),
]);
} else {
$securityId = $security->id;
}
// 找到最早的一笔买入交易作为历史起点
$firstTx = $holding->transactions()->where('action', 'buy')->orderBy('traded_at', 'asc')->first();
if (!$firstTx) continue;
$startDate = Carbon::parse($firstTx->traded_at);
$endDate = Carbon::now();
dump("正在同步 [{$holding->type}] {$holding->symbol} - {$holding->name} 的历史资料自 {$startDate->toDateString()} 起...");
// ----------------------------------------
// 股票 (Stock) 同步
// ----------------------------------------
if ($holding->type === 'stock') {
$current = $startDate->copy()->startOfMonth();
while ($current->lte($endDate)) {
$res = Http::get('https://www.twse.com.tw/rwd/zh/afterTrading/STOCK_DAY', [
'stockNo' => $holding->symbol,
'date' => $current->format('Ymd'),
]);
$data = $res->json();
if (isset($data['stat']) && $data['stat'] === 'OK' && !empty($data['data'])) {
foreach ($data['data'] as $row) {
[$y, $m, $d] = explode('/', $row[0]);
$dateStr = (1911 + (int)$y) . '-' . $m . '-' . $d;
if ($dateStr >= $startDate->toDateString()) {
$closePrice = (float) str_replace(',', '', $row[6]);
// 🟢 成功通过 $securityId 灌入历史表
DB::table('security_price_histories')->updateOrInsert(
[
'security_id' => $securityId,
'price_date' => $dateStr,
],
[
'price' => $closePrice,
'created_at' => now(),
'updated_at' => now(),
]
);
$count++;
}
}
}
$current->addMonth();
usleep(300000); // 防黑名单
}
}
// ----------------------------------------
// 基金 (Fund) 同步
// ----------------------------------------
elseif ($holding->type === 'fund') {
$res = Http::post("https://www.anuefund.com/anuefundApi/FundDetail/Price", [
'priceENUM' => 'NAVHIS',
'fundID' => $holding->symbol,
'strDate' => $startDate->format('Y-m-d'),
'endDate' => $endDate->format('Y-m-d'),
'chartFomatUse' => true,
'reload' => false
]);
if ($res->ok() && !empty($res->json('data'))) {
$pairs = json_decode($res->json('data'), true);
foreach ($pairs as $item) {
if ($item[1] === 0) continue;
$dateStr = date('Y-m-d', intval($item[0]) / 1000);
$navPrice = (float) $item[1];
// 🟢 成功通过 $securityId 灌入历史表
DB::table('security_price_histories')->updateOrInsert(
[
'security_id' => $securityId,
'price_date' => $dateStr,
],
[
'price' => $navPrice,
'created_at' => now(),
'updated_at' => now(),
]
);
$count++;
}
}
}
dump("▶ [{$holding->symbol}] 同步完成。");
}
return "🎉 历史资料回归本地资料库成功!共写录 {$count} 笔历史价格点。";
});
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/expenses/import', [InvoiceImportController::class, 'index'])->name('expenses.import');
Route::post('/expenses/import', [InvoiceImportController::class, 'store'])->name('expenses.import.store');
Route::delete('/expenses/batch', [ExpenseController::class, 'destroyBatch'])
->name('expenses.destroyBatch');
// 注意要放在 resource 路由之前!
Route::resource('expenses', ExpenseController::class)
->only(['index', 'store', 'update', 'destroy']);
Route::resource('categories', CategoryController::class)
->only(['index', 'store', 'update', 'destroy']);
Route::resource('category-rules', CategoryRuleController::class)
->only(['store', 'destroy']);
});
require __DIR__ . '/auth.php'; require __DIR__ . '/auth.php';

11861
storage/logs/laravel.log Normal file

File diff suppressed because it is too large Load Diff