12 Commits

Author SHA1 Message Date
keven1024
60d62da572 feat(worker): initialize Asynq in main function for task processing 2026-06-03 17:05:10 +08:00
keven1024
3757ed606f fix(publish): remove conditional enablement for edge tag in workflow configuration 2026-06-02 08:59:08 +08:00
keven1024
26f1c52198 feat(config): add SMTP configuration options for email setup in config.example.yaml 2026-06-02 08:58:43 +08:00
keven1024
64f3d2e1d5 Merge pull request #42 from TrapStoner/fix/redis-startup-retry
fix(redis): retry with exponential backoff on startup
2026-06-01 10:56:55 +08:00
TrapStoner
9d125ba9bd fix(redis): retry with exponential backoff on startup
rueidis.NewClient pings Redis immediately; if the container starts
before Redis is ready the backend fatals. Retry up to 10 times with
exponential backoff (300ms → 810ms → 2.2s → … capped at 15s).
2026-05-29 02:58:46 +03:00
keven1024
2e2698e281 feat(janitor): implement daily file janitor task to clean up orphaned files and expired uploads 2026-05-25 13:43:45 +08:00
keven1024
ab0587bd4d feat(tasks): add file janitor task for cleaning up unused files and schedule it 2026-05-25 13:43:30 +08:00
keven1024
ed9d39301f refactor(tasks): streamline file removal logic and enhance temporary file handling 2026-05-25 12:06:11 +08:00
keven
f1e956ad4c Merge pull request 'dev/0.11' (#3) from dev/0.11 into main
Reviewed-on: https://gitea.fudaoyuan.icu/keven/015/pulls/3
2026-05-24 18:21:29 +08:00
keven1024
7793bef944 fix(FileShareResult): set staleTime to Infinity for create-share query to prevent data refetching 2026-05-24 18:20:28 +08:00
keven1024
1fdd05f0ea feat(Tiptap): add internationalization support for word and length display in multiple languages 2026-05-24 18:18:20 +08:00
keven1024
7128a8c329 feat(Toaster): implement Sonner component for enhanced notification functionality and update import path in default layout 2026-05-24 17:55:19 +08:00
24 changed files with 401 additions and 31 deletions

View File

@@ -22,7 +22,7 @@ jobs:
images: fudaoyuanicu/015-app images: fudaoyuanicu/015-app
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=raw,value=edge,enable=${{ contains(github.ref_name, '-') }} type=raw,value=edge
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
- name: Set build time - name: Set build time
id: build-time id: build-time
@@ -58,7 +58,7 @@ jobs:
images: fudaoyuanicu/015-worker images: fudaoyuanicu/015-worker
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=raw,value=edge,enable=${{ contains(github.ref_name, '-') }} type=raw,value=edge
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
- name: Set build time - name: Set build time
id: build-time id: build-time

View File

@@ -64,3 +64,10 @@ about:
name: keven name: keven
url: 'https://fudaoyuan.icu' url: 'https://fudaoyuan.icu'
avatar: '' avatar: ''
smtp:
host: example.com # SMTP服务器地址
port: 465 # SMTP端口号通常为465(SSL)或587(TLS)
protocol: ssl # ssl or tls
username: your@example.com # 发送方邮箱
password: your-password # 发送方邮箱密码/授权码

View File

@@ -21,6 +21,7 @@ const { t } = useI18n()
const { createFileShare } = useMyAppShare() const { createFileShare } = useMyAppShare()
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['create-share', ...props?.data?.files?.map((item) => item.id)], queryKey: ['create-share', ...props?.data?.files?.map((item) => item.id)],
staleTime: Infinity,
queryFn: async () => { queryFn: async () => {
const { files, config } = props?.data || {} const { files, config } = props?.data || {}
const data = await createFileShare({ const data = await createFileShare({

View File

@@ -5,6 +5,7 @@ import { Markdown } from 'tiptap-markdown'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import { cx } from 'class-variance-authority' import { cx } from 'class-variance-authority'
import countWords from '@/lib/countWords' import countWords from '@/lib/countWords'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue?: string modelValue?: string
@@ -64,6 +65,6 @@ onUnmounted(() => {
v-if="modelValue?.length && modelValue?.length > 0" v-if="modelValue?.length && modelValue?.length > 0"
class="absolute bottom-2 right-3 flex justify-end px-2 py-1 text-xs text-gray-400 select-none bg-white rounded-md" class="absolute bottom-2 right-3 flex justify-end px-2 py-1 text-xs text-gray-400 select-none bg-white rounded-md"
> >
{{ `${modelValue?.length ?? 0} 长度 · ${countWords(modelValue ?? '')} 字符` }} {{ `${modelValue?.length ?? 0} ${t('common.length')} · ${countWords(modelValue ?? '')} ${t('common.words')}` }}
</div> </div>
</template> </template>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { ToasterProps } from 'vue-sonner'
import 'vue-sonner/style.css'
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from '@lucide/vue'
import { Toaster as Sonner } from 'vue-sonner'
import { cn } from '@/lib/utils'
const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner
:class="cn('toaster group', props.class)"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}"
v-bind="props"
>
<template #success-icon>
<CircleCheckIcon class="size-4" />
</template>
<template #info-icon>
<InfoIcon class="size-4" />
</template>
<template #warning-icon>
<TriangleAlertIcon class="size-4" />
</template>
<template #error-icon>
<OctagonXIcon class="size-4" />
</template>
<template #loading-icon>
<div>
<Loader2Icon class="size-4 animate-spin" />
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
</template>
</Sonner>
</template>

View File

@@ -0,0 +1 @@
export { default as Toaster } from './Sonner.vue'

View File

@@ -38,7 +38,9 @@
}, },
"common": { "common": {
"add": "Hinzufügen", "add": "Hinzufügen",
"copySuccess": "Erfolgreich kopiert" "copySuccess": "Erfolgreich kopiert",
"length": "Länge",
"words": "Wörter"
}, },
"page": { "page": {
"upload": { "upload": {

View File

@@ -38,7 +38,9 @@
}, },
"common": { "common": {
"add": "Add", "add": "Add",
"copySuccess": "Copy Success" "copySuccess": "Copy Success",
"length": "length",
"words": "words"
}, },
"page": { "page": {
"upload": { "upload": {

View File

@@ -38,7 +38,9 @@
}, },
"common": { "common": {
"add": "Ajouter", "add": "Ajouter",
"copySuccess": "Copié avec succès" "copySuccess": "Copié avec succès",
"length": "longueur",
"words": "mots"
}, },
"page": { "page": {
"upload": { "upload": {

View File

@@ -38,7 +38,9 @@
}, },
"common": { "common": {
"add": "追加", "add": "追加",
"copySuccess": "コピーしました" "copySuccess": "コピーしました",
"length": "長さ",
"words": "文字"
}, },
"page": { "page": {
"upload": { "upload": {

View File

@@ -38,7 +38,9 @@
}, },
"common": { "common": {
"add": "추가", "add": "추가",
"copySuccess": "복사되었습니다" "copySuccess": "복사되었습니다",
"length": "길이",
"words": "단어"
}, },
"page": { "page": {
"upload": { "upload": {

View File

@@ -38,7 +38,9 @@
}, },
"common": { "common": {
"add": "添加", "add": "添加",
"copySuccess": "复制成功" "copySuccess": "复制成功",
"length": "长度",
"words": "字符"
}, },
"page": { "page": {
"upload": { "upload": {

View File

@@ -38,7 +38,9 @@
}, },
"common": { "common": {
"add": "新增", "add": "新增",
"copySuccess": "複製成功" "copySuccess": "複製成功",
"length": "長度",
"words": "字符"
}, },
"page": { "page": {
"upload": { "upload": {

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Toaster } from 'vue-sonner' import { Toaster } from '@/components/ui/sonner'
const { locale } = useI18n() const { locale } = useI18n()
await useSeo({ locale: locale.value }) await useSeo({ locale: locale.value })
const appConfig = useMyAppConfig() const appConfig = useMyAppConfig()

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-25

View File

@@ -0,0 +1,69 @@
## Context
worker 目前所有檔案清理都依賴 `file:remove` 延遲任務,由業務邏輯在適當時機主動排程(上傳完成後設定 TTL、share 刪除時觸發)。若任務在排程前 worker 重啟、Redis 任務丟失或業務邏輯有 bug檔案就會永久殘留在磁碟上。`asynq` 已提供 `Scheduler` 元件支援 cron 排程,無需引入新依賴。
## Goals / Non-Goals
**Goals:**
- 每天凌晨自動掃描並清理三類漏網檔案
- 不重複造輪子,清理邏輯複用現有 `file:remove` 任務
- 不影響現有任務流程與 API
**Non-Goals:**
- 不處理 `Expire == 0` 的特殊情況
- 不清理 `fileShareRelational` 裡的孤兒 share 條目
- 不提供手動觸發的 HTTP 介面
## Decisions
**1. 使用 `asynq.Scheduler` 而非外部 cron排程時間為每天凌晨 03:00**
`asynq.Scheduler` 在同一 worker 程式內以獨立 goroutine 運行,透過 Redis 做 leader election避免多實例重複觸發。外部 cron 需要額外基礎設施。選擇 Schedulercron 表達式為 `0 3 * * *`
```
main.go
├── asynq.NewServer(...) // 處理任務
├── asynq.NewScheduler(...) // 排程 file:janitor @ 03:00
└── mux.HandleFunc("file:janitor", tasks.FileJanitor)
```
**2. `FileJanitor` 函式放在 `worker/internal/tasks/file.go`,超過 300 行才拆資料夾**
目前 `file.go` 為 50 行,加入 janitor 後預計約 100 行,遠低於 300 行門檻,直接寫入 `file.go`。若未來該檔超過 300 行,則改為 `worker/internal/tasks/file/` 目錄,拆成 `janitor.go`(清理邏輯)與 `remove.go`(原 `RemoveFile` 邏輯)。
**3. Case 1孤兒本地檔直接 `os.RemoveAll`,不走 `file:remove`**
這類檔案在 fileInfoMap 中不存在,`RemoveFile` handler 會直接 return nil找不到 fileInfo`file:remove` 無法刪除磁碟檔案。必須直接刪除。
**4. Case 2、3 透過 `SetFileRemoveTask(id, 0)` 委派**
複用 `RemoveFile` 現有邏輯(含 share 二次確認避免重複實作。delay=0 表示立即處理。
**5. 掃描策略:一次性全量讀取**
- 本地目錄:`os.ReadDir(uploadPath)` 取得所有檔名(即 fileId
- Redis`GetRedisFileInfoAll()` 一次取得完整 fileInfoMap
- 兩者建立 map 後交叉比對,時間複雜度 O(n)
```
本地檔案 set ────┐
├─ 差集 → Case 1
Redis fileInfoMap ┘
Redis fileInfoMap ─ 遍歷 ─┬─ type==init && expired → Case 2
└─ type==already && no share → Case 3
```
**6. Case 3 的 share 關係查詢**
對每個 `type==already` 的 fileId 呼叫 `GetRedisFileShareRelational(fileId)`,若回傳空 slice 則觸發刪除。此為 O(n) Redis 查詢,數量級與檔案數相同,凌晨低峰期執行可接受。
## Risks / Trade-offs
- **誤刪風險Case 1**:本地檔案剛建立但 fileInfoMap 尚未寫入的時間窗口(上傳初始化中)。→ 凌晨執行,正在上傳中的檔案其 init 記錄通常已存在 Redis風險極低。
- **Redis 查詢量Case 3**:若檔案數量龐大,逐一查詢 fileShareRelational 會產生大量 Redis 請求。→ 目前是輕量平台,可接受;未來可改用 `HGETALL fileShareRelational` 一次取得再做本地比對。
- **Scheduler 單點**`asynq.Scheduler` 依賴 Redis leader electionRedis 不可用時排程失敗。→ 可接受Redis 不可用時整個 worker 本就無法運作。
## Migration Plan
直接部署,無資料遷移。首次執行會清理歷史殘留檔案,屬於預期行為。

View File

@@ -0,0 +1,24 @@
## Why
現有的檔案清理機制依賴 asynq 延遲任務(`file:remove`在異常情況下worker 重啟、任務丟失、Redis 資料不一致)可能導致本地磁碟殘留孤兒檔案,長期累積佔用儲存空間。需要一個每日定期掃描的兜底清理機制。
## What Changes
- 新增 `file:janitor` asynq 任務處理函式(`worker/internal/tasks/janitor.go`
-`worker/main.go` 中加入 `asynq.Scheduler`,每天凌晨 00:00 自動排程 `file:janitor`
-`worker/main.go` 的 mux 中註冊 `file:janitor` handler
## Capabilities
### New Capabilities
- `file-janitor`: 每日定期掃描並清理三類漏網檔案:本地有但 fileInfoMap 無的孤兒檔、init 狀態已過期的未完成上傳、已完成上傳但無任何 share 關係的孤立檔
### Modified Capabilities
## Impact
- **worker/internal/tasks/**:新增 `janitor.go`
- **worker/main.go**:新增 `asynq.Scheduler``file:janitor` handler 註冊
- 不影響任何 API 或前端
- 無新增外部依賴(`asynq.Scheduler` 已在現有依賴中)

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: 每日定期排程 file:janitor 任務
系統 SHALL 在每天凌晨 00:00伺服器本地時間自動排程一次 `file:janitor` asynq 任務。多個 worker 實例並存時,系統 SHALL 保證同一時間只有一個實例觸發排程(透過 asynq.Scheduler 的 Redis leader election 機制)。
#### Scenario: 單實例正常排程
- **WHEN** worker 啟動且時間到達每天 00:00
- **THEN** asynq.Scheduler 將 `file:janitor` 任務加入 asynq 佇列一次
#### Scenario: 多實例防重複觸發
- **WHEN** 多個 worker 實例同時運行且時間到達 00:00
- **THEN** 只有一個實例成功排程,其餘實例因 leader election 未獲鎖而跳過
### Requirement: 清理本地孤兒檔案(無 fileInfoMap 記錄)
系統 SHALL 掃描本地上傳目錄,對每個存在於磁碟但在 Redis `fileInfoMap` 中無對應記錄的檔案,直接執行 `os.RemoveAll` 刪除。
#### Scenario: 刪除孤兒本地檔案
- **WHEN** 本地上傳目錄中存在 fileId 目錄,且 Redis fileInfoMap 中無該 fileId 的記錄
- **THEN** 系統直接刪除該本地目錄
#### Scenario: 保留有 fileInfoMap 記錄的檔案
- **WHEN** 本地上傳目錄中存在 fileId 目錄,且 Redis fileInfoMap 中有該 fileId 的記錄
- **THEN** 系統不刪除該本地目錄,繼續處理下一個
### Requirement: 清理已過期的 init 狀態檔案
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "init"` 且滿足 `CreatedAt + Expire < 當前 Unix 時間戳` 的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
#### Scenario: 排程刪除已過期 init 檔案
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire < now() 的記錄
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
#### Scenario: 保留未過期的 init 檔案
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire >= now() 的記錄
- **THEN** 系統不排程刪除,繼續處理下一個
### Requirement: 清理無 share 關係的已完成上傳檔案
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "already"` 且在 Redis `fileShareRelational` 中無任何 share 關係的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
#### Scenario: 排程刪除無 share 關係的已完成檔案
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 對應的 shareId 列表為空或不存在
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
#### Scenario: 保留有 share 關係的已完成檔案
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 有一個或以上 shareId
- **THEN** 系統不排程刪除,繼續處理下一個

View File

@@ -0,0 +1,19 @@
## 1. 新增 FileJanitor 任務處理函式
- [x] 1.1 在 `worker/internal/tasks/file.go` 末尾新增 `FileJanitor(ctx context.Context, task *asynq.Task) error`(若加入後超過 300 行,則改建 `worker/internal/tasks/file/` 目錄,將 `RemoveFile` 移至 `remove.go``FileJanitor` 放至 `janitor.go`
- [x] 1.2 在 `FileJanitor` 中呼叫 `u.GetUploadDirPath()` 取得本地上傳目錄路徑,並用 `os.ReadDir` 掃描目錄取得本地檔案清單
- [x] 1.3 呼叫 `models.GetRedisFileInfoAll()` 取得 fileInfoMap 全量資料,建立 fileId set
- [x] 1.4 實作 Case 1本地存在但 fileInfoMap 無記錄的 fileId呼叫 `os.RemoveAll(filePath)` 直接刪除
- [x] 1.5 遍歷 fileInfoMap實作 Case 2`FileType == "init"``CreatedAt + Expire < time.Now().Unix()`,呼叫 `pkgservices.SetFileRemoveTask(fileId, 0)`
- [x] 1.6 遍歷 fileInfoMap實作 Case 3`FileType == "already"``GetRedisFileShareRelational(fileId)` 回傳空 slice呼叫 `pkgservices.SetFileRemoveTask(fileId, 0)`
## 2. 在 worker/main.go 中整合
- [x] 2.1 在 `mux` 中註冊 `mux.HandleFunc("file:janitor", tasks.FileJanitor)`
- [x] 2.2 建立 `asynq.NewScheduler`,使用與 `asynq.NewServer` 相同的 Redis 連線設定
- [x] 2.3 呼叫 `scheduler.Register("0 3 * * *", asynq.NewTask("file:janitor", nil))` 設定每日凌晨 03:00 排程
- [x] 2.4 在 `srv.Run(mux)` 之前啟動 `scheduler.Start()`,並在程式結束時呼叫 `scheduler.Shutdown()`
## 3. 驗證
- [x] 3.1 在 `worker/` 目錄下執行 `go build ./...`,確認編譯通過

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: 每日定期排程 file:janitor 任務
系統 SHALL 在每天凌晨 03:00伺服器本地時間自動排程一次 `file:janitor` asynq 任務。多個 worker 實例並存時,系統 SHALL 保證同一時間只有一個實例觸發排程(透過 asynq.Scheduler 的 Redis leader election 機制)。
#### Scenario: 單實例正常排程
- **WHEN** worker 啟動且時間到達每天 03:00
- **THEN** asynq.Scheduler 將 `file:janitor` 任務加入 asynq 佇列一次
#### Scenario: 多實例防重複觸發
- **WHEN** 多個 worker 實例同時運行且時間到達 03:00
- **THEN** 只有一個實例成功排程,其餘實例因 leader election 未獲鎖而跳過
### Requirement: 清理本地孤兒檔案(無 fileInfoMap 記錄)
系統 SHALL 掃描本地上傳目錄,對每個存在於磁碟但在 Redis `fileInfoMap` 中無對應記錄的資料夾,直接執行 `os.RemoveAll` 刪除。臨時上傳資料夾命名格式為 `<fileId>_tmp`,系統 SHALL 將 `_tmp` 後綴去除後再查詢 fileInfoMap以判斷對應的 fileId 是否存在。
#### Scenario: 刪除孤兒本地資料夾
- **WHEN** 本地上傳目錄中存在名為 `<fileId>``<fileId>_tmp` 的資料夾,且 Redis fileInfoMap 中無該 fileId 的記錄
- **THEN** 系統直接刪除該本地資料夾(保留原始名稱,不去除 `_tmp`
#### Scenario: 保留有 fileInfoMap 記錄的資料夾
- **WHEN** 本地上傳目錄中存在名為 `<fileId>``<fileId>_tmp` 的資料夾,且 Redis fileInfoMap 中有該 fileId 的記錄
- **THEN** 系統不刪除該資料夾,繼續處理下一個
### Requirement: 清理已過期的 init 狀態檔案
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "init"` 且滿足 `CreatedAt + Expire < 當前 Unix 時間戳` 的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
#### Scenario: 排程刪除已過期 init 檔案
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire < now() 的記錄
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
#### Scenario: 保留未過期的 init 檔案
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire >= now() 的記錄
- **THEN** 系統不排程刪除,繼續處理下一個
### Requirement: 清理無 share 關係的已完成上傳檔案
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "already"` 且在 Redis `fileShareRelational` 中無任何 share 關係的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
#### Scenario: 排程刪除無 share 關係的已完成檔案
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 對應的 shareId 列表為空或不存在
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
#### Scenario: 保留有 share 關係的已完成檔案
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 有一個或以上 shareId
- **THEN** 系統不排程刪除,繼續處理下一個

View File

@@ -1,22 +1,52 @@
package utils package utils
import ( import (
"fmt"
"log/slog"
"math"
"time"
"github.com/redis/rueidis" "github.com/redis/rueidis"
) )
var rdb rueidis.Client var rdb rueidis.Client
const (
redisMaxRetries = 10
redisBaseDelay = 300 * time.Millisecond
redisBackoffFactor = 2.7
redisMaxDelay = 15 * time.Second
)
func InitRedis() error { func InitRedis() error {
opt, err := rueidis.ParseURL(GetEnv("redis.url")) opt, err := rueidis.ParseURL(GetEnv("redis.url"))
if err != nil { if err != nil {
return err return fmt.Errorf("invalid redis url: %w", err)
} }
client, err := rueidis.NewClient(opt)
if err != nil { var lastErr error
return err for attempt := range redisMaxRetries {
var client rueidis.Client
client, lastErr = rueidis.NewClient(opt)
if lastErr == nil {
rdb = client
return nil
}
if attempt == redisMaxRetries-1 {
break
}
delay := time.Duration(math.Min(
float64(redisBaseDelay)*math.Pow(redisBackoffFactor, float64(attempt)),
float64(redisMaxDelay),
))
slog.Warn("redis connection failed, retrying",
"attempt", attempt+1,
"maxRetries", redisMaxRetries,
"retryIn", delay.String(),
"error", lastErr,
)
time.Sleep(delay)
} }
rdb = client return fmt.Errorf("redis connection failed after %d attempts: %w", redisMaxRetries, lastErr)
return nil
} }
func GetRedisClient() rueidis.Client { func GetRedisClient() rueidis.Client {

View File

@@ -6,7 +6,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"pkg/models" "pkg/models"
pkgservices "pkg/services"
u "pkg/utils" u "pkg/utils"
"strings"
"time"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
) )
@@ -40,6 +43,10 @@ func RemoveFile(ctx context.Context, task *asynq.Task) error {
return err return err
} }
filePath := filepath.Join(uploadPath, payload.FileId) filePath := filepath.Join(uploadPath, payload.FileId)
// 如果是临时文件删除文件夹
if fileInfo.FileType == models.FileTypeInit {
filePath += "_tmp"
}
if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileInfoMap").Field(payload.FileId).Build()).Error(); err != nil { if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileInfoMap").Field(payload.FileId).Build()).Error(); err != nil {
return err return err
} }
@@ -48,3 +55,63 @@ func RemoveFile(ctx context.Context, task *asynq.Task) error {
} }
return nil return nil
} }
func FileJanitor(_ context.Context, _ *asynq.Task) error {
uploadPath, err := u.GetUploadDirPath()
if err != nil {
return err
}
entries, err := os.ReadDir(uploadPath)
if err != nil {
return err
}
allFileInfo, err := models.GetRedisFileInfoAll()
if err != nil {
return err
}
// Case 1: 本地有但 fileInfoMap 無 → 直接刪除
for _, entry := range entries {
name := entry.Name()
fileId := strings.TrimSuffix(name, "_tmp")
if _, exists := allFileInfo[fileId]; !exists {
if err := os.RemoveAll(filepath.Join(uploadPath, name)); err != nil {
return err
}
}
}
// Case 2 & 3: 遍歷 fileInfoMap
now := time.Now().Unix()
for fileId, rawInfo := range allFileInfo {
var info models.RedisFileInfo
if err := json.Unmarshal([]byte(rawInfo), &info); err != nil {
continue
}
// Case 2: init 狀態且已過期
if info.FileType == models.FileTypeInit && info.CreatedAt+info.Expire < now {
if err := pkgservices.SetFileRemoveTask(fileId, 0); err != nil {
return err
}
continue
}
// Case 3: 已完成上傳但無 share 關係
if info.FileType == models.FileTypeUpload {
shareIDs, err := models.GetRedisFileShareRelational(fileId)
if err != nil {
return err
}
if len(shareIDs) == 0 {
if err := pkgservices.SetFileRemoveTask(fileId, 0); err != nil {
return err
}
}
}
}
return nil
}

View File

@@ -5,10 +5,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath"
"pkg/geoip" "pkg/geoip"
"pkg/models" "pkg/models"
pkgservices "pkg/services"
u "pkg/utils" u "pkg/utils"
"worker/internal/services" "worker/internal/services"
@@ -35,21 +34,10 @@ func RemoveShare(ctx context.Context, task *asynq.Task) error {
}) })
if len(shareIDs) == 0 { if len(shareIDs) == 0 {
rdb := u.GetRedisClient() rdb := u.GetRedisClient()
uploadPath, err := u.GetUploadDirPath()
if err != nil {
return err
}
filePath := filepath.Join(uploadPath, payload.FileId)
if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileShareRelational").Field(payload.FileId).Build()).Error(); err != nil { if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileShareRelational").Field(payload.FileId).Build()).Error(); err != nil {
return err return err
} }
if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileInfoMap").Field(payload.FileId).Build()).Error(); err != nil { return pkgservices.SetFileRemoveTask(payload.FileId, 0)
return err
}
if err := os.RemoveAll(filePath); err != nil {
return err
}
return nil
} }
if err := models.SetRedisFileShareRelational(payload.FileId, shareIDs); err != nil { if err := models.SetRedisFileShareRelational(payload.FileId, shareIDs); err != nil {
return err return err

View File

@@ -28,6 +28,10 @@ func main() {
logger.Fatal("redis init failed", zap.Error(err)) logger.Fatal("redis init failed", zap.Error(err))
panic(err) panic(err)
} }
if err := utils.InitAsynq(); err != nil {
logger.Fatal("asynq init failed", zap.Error(err))
panic(err)
}
if err := i18n.Init(); err != nil { if err := i18n.Init(); err != nil {
log.Fatalf("failed to init i18n: %v", err) log.Fatalf("failed to init i18n: %v", err)
@@ -46,10 +50,20 @@ func main() {
mux.HandleFunc("share:remove", tasks.RemoveShare) mux.HandleFunc("share:remove", tasks.RemoveShare)
mux.HandleFunc("share:notify", tasks.ShareNotify) mux.HandleFunc("share:notify", tasks.ShareNotify)
mux.HandleFunc("file:remove", tasks.RemoveFile) mux.HandleFunc("file:remove", tasks.RemoveFile)
mux.HandleFunc("file:janitor", tasks.FileJanitor)
mux.HandleFunc("image:compress", tasks.CompressImage) mux.HandleFunc("image:compress", tasks.CompressImage)
mux.HandleFunc("image:convert", tasks.ConvertImage) mux.HandleFunc("image:convert", tasks.ConvertImage)
mux.HandleFunc("text:translate", tasks.TranslateText) mux.HandleFunc("text:translate", tasks.TranslateText)
scheduler := asynq.NewScheduler(utils.RedisURI2AsynqOpt(utils.GetEnv("redis.url")), nil)
if _, err := scheduler.Register("0 3 * * *", asynq.NewTask("file:janitor", nil)); err != nil {
log.Fatalf("could not register scheduler: %v", err)
}
if err := scheduler.Start(); err != nil {
log.Fatalf("could not start scheduler: %v", err)
}
defer scheduler.Shutdown()
if err := srv.Run(mux); err != nil { if err := srv.Run(mux); err != nil {
log.Fatalf("could not run server: %v", err) log.Fatalf("could not run server: %v", err)
} }