pjpjq commited on
Commit
b034029
·
verified ·
1 Parent(s): eaef278

fix: build usage keeper from source

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +20 -2
  2. .env.example +75 -0
  3. .gitattributes +1 -0
  4. .github/workflows/binary-dev-release.yml +304 -0
  5. .github/workflows/binary-release.yml +251 -0
  6. .github/workflows/ci.yml +76 -0
  7. .github/workflows/docker-dev-publish.yml +91 -0
  8. .github/workflows/docker-publish.yml +54 -0
  9. .gitignore +46 -0
  10. CONTRIBUTORS.md +7 -0
  11. Dockerfile +32 -12
  12. LICENSE +21 -0
  13. Makefile +16 -0
  14. README.en.md +223 -0
  15. README.md +218 -13
  16. cmd/server/main.go +28 -0
  17. docker-compose.example.yml +28 -0
  18. docker-entrypoint.sh +32 -0
  19. go.mod +43 -0
  20. go.sum +104 -0
  21. internal/api/auth.go +199 -0
  22. internal/api/auth_test.go +225 -0
  23. internal/api/errors.go +15 -0
  24. internal/api/health.go +13 -0
  25. internal/api/pricing.go +145 -0
  26. internal/api/pricing_test.go +144 -0
  27. internal/api/router.go +272 -0
  28. internal/api/router_test.go +321 -0
  29. internal/api/usage_analysis.go +126 -0
  30. internal/api/usage_analysis_test.go +128 -0
  31. internal/api/usage_credentials.go +93 -0
  32. internal/api/usage_events.go +245 -0
  33. internal/api/usage_events_test.go +295 -0
  34. internal/api/usage_filter.go +141 -0
  35. internal/api/usage_filter_test.go +209 -0
  36. internal/api/usage_identities.go +125 -0
  37. internal/api/usage_identities_test.go +144 -0
  38. internal/api/usage_overview.go +335 -0
  39. internal/api/usage_overview_test.go +196 -0
  40. internal/api/usage_resolution.go +17 -0
  41. internal/api/usage_source_resolution.go +167 -0
  42. internal/app/app.go +217 -0
  43. internal/app/app_test.go +239 -0
  44. internal/app/backup_runner.go +218 -0
  45. internal/app/backup_runner_test.go +264 -0
  46. internal/app/maintenance.go +98 -0
  47. internal/app/maintenance_test.go +107 -0
  48. internal/app/manual_sync.go +60 -0
  49. internal/app/manual_sync_test.go +100 -0
  50. internal/app/metadata_sync.go +74 -0
.dockerignore CHANGED
@@ -1,4 +1,22 @@
 
1
  .git
2
- __pycache__
3
- *.pyc
 
 
 
 
4
  .DS_Store
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # git
2
  .git
3
+ .gitignore
4
+
5
+ # local env and editor files
6
+ .env
7
+ .env.*
8
+ !.env.example
9
  .DS_Store
10
+
11
+ # runtime data
12
+ /data/
13
+ *.db
14
+ *.db-*
15
+ backups/
16
+
17
+ # frontend deps and build cache
18
+ web/node_modules/
19
+ web/dist/
20
+
21
+ # docs / temp
22
+ .tmp-test.db
.env.example ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CPA 服务基础地址。必填:是。默认值:无。
2
+ # Base URL of the CPA service. Required: yes. Default: none.
3
+ CPA_BASE_URL=http://127.0.0.1:8317
4
+
5
+ # CPA management key,用于拉取管理接口数据。必填:是。默认值:无。
6
+ # CPA management key used to access management endpoints. Required: yes. Default: none.
7
+ CPA_MANAGEMENT_KEY=replace-with-your-management-key
8
+
9
+ # 是否启用登录保护。必填:否。默认值:false。
10
+ # Whether to enable login protection. Required: no. Default: false.
11
+ AUTH_ENABLED=false
12
+
13
+ # 登录密码;仅在启用登录保护时必填。默认值:无。
14
+ # Login password; required only when login protection is enabled. Default: none.
15
+ LOGIN_PASSWORD=replace-with-your-login-password
16
+
17
+ # 登录 session 的有效时长。必填:否。默认值:168h。
18
+ # Lifetime of the login session. Required: no. Default: 168h.
19
+ AUTH_SESSION_TTL=168h
20
+
21
+ # HTTP 服务监听端口。必填:否。默认值:8080。
22
+ # HTTP listen port for the application. Required: no. Default: 8080.
23
+ APP_PORT=8080
24
+
25
+ # 留空表示部署在根路径 /;如需部署到子路径,必须以 / 开头,例如 /cpa。允许写成 /cpa/,程序会自动规范成 /cpa;不建议写成不带前导斜杠的 cpa。必填:否。默认值:空(根路径 /)。
26
+ # Leave empty to serve from /. For subpath deployment, the value must start with /, for example /cpa. A trailing slash like /cpa/ is accepted and normalized to /cpa; a value without the leading slash such as cpa is invalid. Required: no. Default: empty (root path /).
27
+ APP_BASE_PATH=
28
+
29
+ # 项目业务时区,影响 Today、按天聚合、每日 03:00 清理和日志时间。必填:否。默认值:Asia/Shanghai。
30
+ # Project business timezone. Affects Today, daily aggregation, daily 03:00 cleanup, and log timestamps. Required: no. Default: Asia/Shanghai.
31
+ TZ=Asia/Shanghai
32
+
33
+ # CPA management data stream 的 Redis/RESP TCP 地址。留空时默认使用 CPA_BASE_URL 的主机名加 8317 端口;如果通过 nginx stream 暴露到其它端口,请显式填写 host:port。
34
+ # Redis/RESP TCP address for the CPA management data stream. When empty, defaults to the CPA_BASE_URL hostname plus port 8317; set host:port explicitly when exposed through nginx stream on another port.
35
+ REDIS_QUEUE_ADDR=
36
+
37
+ # 每次 Redis LPOP 最多拉取的队列记录数。redis/auto 模式会持续 drain,非空批次会立即继续拉取。必填:否。默认值:1000。
38
+ # Maximum queue records per Redis LPOP. redis/auto modes continuously drain and immediately continue after a non-empty batch. Required: no. Default: 1000.
39
+ REDIS_QUEUE_BATCH_SIZE=1000
40
+
41
+ # Redis 队列为空时的 idle 检查间隔。必填:否。默认值:1s。
42
+ # Idle check interval when the Redis queue is empty. Required: no. Default: 1s.
43
+ REDIS_QUEUE_IDLE_INTERVAL=1s
44
+
45
+ # 请求 CPA 接口时的超时时间。必填:否。默认值:30s。
46
+ # Timeout for requests to the CPA service. Required: no. Default: 30s.
47
+ REQUEST_TIMEOUT=30s
48
+
49
+ # 应用工作目录,SQLite 数据库、日志和备份默认都写入这里。Docker 中默认工作目录为 /,因此 ./data 对应 /data;二进制通过 --env 或同目录 .env 加载时,./data 对应 env 文件同目录的 data 子目录。必填:否。默认值:./data。
50
+ # Application work directory. SQLite database, logs, and backups are stored here by default. In Docker the runtime working directory is /, so ./data maps to /data; for binaries loaded with --env or package-local .env, ./data maps to a data directory next to the env file. Required: no. Default: ./data.
51
+ WORK_DIR=./data
52
+
53
+ # 应用日志级别。必填:否。默认值:info。
54
+ # Application log level. Required: no. Default: info.
55
+ LOG_LEVEL=info
56
+
57
+ # 是否写入持久化日志文件。必填:否。默认值:true。
58
+ # Whether to write persistent log files. Required: no. Default: true.
59
+ LOG_FILE_ENABLED=true
60
+
61
+ # 日志文件保留天数;0 表示不自动清理。必填:否。默认值:7。
62
+ # Number of days to retain log files; 0 disables cleanup. Required: no. Default: 7.
63
+ LOG_RETENTION_DAYS=7
64
+
65
+ # 是否启用 SQLite 数据库备份。必填:否。默认值:true。
66
+ # Whether to enable SQLite database backups. Required: no. Default: true.
67
+ BACKUP_ENABLED=true
68
+
69
+ # 数据库备份间隔。必填:否。默认值:24h。
70
+ # Database backup interval. Required: no. Default: 24h.
71
+ BACKUP_INTERVAL=24h
72
+
73
+ # 备份文件保留天数。必填:否。默认值:7。
74
+ # Number of days to retain backup files. Required: no. Default: 7.
75
+ BACKUP_RETENTION_DAYS=7
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ web/src/assets/cli-proxy-api-favicon.png filter=lfs diff=lfs merge=lfs -text
.github/workflows/binary-dev-release.yml ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish dev binaries
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ specific_name:
7
+ description: 'Optional binary package name suffix; defaults to the current branch name, for example pr-123 or feature-login-test'
8
+ required: false
9
+ type: string
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ resolve-dev-name:
16
+ runs-on: ubuntu-24.04
17
+ outputs:
18
+ specific_name: ${{ steps.dev_name.outputs.specific_name }}
19
+ steps:
20
+ - name: Validate branch ref
21
+ if: ${{ !startsWith(github.ref, 'refs/heads/') || github.ref_name == 'main' }}
22
+ run: |
23
+ echo "Manual dev binary publishing must be run from a non-main branch." >&2
24
+ echo "Current ref: $GITHUB_REF" >&2
25
+ exit 1
26
+
27
+ - name: Resolve specific binary package name
28
+ id: dev_name
29
+ env:
30
+ INPUT_NAME: ${{ inputs.specific_name }}
31
+ run: |
32
+ raw_name="${INPUT_NAME:-$GITHUB_REF_NAME}"
33
+ specific_name=$(printf '%s' "$raw_name" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[^a-z0-9]+//; s/[._-]+$//' | cut -c1-96)
34
+
35
+ if [[ -z "$INPUT_NAME" ]]; then
36
+ case "$specific_name" in
37
+ dev|latest|sha-*)
38
+ specific_name=$(printf '%s-%s' "$specific_name" "${GITHUB_SHA::7}" | cut -c1-96)
39
+ ;;
40
+ esac
41
+ fi
42
+
43
+ if [[ ! "$specific_name" =~ ^[a-z0-9][a-z0-9._-]{0,95}$ ]]; then
44
+ echo "Unable to generate a valid binary package name from: $raw_name" >&2
45
+ exit 1
46
+ fi
47
+
48
+ case "$specific_name" in
49
+ dev|latest|sha-*)
50
+ echo "Invalid binary package name: $specific_name is reserved." >&2
51
+ exit 1
52
+ ;;
53
+ esac
54
+
55
+ echo "specific_name=$specific_name" >> "$GITHUB_OUTPUT"
56
+ echo "Using specific binary package name: $specific_name"
57
+
58
+ binary-dev-linux:
59
+ runs-on: ubuntu-24.04
60
+ needs: resolve-dev-name
61
+ strategy:
62
+ fail-fast: false
63
+ matrix:
64
+ include:
65
+ - goos: linux
66
+ goarch: amd64
67
+ cc: gcc
68
+ asset_arch: amd64
69
+ - goos: linux
70
+ goarch: arm64
71
+ cc: aarch64-linux-gnu-gcc
72
+ asset_arch: arm64
73
+ steps:
74
+ - name: Checkout repository
75
+ uses: actions/checkout@v6
76
+
77
+ - name: Set up Go
78
+ uses: actions/setup-go@v6
79
+ with:
80
+ go-version: stable
81
+ check-latest: true
82
+ cache: true
83
+
84
+ - name: Set up Node.js
85
+ uses: actions/setup-node@v6
86
+ with:
87
+ node-version: '24'
88
+ cache: npm
89
+ cache-dependency-path: web/package-lock.json
90
+
91
+ - name: Install build dependencies
92
+ run: sudo apt-get update && sudo apt-get install -y build-essential gcc-aarch64-linux-gnu
93
+
94
+ - name: Install frontend dependencies
95
+ run: npm --prefix ./web ci
96
+
97
+ - name: Build frontend
98
+ run: npm --prefix ./web run build
99
+
100
+ - name: Build binary package
101
+ env:
102
+ PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
103
+ GOOS: ${{ matrix.goos }}
104
+ GOARCH: ${{ matrix.goarch }}
105
+ CC: ${{ matrix.cc }}
106
+ ASSET_ARCH: ${{ matrix.asset_arch }}
107
+ run: |
108
+ set -euo pipefail
109
+ package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}"
110
+ package_dir="dist/${package_name}"
111
+ mkdir -p "${package_dir}/data"
112
+
113
+ CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
114
+ go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
115
+ cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
116
+ tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
117
+
118
+ - name: Upload binary package artifact
119
+ uses: actions/upload-artifact@v6
120
+ with:
121
+ name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
122
+ path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
123
+ if-no-files-found: error
124
+
125
+ binary-dev-darwin:
126
+ runs-on: ${{ matrix.runs_on }}
127
+ needs: resolve-dev-name
128
+ strategy:
129
+ fail-fast: false
130
+ matrix:
131
+ include:
132
+ - goos: darwin
133
+ goarch: amd64
134
+ asset_arch: amd64
135
+ runs_on: macos-15-intel
136
+ - goos: darwin
137
+ goarch: arm64
138
+ asset_arch: arm64
139
+ runs_on: macos-15
140
+ steps:
141
+ - name: Checkout repository
142
+ uses: actions/checkout@v6
143
+
144
+ - name: Set up Go
145
+ uses: actions/setup-go@v6
146
+ with:
147
+ go-version: stable
148
+ check-latest: true
149
+ cache: true
150
+
151
+ - name: Set up Node.js
152
+ uses: actions/setup-node@v6
153
+ with:
154
+ node-version: '24'
155
+ cache: npm
156
+ cache-dependency-path: web/package-lock.json
157
+
158
+ - name: Install frontend dependencies
159
+ run: npm --prefix ./web ci
160
+
161
+ - name: Build frontend
162
+ run: npm --prefix ./web run build
163
+
164
+ - name: Build binary package
165
+ env:
166
+ PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
167
+ GOOS: ${{ matrix.goos }}
168
+ GOARCH: ${{ matrix.goarch }}
169
+ ASSET_ARCH: ${{ matrix.asset_arch }}
170
+ run: |
171
+ set -euo pipefail
172
+ package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}"
173
+ package_dir="dist/${package_name}"
174
+ mkdir -p "${package_dir}/data"
175
+
176
+ CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" \
177
+ go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
178
+ cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
179
+ tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
180
+
181
+ - name: Upload binary package artifact
182
+ uses: actions/upload-artifact@v6
183
+ with:
184
+ name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
185
+ path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
186
+ if-no-files-found: error
187
+
188
+ binary-dev-windows:
189
+ runs-on: ${{ matrix.runs_on }}
190
+ needs: resolve-dev-name
191
+ strategy:
192
+ fail-fast: false
193
+ matrix:
194
+ include:
195
+ - goarch: amd64
196
+ asset_arch: amd64
197
+ runs_on: windows-2025
198
+ msystem: UCRT64
199
+ install: mingw-w64-ucrt-x86_64-gcc
200
+ cc: gcc
201
+ - goarch: arm64
202
+ asset_arch: arm64
203
+ runs_on: windows-11-arm
204
+ msystem: CLANGARM64
205
+ install: mingw-w64-clang-aarch64-gcc
206
+ cc: gcc
207
+ steps:
208
+ - name: Checkout repository
209
+ uses: actions/checkout@v6
210
+
211
+ - name: Set up Go
212
+ uses: actions/setup-go@v6
213
+ with:
214
+ go-version: stable
215
+ check-latest: true
216
+ cache: true
217
+
218
+ - name: Set up Node.js
219
+ uses: actions/setup-node@v6
220
+ with:
221
+ node-version: '24'
222
+ cache: npm
223
+ cache-dependency-path: web/package-lock.json
224
+
225
+ - name: Set up MSYS2
226
+ uses: msys2/setup-msys2@v2
227
+ with:
228
+ msystem: ${{ matrix.msystem }}
229
+ update: true
230
+ path-type: inherit
231
+ install: ${{ matrix.install }}
232
+
233
+ - name: Install frontend dependencies
234
+ shell: bash
235
+ run: npm --prefix ./web ci
236
+
237
+ - name: Build frontend
238
+ shell: bash
239
+ run: npm --prefix ./web run build
240
+
241
+ - name: Build binary package
242
+ shell: msys2 {0}
243
+ env:
244
+ PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
245
+ GOOS: windows
246
+ GOARCH: ${{ matrix.goarch }}
247
+ ASSET_ARCH: ${{ matrix.asset_arch }}
248
+ CC: ${{ matrix.cc }}
249
+ run: |
250
+ set -euo pipefail
251
+ package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}"
252
+ package_dir="dist/${package_name}"
253
+ mkdir -p "${package_dir}/data"
254
+
255
+ CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
256
+ go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper.exe" ./cmd/server/main.go
257
+ cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
258
+
259
+ - name: Archive binary package
260
+ shell: pwsh
261
+ env:
262
+ PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }}
263
+ ASSET_ARCH: ${{ matrix.asset_arch }}
264
+ run: |
265
+ $shortSha = "${env:GITHUB_SHA}".Substring(0, 7)
266
+ $packageName = "cpa-usage-keeper_dev_${env:PACKAGE_SUFFIX}_${shortSha}_windows_${env:ASSET_ARCH}"
267
+ Compress-Archive -Path "dist/$packageName" -DestinationPath "dist/$packageName.zip"
268
+
269
+ - name: Upload binary package artifact
270
+ uses: actions/upload-artifact@v6
271
+ with:
272
+ name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-windows-${{ matrix.asset_arch }}
273
+ path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_windows_${{ matrix.asset_arch }}.zip
274
+ if-no-files-found: error
275
+
276
+ publish-dev-artifacts:
277
+ runs-on: ubuntu-24.04
278
+ needs:
279
+ - resolve-dev-name
280
+ - binary-dev-linux
281
+ - binary-dev-darwin
282
+ - binary-dev-windows
283
+ steps:
284
+ - name: Download binary package artifacts
285
+ uses: actions/download-artifact@v6
286
+ with:
287
+ path: dist
288
+ merge-multiple: true
289
+
290
+ - name: Generate checksums
291
+ run: |
292
+ set -euo pipefail
293
+ cd dist
294
+ sha256sum *.tar.gz *.zip > checksums.txt
295
+
296
+ - name: Upload dev binary packages
297
+ uses: actions/upload-artifact@v6
298
+ with:
299
+ name: cpa-usage-keeper-dev-binaries-${{ github.run_id }}
300
+ path: |
301
+ dist/*.tar.gz
302
+ dist/*.zip
303
+ dist/checksums.txt
304
+ if-no-files-found: error
.github/workflows/binary-release.yml ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish release binaries
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ binary-release-linux:
13
+ runs-on: ubuntu-24.04
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ include:
18
+ - goos: linux
19
+ goarch: amd64
20
+ cc: gcc
21
+ asset_arch: amd64
22
+ - goos: linux
23
+ goarch: arm64
24
+ cc: aarch64-linux-gnu-gcc
25
+ asset_arch: arm64
26
+ steps:
27
+ - name: Checkout repository
28
+ uses: actions/checkout@v6
29
+
30
+ - name: Set up Go
31
+ uses: actions/setup-go@v6
32
+ with:
33
+ go-version: stable
34
+ check-latest: true
35
+ cache: true
36
+
37
+ - name: Set up Node.js
38
+ uses: actions/setup-node@v6
39
+ with:
40
+ node-version: '24'
41
+ cache: npm
42
+ cache-dependency-path: web/package-lock.json
43
+
44
+ - name: Install build dependencies
45
+ run: sudo apt-get update && sudo apt-get install -y build-essential gcc-aarch64-linux-gnu
46
+
47
+ - name: Install frontend dependencies
48
+ run: npm --prefix ./web ci
49
+
50
+ - name: Build frontend
51
+ run: npm --prefix ./web run build
52
+
53
+ - name: Build binary package
54
+ env:
55
+ VERSION: ${{ github.ref_name }}
56
+ GOOS: ${{ matrix.goos }}
57
+ GOARCH: ${{ matrix.goarch }}
58
+ CC: ${{ matrix.cc }}
59
+ ASSET_ARCH: ${{ matrix.asset_arch }}
60
+ run: |
61
+ set -euo pipefail
62
+ package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}"
63
+ package_dir="dist/${package_name}"
64
+ mkdir -p "${package_dir}/data"
65
+
66
+ CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
67
+ go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
68
+ cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
69
+ tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
70
+
71
+ - name: Upload binary package artifact
72
+ uses: actions/upload-artifact@v6
73
+ with:
74
+ name: cpa-usage-keeper-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
75
+ path: dist/cpa-usage-keeper_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
76
+ if-no-files-found: error
77
+
78
+ binary-release-darwin:
79
+ runs-on: ${{ matrix.runs_on }}
80
+ strategy:
81
+ fail-fast: false
82
+ matrix:
83
+ include:
84
+ - goos: darwin
85
+ goarch: amd64
86
+ asset_arch: amd64
87
+ runs_on: macos-15-intel
88
+ - goos: darwin
89
+ goarch: arm64
90
+ asset_arch: arm64
91
+ runs_on: macos-15
92
+ steps:
93
+ - name: Checkout repository
94
+ uses: actions/checkout@v6
95
+
96
+ - name: Set up Go
97
+ uses: actions/setup-go@v6
98
+ with:
99
+ go-version: stable
100
+ check-latest: true
101
+ cache: true
102
+
103
+ - name: Set up Node.js
104
+ uses: actions/setup-node@v6
105
+ with:
106
+ node-version: '24'
107
+ cache: npm
108
+ cache-dependency-path: web/package-lock.json
109
+
110
+ - name: Install frontend dependencies
111
+ run: npm --prefix ./web ci
112
+
113
+ - name: Build frontend
114
+ run: npm --prefix ./web run build
115
+
116
+ - name: Build binary package
117
+ env:
118
+ VERSION: ${{ github.ref_name }}
119
+ GOOS: ${{ matrix.goos }}
120
+ GOARCH: ${{ matrix.goarch }}
121
+ ASSET_ARCH: ${{ matrix.asset_arch }}
122
+ run: |
123
+ set -euo pipefail
124
+ package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}"
125
+ package_dir="dist/${package_name}"
126
+ mkdir -p "${package_dir}/data"
127
+
128
+ CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" \
129
+ go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go
130
+ cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
131
+ tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}"
132
+
133
+ - name: Upload binary package artifact
134
+ uses: actions/upload-artifact@v6
135
+ with:
136
+ name: cpa-usage-keeper-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }}
137
+ path: dist/cpa-usage-keeper_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz
138
+ if-no-files-found: error
139
+
140
+ binary-release-windows:
141
+ runs-on: ${{ matrix.runs_on }}
142
+ strategy:
143
+ fail-fast: false
144
+ matrix:
145
+ include:
146
+ - goarch: amd64
147
+ asset_arch: amd64
148
+ runs_on: windows-2025
149
+ msystem: UCRT64
150
+ install: mingw-w64-ucrt-x86_64-gcc
151
+ cc: gcc
152
+ - goarch: arm64
153
+ asset_arch: arm64
154
+ runs_on: windows-11-arm
155
+ msystem: CLANGARM64
156
+ install: mingw-w64-clang-aarch64-gcc
157
+ cc: gcc
158
+ steps:
159
+ - name: Checkout repository
160
+ uses: actions/checkout@v6
161
+
162
+ - name: Set up Go
163
+ uses: actions/setup-go@v6
164
+ with:
165
+ go-version: stable
166
+ check-latest: true
167
+ cache: true
168
+
169
+ - name: Set up Node.js
170
+ uses: actions/setup-node@v6
171
+ with:
172
+ node-version: '24'
173
+ cache: npm
174
+ cache-dependency-path: web/package-lock.json
175
+
176
+ - name: Set up MSYS2
177
+ uses: msys2/setup-msys2@v2
178
+ with:
179
+ msystem: ${{ matrix.msystem }}
180
+ update: true
181
+ path-type: inherit
182
+ install: ${{ matrix.install }}
183
+
184
+ - name: Install frontend dependencies
185
+ shell: bash
186
+ run: npm --prefix ./web ci
187
+
188
+ - name: Build frontend
189
+ shell: bash
190
+ run: npm --prefix ./web run build
191
+
192
+ - name: Build binary package
193
+ shell: msys2 {0}
194
+ env:
195
+ VERSION: ${{ github.ref_name }}
196
+ GOOS: windows
197
+ GOARCH: ${{ matrix.goarch }}
198
+ ASSET_ARCH: ${{ matrix.asset_arch }}
199
+ CC: ${{ matrix.cc }}
200
+ run: |
201
+ set -euo pipefail
202
+ package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}"
203
+ package_dir="dist/${package_name}"
204
+ mkdir -p "${package_dir}/data"
205
+
206
+ CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \
207
+ go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper.exe" ./cmd/server/main.go
208
+ cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/"
209
+
210
+ - name: Archive binary package
211
+ shell: pwsh
212
+ env:
213
+ VERSION: ${{ github.ref_name }}
214
+ ASSET_ARCH: ${{ matrix.asset_arch }}
215
+ run: |
216
+ $packageName = "cpa-usage-keeper_${env:VERSION}_windows_${env:ASSET_ARCH}"
217
+ Compress-Archive -Path "dist/$packageName" -DestinationPath "dist/$packageName.zip"
218
+
219
+ - name: Upload binary package artifact
220
+ uses: actions/upload-artifact@v6
221
+ with:
222
+ name: cpa-usage-keeper-${{ github.ref_name }}-windows-${{ matrix.asset_arch }}
223
+ path: dist/cpa-usage-keeper_${{ github.ref_name }}_windows_${{ matrix.asset_arch }}.zip
224
+ if-no-files-found: error
225
+
226
+ publish-release:
227
+ runs-on: ubuntu-24.04
228
+ needs:
229
+ - binary-release-linux
230
+ - binary-release-darwin
231
+ - binary-release-windows
232
+ steps:
233
+ - name: Download binary package artifacts
234
+ uses: actions/download-artifact@v6
235
+ with:
236
+ path: dist
237
+ merge-multiple: true
238
+
239
+ - name: Generate checksums
240
+ run: |
241
+ set -euo pipefail
242
+ cd dist
243
+ sha256sum *.tar.gz *.zip > checksums.txt
244
+
245
+ - name: Upload release assets
246
+ uses: softprops/action-gh-release@v3
247
+ with:
248
+ files: |
249
+ dist/*.tar.gz
250
+ dist/*.zip
251
+ dist/checksums.txt
.github/workflows/ci.yml ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ backend:
14
+ runs-on: ${{ matrix.runs-on }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ runs-on:
19
+ - ubuntu-latest
20
+ - macos-latest
21
+ - windows-latest
22
+ steps:
23
+ - name: Checkout repository
24
+ uses: actions/checkout@v6
25
+
26
+ - name: Set up Go
27
+ uses: actions/setup-go@v6
28
+ with:
29
+ go-version: '1.22'
30
+ cache: true
31
+
32
+ - name: Set up MSYS2
33
+ if: ${{ runner.os == 'Windows' }}
34
+ uses: msys2/setup-msys2@v2
35
+ with:
36
+ msystem: UCRT64
37
+ update: true
38
+ path-type: inherit
39
+ install: mingw-w64-ucrt-x86_64-gcc
40
+
41
+ - name: Run backend tests
42
+ if: ${{ runner.os != 'Windows' }}
43
+ run: go test ./cmd/... ./internal/...
44
+
45
+ - name: Run backend tests
46
+ if: ${{ runner.os == 'Windows' }}
47
+ shell: msys2 {0}
48
+ run: go test ./cmd/... ./internal/...
49
+
50
+ frontend:
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - name: Checkout repository
54
+ uses: actions/checkout@v6
55
+
56
+ - name: Set up Node.js
57
+ uses: actions/setup-node@v6
58
+ with:
59
+ node-version: '22'
60
+ cache: npm
61
+ cache-dependency-path: web/package-lock.json
62
+
63
+ - name: Install frontend dependencies
64
+ run: npm --prefix ./web ci
65
+
66
+ - name: Run frontend tests
67
+ run: npm --prefix ./web run test
68
+
69
+ - name: Run frontend lint
70
+ run: npm --prefix ./web run lint
71
+
72
+ - name: Run frontend typecheck
73
+ run: npm --prefix ./web run typecheck
74
+
75
+ - name: Build frontend
76
+ run: npm --prefix ./web run build
.github/workflows/docker-dev-publish.yml ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish dev Docker image
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ specific_tag:
7
+ description: 'Optional Docker tag to publish; defaults to the current branch name, for example pr-123 or feature-login-test'
8
+ required: false
9
+ type: string
10
+
11
+ permissions:
12
+ contents: read
13
+ packages: write
14
+
15
+ jobs:
16
+ docker-dev:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - name: Validate branch ref
20
+ if: ${{ !startsWith(github.ref, 'refs/heads/') || github.ref_name == 'main' }}
21
+ run: |
22
+ echo "Manual dev Docker publishing must be run from a non-main branch." >&2
23
+ echo "Current ref: $GITHUB_REF" >&2
24
+ exit 1
25
+
26
+ - name: Resolve specific Docker tag
27
+ id: dev_tag
28
+ env:
29
+ INPUT_TAG: ${{ inputs.specific_tag }}
30
+ run: |
31
+ raw_tag="${INPUT_TAG:-$GITHUB_REF_NAME}"
32
+ specific_tag=$(printf '%s' "$raw_tag" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[^a-z0-9]+//; s/[._-]+$//' | cut -c1-128)
33
+
34
+ if [[ -z "$INPUT_TAG" ]]; then
35
+ case "$specific_tag" in
36
+ dev|latest|sha-*)
37
+ specific_tag=$(printf '%s-%s' "$specific_tag" "${GITHUB_SHA::7}" | cut -c1-128)
38
+ ;;
39
+ esac
40
+ fi
41
+
42
+ if [[ ! "$specific_tag" =~ ^[a-z0-9][a-z0-9._-]{0,127}$ ]]; then
43
+ echo "Unable to generate a valid Docker tag from: $raw_tag" >&2
44
+ exit 1
45
+ fi
46
+
47
+ case "$specific_tag" in
48
+ dev|latest|sha-*)
49
+ echo "Invalid Docker tag: $specific_tag is reserved." >&2
50
+ exit 1
51
+ ;;
52
+ esac
53
+
54
+ echo "specific_tag=$specific_tag" >> "$GITHUB_OUTPUT"
55
+ echo "Using specific Docker tag: $specific_tag"
56
+
57
+ - name: Checkout repository
58
+ uses: actions/checkout@v6
59
+
60
+ - name: Set up Docker Buildx
61
+ uses: docker/setup-buildx-action@v4
62
+
63
+ - name: Convert image name to lowercase
64
+ id: repo
65
+ run: echo "image=ghcr.io/${GITHUB_REPOSITORY@L}" >> "$GITHUB_OUTPUT"
66
+
67
+ - name: Generate Docker metadata
68
+ id: meta
69
+ uses: docker/metadata-action@v6
70
+ with:
71
+ images: ${{ steps.repo.outputs.image }}
72
+ tags: |
73
+ type=raw,value=dev
74
+ type=raw,value=${{ steps.dev_tag.outputs.specific_tag }}
75
+
76
+ - name: Log in to GitHub Container Registry
77
+ uses: docker/login-action@v4
78
+ with:
79
+ registry: ghcr.io
80
+ username: ${{ github.actor }}
81
+ password: ${{ secrets.GITHUB_TOKEN }}
82
+
83
+ - name: Build and push Docker image
84
+ uses: docker/build-push-action@v7
85
+ with:
86
+ context: .
87
+ file: ./Dockerfile
88
+ platforms: linux/amd64,linux/arm64
89
+ push: true
90
+ tags: ${{ steps.meta.outputs.tags }}
91
+ labels: ${{ steps.meta.outputs.labels }}
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish release Docker image
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: read
10
+ packages: write
11
+
12
+ jobs:
13
+ docker:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v6
18
+
19
+ - name: Set up Docker Buildx
20
+ uses: docker/setup-buildx-action@v4
21
+
22
+ - name: Convert image name to lowercase
23
+ id: repo
24
+ run: echo "image=ghcr.io/${GITHUB_REPOSITORY@L}" >> "$GITHUB_OUTPUT"
25
+
26
+ - name: Generate Docker metadata
27
+ id: meta
28
+ uses: docker/metadata-action@v6
29
+ with:
30
+ images: ${{ steps.repo.outputs.image }}
31
+ tags: |
32
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
33
+ type=sha,prefix=sha-
34
+ type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
35
+ type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
36
+ type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
37
+ type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
38
+
39
+ - name: Log in to GitHub Container Registry
40
+ uses: docker/login-action@v4
41
+ with:
42
+ registry: ghcr.io
43
+ username: ${{ github.actor }}
44
+ password: ${{ secrets.GITHUB_TOKEN }}
45
+
46
+ - name: Build and push Docker image
47
+ uses: docker/build-push-action@v7
48
+ with:
49
+ context: .
50
+ file: ./Dockerfile
51
+ platforms: linux/amd64,linux/arm64
52
+ push: true
53
+ tags: ${{ steps.meta.outputs.tags }}
54
+ labels: ${{ steps.meta.outputs.labels }}
.gitignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # internal docs
2
+ .dev-docs/
3
+ .claude/
4
+ .worktrees/
5
+
6
+ # local environment files
7
+ .env
8
+ .env.*
9
+ !.env.example
10
+ !deploy/.env.example
11
+
12
+ # runtime data
13
+ /data/
14
+ /test/
15
+ *.db
16
+ *.db-*
17
+ .tmp-test.db
18
+ backups/
19
+ .runtime-test/
20
+
21
+ # frontend dependencies and build output
22
+ web/node_modules/
23
+ web/dist/
24
+ web/dist-ssr/
25
+
26
+ # logs
27
+ logs/
28
+ *.log
29
+ npm-debug.log*
30
+ yarn-debug.log*
31
+ yarn-error.log*
32
+ pnpm-debug.log*
33
+ lerna-debug.log*
34
+
35
+ # local overrides
36
+ *.local
37
+
38
+ # editor and OS files
39
+ .DS_Store
40
+ .vscode/
41
+ .idea/
42
+ *.suo
43
+ *.ntvs*
44
+ *.njsproj
45
+ *.sln
46
+ *.sw?
CONTRIBUTORS.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Contributors
2
+
3
+ This project is made possible by the work and ideas of the people below.
4
+
5
+ ## Acknowledgements
6
+
7
+ - [luispater](https://github.com/luispater) — Author of CPA, the upstream project that this usage keeper integrates with.
Dockerfile CHANGED
@@ -1,16 +1,36 @@
1
- FROM ghcr.io/willxup/cpa-usage-keeper:latest
2
 
3
- ENV APP_PORT=8080 \
4
- CPA_BASE_URL=https://pjpjq-daili.hf.space \
5
- REDIS_QUEUE_ADDR=127.0.0.1:9 \
6
- AUTH_ENABLED=true \
7
- TZ=Asia/Shanghai \
8
- WORK_DIR=/data \
9
- LOG_FILE_ENABLED=true \
10
- BACKUP_ENABLED=true
11
 
12
- COPY entrypoint.space.sh /usr/local/bin/entrypoint.space.sh
13
- RUN chmod +x /usr/local/bin/entrypoint.space.sh
 
 
 
 
 
 
 
 
14
 
15
- ENTRYPOINT ["/usr/local/bin/entrypoint.space.sh"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  CMD ["/app/cpa-usage-keeper"]
 
1
+ # syntax=docker/dockerfile:1
2
 
3
+ FROM node:22-alpine AS web-builder
4
+ WORKDIR /app/web
5
+ COPY web/package.json web/package-lock.json ./
6
+ RUN npm ci
7
+ COPY web/ ./
8
+ RUN npm run build
 
 
9
 
10
+ FROM golang:1.22-alpine AS go-builder
11
+ WORKDIR /app
12
+ RUN apk add --no-cache build-base
13
+ COPY go.mod go.sum ./
14
+ RUN go mod download
15
+ COPY cmd/ ./cmd/
16
+ COPY internal/ ./internal/
17
+ COPY --from=web-builder /app/web/dist ./web/dist
18
+ COPY web/static.go ./web/static.go
19
+ RUN CGO_ENABLED=1 GOOS=linux go build -o /out/cpa-usage-keeper ./cmd/server/main.go
20
 
21
+ FROM alpine:3.20
22
+ WORKDIR /
23
+ RUN apk add --no-cache ca-certificates tzdata su-exec \
24
+ && addgroup -S app \
25
+ && adduser -S -G app app \
26
+ && mkdir -p /data \
27
+ && chown -R app:app /data
28
+ COPY --from=go-builder /out/cpa-usage-keeper /app/cpa-usage-keeper
29
+ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
30
+ RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
31
+ && chmod +x /usr/local/bin/docker-entrypoint.sh
32
+ VOLUME ["/data"]
33
+ EXPOSE 8080
34
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${APP_PORT:-8080}${APP_BASE_PATH:-}/healthz" || exit 1
35
+ ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
36
  CMD ["/app/cpa-usage-keeper"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Will
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: verify verify-backend verify-frontend verify-docker
2
+
3
+ verify: verify-backend verify-frontend
4
+
5
+ verify-backend:
6
+ go test ./cmd/... ./internal/...
7
+
8
+ verify-frontend:
9
+ npm --prefix ./web ci
10
+ npm --prefix ./web run test
11
+ npm --prefix ./web run lint
12
+ npm --prefix ./web run typecheck
13
+ npm --prefix ./web run build
14
+
15
+ verify-docker:
16
+ docker build -t cpa-usage-keeper:ci .
README.en.md ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CPA Usage Keeper
2
+
3
+ [中文说明](./README.md)
4
+
5
+ CPA Usage Keeper is a standalone CPA usage persistence and dashboard service.
6
+
7
+ It relies on [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI) as the backend CPA data source and adds persistent storage and statistical analysis capabilities on top of CPA. The service consumes events from the CPA Redis usage queue into SQLite, periodically pulls CPA metadata, exposes aggregation APIs, and serves a built-in web dashboard for usage, pricing, request health, and model/API statistics.
8
+
9
+ ![cpa-usage-keeper-screenshot](https://images.bitskyline.com/i/2026/05/1pmg6l.png)
10
+
11
+ ## Features
12
+
13
+ - CPA usage persistence in SQLite
14
+ - Aggregated usage and pricing APIs
15
+ - Built-in React dashboard
16
+ - Optional password login protection
17
+ - Local SQLite database backups with retention
18
+ - Docker / Docker Compose deployment
19
+
20
+ ## Project Structure
21
+
22
+ ```text
23
+ cmd/ Application entrypoint
24
+ internal/api/ HTTP routes and handlers
25
+ internal/app/ App wiring and startup
26
+ internal/auth/ In-memory session auth
27
+ internal/backup/ SQLite database backup management
28
+ internal/config/ Environment config loading
29
+ internal/cpa/ CPA client and types
30
+ internal/models/ GORM models
31
+ internal/poller/ Background sync loop
32
+ internal/repository/ SQLite access and aggregations
33
+ internal/service/ Sync, usage, and pricing services
34
+ web/ React + TypeScript frontend
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ Copy the example config:
40
+
41
+ ```bash
42
+ cp .env.example .env
43
+ ```
44
+
45
+ | Variable | Required | Default | Description |
46
+ | --- | --- | --- | --- |
47
+ | `CPA_BASE_URL` | Yes | - | CPA server URL |
48
+ | `CPA_MANAGEMENT_KEY` | Yes | - | CPA management key |
49
+ | `AUTH_ENABLED` | No | `false` | Enable login protection |
50
+ | `LOGIN_PASSWORD` | When auth is enabled | - | Login password |
51
+ | `AUTH_SESSION_TTL` | No | `168h` | Session lifetime |
52
+ | `APP_PORT` | No | `8080` | HTTP listen port |
53
+ | `APP_BASE_PATH` | No | root path | Subpath prefix such as `/cpa`; empty means `/` |
54
+ | `TZ` | No | `Asia/Shanghai` | Project business timezone; affects Today, daily aggregation, scheduled tasks, and log timestamps |
55
+ | `REDIS_QUEUE_ADDR` | No | `CPA_BASE_URL` hostname + `8317` | CPA Redis/RESP TCP address; set `host:port` for non-default ports |
56
+ | `REDIS_QUEUE_BATCH_SIZE` | No | `1000` | Maximum queue records per pull |
57
+ | `REDIS_QUEUE_IDLE_INTERVAL` | No | `1s` | Empty queue check interval |
58
+ | `REQUEST_TIMEOUT` | No | `30s` | CPA request timeout |
59
+ | `WORK_DIR` | No | `./data` | Application work directory; database, logs, and backups default to `app.db`, `logs/`, and `backups/` under it |
60
+ | `LOG_LEVEL` | No | `info` | Log level |
61
+ | `LOG_FILE_ENABLED` | No | `true` | Write persistent log files |
62
+ | `LOG_RETENTION_DAYS` | No | `7` | Log retention days; `0` disables cleanup |
63
+ | `BACKUP_ENABLED` | No | `true` | Enable SQLite database backups |
64
+ | `BACKUP_INTERVAL` | No | `24h` | Database backup interval |
65
+ | `BACKUP_RETENTION_DAYS` | No | `7` | Backup retention days |
66
+
67
+ `APP_BASE_PATH` must be empty or start with `/`; for example `/cpa`. `/cpa/` is normalized to `/cpa`.
68
+
69
+ Security and data notes:
70
+
71
+ - SQLite database backups store original data from the application database, and backup files are not encrypted.
72
+ - Browser-facing APIs redact key-like source/lookup fields or map them to stable public identifiers, but raw database values are unchanged.
73
+ - For public deployments, enable `AUTH_ENABLED=true` and terminate HTTPS at your reverse proxy.
74
+ - Login sessions are stored in process memory and become invalid after restart.
75
+ - Redis inbox raw messages are cleaned up automatically: successful rows are kept until the end of the current day, and failed rows are kept for 7 days.
76
+
77
+ ## Development
78
+
79
+ ### Prerequisites
80
+
81
+ - Go 1.22+
82
+ - Node.js 22+
83
+ - npm
84
+ - A running [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI) instance
85
+
86
+ ### Run locally
87
+
88
+ 1. Create your local config:
89
+
90
+ ```bash
91
+ cp .env.example .env
92
+ ```
93
+
94
+ 2. Start the backend:
95
+
96
+ ```bash
97
+ go run ./cmd/server/main.go
98
+ ```
99
+
100
+ 3. In another terminal, install frontend dependencies and start the dev server:
101
+
102
+ ```bash
103
+ npm --prefix ./web ci
104
+ npm --prefix ./web run dev -- --host 127.0.0.1
105
+ ```
106
+
107
+ 4. Build the frontend for production:
108
+
109
+ ```bash
110
+ npm --prefix ./web run build
111
+ ```
112
+
113
+ ### Tests
114
+
115
+ Run the full local verification baseline:
116
+
117
+ ```bash
118
+ make verify
119
+ ```
120
+
121
+ Or run checks individually:
122
+
123
+ ```bash
124
+ go test ./cmd/... ./internal/...
125
+ npm --prefix ./web run test
126
+ npm --prefix ./web run lint
127
+ npm --prefix ./web run typecheck
128
+ npm --prefix ./web run build
129
+ ```
130
+
131
+ ## Docker
132
+
133
+ If CPA is already running on the host:
134
+
135
+ ```bash
136
+ # TZ sets the container timezone; log timestamps are displayed in this timezone.
137
+ docker run -d \
138
+ --name cpa-usage-keeper \
139
+ --add-host=host.docker.internal:host-gateway \
140
+ -p 8080:8080 \
141
+ -v "$(pwd)/keeper/data:/data" \
142
+ -e TZ=Asia/Shanghai \
143
+ -e CPA_BASE_URL=http://host.docker.internal:8317 \
144
+ -e CPA_MANAGEMENT_KEY=replace-with-your-management-key \
145
+ -e REDIS_QUEUE_ADDR=host.docker.internal:8317 \
146
+ -e AUTH_ENABLED=true \
147
+ -e LOGIN_PASSWORD=replace-with-your-login-password \
148
+ ghcr.io/willxup/cpa-usage-keeper:latest
149
+ ```
150
+
151
+ `/data` stores the SQLite database, backups, and log files. Mount it to persistent storage.
152
+
153
+ ## Docker Compose
154
+
155
+ The repository includes a minimal `docker-compose.yaml` example for running CPA and CPA Usage Keeper together:
156
+
157
+ ```yaml
158
+ services:
159
+ cli-proxy-api:
160
+ image: eceasy/cli-proxy-api:latest
161
+ container_name: cli-proxy-api
162
+ restart: unless-stopped
163
+ ports:
164
+ - "8317:8317"
165
+ - "1455:1455"
166
+ volumes:
167
+ - ./cpa/config.yaml:/CLIProxyAPI/config.yaml
168
+ - ./cpa/auths:/root/.cli-proxy-api
169
+ - ./cpa/logs:/CLIProxyAPI/logs
170
+ networks:
171
+ - cpa-network
172
+
173
+ cpa-usage-keeper:
174
+ image: ghcr.io/willxup/cpa-usage-keeper:latest
175
+ container_name: cpa-usage-keeper
176
+ restart: unless-stopped
177
+ depends_on:
178
+ - cli-proxy-api
179
+ ports:
180
+ - "8080:8080"
181
+ environment:
182
+ TZ: Asia/Shanghai # Sets the container timezone; log timestamps use this timezone.
183
+ CPA_BASE_URL: http://cli-proxy-api:8317
184
+ CPA_MANAGEMENT_KEY: replace-with-your-management-key
185
+ REDIS_QUEUE_ADDR: cli-proxy-api:8317
186
+ AUTH_ENABLED: true
187
+ LOGIN_PASSWORD: replace-with-your-login-password
188
+ volumes:
189
+ - ./keeper:/data
190
+ networks:
191
+ - cpa-network
192
+
193
+ networks:
194
+ cpa-network:
195
+ driver: bridge
196
+ ```
197
+
198
+ Start:
199
+
200
+ ```bash
201
+ docker compose up -d
202
+ ```
203
+
204
+ Stop:
205
+
206
+ ```bash
207
+ docker compose down
208
+ ```
209
+
210
+ CPA files are stored under `./cpa`, and CPA Usage Keeper data is stored under `./keeper`.
211
+
212
+ ## Subpath reverse proxy
213
+
214
+ When serving under `/cpa`, set `APP_BASE_PATH=/cpa` and keep the prefix in your reverse proxy:
215
+
216
+ ```nginx
217
+ location /cpa/ {
218
+ proxy_pass http://127.0.0.1:8080;
219
+ proxy_set_header Host $host;
220
+ proxy_set_header X-Forwarded-Proto $scheme;
221
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
222
+ }
223
+ ```
README.md CHANGED
@@ -8,25 +8,230 @@ app_port: 8080
8
  pinned: false
9
  ---
10
 
11
- # Daili Usage Keeper
12
 
13
- A standalone [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) Space for `pjpjq/daili`.
14
 
15
- This Space intentionally runs separately from the main CPA Space so the proxy path is not coupled to usage visualization.
16
 
17
- Required secret before it starts the real dashboard:
18
 
19
- - `CPA_MANAGEMENT_KEY`: management key for `https://pjpjq-daili.hf.space`
20
 
21
- Default variables:
22
 
23
- - `CPA_BASE_URL=https://pjpjq-daili.hf.space`
24
- - `REDIS_QUEUE_ADDR=127.0.0.1:9` to force fast HTTP fallback to CPA `/v0/management/usage-queue`, because Hugging Face Spaces cannot reach the raw CPA TCP/RESP port across Spaces.
25
- - `AUTH_ENABLED=true`; set `LOGIN_PASSWORD` as a Space secret. The Space can be public while the dashboard remains password protected.
 
 
 
26
 
27
- Persistent history requires Hugging Face persistent storage or another backup strategy for `/data`.
28
 
29
- Runtime secrets configured on Hugging Face:
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- - `CPA_MANAGEMENT_KEY`
32
- - `LOGIN_PASSWORD`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  pinned: false
9
  ---
10
 
11
+ # CPA Usage Keeper
12
 
13
+ [English README](./README.en.md)
14
 
15
+ `CPA Usage Keeper` 是一个独立的 CPA 用量持久化与可视化服务。
16
 
17
+ 它依赖 [CLIProxyAPI(CPA)](https://github.com/router-for-me/CLIProxyAPI) 作为后端 CPA 数据来源,目标是在 CPA 之上补充持久化存储与统计分析能力。服务会从 CPA Redis usage 队列消费事件并写入 SQLite,定时拉取 CPA metadata,暴露聚合 API,并提供内置 Web Dashboard 用于查看 usage、pricing、request health 和 model/API 维度的统计信息。
18
 
19
+ ![cpa-usage-keeper-screenshot](https://images.bitskyline.com/i/2026/05/1pmg6l.png)
20
 
21
+ ## 功能特性
22
 
23
+ - CPA usage 数据持久化到 SQLite
24
+ - usage 聚合 API pricing API
25
+ - 内置 React Dashboard
26
+ - 可选密码登录保护
27
+ - SQLite 数据库本地备份与保留策略
28
+ - Docker / Docker Compose 部署
29
 
30
+ ## 项目结构
31
 
32
+ ```text
33
+ cmd/ 应用入口
34
+ internal/api/ HTTP 路由与处理器
35
+ internal/app/ 应用装配与启动
36
+ internal/auth/ 内存 session 鉴权
37
+ internal/backup/ SQLite 数据库备份管理
38
+ internal/config/ 环境配置加载
39
+ internal/cpa/ CPA 客户端与类型定义
40
+ internal/models/ GORM 模型
41
+ internal/poller/ 后台同步轮询
42
+ internal/repository/ SQLite 访问与聚合逻辑
43
+ internal/service/ 同步、usage 与 pricing 服务
44
+ web/ React + TypeScript 前端
45
+ ```
46
 
47
+ ## 配置
48
+
49
+ 复制配置模板:
50
+
51
+ ```bash
52
+ cp .env.example .env
53
+ ```
54
+
55
+ | 变量 | 必填 | 默认值 | 说明 |
56
+ | --- | --- | --- | --- |
57
+ | `CPA_BASE_URL` | 是 | - | CPA 服务地址 |
58
+ | `CPA_MANAGEMENT_KEY` | 是 | - | CPA management key |
59
+ | `AUTH_ENABLED` | 否 | `false` | 是否启用登录保护 |
60
+ | `LOGIN_PASSWORD` | 鉴权启用时必填 | - | 登录密码 |
61
+ | `AUTH_SESSION_TTL` | 否 | `168h` | Session 生命周期 |
62
+ | `APP_PORT` | 否 | `8080` | HTTP 监听端口 |
63
+ | `APP_BASE_PATH` | 否 | 根路径 | 子路径部署前缀,例如 `/cpa`;留空表示 `/` |
64
+ | `TZ` | 否 | `Asia/Shanghai` | 项目业务时区,影响 Today、按天聚合、定时任务和日志时间 |
65
+ | `REDIS_QUEUE_ADDR` | 否 | `CPA_BASE_URL` 主机名 + `8317` | CPA Redis/RESP TCP 地址;非默认端口时填写 `host:port` |
66
+ | `REDIS_QUEUE_BATCH_SIZE` | 否 | `1000` | 每次最多拉取的队列记录数 |
67
+ | `REDIS_QUEUE_IDLE_INTERVAL` | 否 | `1s` | 队列为空时的检查间隔 |
68
+ | `REQUEST_TIMEOUT` | 否 | `30s` | CPA 请求超时 |
69
+ | `WORK_DIR` | 否 | `./data` | 应用工作目录;数据库、日志和备份默认分别写入 `app.db`、`logs/`、`backups/` |
70
+ | `LOG_LEVEL` | 否 | `info` | 日志级别 |
71
+ | `LOG_FILE_ENABLED` | 否 | `true` | 是否写入持久化日志文件 |
72
+ | `LOG_RETENTION_DAYS` | 否 | `7` | 日志保留天数;`0` 表示不自动清理 |
73
+ | `BACKUP_ENABLED` | 否 | `true` | 是否启用 SQLite 数据库备份 |
74
+ | `BACKUP_INTERVAL` | 否 | `24h` | 数据库备份间隔 |
75
+ | `BACKUP_RETENTION_DAYS` | 否 | `7` | 备份保留天数 |
76
+
77
+ `APP_BASE_PATH` 必须为空或以 `/` 开头;例如 `/cpa`,`/cpa/` 会规范为 `/cpa`。
78
+
79
+ 安全与数据说明:
80
+
81
+ - SQLite 数据库备份会保存应用数据库中的原始数据,备份文件不做加密。
82
+ - 面向浏览器的 API 会对 key-like source/lookup 字段做脱敏或稳定公开标识映射,但不会修改数据库原始值。
83
+ - 公开部署建议开启 `AUTH_ENABLED=true`,并在反向代理层配置 HTTPS。
84
+ - 登录 session 存在服务进程内存中,服务重启后已登录 session 会失效。
85
+ - Redis inbox 原始消息会自动清理:成功数据保留到当天结束后清理,失败数据保留 7 天。
86
+
87
+ ## 本地开发
88
+
89
+ ### 前置依赖
90
+
91
+ - Go 1.22+
92
+ - Node.js 22+
93
+ - npm
94
+ - 已运行的 [CLIProxyAPI(CPA)](https://github.com/router-for-me/CLIProxyAPI)
95
+
96
+ ### 本地启动
97
+
98
+ 1. 复制本地配置:
99
+
100
+ ```bash
101
+ cp .env.example .env
102
+ ```
103
+
104
+ 2. 启动后端:
105
+
106
+ ```bash
107
+ go run ./cmd/server/main.go
108
+ ```
109
+
110
+ 3. 在另一个终端安装前端依赖并启动开发服务器:
111
+
112
+ ```bash
113
+ npm --prefix ./web ci
114
+ npm --prefix ./web run dev -- --host 127.0.0.1
115
+ ```
116
+
117
+ 4. 构建前端生产产物:
118
+
119
+ ```bash
120
+ npm --prefix ./web run build
121
+ ```
122
+
123
+ ### 测试
124
+
125
+ 运行完整的本地验证基线:
126
+
127
+ ```bash
128
+ make verify
129
+ ```
130
+
131
+ 也可以单独运行各项检查:
132
+
133
+ ```bash
134
+ go test ./cmd/... ./internal/...
135
+ npm --prefix ./web run test
136
+ npm --prefix ./web run lint
137
+ npm --prefix ./web run typecheck
138
+ npm --prefix ./web run build
139
+ ```
140
+
141
+ ## Docker
142
+
143
+ 如果 CPA 已在宿主机运行:
144
+
145
+ ```bash
146
+ # TZ 设置容器时区,日志时间会按该时区显示。
147
+ docker run -d \
148
+ --name cpa-usage-keeper \
149
+ --add-host=host.docker.internal:host-gateway \
150
+ -p 8080:8080 \
151
+ -v "$(pwd)/keeper/data:/data" \
152
+ -e TZ=Asia/Shanghai \
153
+ -e CPA_BASE_URL=http://host.docker.internal:8317 \
154
+ -e CPA_MANAGEMENT_KEY=replace-with-your-management-key \
155
+ -e REDIS_QUEUE_ADDR=host.docker.internal:8317 \
156
+ -e AUTH_ENABLED=true \
157
+ -e LOGIN_PASSWORD=replace-with-your-login-password \
158
+ ghcr.io/willxup/cpa-usage-keeper:latest
159
+ ```
160
+
161
+ `/data` 用于保存 SQLite 数据库、备份文件和日志文件,请挂载到持久化目录。
162
+
163
+ ## Docker Compose
164
+
165
+ 仓库提供了一个最简 `docker-compose.yaml` 示例,用于同时部署 CPA 和 CPA Usage Keeper:
166
+
167
+ ```yaml
168
+ services:
169
+ cli-proxy-api:
170
+ image: eceasy/cli-proxy-api:latest
171
+ container_name: cli-proxy-api
172
+ restart: unless-stopped
173
+ ports:
174
+ - "8317:8317"
175
+ - "1455:1455"
176
+ volumes:
177
+ - ./cpa/config.yaml:/CLIProxyAPI/config.yaml
178
+ - ./cpa/auths:/root/.cli-proxy-api
179
+ - ./cpa/logs:/CLIProxyAPI/logs
180
+ networks:
181
+ - cpa-network
182
+
183
+ cpa-usage-keeper:
184
+ image: ghcr.io/willxup/cpa-usage-keeper:latest
185
+ container_name: cpa-usage-keeper
186
+ restart: unless-stopped
187
+ depends_on:
188
+ - cli-proxy-api
189
+ ports:
190
+ - "8080:8080"
191
+ environment:
192
+ TZ: Asia/Shanghai # 设置容器时区,日志时间会按该时区显示。
193
+ CPA_BASE_URL: http://cli-proxy-api:8317
194
+ CPA_MANAGEMENT_KEY: replace-with-your-management-key
195
+ REDIS_QUEUE_ADDR: cli-proxy-api:8317
196
+ AUTH_ENABLED: true
197
+ LOGIN_PASSWORD: replace-with-your-login-password
198
+ volumes:
199
+ - ./keeper:/data
200
+ networks:
201
+ - cpa-network
202
+
203
+ networks:
204
+ cpa-network:
205
+ driver: bridge
206
+ ```
207
+
208
+ 启动:
209
+
210
+ ```bash
211
+ docker compose up -d
212
+ ```
213
+
214
+ 停止:
215
+
216
+ ```bash
217
+ docker compose down
218
+ ```
219
+
220
+ CPA 文件放在 `./cpa`,CPA Usage Keeper 数据放在 `./keeper`。
221
+
222
+ ## 子路径反代
223
+
224
+ 部署到 `/cpa` 时设置 `APP_BASE_PATH=/cpa`,并在反向代理中保留该前缀:
225
+
226
+ ```nginx
227
+ location /cpa/ {
228
+ proxy_pass http://127.0.0.1:8080;
229
+ proxy_set_header Host $host;
230
+ proxy_set_header X-Forwarded-Proto $scheme;
231
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
232
+ }
233
+ ```
234
+
235
+ ## Daili Space deployment
236
+
237
+ This Space builds CPA Usage Keeper from source and connects to `https://pjpjq-daili.hf.space` through CPA's HTTP `/v0/management/usage-queue` fallback. Dashboard auth is enabled with `LOGIN_PASSWORD`.
cmd/server/main.go ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "flag"
5
+ "log"
6
+ "os"
7
+
8
+ "cpa-usage-keeper/internal/app"
9
+ )
10
+
11
+ func main() {
12
+ envFile := flag.String("env", "", "path to env file")
13
+ flag.Parse()
14
+
15
+ application, err := app.NewWithOptions(app.Options{EnvFile: *envFile})
16
+ if err != nil {
17
+ log.Fatalf("initialize app: %v", err)
18
+ }
19
+ defer application.Close()
20
+
21
+ if err := application.Run(); err != nil {
22
+ log.Printf("run app: %v", err)
23
+ if closeErr := application.Close(); closeErr != nil {
24
+ log.Printf("close app: %v", closeErr)
25
+ }
26
+ os.Exit(1)
27
+ }
28
+ }
docker-compose.example.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ cpa-usage-keeper:
3
+ image: ghcr.io/willxup/cpa-usage-keeper:latest
4
+ ports:
5
+ - "8080:8080"
6
+ environment:
7
+ CPA_BASE_URL: ${CPA_BASE_URL}
8
+ CPA_MANAGEMENT_KEY: ${CPA_MANAGEMENT_KEY}
9
+ AUTH_ENABLED: ${AUTH_ENABLED:-false}
10
+ LOGIN_PASSWORD: ${LOGIN_PASSWORD:-}
11
+ AUTH_SESSION_TTL: ${AUTH_SESSION_TTL:-168h}
12
+ APP_PORT: ${APP_PORT:-8080}
13
+ APP_BASE_PATH: ${APP_BASE_PATH:-}
14
+ TZ: ${TZ:-Asia/Shanghai}
15
+ REDIS_QUEUE_ADDR: ${REDIS_QUEUE_ADDR:-}
16
+ REDIS_QUEUE_BATCH_SIZE: ${REDIS_QUEUE_BATCH_SIZE:-1000}
17
+ REDIS_QUEUE_IDLE_INTERVAL: ${REDIS_QUEUE_IDLE_INTERVAL:-1s}
18
+ REQUEST_TIMEOUT: ${REQUEST_TIMEOUT:-30s}
19
+ WORK_DIR: ${WORK_DIR:-./data}
20
+ LOG_LEVEL: ${LOG_LEVEL:-info}
21
+ LOG_FILE_ENABLED: ${LOG_FILE_ENABLED:-true}
22
+ LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-7}
23
+ BACKUP_ENABLED: ${BACKUP_ENABLED:-true}
24
+ BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h}
25
+ BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
26
+ volumes:
27
+ - ./data:/data
28
+ restart: unless-stopped
docker-entrypoint.sh ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ ensure_writable_dir() {
5
+ dir="$1"
6
+ if [ -z "$dir" ]; then
7
+ return
8
+ fi
9
+ mkdir -p "$dir"
10
+ chown -R app:app "$dir"
11
+ }
12
+
13
+ work_dir="${WORK_DIR:-./data}"
14
+ ensure_writable_dir "$work_dir"
15
+
16
+ case "${BACKUP_ENABLED:-true}" in
17
+ false|FALSE|False|0)
18
+ ;;
19
+ *)
20
+ ensure_writable_dir "$work_dir/backups"
21
+ ;;
22
+ esac
23
+
24
+ case "${LOG_FILE_ENABLED:-true}" in
25
+ false|FALSE|False|0)
26
+ ;;
27
+ *)
28
+ ensure_writable_dir "$work_dir/logs"
29
+ ;;
30
+ esac
31
+
32
+ exec su-exec app "$@"
go.mod ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module cpa-usage-keeper
2
+
3
+ go 1.22
4
+
5
+ require (
6
+ github.com/gin-gonic/gin v1.10.0
7
+ github.com/joho/godotenv v1.5.1
8
+ github.com/sirupsen/logrus v1.9.3
9
+ gorm.io/driver/sqlite v1.5.7
10
+ gorm.io/gorm v1.25.12
11
+ )
12
+
13
+ require (
14
+ github.com/bytedance/sonic v1.11.6 // indirect
15
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
16
+ github.com/cloudwego/base64x v0.1.4 // indirect
17
+ github.com/cloudwego/iasm v0.2.0 // indirect
18
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
19
+ github.com/gin-contrib/sse v0.1.0 // indirect
20
+ github.com/go-playground/locales v0.14.1 // indirect
21
+ github.com/go-playground/universal-translator v0.18.1 // indirect
22
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
23
+ github.com/goccy/go-json v0.10.2 // indirect
24
+ github.com/jinzhu/inflection v1.0.0 // indirect
25
+ github.com/jinzhu/now v1.1.5 // indirect
26
+ github.com/json-iterator/go v1.1.12 // indirect
27
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
28
+ github.com/leodido/go-urn v1.4.0 // indirect
29
+ github.com/mattn/go-isatty v0.0.20 // indirect
30
+ github.com/mattn/go-sqlite3 v1.14.22 // indirect
31
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
32
+ github.com/modern-go/reflect2 v1.0.2 // indirect
33
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
34
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
35
+ github.com/ugorji/go/codec v1.2.12 // indirect
36
+ golang.org/x/arch v0.8.0 // indirect
37
+ golang.org/x/crypto v0.23.0 // indirect
38
+ golang.org/x/net v0.25.0 // indirect
39
+ golang.org/x/sys v0.20.0 // indirect
40
+ golang.org/x/text v0.15.0 // indirect
41
+ google.golang.org/protobuf v1.34.1 // indirect
42
+ gopkg.in/yaml.v3 v3.0.1 // indirect
43
+ )
go.sum ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
2
+ github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
3
+ github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
4
+ github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
5
+ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
6
+ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
7
+ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
8
+ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
9
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
11
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12
+ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
13
+ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
14
+ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
15
+ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
16
+ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
17
+ github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
18
+ github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
19
+ github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
20
+ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
21
+ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
22
+ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
23
+ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
24
+ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
25
+ github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
26
+ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
27
+ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
28
+ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
29
+ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
30
+ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
31
+ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
32
+ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
33
+ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
34
+ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
35
+ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
36
+ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
37
+ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
38
+ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
39
+ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
40
+ github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
41
+ github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
42
+ github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
43
+ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
44
+ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
45
+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
46
+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
47
+ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
48
+ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
49
+ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
50
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
51
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
52
+ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
53
+ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
54
+ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
55
+ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
56
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
57
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
58
+ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
59
+ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
60
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
61
+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
62
+ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
63
+ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
64
+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
65
+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
66
+ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
67
+ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
68
+ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
69
+ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
70
+ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
71
+ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
72
+ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
73
+ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
74
+ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
75
+ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
76
+ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
77
+ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
78
+ golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
79
+ golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
80
+ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
81
+ golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
82
+ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
83
+ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
84
+ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
85
+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
86
+ golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
87
+ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
88
+ golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
89
+ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
90
+ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
91
+ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
92
+ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
93
+ google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
94
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
95
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
96
+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
97
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
98
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
99
+ gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
100
+ gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
101
+ gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
102
+ gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
103
+ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
104
+ rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
internal/api/auth.go ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "crypto/subtle"
5
+ "net"
6
+ "net/http"
7
+ "sync"
8
+ "time"
9
+
10
+ "cpa-usage-keeper/internal/auth"
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ const sessionCookieName = "cpa_usage_keeper_session"
15
+
16
+ const maxFailedLoginAttempts = 5
17
+
18
+ type AuthConfig struct {
19
+ Enabled bool
20
+ LoginPassword string
21
+ SessionTTL time.Duration
22
+ BasePath string
23
+ }
24
+
25
+ type authHandler struct {
26
+ config AuthConfig
27
+ sessions *auth.SessionManager
28
+
29
+ mu sync.Mutex
30
+ failedAttempts map[string]int
31
+ }
32
+
33
+ type loginRequest struct {
34
+ Password string `json:"password"`
35
+ }
36
+
37
+ type sessionResponse struct {
38
+ Authenticated bool `json:"authenticated"`
39
+ }
40
+
41
+ func NewAuthHandler(config AuthConfig, sessions *auth.SessionManager) *authHandler {
42
+ return &authHandler{config: config, sessions: sessions, failedAttempts: make(map[string]int)}
43
+ }
44
+
45
+ func (h *authHandler) registerRoutes(router gin.IRoutes) {
46
+ router.GET("/session", h.getSession)
47
+ router.POST("/login", h.login)
48
+ router.POST("/logout", h.logout)
49
+ }
50
+
51
+ func (h *authHandler) middleware() gin.HandlerFunc {
52
+ return func(c *gin.Context) {
53
+ if h == nil || !h.config.Enabled {
54
+ c.Next()
55
+ return
56
+ }
57
+ if h.sessions == nil {
58
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
59
+ return
60
+ }
61
+
62
+ token, err := c.Cookie(sessionCookieName)
63
+ if err != nil || !h.sessions.Validate(token) {
64
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
65
+ return
66
+ }
67
+
68
+ c.Next()
69
+ }
70
+ }
71
+
72
+ func (h *authHandler) getSession(c *gin.Context) {
73
+ if h == nil || !h.config.Enabled {
74
+ c.JSON(http.StatusOK, sessionResponse{Authenticated: true})
75
+ return
76
+ }
77
+ if h.sessions == nil {
78
+ c.JSON(http.StatusOK, sessionResponse{Authenticated: false})
79
+ return
80
+ }
81
+
82
+ token, err := c.Cookie(sessionCookieName)
83
+ if err != nil {
84
+ c.JSON(http.StatusOK, sessionResponse{Authenticated: false})
85
+ return
86
+ }
87
+
88
+ c.JSON(http.StatusOK, sessionResponse{Authenticated: h.sessions.Validate(token)})
89
+ }
90
+
91
+ func (h *authHandler) login(c *gin.Context) {
92
+ if h == nil || !h.config.Enabled {
93
+ c.Status(http.StatusNoContent)
94
+ return
95
+ }
96
+ if h.sessions == nil {
97
+ writeInternalError(c, "session manager is not configured", nil)
98
+ return
99
+ }
100
+
101
+ var request loginRequest
102
+ if err := c.ShouldBindJSON(&request); err != nil {
103
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
104
+ return
105
+ }
106
+
107
+ clientKey := loginClientKey(c)
108
+ passwordMatches := subtle.ConstantTimeCompare([]byte(request.Password), []byte(h.config.LoginPassword)) == 1
109
+ if h.tooManyFailedAttempts(clientKey) && !passwordMatches {
110
+ c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many failed login attempts"})
111
+ return
112
+ }
113
+
114
+ if !passwordMatches {
115
+ h.recordFailedAttempt(clientKey)
116
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
117
+ return
118
+ }
119
+ h.clearFailedAttempts(clientKey)
120
+
121
+ token, expiresAt, err := h.sessions.Create()
122
+ if err != nil {
123
+ writeInternalError(c, "create auth session failed", err)
124
+ return
125
+ }
126
+
127
+ secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
128
+ cookiePath := h.config.BasePath
129
+ if cookiePath == "" {
130
+ cookiePath = "/"
131
+ }
132
+ http.SetCookie(c.Writer, &http.Cookie{
133
+ Name: sessionCookieName,
134
+ Value: token,
135
+ Path: cookiePath,
136
+ HttpOnly: true,
137
+ Secure: secure,
138
+ SameSite: http.SameSiteLaxMode,
139
+ Expires: expiresAt,
140
+ MaxAge: int(time.Until(expiresAt).Seconds()),
141
+ })
142
+ c.Status(http.StatusNoContent)
143
+ }
144
+
145
+ func (h *authHandler) logout(c *gin.Context) {
146
+ if h == nil || !h.config.Enabled {
147
+ c.Status(http.StatusNoContent)
148
+ return
149
+ }
150
+ if h.sessions != nil {
151
+ if token, err := c.Cookie(sessionCookieName); err == nil {
152
+ h.sessions.Delete(token)
153
+ }
154
+ }
155
+ clearSessionCookie(c, h.config.BasePath)
156
+ c.Status(http.StatusNoContent)
157
+ }
158
+
159
+ func (h *authHandler) tooManyFailedAttempts(key string) bool {
160
+ h.mu.Lock()
161
+ defer h.mu.Unlock()
162
+ return h.failedAttempts[key] >= maxFailedLoginAttempts
163
+ }
164
+
165
+ func (h *authHandler) recordFailedAttempt(key string) {
166
+ h.mu.Lock()
167
+ defer h.mu.Unlock()
168
+ h.failedAttempts[key]++
169
+ }
170
+
171
+ func (h *authHandler) clearFailedAttempts(key string) {
172
+ h.mu.Lock()
173
+ defer h.mu.Unlock()
174
+ delete(h.failedAttempts, key)
175
+ }
176
+
177
+ func loginClientKey(c *gin.Context) string {
178
+ host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
179
+ if err == nil && host != "" {
180
+ return host
181
+ }
182
+ return c.ClientIP()
183
+ }
184
+
185
+ func clearSessionCookie(c *gin.Context, basePath string) {
186
+ cookiePath := basePath
187
+ if cookiePath == "" {
188
+ cookiePath = "/"
189
+ }
190
+ http.SetCookie(c.Writer, &http.Cookie{
191
+ Name: sessionCookieName,
192
+ Value: "",
193
+ Path: cookiePath,
194
+ HttpOnly: true,
195
+ SameSite: http.SameSiteLaxMode,
196
+ Expires: time.Unix(0, 0),
197
+ MaxAge: -1,
198
+ })
199
+ }
internal/api/auth_test.go ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+
10
+ "cpa-usage-keeper/internal/auth"
11
+ )
12
+
13
+ func TestAuthSessionReportsAuthenticatedWhenDisabled(t *testing.T) {
14
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{Enabled: false}, nil, "")
15
+ resp := httptest.NewRecorder()
16
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)
17
+
18
+ router.ServeHTTP(resp, req)
19
+
20
+ if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"authenticated":true`) {
21
+ t.Fatalf("unexpected response: %d %s", resp.Code, resp.Body.String())
22
+ }
23
+ }
24
+
25
+ func TestAuthProtectedRouteRequiresSessionWhenEnabled(t *testing.T) {
26
+ sessions := auth.NewSessionManager(time.Hour)
27
+ config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
28
+ router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
29
+ resp := httptest.NewRecorder()
30
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
31
+
32
+ router.ServeHTTP(resp, req)
33
+
34
+ if resp.Code != http.StatusUnauthorized {
35
+ t.Fatalf("expected status 401, got %d", resp.Code)
36
+ }
37
+ }
38
+
39
+ func TestAuthLoginSetsCookieAndUnlocksProtectedRoute(t *testing.T) {
40
+ sessions := auth.NewSessionManager(time.Hour)
41
+ config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
42
+ handler := NewAuthHandler(config, sessions)
43
+ router := NewRouter(nil, nil, nil, nil, config, handler, "")
44
+
45
+ loginResp := httptest.NewRecorder()
46
+ loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
47
+ loginReq.Header.Set("Content-Type", "application/json")
48
+ router.ServeHTTP(loginResp, loginReq)
49
+
50
+ if loginResp.Code != http.StatusNoContent {
51
+ t.Fatalf("expected login status 204, got %d", loginResp.Code)
52
+ }
53
+ cookie := loginResp.Result().Cookies()
54
+ if len(cookie) == 0 {
55
+ t.Fatal("expected auth cookie to be set")
56
+ }
57
+ if cookie[0].Name != sessionCookieName {
58
+ t.Fatalf("expected cookie %q, got %q", sessionCookieName, cookie[0].Name)
59
+ }
60
+ if cookie[0].Path != "/" {
61
+ t.Fatalf("expected root cookie path '/', got %q", cookie[0].Path)
62
+ }
63
+
64
+ usageResp := httptest.NewRecorder()
65
+ usageReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
66
+ usageReq.AddCookie(cookie[0])
67
+ router.ServeHTTP(usageResp, usageReq)
68
+
69
+ if usageResp.Code != http.StatusOK {
70
+ t.Fatalf("expected protected route to succeed, got %d %s", usageResp.Code, usageResp.Body.String())
71
+ }
72
+ }
73
+
74
+ func TestAuthLoginRejectsWrongPassword(t *testing.T) {
75
+ sessions := auth.NewSessionManager(time.Hour)
76
+ config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
77
+ router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
78
+ resp := httptest.NewRecorder()
79
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
80
+ req.Header.Set("Content-Type", "application/json")
81
+
82
+ router.ServeHTTP(resp, req)
83
+
84
+ if resp.Code != http.StatusUnauthorized {
85
+ t.Fatalf("expected status 401, got %d", resp.Code)
86
+ }
87
+ }
88
+
89
+ func TestAuthLoginRateLimitsRepeatedFailures(t *testing.T) {
90
+ sessions := auth.NewSessionManager(time.Hour)
91
+ config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
92
+ router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
93
+
94
+ for i := 0; i < 5; i++ {
95
+ resp := httptest.NewRecorder()
96
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
97
+ req.Header.Set("Content-Type", "application/json")
98
+ req.RemoteAddr = "198.51.100.1:1234"
99
+ router.ServeHTTP(resp, req)
100
+ if resp.Code != http.StatusUnauthorized {
101
+ t.Fatalf("expected failed attempt %d to return 401, got %d", i+1, resp.Code)
102
+ }
103
+ }
104
+
105
+ resp := httptest.NewRecorder()
106
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
107
+ req.Header.Set("Content-Type", "application/json")
108
+ req.RemoteAddr = "198.51.100.1:1234"
109
+ router.ServeHTTP(resp, req)
110
+
111
+ if resp.Code != http.StatusTooManyRequests {
112
+ t.Fatalf("expected repeated failed attempts to return 429, got %d", resp.Code)
113
+ }
114
+ }
115
+
116
+ func TestAuthLoginAllowsCorrectPasswordAfterRateLimitThreshold(t *testing.T) {
117
+ sessions := auth.NewSessionManager(time.Hour)
118
+ config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
119
+ router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "")
120
+
121
+ for i := 0; i < 5; i++ {
122
+ resp := httptest.NewRecorder()
123
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`))
124
+ req.Header.Set("Content-Type", "application/json")
125
+ req.RemoteAddr = "198.51.100.2:1234"
126
+ router.ServeHTTP(resp, req)
127
+ if resp.Code != http.StatusUnauthorized {
128
+ t.Fatalf("expected failed attempt %d to return 401, got %d", i+1, resp.Code)
129
+ }
130
+ }
131
+
132
+ resp := httptest.NewRecorder()
133
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
134
+ req.Header.Set("Content-Type", "application/json")
135
+ req.RemoteAddr = "198.51.100.2:1234"
136
+ router.ServeHTTP(resp, req)
137
+
138
+ if resp.Code != http.StatusNoContent {
139
+ t.Fatalf("expected correct password to clear failed attempts and login, got %d", resp.Code)
140
+ }
141
+ }
142
+
143
+ func TestAuthLogoutDeletesSessionCookie(t *testing.T) {
144
+ sessions := auth.NewSessionManager(time.Hour)
145
+ config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}
146
+ handler := NewAuthHandler(config, sessions)
147
+ router := NewRouter(nil, nil, nil, nil, config, handler, "")
148
+
149
+ loginResp := httptest.NewRecorder()
150
+ loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
151
+ loginReq.Header.Set("Content-Type", "application/json")
152
+ router.ServeHTTP(loginResp, loginReq)
153
+ if loginResp.Code != http.StatusNoContent {
154
+ t.Fatalf("expected login status 204, got %d", loginResp.Code)
155
+ }
156
+ cookies := loginResp.Result().Cookies()
157
+ if len(cookies) == 0 {
158
+ t.Fatal("expected auth cookie to be set")
159
+ }
160
+
161
+ logoutResp := httptest.NewRecorder()
162
+ logoutReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
163
+ logoutReq.AddCookie(cookies[0])
164
+ router.ServeHTTP(logoutResp, logoutReq)
165
+ if logoutResp.Code != http.StatusNoContent {
166
+ t.Fatalf("expected logout status 204, got %d", logoutResp.Code)
167
+ }
168
+ clearCookies := logoutResp.Result().Cookies()
169
+ if len(clearCookies) == 0 || clearCookies[0].Name != sessionCookieName || clearCookies[0].MaxAge >= 0 {
170
+ t.Fatalf("expected logout to clear session cookie, got %+v", clearCookies)
171
+ }
172
+
173
+ usageResp := httptest.NewRecorder()
174
+ usageReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
175
+ usageReq.AddCookie(cookies[0])
176
+ router.ServeHTTP(usageResp, usageReq)
177
+ if usageResp.Code != http.StatusUnauthorized {
178
+ t.Fatalf("expected logged out session to be rejected, got %d", usageResp.Code)
179
+ }
180
+ }
181
+
182
+ func TestSubpathAuthUsesPrefixedRoutesAndCookiePath(t *testing.T) {
183
+ sessions := auth.NewSessionManager(time.Hour)
184
+ config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour, BasePath: "/cpa"}
185
+ handler := NewAuthHandler(config, sessions)
186
+ router := NewRouter(nil, nil, nil, nil, config, handler, "/cpa")
187
+
188
+ sessionResp := httptest.NewRecorder()
189
+ sessionReq := httptest.NewRequest(http.MethodGet, "/cpa/api/v1/auth/session", nil)
190
+ router.ServeHTTP(sessionResp, sessionReq)
191
+ if sessionResp.Code != http.StatusOK || !contains(sessionResp.Body.String(), `"authenticated":false`) {
192
+ t.Fatalf("unexpected session response: %d %s", sessionResp.Code, sessionResp.Body.String())
193
+ }
194
+
195
+ loginResp := httptest.NewRecorder()
196
+ loginReq := httptest.NewRequest(http.MethodPost, "/cpa/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`))
197
+ loginReq.Header.Set("Content-Type", "application/json")
198
+ router.ServeHTTP(loginResp, loginReq)
199
+ if loginResp.Code != http.StatusNoContent {
200
+ t.Fatalf("expected login status 204, got %d", loginResp.Code)
201
+ }
202
+ cookies := loginResp.Result().Cookies()
203
+ if len(cookies) == 0 {
204
+ t.Fatal("expected auth cookie to be set")
205
+ }
206
+ if cookies[0].Path != "/cpa" {
207
+ t.Fatalf("expected subpath cookie path '/cpa', got %q", cookies[0].Path)
208
+ }
209
+
210
+ usageResp := httptest.NewRecorder()
211
+ usageReq := httptest.NewRequest(http.MethodGet, "/cpa/api/v1/usage/overview", nil)
212
+ usageReq.AddCookie(cookies[0])
213
+ router.ServeHTTP(usageResp, usageReq)
214
+ if usageResp.Code != http.StatusOK {
215
+ t.Fatalf("expected protected route under subpath to succeed, got %d %s", usageResp.Code, usageResp.Body.String())
216
+ }
217
+
218
+ unprefixedResp := httptest.NewRecorder()
219
+ unprefixedReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil)
220
+ unprefixedReq.AddCookie(cookies[0])
221
+ router.ServeHTTP(unprefixedResp, unprefixedReq)
222
+ if unprefixedResp.Code != http.StatusNotFound {
223
+ t.Fatalf("expected unprefixed route to 404, got %d", unprefixedResp.Code)
224
+ }
225
+ }
internal/api/errors.go ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "log/slog"
5
+ "net/http"
6
+
7
+ "github.com/gin-gonic/gin"
8
+ )
9
+
10
+ func writeInternalError(c *gin.Context, message string, err error) {
11
+ if err != nil {
12
+ slog.Error(message, "error", err)
13
+ }
14
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
15
+ }
internal/api/health.go ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ func registerHealthRoutes(router gin.IRoutes) {
10
+ router.GET("/healthz", func(c *gin.Context) {
11
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
12
+ })
13
+ }
internal/api/pricing.go ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+
7
+ "cpa-usage-keeper/internal/service"
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ type usedModelsResponse struct {
12
+ Models []string `json:"models"`
13
+ }
14
+
15
+ type pricingEntryResponse struct {
16
+ Model string `json:"model"`
17
+ PromptPricePer1M float64 `json:"prompt_price_per_1m"`
18
+ CompletionPricePer1M float64 `json:"completion_price_per_1m"`
19
+ CachePricePer1M float64 `json:"cache_price_per_1m"`
20
+ }
21
+
22
+ type pricingListResponse struct {
23
+ Pricing []pricingEntryResponse `json:"pricing"`
24
+ }
25
+
26
+ type updatePricingRequest struct {
27
+ Model string `json:"model"`
28
+ PromptPricePer1M float64 `json:"prompt_price_per_1m"`
29
+ CompletionPricePer1M float64 `json:"completion_price_per_1m"`
30
+ CachePricePer1M float64 `json:"cache_price_per_1m"`
31
+ }
32
+
33
+ func registerPricingRoutes(router gin.IRoutes, pricingProvider service.PricingProvider) {
34
+ router.GET("/models/used", func(c *gin.Context) {
35
+ if pricingProvider == nil {
36
+ c.JSON(http.StatusOK, usedModelsResponse{Models: []string{}})
37
+ return
38
+ }
39
+
40
+ models, err := pricingProvider.ListUsedModels(c.Request.Context())
41
+ if err != nil {
42
+ writeInternalError(c, "list used models failed", err)
43
+ return
44
+ }
45
+
46
+ c.JSON(http.StatusOK, usedModelsResponse{Models: models})
47
+ })
48
+
49
+ router.GET("/pricing", func(c *gin.Context) {
50
+ if pricingProvider == nil {
51
+ c.JSON(http.StatusOK, pricingListResponse{Pricing: []pricingEntryResponse{}})
52
+ return
53
+ }
54
+
55
+ settings, err := pricingProvider.ListPricing(c.Request.Context())
56
+ if err != nil {
57
+ writeInternalError(c, "list pricing failed", err)
58
+ return
59
+ }
60
+
61
+ response := make([]pricingEntryResponse, 0, len(settings))
62
+ for _, setting := range settings {
63
+ response = append(response, pricingEntryResponse{
64
+ Model: setting.Model,
65
+ PromptPricePer1M: setting.PromptPricePer1M,
66
+ CompletionPricePer1M: setting.CompletionPricePer1M,
67
+ CachePricePer1M: setting.CachePricePer1M,
68
+ })
69
+ }
70
+ c.JSON(http.StatusOK, pricingListResponse{Pricing: response})
71
+ })
72
+
73
+ router.PUT("/pricing", func(c *gin.Context) {
74
+ updatePricing(c, pricingProvider, "")
75
+ })
76
+
77
+ router.PUT("/pricing/:model", func(c *gin.Context) {
78
+ updatePricing(c, pricingProvider, c.Param("model"))
79
+ })
80
+
81
+ router.DELETE("/pricing", func(c *gin.Context) {
82
+ if pricingProvider == nil {
83
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "pricing provider is not configured"})
84
+ return
85
+ }
86
+ model := strings.TrimSpace(c.Query("model"))
87
+ if model == "" {
88
+ c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"})
89
+ return
90
+ }
91
+ if err := pricingProvider.DeletePricing(c.Request.Context(), model); err != nil {
92
+ if strings.Contains(err.Error(), "required") {
93
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
94
+ return
95
+ }
96
+ writeInternalError(c, "delete pricing failed", err)
97
+ return
98
+ }
99
+ c.Status(http.StatusNoContent)
100
+ })
101
+ }
102
+
103
+ func updatePricing(c *gin.Context, pricingProvider service.PricingProvider, pathModel string) {
104
+ if pricingProvider == nil {
105
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "pricing provider is not configured"})
106
+ return
107
+ }
108
+
109
+ var request updatePricingRequest
110
+ if err := c.ShouldBindJSON(&request); err != nil {
111
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
112
+ return
113
+ }
114
+
115
+ model := strings.TrimSpace(pathModel)
116
+ if model == "" {
117
+ model = strings.TrimSpace(request.Model)
118
+ }
119
+ if model == "" {
120
+ c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"})
121
+ return
122
+ }
123
+
124
+ setting, err := pricingProvider.UpdatePricing(c.Request.Context(), service.UpdatePricingInput{
125
+ Model: model,
126
+ PromptPricePer1M: request.PromptPricePer1M,
127
+ CompletionPricePer1M: request.CompletionPricePer1M,
128
+ CachePricePer1M: request.CachePricePer1M,
129
+ })
130
+ if err != nil {
131
+ if strings.Contains(err.Error(), "has not been used") || strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "non-negative") {
132
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
133
+ return
134
+ }
135
+ writeInternalError(c, "update pricing failed", err)
136
+ return
137
+ }
138
+
139
+ c.JSON(http.StatusOK, pricingEntryResponse{
140
+ Model: setting.Model,
141
+ PromptPricePer1M: setting.PromptPricePer1M,
142
+ CompletionPricePer1M: setting.CompletionPricePer1M,
143
+ CachePricePer1M: setting.CachePricePer1M,
144
+ })
145
+ }
internal/api/pricing_test.go ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "strings"
8
+ "testing"
9
+
10
+ "cpa-usage-keeper/internal/models"
11
+ "cpa-usage-keeper/internal/service"
12
+ )
13
+
14
+ type pricingStub struct {
15
+ usedModels []string
16
+ pricing []models.ModelPriceSetting
17
+ updated *models.ModelPriceSetting
18
+ lastUpdate *service.UpdatePricingInput
19
+ deleted string
20
+ err error
21
+ }
22
+
23
+ func (s pricingStub) ListUsedModels(context.Context) ([]string, error) {
24
+ return s.usedModels, s.err
25
+ }
26
+
27
+ func (s pricingStub) ListPricing(context.Context) ([]models.ModelPriceSetting, error) {
28
+ return s.pricing, s.err
29
+ }
30
+
31
+ func (s *pricingStub) UpdatePricing(_ context.Context, input service.UpdatePricingInput) (*models.ModelPriceSetting, error) {
32
+ s.lastUpdate = &input
33
+ return s.updated, s.err
34
+ }
35
+
36
+ func (s *pricingStub) DeletePricing(_ context.Context, model string) error {
37
+ s.deleted = model
38
+ return s.err
39
+ }
40
+
41
+ func TestPricingRoutesReturnEmptyResponsesWithoutProvider(t *testing.T) {
42
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
43
+
44
+ usedReq := httptest.NewRequest(http.MethodGet, "/api/v1/models/used", nil)
45
+ usedResp := httptest.NewRecorder()
46
+ router.ServeHTTP(usedResp, usedReq)
47
+ if usedResp.Code != http.StatusOK || !contains(usedResp.Body.String(), `"models":[]`) {
48
+ t.Fatalf("unexpected used models response: %d %s", usedResp.Code, usedResp.Body.String())
49
+ }
50
+
51
+ pricingReq := httptest.NewRequest(http.MethodGet, "/api/v1/pricing", nil)
52
+ pricingResp := httptest.NewRecorder()
53
+ router.ServeHTTP(pricingResp, pricingReq)
54
+ if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"pricing":[]`) {
55
+ t.Fatalf("unexpected pricing response: %d %s", pricingResp.Code, pricingResp.Body.String())
56
+ }
57
+ }
58
+
59
+ func TestPricingRoutesReturnConfiguredData(t *testing.T) {
60
+ router := NewRouter(nil, nil, nil, &pricingStub{
61
+ usedModels: []string{"claude-sonnet"},
62
+ pricing: []models.ModelPriceSetting{{
63
+ Model: "claude-sonnet",
64
+ PromptPricePer1M: 3,
65
+ CompletionPricePer1M: 15,
66
+ CachePricePer1M: 0.3,
67
+ }},
68
+ }, AuthConfig{}, nil, "")
69
+
70
+ usedReq := httptest.NewRequest(http.MethodGet, "/api/v1/models/used", nil)
71
+ usedResp := httptest.NewRecorder()
72
+ router.ServeHTTP(usedResp, usedReq)
73
+ if usedResp.Code != http.StatusOK || !contains(usedResp.Body.String(), `claude-sonnet`) {
74
+ t.Fatalf("unexpected used models response: %d %s", usedResp.Code, usedResp.Body.String())
75
+ }
76
+
77
+ pricingReq := httptest.NewRequest(http.MethodGet, "/api/v1/pricing", nil)
78
+ pricingResp := httptest.NewRecorder()
79
+ router.ServeHTTP(pricingResp, pricingReq)
80
+ if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"prompt_price_per_1m":3`) {
81
+ t.Fatalf("unexpected pricing response: %d %s", pricingResp.Code, pricingResp.Body.String())
82
+ }
83
+ }
84
+
85
+ func TestUpdatePricingRoute(t *testing.T) {
86
+ provider := &pricingStub{
87
+ updated: &models.ModelPriceSetting{
88
+ Model: "claude-sonnet",
89
+ PromptPricePer1M: 3,
90
+ CompletionPricePer1M: 15,
91
+ CachePricePer1M: 0.3,
92
+ },
93
+ }
94
+ router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")
95
+
96
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing/claude-sonnet", strings.NewReader(`{"prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`))
97
+ req.Header.Set("Content-Type", "application/json")
98
+ resp := httptest.NewRecorder()
99
+ router.ServeHTTP(resp, req)
100
+
101
+ if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"claude-sonnet"`) {
102
+ t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String())
103
+ }
104
+ }
105
+
106
+ func TestUpdatePricingRouteAcceptsModelInBody(t *testing.T) {
107
+ provider := &pricingStub{
108
+ updated: &models.ModelPriceSetting{
109
+ Model: "openai/gpt-4.1",
110
+ PromptPricePer1M: 3,
111
+ CompletionPricePer1M: 15,
112
+ CachePricePer1M: 0.3,
113
+ },
114
+ }
115
+ router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")
116
+
117
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing", strings.NewReader(`{"model":"openai/gpt-4.1","prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`))
118
+ req.Header.Set("Content-Type", "application/json")
119
+ resp := httptest.NewRecorder()
120
+ router.ServeHTTP(resp, req)
121
+
122
+ if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"openai/gpt-4.1"`) {
123
+ t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String())
124
+ }
125
+ if provider.lastUpdate == nil || provider.lastUpdate.Model != "openai/gpt-4.1" {
126
+ t.Fatalf("expected model from body to be passed through, got %+v", provider.lastUpdate)
127
+ }
128
+ }
129
+
130
+ func TestDeletePricingRoute(t *testing.T) {
131
+ provider := &pricingStub{}
132
+ router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "")
133
+
134
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/pricing?model=openai%2Fgpt-4.1", nil)
135
+ resp := httptest.NewRecorder()
136
+ router.ServeHTTP(resp, req)
137
+
138
+ if resp.Code != http.StatusNoContent {
139
+ t.Fatalf("expected status 204, got %d %s", resp.Code, resp.Body.String())
140
+ }
141
+ if provider.deleted != "openai/gpt-4.1" {
142
+ t.Fatalf("expected model to be deleted, got %q", provider.deleted)
143
+ }
144
+ }
internal/api/router.go ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "errors"
7
+ "io"
8
+ "io/fs"
9
+ "log/slog"
10
+ "net/http"
11
+ "path"
12
+ "strconv"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "cpa-usage-keeper/internal/poller"
18
+ "cpa-usage-keeper/internal/service"
19
+ "github.com/gin-gonic/gin"
20
+ )
21
+
22
+ const appBasePathPlaceholder = "__APP_BASE_PATH__"
23
+ const manualSyncRateLimitWindow = time.Second
24
+
25
+ type syncLimiter struct {
26
+ mu sync.Mutex
27
+ window time.Duration
28
+ lastSync time.Time
29
+ }
30
+
31
+ func (l *syncLimiter) allow(now time.Time) bool {
32
+ l.mu.Lock()
33
+ defer l.mu.Unlock()
34
+ if !l.lastSync.IsZero() && now.Sub(l.lastSync) < l.window {
35
+ return false
36
+ }
37
+ l.lastSync = now
38
+ return true
39
+ }
40
+
41
+ type StatusProvider interface {
42
+ Status() poller.Status
43
+ }
44
+
45
+ type SyncRunner interface {
46
+ SyncNow(ctx context.Context) error
47
+ }
48
+
49
+ type syncUserMessageError interface {
50
+ UserMessage() string
51
+ }
52
+
53
+ func NewRouter(
54
+ staticFS fs.FS,
55
+ statusProvider StatusProvider,
56
+ usageProvider service.UsageProvider,
57
+ pricingProvider service.PricingProvider,
58
+ authConfig AuthConfig,
59
+ authHandler *authHandler,
60
+ basePath string,
61
+ usageIdentityProviders ...service.UsageIdentityProvider,
62
+ ) *gin.Engine {
63
+ router := gin.New()
64
+ router.Use(gin.Recovery())
65
+
66
+ appGroup := router.Group(basePath)
67
+ registerHealthRoutes(appGroup)
68
+
69
+ apiV1 := appGroup.Group("/api/v1")
70
+ apiV1.GET("/ping", func(c *gin.Context) {
71
+ c.JSON(http.StatusOK, gin.H{"message": "ok"})
72
+ })
73
+
74
+ authGroup := apiV1.Group("/auth")
75
+ if authHandler == nil {
76
+ authHandler = NewAuthHandler(authConfig, nil)
77
+ }
78
+ authHandler.registerRoutes(authGroup)
79
+
80
+ var usageIdentityProvider service.UsageIdentityProvider
81
+ if len(usageIdentityProviders) > 0 {
82
+ usageIdentityProvider = usageIdentityProviders[0]
83
+ }
84
+
85
+ protected := apiV1.Group("")
86
+ protected.Use(authHandler.middleware())
87
+ registerStatusRoutes(protected, statusProvider)
88
+ registerSyncRoutes(protected, statusProvider, &syncLimiter{window: manualSyncRateLimitWindow})
89
+ registerUsageOverviewRoute(protected, usageProvider)
90
+ registerUsageAnalysisRoute(protected, usageProvider)
91
+ registerUsageEventsRoute(protected, usageProvider, usageIdentityProvider)
92
+ registerUsageCredentialsRoute(protected, usageProvider, usageIdentityProvider)
93
+ registerUsageIdentityRoutes(protected, usageIdentityProvider)
94
+ registerPricingRoutes(protected, pricingProvider)
95
+
96
+ if staticFS != nil {
97
+ if indexFile, err := staticFS.Open("index.html"); err == nil {
98
+ _ = indexFile.Close()
99
+ httpFS := http.FS(staticFS)
100
+ serveIndex := func(c *gin.Context) {
101
+ indexHTML, err := renderIndexHTML(staticFS, basePath)
102
+ if err != nil {
103
+ c.Status(http.StatusNotFound)
104
+ return
105
+ }
106
+ c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML)
107
+ }
108
+
109
+ appGroup.GET("/", serveIndex)
110
+ assetsFS, _ := fs.Sub(staticFS, "assets")
111
+ appGroup.StaticFS("/assets", http.FS(assetsFS))
112
+ router.NoRoute(func(c *gin.Context) {
113
+ requestPath, ok := stripBasePath(basePath, c.Request.URL.Path)
114
+ if !ok {
115
+ c.Status(http.StatusNotFound)
116
+ return
117
+ }
118
+ if strings.HasPrefix(requestPath, "/api/") {
119
+ c.Status(http.StatusNotFound)
120
+ return
121
+ }
122
+
123
+ if assetPath, ok := staticAssetPath(requestPath); ok {
124
+ if assetFile, err := staticFS.Open(assetPath); err == nil {
125
+ _ = assetFile.Close()
126
+ c.FileFromFS(assetPath, httpFS)
127
+ return
128
+ }
129
+ }
130
+
131
+ serveIndex(c)
132
+ })
133
+ }
134
+ }
135
+
136
+ return router
137
+ }
138
+
139
+ func renderIndexHTML(staticFS fs.FS, basePath string) ([]byte, error) {
140
+ indexFile, err := staticFS.Open("index.html")
141
+ if err != nil {
142
+ return nil, err
143
+ }
144
+ defer indexFile.Close()
145
+ indexHTML, err := io.ReadAll(indexFile)
146
+ if err != nil {
147
+ return nil, err
148
+ }
149
+
150
+ return bytes.ReplaceAll(
151
+ indexHTML,
152
+ []byte(strconv.Quote(appBasePathPlaceholder)),
153
+ []byte(strconv.Quote(basePath)),
154
+ ), nil
155
+ }
156
+
157
+ func cleanURLPath(requestPath string) string {
158
+ cleaned := path.Clean(requestPath)
159
+ if cleaned == "." {
160
+ return "/"
161
+ }
162
+ if !strings.HasPrefix(cleaned, "/") {
163
+ return "/" + cleaned
164
+ }
165
+ return cleaned
166
+ }
167
+
168
+ func staticAssetPath(requestPath string) (string, bool) {
169
+ cleaned := cleanURLPath(requestPath)
170
+ if strings.Contains(cleaned, "\\") {
171
+ return "", false
172
+ }
173
+ relPath := strings.TrimPrefix(cleaned, "/")
174
+ if relPath == "" {
175
+ return "", false
176
+ }
177
+ return relPath, true
178
+ }
179
+
180
+ func stripBasePath(basePath, requestPath string) (string, bool) {
181
+ cleaned := cleanURLPath(requestPath)
182
+ if basePath == "" {
183
+ return cleaned, true
184
+ }
185
+ if cleaned == basePath {
186
+ return "/", true
187
+ }
188
+ if !strings.HasPrefix(cleaned, basePath+"/") {
189
+ return "", false
190
+ }
191
+ trimmed := strings.TrimPrefix(cleaned, basePath)
192
+ if trimmed == "" {
193
+ return "/", true
194
+ }
195
+ return trimmed, true
196
+ }
197
+
198
+ type statusResponse struct {
199
+ Running bool `json:"running"`
200
+ SyncRunning bool `json:"sync_running"`
201
+ Timezone string `json:"timezone"`
202
+ LastRunAt *time.Time `json:"last_run_at,omitempty"`
203
+ LastError string `json:"last_error,omitempty"`
204
+ LastWarning string `json:"last_warning,omitempty"`
205
+ LastStatus string `json:"last_status,omitempty"`
206
+ }
207
+
208
+ func registerStatusRoutes(router gin.IRoutes, statusProvider StatusProvider) {
209
+ router.GET("/status", func(c *gin.Context) {
210
+ if statusProvider == nil {
211
+ c.JSON(http.StatusOK, statusResponse{Timezone: time.Local.String()})
212
+ return
213
+ }
214
+
215
+ c.JSON(http.StatusOK, buildStatusResponse(statusProvider.Status()))
216
+ })
217
+ }
218
+
219
+ func manualSyncErrorMessage(err error) string {
220
+ var userMessage syncUserMessageError
221
+ if errors.As(err, &userMessage) && userMessage.UserMessage() != "" {
222
+ return userMessage.UserMessage()
223
+ }
224
+ return "manual sync failed"
225
+ }
226
+
227
+ func registerSyncRoutes(router gin.IRoutes, statusProvider StatusProvider, limiter *syncLimiter) {
228
+ router.POST("/sync", func(c *gin.Context) {
229
+ if limiter != nil && !limiter.allow(time.Now()) {
230
+ c.JSON(http.StatusTooManyRequests, gin.H{"error": "sync rate limit exceeded"})
231
+ return
232
+ }
233
+
234
+ syncRunner, ok := statusProvider.(SyncRunner)
235
+ if !ok || syncRunner == nil {
236
+ writeInternalError(c, "sync runner is not configured", nil)
237
+ return
238
+ }
239
+
240
+ if err := syncRunner.SyncNow(c.Request.Context()); err != nil {
241
+ if errors.Is(err, poller.ErrSyncAlreadyRunning) {
242
+ c.JSON(http.StatusConflict, gin.H{"error": "sync already running"})
243
+ return
244
+ }
245
+ slog.Error("manual sync failed", "error", err)
246
+ c.JSON(http.StatusInternalServerError, gin.H{"error": manualSyncErrorMessage(err)})
247
+ return
248
+ }
249
+
250
+ if statusProvider, ok := syncRunner.(StatusProvider); ok {
251
+ c.JSON(http.StatusOK, buildStatusResponse(statusProvider.Status()))
252
+ return
253
+ }
254
+ c.JSON(http.StatusOK, gin.H{"sync_running": false})
255
+ })
256
+ }
257
+
258
+ func buildStatusResponse(status poller.Status) statusResponse {
259
+ response := statusResponse{
260
+ Running: status.Running,
261
+ SyncRunning: status.SyncRunning,
262
+ Timezone: time.Local.String(),
263
+ LastError: status.LastError,
264
+ LastWarning: status.LastWarning,
265
+ LastStatus: status.LastStatus,
266
+ }
267
+ if !status.LastRunAt.IsZero() {
268
+ lastRunAt := status.LastRunAt.UTC()
269
+ response.LastRunAt = &lastRunAt
270
+ }
271
+ return response
272
+ }
internal/api/router_test.go ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "io/fs"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+ "testing/fstest"
10
+ "time"
11
+
12
+ "cpa-usage-keeper/internal/poller"
13
+ )
14
+
15
+ func testStaticFS(t *testing.T, files map[string]string) fs.FS {
16
+ t.Helper()
17
+ staticFS := fstest.MapFS{}
18
+ for name, content := range files {
19
+ staticFS[name] = &fstest.MapFile{Data: []byte(content), Mode: 0o644}
20
+ }
21
+ return staticFS
22
+ }
23
+
24
+ type statusStub struct {
25
+ status poller.Status
26
+ }
27
+
28
+ type syncStatusStub struct {
29
+ status poller.Status
30
+ calls int
31
+ err error
32
+ }
33
+
34
+ type userFacingSyncError struct {
35
+ message string
36
+ }
37
+
38
+ func (e userFacingSyncError) Error() string {
39
+ return e.message
40
+ }
41
+
42
+ func (e userFacingSyncError) UserMessage() string {
43
+ return e.message
44
+ }
45
+
46
+ func (s statusStub) Status() poller.Status {
47
+ return s.status
48
+ }
49
+
50
+ func (s *syncStatusStub) Status() poller.Status {
51
+ return s.status
52
+ }
53
+
54
+ func (s *syncStatusStub) SyncNow(context.Context) error {
55
+ s.calls++
56
+ return s.err
57
+ }
58
+
59
+ func TestHealthzReturnsOK(t *testing.T) {
60
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
61
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
62
+ resp := httptest.NewRecorder()
63
+
64
+ router.ServeHTTP(resp, req)
65
+
66
+ if resp.Code != http.StatusOK {
67
+ t.Fatalf("expected status 200, got %d", resp.Code)
68
+ }
69
+ }
70
+
71
+ func TestStatusReturnsPollerState(t *testing.T) {
72
+ lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
73
+ router := NewRouter(nil, statusStub{status: poller.Status{
74
+ Running: true,
75
+ SyncRunning: false,
76
+ LastRunAt: lastRunAt,
77
+ LastError: "boom",
78
+ LastWarning: "metadata unavailable",
79
+ LastStatus: "completed_with_warnings",
80
+ }}, nil, nil, AuthConfig{}, nil, "")
81
+
82
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
83
+ resp := httptest.NewRecorder()
84
+ router.ServeHTTP(resp, req)
85
+
86
+ if resp.Code != http.StatusOK {
87
+ t.Fatalf("expected status 200, got %d", resp.Code)
88
+ }
89
+ body := resp.Body.String()
90
+ if !(contains(body, `"running":true`) && contains(body, `"sync_running":false`) && contains(body, `"last_error":"boom"`) && contains(body, `"last_warning":"metadata unavailable"`) && contains(body, `"last_status":"completed_with_warnings"`) && contains(body, `"last_run_at":"2026-04-16T12:00:00Z"`)) {
91
+ t.Fatalf("unexpected response body: %s", body)
92
+ }
93
+ }
94
+
95
+ func TestStatusReturnsProjectTimezone(t *testing.T) {
96
+ previousLocal := time.Local
97
+ location, err := time.LoadLocation("Asia/Shanghai")
98
+ if err != nil {
99
+ t.Fatalf("load location: %v", err)
100
+ }
101
+ t.Cleanup(func() { time.Local = previousLocal })
102
+ time.Local = location
103
+
104
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
105
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
106
+ resp := httptest.NewRecorder()
107
+ router.ServeHTTP(resp, req)
108
+
109
+ if resp.Code != http.StatusOK {
110
+ t.Fatalf("expected status 200, got %d", resp.Code)
111
+ }
112
+ if body := resp.Body.String(); !contains(body, `"timezone":"Asia/Shanghai"`) {
113
+ t.Fatalf("expected status response to include project timezone, got %s", body)
114
+ }
115
+ }
116
+
117
+ func TestStatusReturnsEmptyStateWithoutProvider(t *testing.T) {
118
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "")
119
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
120
+ resp := httptest.NewRecorder()
121
+ router.ServeHTTP(resp, req)
122
+
123
+ if resp.Code != http.StatusOK {
124
+ t.Fatalf("expected status 200, got %d", resp.Code)
125
+ }
126
+ if body := resp.Body.String(); !contains(body, `"running":false`) || !contains(body, `"sync_running":false`) || !contains(body, `"timezone":`) {
127
+ t.Fatalf("unexpected response body: %s", body)
128
+ }
129
+ }
130
+
131
+ func TestManualSyncTriggersSyncRunner(t *testing.T) {
132
+ lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
133
+ syncer := &syncStatusStub{status: poller.Status{Running: true, LastRunAt: lastRunAt, LastStatus: "completed"}}
134
+ router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
135
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
136
+ resp := httptest.NewRecorder()
137
+
138
+ router.ServeHTTP(resp, req)
139
+
140
+ if resp.Code != http.StatusOK {
141
+ t.Fatalf("expected status 200, got %d", resp.Code)
142
+ }
143
+ if syncer.calls != 1 {
144
+ t.Fatalf("expected sync runner to be called once, got %d", syncer.calls)
145
+ }
146
+ body := resp.Body.String()
147
+ if !(contains(body, `"last_status":"completed"`) && contains(body, `"last_run_at":"2026-04-16T12:00:00Z"`)) {
148
+ t.Fatalf("unexpected response body: %s", body)
149
+ }
150
+ }
151
+
152
+ func TestManualSyncReturnsConflictWhenAlreadyRunning(t *testing.T) {
153
+ syncer := &syncStatusStub{err: poller.ErrSyncAlreadyRunning}
154
+ router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
155
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
156
+ resp := httptest.NewRecorder()
157
+
158
+ router.ServeHTTP(resp, req)
159
+
160
+ if resp.Code != http.StatusConflict {
161
+ t.Fatalf("expected status 409, got %d", resp.Code)
162
+ }
163
+ }
164
+
165
+ func TestManualSyncReturnsWarningsAsError(t *testing.T) {
166
+ syncer := &syncStatusStub{
167
+ status: poller.Status{LastStatus: "completed_with_warnings", LastWarning: "metadata unavailable"},
168
+ err: poller.ErrSyncCompletedWithWarnings,
169
+ }
170
+ router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
171
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
172
+ resp := httptest.NewRecorder()
173
+
174
+ router.ServeHTTP(resp, req)
175
+
176
+ if resp.Code != http.StatusInternalServerError {
177
+ t.Fatalf("expected status 500, got %d", resp.Code)
178
+ }
179
+ if body := resp.Body.String(); !contains(body, `"error":"manual sync failed"`) {
180
+ t.Fatalf("unexpected response body: %s", body)
181
+ }
182
+ }
183
+
184
+ func TestManualSyncReturnsUserFacingStageError(t *testing.T) {
185
+ syncer := &syncStatusStub{err: userFacingSyncError{message: "metadata sync failed"}}
186
+ router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
187
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
188
+ resp := httptest.NewRecorder()
189
+
190
+ router.ServeHTTP(resp, req)
191
+
192
+ if resp.Code != http.StatusInternalServerError {
193
+ t.Fatalf("expected status 500, got %d", resp.Code)
194
+ }
195
+ if body := resp.Body.String(); !contains(body, `"error":"metadata sync failed"`) {
196
+ t.Fatalf("unexpected response body: %s", body)
197
+ }
198
+ }
199
+
200
+ func TestManualSyncRateLimitsRepeatedRequests(t *testing.T) {
201
+ syncer := &syncStatusStub{status: poller.Status{LastStatus: "completed"}}
202
+ router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "")
203
+
204
+ firstResp := httptest.NewRecorder()
205
+ firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
206
+ router.ServeHTTP(firstResp, firstReq)
207
+ if firstResp.Code != http.StatusOK {
208
+ t.Fatalf("expected first sync to return 200, got %d", firstResp.Code)
209
+ }
210
+
211
+ secondResp := httptest.NewRecorder()
212
+ secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
213
+ router.ServeHTTP(secondResp, secondReq)
214
+ if secondResp.Code != http.StatusTooManyRequests {
215
+ t.Fatalf("expected second sync within one second to return 429, got %d", secondResp.Code)
216
+ }
217
+ if syncer.calls != 1 {
218
+ t.Fatalf("expected rate-limited sync not to call runner again, got %d calls", syncer.calls)
219
+ }
220
+ }
221
+
222
+ func TestSubpathRoutesOnlyServePrefixedEndpoints(t *testing.T) {
223
+ lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
224
+ router := NewRouter(nil, statusStub{status: poller.Status{
225
+ Running: true,
226
+ LastRunAt: lastRunAt,
227
+ }}, nil, nil, AuthConfig{BasePath: "/cpa"}, nil, "/cpa")
228
+
229
+ for _, testCase := range []struct {
230
+ path string
231
+ statusCode int
232
+ }{
233
+ {path: "/cpa/healthz", statusCode: http.StatusOK},
234
+ {path: "/cpa/api/v1/status", statusCode: http.StatusOK},
235
+ {path: "/healthz", statusCode: http.StatusNotFound},
236
+ {path: "/api/v1/status", statusCode: http.StatusNotFound},
237
+ } {
238
+ resp := httptest.NewRecorder()
239
+ req := httptest.NewRequest(http.MethodGet, testCase.path, nil)
240
+ router.ServeHTTP(resp, req)
241
+ if resp.Code != testCase.statusCode {
242
+ t.Fatalf("expected %s to return %d, got %d", testCase.path, testCase.statusCode, resp.Code)
243
+ }
244
+ }
245
+ }
246
+
247
+ func TestSubpathStaticRoutesServeOnlyUnderPrefix(t *testing.T) {
248
+ staticFS := testStaticFS(t, map[string]string{
249
+ "index.html": `<html><head><script>window.__APP_BASE_PATH__ = "__APP_BASE_PATH__";</script></head><body>app</body></html>`,
250
+ "assets/app.js": "console.log('ok')",
251
+ })
252
+
253
+ router := NewRouter(staticFS, nil, nil, nil, AuthConfig{BasePath: "/cpa"}, nil, "/cpa")
254
+
255
+ for _, testCase := range []struct {
256
+ path string
257
+ statusCode int
258
+ contains string
259
+ }{
260
+ {path: "/cpa/", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`},
261
+ {path: "/cpa/dashboard", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`},
262
+ {path: "/cpa/assets/app.js", statusCode: http.StatusOK, contains: "console.log('ok')"},
263
+ {path: "/cpa/missing.html", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`},
264
+ {path: "/foo", statusCode: http.StatusNotFound},
265
+ {path: "/assets/app.js", statusCode: http.StatusNotFound},
266
+ {path: "/cpa/api/unknown", statusCode: http.StatusNotFound},
267
+ } {
268
+ resp := httptest.NewRecorder()
269
+ req := httptest.NewRequest(http.MethodGet, testCase.path, nil)
270
+ router.ServeHTTP(resp, req)
271
+ if resp.Code != testCase.statusCode {
272
+ t.Fatalf("expected %s to return %d, got %d", testCase.path, testCase.statusCode, resp.Code)
273
+ }
274
+ if testCase.contains != "" && !contains(resp.Body.String(), testCase.contains) {
275
+ t.Fatalf("expected %s response to contain %q, got %s", testCase.path, testCase.contains, resp.Body.String())
276
+ }
277
+ }
278
+ }
279
+
280
+ func TestCleanURLPathUsesSlashSemantics(t *testing.T) {
281
+ if cleaned := cleanURLPath("/cpa//dashboard/../assets/app.js"); cleaned != "/cpa/assets/app.js" {
282
+ t.Fatalf("expected slash-normalized URL path, got %q", cleaned)
283
+ }
284
+ }
285
+
286
+ func TestStaticAssetPathRejectsBackslashTraversal(t *testing.T) {
287
+ if _, ok := staticAssetPath(`/..\.env`); ok {
288
+ t.Fatal("expected backslash traversal path to be rejected")
289
+ }
290
+ }
291
+
292
+ func TestRootStaticRouteInjectsEmptyBasePath(t *testing.T) {
293
+ staticFS := testStaticFS(t, map[string]string{
294
+ "index.html": `<html><head><script>window.__APP_BASE_PATH__ = "__APP_BASE_PATH__";</script></head><body>app</body></html>`,
295
+ })
296
+
297
+ router := NewRouter(staticFS, nil, nil, nil, AuthConfig{}, nil, "")
298
+ resp := httptest.NewRecorder()
299
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
300
+ router.ServeHTTP(resp, req)
301
+
302
+ if resp.Code != http.StatusOK {
303
+ t.Fatalf("expected status 200, got %d", resp.Code)
304
+ }
305
+ if !contains(resp.Body.String(), `window.__APP_BASE_PATH__ = "";`) {
306
+ t.Fatalf("expected injected empty base path, got %s", resp.Body.String())
307
+ }
308
+ }
309
+
310
+ func contains(s, sub string) bool {
311
+ return len(sub) == 0 || (len(s) >= len(sub) && (func() bool { return stringContains(s, sub) })())
312
+ }
313
+
314
+ func stringContains(s, sub string) bool {
315
+ for i := 0; i+len(sub) <= len(s); i++ {
316
+ if s[i:i+len(sub)] == sub {
317
+ return true
318
+ }
319
+ }
320
+ return false
321
+ }
internal/api/usage_analysis.go ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "time"
6
+
7
+ "cpa-usage-keeper/internal/redact"
8
+ "cpa-usage-keeper/internal/service"
9
+ "github.com/gin-gonic/gin"
10
+ )
11
+
12
+ type usageAnalysisResponse struct {
13
+ APIs []usageAnalysisAPIPayload `json:"apis"`
14
+ Models []usageAnalysisModelPayload `json:"models"`
15
+ }
16
+
17
+ type usageAnalysisAPIPayload struct {
18
+ APIKey string `json:"api_key"`
19
+ DisplayName string `json:"display_name"`
20
+ TotalRequests int64 `json:"total_requests"`
21
+ SuccessCount int64 `json:"success_count"`
22
+ FailureCount int64 `json:"failure_count"`
23
+ InputTokens int64 `json:"input_tokens"`
24
+ OutputTokens int64 `json:"output_tokens"`
25
+ ReasoningTokens int64 `json:"reasoning_tokens"`
26
+ CachedTokens int64 `json:"cached_tokens"`
27
+ TotalTokens int64 `json:"total_tokens"`
28
+ Models []usageAnalysisModelPayload `json:"models"`
29
+ }
30
+
31
+ type usageAnalysisModelPayload struct {
32
+ Model string `json:"model"`
33
+ TotalRequests int64 `json:"total_requests"`
34
+ SuccessCount int64 `json:"success_count"`
35
+ FailureCount int64 `json:"failure_count"`
36
+ InputTokens int64 `json:"input_tokens"`
37
+ OutputTokens int64 `json:"output_tokens"`
38
+ ReasoningTokens int64 `json:"reasoning_tokens"`
39
+ CachedTokens int64 `json:"cached_tokens"`
40
+ TotalTokens int64 `json:"total_tokens"`
41
+ TotalLatencyMS int64 `json:"total_latency_ms"`
42
+ LatencySampleCount int64 `json:"latency_sample_count"`
43
+ }
44
+
45
+ func registerUsageAnalysisRoute(router gin.IRoutes, usageProvider service.UsageProvider) {
46
+ router.GET("/usage/analysis", func(c *gin.Context) {
47
+ if usageProvider == nil {
48
+ c.JSON(http.StatusOK, usageAnalysisResponse{APIs: []usageAnalysisAPIPayload{}, Models: []usageAnalysisModelPayload{}})
49
+ return
50
+ }
51
+
52
+ filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
53
+ if err != nil {
54
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
55
+ return
56
+ }
57
+
58
+ analysis, err := usageProvider.GetUsageAnalysis(c.Request.Context(), filter)
59
+ if err != nil {
60
+ writeInternalError(c, "get usage analysis failed", err)
61
+ return
62
+ }
63
+
64
+ c.JSON(http.StatusOK, buildUsageAnalysisPayload(analysis))
65
+ })
66
+ }
67
+
68
+ func buildUsageAnalysisPayload(snapshot *service.UsageAnalysisSnapshot) usageAnalysisResponse {
69
+ if snapshot == nil {
70
+ return usageAnalysisResponse{APIs: []usageAnalysisAPIPayload{}, Models: []usageAnalysisModelPayload{}}
71
+ }
72
+
73
+ apis := make([]usageAnalysisAPIPayload, 0, len(snapshot.APIs))
74
+ for _, api := range snapshot.APIs {
75
+ models := make([]usageAnalysisModelPayload, 0, len(api.Models))
76
+ for _, model := range api.Models {
77
+ models = append(models, usageAnalysisModelPayload{
78
+ Model: model.Model,
79
+ TotalRequests: model.TotalRequests,
80
+ SuccessCount: model.SuccessCount,
81
+ FailureCount: model.FailureCount,
82
+ InputTokens: model.InputTokens,
83
+ OutputTokens: model.OutputTokens,
84
+ ReasoningTokens: model.ReasoningTokens,
85
+ CachedTokens: model.CachedTokens,
86
+ TotalTokens: model.TotalTokens,
87
+ TotalLatencyMS: model.TotalLatencyMS,
88
+ LatencySampleCount: model.LatencySampleCount,
89
+ })
90
+ }
91
+ apiKey := redact.APIAlias(api.APIKey)
92
+ displayName := redact.APIKeyDisplayName(api.APIKey)
93
+ apis = append(apis, usageAnalysisAPIPayload{
94
+ APIKey: apiKey,
95
+ DisplayName: displayName,
96
+ TotalRequests: api.TotalRequests,
97
+ SuccessCount: api.SuccessCount,
98
+ FailureCount: api.FailureCount,
99
+ InputTokens: api.InputTokens,
100
+ OutputTokens: api.OutputTokens,
101
+ ReasoningTokens: api.ReasoningTokens,
102
+ CachedTokens: api.CachedTokens,
103
+ TotalTokens: api.TotalTokens,
104
+ Models: models,
105
+ })
106
+ }
107
+
108
+ models := make([]usageAnalysisModelPayload, 0, len(snapshot.Models))
109
+ for _, model := range snapshot.Models {
110
+ models = append(models, usageAnalysisModelPayload{
111
+ Model: model.Model,
112
+ TotalRequests: model.TotalRequests,
113
+ SuccessCount: model.SuccessCount,
114
+ FailureCount: model.FailureCount,
115
+ InputTokens: model.InputTokens,
116
+ OutputTokens: model.OutputTokens,
117
+ ReasoningTokens: model.ReasoningTokens,
118
+ CachedTokens: model.CachedTokens,
119
+ TotalTokens: model.TotalTokens,
120
+ TotalLatencyMS: model.TotalLatencyMS,
121
+ LatencySampleCount: model.LatencySampleCount,
122
+ })
123
+ }
124
+
125
+ return usageAnalysisResponse{APIs: apis, Models: models}
126
+ }
internal/api/usage_analysis_test.go ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+ "time"
9
+
10
+ "cpa-usage-keeper/internal/cpa"
11
+ "cpa-usage-keeper/internal/redact"
12
+ "cpa-usage-keeper/internal/service"
13
+ )
14
+
15
+ type usageAnalysisStub struct {
16
+ analysis *service.UsageAnalysisSnapshot
17
+ err error
18
+ lastFilter service.UsageFilter
19
+ analysisCalls int
20
+ }
21
+
22
+ func (s *usageAnalysisStub) GetUsageWithFilter(context.Context, service.UsageFilter) (*cpa.StatisticsSnapshot, error) {
23
+ return nil, nil
24
+ }
25
+
26
+ func (s *usageAnalysisStub) GetUsageOverview(context.Context, service.UsageFilter) (*service.UsageOverviewSnapshot, error) {
27
+ return nil, nil
28
+ }
29
+
30
+ func (s *usageAnalysisStub) ListUsageEvents(context.Context, service.UsageFilter) (*service.UsageEventsPage, error) {
31
+ return nil, nil
32
+ }
33
+
34
+ func (s *usageAnalysisStub) ListUsageEventFilterOptions(context.Context, service.UsageFilter) (*service.UsageEventFilterOptions, error) {
35
+ return nil, nil
36
+ }
37
+
38
+ func (s *usageAnalysisStub) ListUsageCredentialStats(context.Context, service.UsageFilter) ([]service.UsageCredentialStat, error) {
39
+ return nil, nil
40
+ }
41
+
42
+ func (s *usageAnalysisStub) GetUsageAnalysis(_ context.Context, filter service.UsageFilter) (*service.UsageAnalysisSnapshot, error) {
43
+ s.lastFilter = filter
44
+ s.analysisCalls++
45
+ return s.analysis, s.err
46
+ }
47
+
48
+ func TestUsageAnalysisReturnsAggregatedRows(t *testing.T) {
49
+ provider := &usageAnalysisStub{analysis: &service.UsageAnalysisSnapshot{
50
+ APIs: []service.UsageAnalysisAPIStat{{
51
+ APIKey: "provider-a",
52
+ DisplayName: "provider-a",
53
+ TotalRequests: 2,
54
+ SuccessCount: 1,
55
+ FailureCount: 1,
56
+ TotalTokens: 42,
57
+ Models: []service.UsageAnalysisModelStat{{
58
+ Model: "claude-sonnet",
59
+ TotalRequests: 2,
60
+ SuccessCount: 1,
61
+ FailureCount: 1,
62
+ InputTokens: 30,
63
+ OutputTokens: 9,
64
+ ReasoningTokens: 2,
65
+ CachedTokens: 1,
66
+ TotalTokens: 42,
67
+ TotalLatencyMS: 350,
68
+ LatencySampleCount: 2,
69
+ }},
70
+ }},
71
+ Models: []service.UsageAnalysisModelStat{{
72
+ Model: "claude-sonnet",
73
+ TotalRequests: 2,
74
+ SuccessCount: 1,
75
+ FailureCount: 1,
76
+ InputTokens: 30,
77
+ OutputTokens: 9,
78
+ ReasoningTokens: 2,
79
+ CachedTokens: 1,
80
+ TotalTokens: 42,
81
+ TotalLatencyMS: 350,
82
+ LatencySampleCount: 2,
83
+ }},
84
+ }}
85
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
86
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/analysis?range=24h", nil)
87
+ resp := httptest.NewRecorder()
88
+
89
+ router.ServeHTTP(resp, req)
90
+
91
+ if resp.Code != http.StatusOK {
92
+ t.Fatalf("expected status 200, got %d", resp.Code)
93
+ }
94
+ body := resp.Body.String()
95
+ if !contains(body, `"apis":[`) || !contains(body, `"models":[`) {
96
+ t.Fatalf("unexpected response body: %s", body)
97
+ }
98
+ if !contains(body, `"display_name":"prov**er-a"`) {
99
+ t.Fatalf("expected display name in response body: %s", body)
100
+ }
101
+ if !contains(body, `"api_key":"`+redact.APIAlias("provider-a")+`"`) {
102
+ t.Fatalf("expected redacted api key alias in response body: %s", body)
103
+ }
104
+ if !contains(body, `"model":"claude-sonnet"`) || !contains(body, `"latency_sample_count":2`) || !contains(body, `"total_latency_ms":350`) {
105
+ t.Fatalf("expected model latency aggregates in response body: %s", body)
106
+ }
107
+ if provider.analysisCalls != 1 {
108
+ t.Fatalf("expected GetUsageAnalysis to be called once, got %d", provider.analysisCalls)
109
+ }
110
+ if provider.lastFilter.Range != "24h" {
111
+ t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
112
+ }
113
+ if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
114
+ t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
115
+ }
116
+ }
117
+
118
+ func TestUsageAnalysisRequiresAuthWhenEnabled(t *testing.T) {
119
+ router := NewRouter(nil, nil, &usageAnalysisStub{}, nil, AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}, nil, "")
120
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/analysis", nil)
121
+ resp := httptest.NewRecorder()
122
+
123
+ router.ServeHTTP(resp, req)
124
+
125
+ if resp.Code != http.StatusUnauthorized {
126
+ t.Fatalf("expected status 401, got %d", resp.Code)
127
+ }
128
+ }
internal/api/usage_credentials.go ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "time"
6
+
7
+ "cpa-usage-keeper/internal/service"
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ type usageCredentialsResponse struct {
12
+ Credentials []usageCredentialPayload `json:"credentials"`
13
+ }
14
+
15
+ type usageCredentialPayload struct {
16
+ Source string `json:"source"`
17
+ SourceType string `json:"source_type,omitempty"`
18
+ SourceKey string `json:"source_key,omitempty"`
19
+ SuccessCount int64 `json:"success_count"`
20
+ FailureCount int64 `json:"failure_count"`
21
+ TotalCount int64 `json:"total_count"`
22
+ }
23
+
24
+ func registerUsageCredentialsRoute(
25
+ router gin.IRoutes,
26
+ usageProvider service.UsageProvider,
27
+ usageIdentityProvider service.UsageIdentityProvider,
28
+ ) {
29
+ router.GET("/usage/credentials", func(c *gin.Context) {
30
+ if usageProvider == nil {
31
+ c.JSON(http.StatusOK, usageCredentialsResponse{Credentials: []usageCredentialPayload{}})
32
+ return
33
+ }
34
+
35
+ filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
36
+ if err != nil {
37
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
38
+ return
39
+ }
40
+
41
+ rows, err := usageProvider.ListUsageCredentialStats(c.Request.Context(), filter)
42
+ if err != nil {
43
+ writeInternalError(c, "list usage credential stats failed", err)
44
+ return
45
+ }
46
+
47
+ identities, err := loadUsageResolutionData(c, usageIdentityProvider)
48
+ if err != nil {
49
+ writeInternalError(c, "load usage resolution data failed", err)
50
+ return
51
+ }
52
+ resolver := newUsageSourceResolver(identities)
53
+ c.JSON(http.StatusOK, usageCredentialsResponse{Credentials: buildUsageCredentialsPayload(rows, resolver)})
54
+ })
55
+ }
56
+
57
+ func buildUsageCredentialsPayload(rows []service.UsageCredentialStat, resolver usageSourceResolver) []usageCredentialPayload {
58
+ if len(rows) == 0 {
59
+ return []usageCredentialPayload{}
60
+ }
61
+
62
+ buckets := make(map[string]*usageCredentialPayload, len(rows))
63
+ orderedKeys := make([]string, 0, len(rows))
64
+ for _, row := range rows {
65
+ resolved := resolver.resolve(row.Source, row.AuthIndex)
66
+ bucketKey := resolved.SourceKey
67
+ if bucketKey == "" {
68
+ bucketKey = resolved.DisplayName
69
+ }
70
+ payload, ok := buckets[bucketKey]
71
+ if !ok {
72
+ payload = &usageCredentialPayload{
73
+ Source: resolved.DisplayName,
74
+ SourceType: resolved.SourceType,
75
+ SourceKey: resolved.SourceKey,
76
+ }
77
+ buckets[bucketKey] = payload
78
+ orderedKeys = append(orderedKeys, bucketKey)
79
+ }
80
+ if row.Failed {
81
+ payload.FailureCount += row.RequestCount
82
+ } else {
83
+ payload.SuccessCount += row.RequestCount
84
+ }
85
+ payload.TotalCount = payload.SuccessCount + payload.FailureCount
86
+ }
87
+
88
+ result := make([]usageCredentialPayload, 0, len(orderedKeys))
89
+ for _, key := range orderedKeys {
90
+ result = append(result, *buckets[key])
91
+ }
92
+ return result
93
+ }
internal/api/usage_events.go ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "fmt"
5
+ "net/http"
6
+ "strings"
7
+ "time"
8
+
9
+ "cpa-usage-keeper/internal/models"
10
+ "cpa-usage-keeper/internal/service"
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ type usageEventsResponse struct {
15
+ Events []usageEventPayload `json:"events"`
16
+ Models []string `json:"models"`
17
+ Sources []usageSourceFilterOption `json:"sources"`
18
+ TotalCount int64 `json:"total_count"`
19
+ Page int `json:"page"`
20
+ PageSize int `json:"page_size"`
21
+ TotalPages int `json:"total_pages"`
22
+ }
23
+
24
+ type usageSourceFilterOption struct {
25
+ Value string `json:"value"`
26
+ Label string `json:"label"`
27
+ }
28
+
29
+ type usageEventFilterOptionsResponse struct {
30
+ Models []string `json:"models"`
31
+ Sources []usageSourceFilterOption `json:"sources"`
32
+ }
33
+
34
+ type usageEventPayload struct {
35
+ ID uint `json:"id,omitempty"`
36
+ Timestamp string `json:"timestamp"`
37
+ Model string `json:"model"`
38
+ Source string `json:"source"`
39
+ SourceRaw string `json:"source_raw,omitempty"`
40
+ SourceType string `json:"source_type,omitempty"`
41
+ SourceKey string `json:"source_key,omitempty"`
42
+ AuthIndex string `json:"auth_index,omitempty"`
43
+ Failed bool `json:"failed"`
44
+ LatencyMS int64 `json:"latency_ms"`
45
+ Tokens usageEventTokenPayload `json:"tokens"`
46
+ }
47
+
48
+ type usageEventTokenPayload struct {
49
+ InputTokens int64 `json:"input_tokens"`
50
+ OutputTokens int64 `json:"output_tokens"`
51
+ ReasoningTokens int64 `json:"reasoning_tokens"`
52
+ CachedTokens int64 `json:"cached_tokens"`
53
+ TotalTokens int64 `json:"total_tokens"`
54
+ }
55
+
56
+ func registerUsageEventsRoute(
57
+ router gin.IRoutes,
58
+ usageProvider service.UsageProvider,
59
+ usageIdentityProvider service.UsageIdentityProvider,
60
+ ) {
61
+ router.GET("/usage/events/filters", func(c *gin.Context) {
62
+ if usageProvider == nil {
63
+ c.JSON(http.StatusOK, usageEventFilterOptionsResponse{Models: []string{}, Sources: []usageSourceFilterOption{}})
64
+ return
65
+ }
66
+
67
+ options, err := usageProvider.ListUsageEventFilterOptions(c.Request.Context(), service.UsageFilter{})
68
+ if err != nil {
69
+ writeInternalError(c, "list usage event filter options failed", err)
70
+ return
71
+ }
72
+
73
+ identities, err := loadUsageResolutionData(c, usageIdentityProvider)
74
+ if err != nil {
75
+ writeInternalError(c, "load usage resolution data failed", err)
76
+ return
77
+ }
78
+ c.JSON(http.StatusOK, usageEventFilterOptionsResponse{
79
+ Models: options.Models,
80
+ Sources: buildUsageSourceFilterOptions(options.Sources, identities),
81
+ })
82
+ })
83
+
84
+ router.GET("/usage/events", func(c *gin.Context) {
85
+ if usageProvider == nil {
86
+ c.JSON(http.StatusOK, usageEventsResponse{Events: []usageEventPayload{}, Models: []string{}, Sources: []usageSourceFilterOption{}, Page: 1, PageSize: service.DefaultUsageEventsLimit})
87
+ return
88
+ }
89
+
90
+ filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
91
+ if err != nil {
92
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
93
+ return
94
+ }
95
+ if err := applyUsageEventsSourceFilter(&filter); err != nil {
96
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
97
+ return
98
+ }
99
+
100
+ rows, err := usageProvider.ListUsageEvents(c.Request.Context(), filter)
101
+ if err != nil {
102
+ writeInternalError(c, "list usage events failed", err)
103
+ return
104
+ }
105
+
106
+ identities, err := loadUsageResolutionData(c, usageIdentityProvider)
107
+ if err != nil {
108
+ writeInternalError(c, "load usage resolution data failed", err)
109
+ return
110
+ }
111
+ c.JSON(http.StatusOK, usageEventsResponse{
112
+ Events: buildUsageEventsPayload(rows.Events),
113
+ Models: rows.Models,
114
+ Sources: buildUsageSourceFilterOptions(rows.Sources, identities),
115
+ TotalCount: rows.TotalCount,
116
+ Page: rows.Page,
117
+ PageSize: rows.PageSize,
118
+ TotalPages: rows.TotalPages,
119
+ })
120
+ })
121
+ }
122
+
123
+ func applyUsageEventsSourceFilter(filter *service.UsageFilter) error {
124
+ if filter == nil {
125
+ return nil
126
+ }
127
+ source := strings.TrimSpace(filter.Source)
128
+ if source == "" {
129
+ return nil
130
+ }
131
+ if value, ok := strings.CutPrefix(source, "auth:"); ok {
132
+ value = strings.TrimSpace(value)
133
+ if value == "" {
134
+ return fmt.Errorf("source auth filter value is required")
135
+ }
136
+ filter.AuthType = "oauth"
137
+ filter.AuthIndex = value
138
+ filter.Source = value
139
+ filter.Provider = ""
140
+ return nil
141
+ }
142
+ if value, ok := strings.CutPrefix(source, "provider:"); ok {
143
+ value = strings.TrimSpace(value)
144
+ if value == "" {
145
+ return fmt.Errorf("source provider filter value is required")
146
+ }
147
+ filter.AuthType = "apikey"
148
+ filter.Provider = value
149
+ filter.Source = ""
150
+ filter.AuthIndex = ""
151
+ }
152
+ return nil
153
+ }
154
+
155
+ func buildUsageEventsPayload(rows []service.UsageEventRecord) []usageEventPayload {
156
+ if len(rows) == 0 {
157
+ return []usageEventPayload{}
158
+ }
159
+ payload := make([]usageEventPayload, 0, len(rows))
160
+ for _, row := range rows {
161
+ source, sourceKey := usageEventPublicSource(row)
162
+ payload = append(payload, usageEventPayload{
163
+ ID: row.ID,
164
+ Timestamp: row.Timestamp.UTC().Format(time.RFC3339),
165
+ Model: row.Model,
166
+ Source: source,
167
+ SourceKey: sourceKey,
168
+ AuthIndex: row.AuthIndex,
169
+ Failed: row.Failed,
170
+ LatencyMS: row.LatencyMS,
171
+ Tokens: usageEventTokenPayload{
172
+ InputTokens: row.InputTokens,
173
+ OutputTokens: row.OutputTokens,
174
+ ReasoningTokens: row.ReasoningTokens,
175
+ CachedTokens: row.CachedTokens,
176
+ TotalTokens: row.TotalTokens,
177
+ },
178
+ })
179
+ }
180
+ return payload
181
+ }
182
+
183
+ func usageEventPublicSource(row service.UsageEventRecord) (string, string) {
184
+ switch strings.TrimSpace(row.AuthType) {
185
+ case "apikey":
186
+ provider := strings.TrimSpace(row.Provider)
187
+ if provider == "" {
188
+ provider = "AI Provider"
189
+ }
190
+ return provider, "provider:" + provider
191
+ case "oauth":
192
+ source := firstNonEmptyString(row.Source, row.AuthIndex, "unknown")
193
+ return source, "auth:" + source
194
+ default:
195
+ if provider := strings.TrimSpace(row.Provider); provider != "" {
196
+ return provider, "provider:" + provider
197
+ }
198
+ source := firstNonEmptyString(row.Source, row.AuthIndex, "unknown")
199
+ return source, "auth:" + source
200
+ }
201
+ }
202
+
203
+ func buildUsageSourceFilterOptions(sources []string, identities []models.UsageIdentity) []usageSourceFilterOption {
204
+ if len(identities) == 0 {
205
+ return []usageSourceFilterOption{}
206
+ }
207
+ options := make([]usageSourceFilterOption, 0, len(identities))
208
+ seen := make(map[string]struct{}, len(identities))
209
+ for _, identity := range identities {
210
+ if identity.TotalRequests == 0 {
211
+ continue
212
+ }
213
+ option, ok := usageSourceFilterOptionFromIdentity(identity)
214
+ if !ok {
215
+ continue
216
+ }
217
+ if _, exists := seen[option.Value]; exists {
218
+ continue
219
+ }
220
+ seen[option.Value] = struct{}{}
221
+ options = append(options, option)
222
+ }
223
+ return options
224
+ }
225
+
226
+ func usageSourceFilterOptionFromIdentity(identity models.UsageIdentity) (usageSourceFilterOption, bool) {
227
+ switch identity.AuthType {
228
+ case models.UsageIdentityAuthTypeAuthFile:
229
+ value := strings.TrimSpace(identity.Identity)
230
+ if value == "" {
231
+ return usageSourceFilterOption{}, false
232
+ }
233
+ label := firstNonEmptyString(identity.Name, value)
234
+ return usageSourceFilterOption{Value: "auth:" + value, Label: label}, true
235
+ case models.UsageIdentityAuthTypeAIProvider:
236
+ provider := safeAIProviderDisplayValue(identity.Provider, identity.Identity, "")
237
+ label := firstNonEmptyString(provider, safeAIProviderDisplayValue(identity.Name, identity.Identity, ""), safeAIProviderDisplayValue(identity.Type, identity.Identity, ""))
238
+ if label == "" {
239
+ return usageSourceFilterOption{}, false
240
+ }
241
+ return usageSourceFilterOption{Value: "provider:" + label, Label: label}, true
242
+ default:
243
+ return usageSourceFilterOption{}, false
244
+ }
245
+ }
internal/api/usage_events_test.go ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+ "time"
9
+
10
+ "cpa-usage-keeper/internal/cpa"
11
+ "cpa-usage-keeper/internal/models"
12
+ "cpa-usage-keeper/internal/service"
13
+ )
14
+
15
+ type usageEventsStub struct {
16
+ events []service.UsageEventRecord
17
+ eventsPage *service.UsageEventsPage
18
+ eventFilterOptions *service.UsageEventFilterOptions
19
+ credentialStats []service.UsageCredentialStat
20
+ err error
21
+ lastFilter service.UsageFilter
22
+ filterCalls int
23
+ filterOptionCalls int
24
+ credentialsCalls int
25
+ }
26
+
27
+ func (s *usageEventsStub) GetUsageWithFilter(context.Context, service.UsageFilter) (*cpa.StatisticsSnapshot, error) {
28
+ return nil, nil
29
+ }
30
+
31
+ func (s *usageEventsStub) GetUsageOverview(context.Context, service.UsageFilter) (*service.UsageOverviewSnapshot, error) {
32
+ return nil, nil
33
+ }
34
+
35
+ func (s *usageEventsStub) ListUsageEvents(_ context.Context, filter service.UsageFilter) (*service.UsageEventsPage, error) {
36
+ s.lastFilter = filter
37
+ s.filterCalls++
38
+ if s.eventsPage != nil {
39
+ return s.eventsPage, s.err
40
+ }
41
+ return &service.UsageEventsPage{Events: s.events, TotalCount: int64(len(s.events)), Page: 1, PageSize: service.DefaultUsageEventsLimit, TotalPages: 1}, s.err
42
+ }
43
+
44
+ func (s *usageEventsStub) ListUsageEventFilterOptions(_ context.Context, filter service.UsageFilter) (*service.UsageEventFilterOptions, error) {
45
+ s.lastFilter = filter
46
+ s.filterOptionCalls++
47
+ if s.eventFilterOptions != nil {
48
+ return s.eventFilterOptions, s.err
49
+ }
50
+ return &service.UsageEventFilterOptions{}, s.err
51
+ }
52
+
53
+ func (s *usageEventsStub) ListUsageCredentialStats(_ context.Context, filter service.UsageFilter) ([]service.UsageCredentialStat, error) {
54
+ s.lastFilter = filter
55
+ s.credentialsCalls++
56
+ return s.credentialStats, s.err
57
+ }
58
+
59
+ func (s *usageEventsStub) GetUsageAnalysis(context.Context, service.UsageFilter) (*service.UsageAnalysisSnapshot, error) {
60
+ return nil, s.err
61
+ }
62
+
63
+ func TestUsageEventsReturnsFilteredRows(t *testing.T) {
64
+ provider := &usageEventsStub{events: []service.UsageEventRecord{{
65
+ ID: 42,
66
+ Timestamp: time.Date(2026, 4, 22, 11, 0, 0, 0, time.UTC),
67
+ Model: "claude-sonnet",
68
+ AuthType: "apikey",
69
+ Provider: "OpenAI Mirror",
70
+ Source: "sk-provider-key",
71
+ AuthIndex: "2",
72
+ Failed: false,
73
+ LatencyMS: 321,
74
+ InputTokens: 10,
75
+ OutputTokens: 5,
76
+ ReasoningTokens: 2,
77
+ CachedTokens: 1,
78
+ TotalTokens: 18,
79
+ }}}
80
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
81
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?range=24h", nil)
82
+ resp := httptest.NewRecorder()
83
+
84
+ router.ServeHTTP(resp, req)
85
+
86
+ if resp.Code != http.StatusOK {
87
+ t.Fatalf("expected status 200, got %d", resp.Code)
88
+ }
89
+ body := resp.Body.String()
90
+ if !contains(body, `"events":[`) || !contains(body, `"model":"claude-sonnet"`) {
91
+ t.Fatalf("unexpected response body: %s", body)
92
+ }
93
+ if !contains(body, `"id":42`) || !contains(body, `"total_count":1`) || !contains(body, `"page":1`) || !contains(body, `"page_size":100`) || !contains(body, `"total_pages":1`) {
94
+ t.Fatalf("expected pagination metadata and event id in response body: %s", body)
95
+ }
96
+ if !contains(body, `"source":"OpenAI Mirror"`) {
97
+ t.Fatalf("expected resolved source display in response body: %s", body)
98
+ }
99
+ if contains(body, `sk-provider-key`) || contains(body, `sk-provider-prefix`) {
100
+ t.Fatalf("expected raw source values to be redacted from response body: %s", body)
101
+ }
102
+ if contains(body, `"source_type"`) || !contains(body, `"source_key":"provider:OpenAI Mirror"`) {
103
+ t.Fatalf("expected provider source key from usage event provider only, got %s", body)
104
+ }
105
+ if !contains(body, `"auth_index":"2"`) {
106
+ t.Fatalf("expected auth index in response body: %s", body)
107
+ }
108
+ if provider.filterCalls != 1 {
109
+ t.Fatalf("expected ListUsageEvents to be called once, got %d", provider.filterCalls)
110
+ }
111
+ if provider.lastFilter.Range != "24h" {
112
+ t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
113
+ }
114
+ if provider.lastFilter.Page != 1 || provider.lastFilter.PageSize != 100 || provider.lastFilter.Offset != 0 {
115
+ t.Fatalf("expected default pagination to be passed through, got %+v", provider.lastFilter)
116
+ }
117
+ if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
118
+ t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
119
+ }
120
+ }
121
+
122
+ func TestUsageEventsPassesPaginationAndProviderSourceFilter(t *testing.T) {
123
+ provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 3, PageSize: 100, TotalPages: 0}}
124
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
125
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?page=3&page_size=100&model=claude-sonnet&source=provider:OpenAI%20Mirror&result=failed", nil)
126
+ resp := httptest.NewRecorder()
127
+
128
+ router.ServeHTTP(resp, req)
129
+
130
+ if resp.Code != http.StatusOK {
131
+ t.Fatalf("expected status 200, got %d", resp.Code)
132
+ }
133
+ if provider.lastFilter.Page != 3 || provider.lastFilter.PageSize != 100 || provider.lastFilter.Offset != 200 {
134
+ t.Fatalf("expected pagination filter, got %+v", provider.lastFilter)
135
+ }
136
+ if provider.lastFilter.Model != "claude-sonnet" || provider.lastFilter.AuthType != "apikey" || provider.lastFilter.Provider != "OpenAI Mirror" || provider.lastFilter.Source != "" || provider.lastFilter.Result != "failed" {
137
+ t.Fatalf("expected provider source filter to be translated, got %+v", provider.lastFilter)
138
+ }
139
+ body := resp.Body.String()
140
+ if !contains(body, `"page":3`) || !contains(body, `"page_size":100`) || !contains(body, `"total_count":0`) || !contains(body, `"total_pages":0`) {
141
+ t.Fatalf("expected response pagination metadata, got %s", body)
142
+ }
143
+ }
144
+
145
+ func TestUsageEventsPassesAuthSourceFilter(t *testing.T) {
146
+ provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 1, PageSize: 100, TotalPages: 0}}
147
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
148
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?source=auth:2", nil)
149
+ resp := httptest.NewRecorder()
150
+
151
+ router.ServeHTTP(resp, req)
152
+
153
+ if resp.Code != http.StatusOK {
154
+ t.Fatalf("expected status 200, got %d", resp.Code)
155
+ }
156
+ if provider.lastFilter.AuthType != "oauth" || provider.lastFilter.AuthIndex != "2" || provider.lastFilter.Source != "2" {
157
+ t.Fatalf("expected auth source filter to be translated, got %+v", provider.lastFilter)
158
+ }
159
+ }
160
+
161
+ func TestUsageEventsRejectsEmptyPrefixedSourceFilter(t *testing.T) {
162
+ for _, path := range []string{"/api/v1/usage/events?source=auth:", "/api/v1/usage/events?source=provider:"} {
163
+ t.Run(path, func(t *testing.T) {
164
+ provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 1, PageSize: 100, TotalPages: 0}}
165
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
166
+ req := httptest.NewRequest(http.MethodGet, path, nil)
167
+ resp := httptest.NewRecorder()
168
+
169
+ router.ServeHTTP(resp, req)
170
+
171
+ if resp.Code != http.StatusBadRequest {
172
+ t.Fatalf("expected status 400, got %d with body %s", resp.Code, resp.Body.String())
173
+ }
174
+ if provider.filterCalls != 0 {
175
+ t.Fatalf("expected invalid source filter not to query events, got %d calls", provider.filterCalls)
176
+ }
177
+ })
178
+ }
179
+ }
180
+
181
+ func TestUsageEventsReturnsFilterOptions(t *testing.T) {
182
+ provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{
183
+ Events: []service.UsageEventRecord{{
184
+ ID: 7, Timestamp: time.Date(2026, 4, 22, 11, 0, 0, 0, time.UTC), Model: "gpt-5", AuthType: "apikey", Provider: "Provider A", Source: "source-a", Failed: true,
185
+ }},
186
+ Models: []string{"claude-sonnet", "gpt-5"},
187
+ Sources: []string{"source-a", "source-b"},
188
+ TotalCount: 2, Page: 1, PageSize: 20, TotalPages: 1,
189
+ }}
190
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-source-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-a", Type: "openai", Provider: "Provider A", TotalRequests: 1}, {ID: 2, Name: "Provider A", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-b", Type: "openai", Provider: "Provider A", TotalRequests: 1}, {ID: 3, Name: "Auth User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-1", Type: "claude", Provider: "Claude", TotalRequests: 1}}})
191
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events", nil)
192
+ resp := httptest.NewRecorder()
193
+
194
+ router.ServeHTTP(resp, req)
195
+
196
+ if resp.Code != http.StatusOK {
197
+ t.Fatalf("expected status 200, got %d", resp.Code)
198
+ }
199
+ body := resp.Body.String()
200
+ if !contains(body, `"models":["claude-sonnet","gpt-5"]`) {
201
+ t.Fatalf("expected model filter options, got %s", body)
202
+ }
203
+ if !contains(body, `"sources":[`) || !contains(body, `"value":"auth:auth-1"`) || !contains(body, `"label":"Auth User"`) || !contains(body, `"value":"provider:Provider A"`) || !contains(body, `"label":"Provider A"`) {
204
+ t.Fatalf("expected prefixed source filter options, got %s", body)
205
+ }
206
+ if contains(body, `"value":"source-a"`) || contains(body, `"value":"source-b"`) || contains(body, `"provider:1"`) || contains(body, `"provider:2"`) || contains(body, `sk-source-prefix`) {
207
+ t.Fatalf("expected raw source filter values to be redacted, got %s", body)
208
+ }
209
+ }
210
+
211
+ func TestUsageEventFilterOptionsReturnsStableModelsAndSources(t *testing.T) {
212
+ provider := &usageEventsStub{eventFilterOptions: &service.UsageEventFilterOptions{
213
+ Models: []string{"claude-sonnet", "gpt-5"},
214
+ Sources: []string{"source-a", "source-b"},
215
+ }}
216
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-source-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-a", Type: "openai", Provider: "Provider A", TotalRequests: 3}, {ID: 2, Name: "Provider A", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-b", Type: "openai", Provider: "Provider A"}, {ID: 3, Name: "Auth User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-1", Type: "claude", Provider: "Claude", TotalRequests: 2}, {ID: 4, Name: "Zero Request User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-zero", Type: "claude", Provider: "Claude"}, {ID: 5, Name: "Zero Provider", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-zero", Type: "openai", Provider: "Zero Provider"}}})
217
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events/filters?range=24h&model=ignored&source=ignored&result=failed&page=3&page_size=20", nil)
218
+ resp := httptest.NewRecorder()
219
+
220
+ router.ServeHTTP(resp, req)
221
+
222
+ if resp.Code != http.StatusOK {
223
+ t.Fatalf("expected status 200, got %d", resp.Code)
224
+ }
225
+ if provider.filterOptionCalls != 1 || provider.filterCalls != 0 {
226
+ t.Fatalf("expected filter options endpoint only, events=%d filterOptions=%d", provider.filterCalls, provider.filterOptionCalls)
227
+ }
228
+ if provider.lastFilter.Range != "" || provider.lastFilter.StartTime != nil || provider.lastFilter.EndTime != nil || provider.lastFilter.Model != "" || provider.lastFilter.Source != "" || provider.lastFilter.Result != "" || provider.lastFilter.Page != 0 || provider.lastFilter.PageSize != 0 {
229
+ t.Fatalf("expected filters endpoint to ignore query filters, got %+v", provider.lastFilter)
230
+ }
231
+ body := resp.Body.String()
232
+ if !contains(body, `"models":["claude-sonnet","gpt-5"]`) {
233
+ t.Fatalf("expected stable model filter options, got %s", body)
234
+ }
235
+ if !contains(body, `"sources":[`) || !contains(body, `"value":"auth:auth-1"`) || !contains(body, `"label":"Auth User"`) || !contains(body, `"value":"provider:Provider A"`) || !contains(body, `"label":"Provider A"`) {
236
+ t.Fatalf("expected stable prefixed source filter options, got %s", body)
237
+ }
238
+ if contains(body, `"value":"source-a"`) || contains(body, `"value":"source-b"`) || contains(body, `"provider:1"`) || contains(body, `"provider:2"`) || contains(body, `sk-source-prefix`) {
239
+ t.Fatalf("expected raw source filter values to be redacted, got %s", body)
240
+ }
241
+ if contains(body, `Zero Request User`) || contains(body, `Zero Provider`) || contains(body, `auth-zero`) || contains(body, `source-zero`) {
242
+ t.Fatalf("expected zero-request source filter options to be omitted, got %s", body)
243
+ }
244
+ }
245
+
246
+ func TestUsageCredentialsReturnsAggregatedRows(t *testing.T) {
247
+ provider := &usageEventsStub{credentialStats: []service.UsageCredentialStat{{
248
+ Source: "sk-provider-key",
249
+ AuthIndex: "2",
250
+ Failed: false,
251
+ RequestCount: 3,
252
+ }, {
253
+ Source: "sk-provider-key",
254
+ AuthIndex: "2",
255
+ Failed: true,
256
+ RequestCount: 1,
257
+ }}}
258
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-provider-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "sk-provider-key", Type: "openai", Provider: "OpenAI Mirror"}}})
259
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/credentials?range=24h", nil)
260
+ resp := httptest.NewRecorder()
261
+
262
+ router.ServeHTTP(resp, req)
263
+
264
+ if resp.Code != http.StatusOK {
265
+ t.Fatalf("expected status 200, got %d", resp.Code)
266
+ }
267
+ body := resp.Body.String()
268
+ if !contains(body, `"credentials":[`) {
269
+ t.Fatalf("unexpected response body: %s", body)
270
+ }
271
+ if !contains(body, `"source":"OpenAI Mirror"`) {
272
+ t.Fatalf("expected resolved source display in response body: %s", body)
273
+ }
274
+ if !contains(body, `"source_type":"openai"`) {
275
+ t.Fatalf("expected source type in response body: %s", body)
276
+ }
277
+ if !contains(body, `"source_key":"provider:1"`) {
278
+ t.Fatalf("expected source key in response body: %s", body)
279
+ }
280
+ if contains(body, `sk-provider-key`) || contains(body, `sk-provider-prefix`) {
281
+ t.Fatalf("expected raw source values to be redacted from response body: %s", body)
282
+ }
283
+ if !contains(body, `"success_count":3`) || !contains(body, `"failure_count":1`) || !contains(body, `"total_count":4`) {
284
+ t.Fatalf("expected aggregated counts in response body: %s", body)
285
+ }
286
+ if provider.credentialsCalls != 1 {
287
+ t.Fatalf("expected ListUsageCredentialStats to be called once, got %d", provider.credentialsCalls)
288
+ }
289
+ if provider.lastFilter.Range != "24h" {
290
+ t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
291
+ }
292
+ if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
293
+ t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
294
+ }
295
+ }
internal/api/usage_filter.go ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "fmt"
5
+ "net/http"
6
+ "strconv"
7
+ "strings"
8
+ "time"
9
+
10
+ "cpa-usage-keeper/internal/service"
11
+ )
12
+
13
+ var presetUsageRangeDurations = map[string]time.Duration{
14
+ "4h": 4 * time.Hour,
15
+ "8h": 8 * time.Hour,
16
+ "12h": 12 * time.Hour,
17
+ "24h": 24 * time.Hour,
18
+ "7d": 7 * 24 * time.Hour,
19
+ }
20
+
21
+ var allowedUsageEventsPageSizes = map[int]struct{}{
22
+ 20: {},
23
+ 50: {},
24
+ 100: {},
25
+ 500: {},
26
+ 1000: {},
27
+ }
28
+
29
+ func parseUsageTimeFilterQuery(req *http.Request, anchor time.Time) (service.UsageFilter, error) {
30
+ filter, err := parseUsageFilterQuery(req, anchor)
31
+ if err != nil {
32
+ return service.UsageFilter{}, err
33
+ }
34
+ filter.Limit = 0
35
+ filter.Page = 0
36
+ filter.PageSize = 0
37
+ filter.Offset = 0
38
+ filter.Model = ""
39
+ filter.Source = ""
40
+ filter.AuthIndex = ""
41
+ filter.Result = ""
42
+ return filter, nil
43
+ }
44
+
45
+ func parseCustomUsageRangeBoundary(value string, endOfDay bool) (time.Time, error) {
46
+ if date, err := time.ParseInLocation(time.DateOnly, value, time.Local); err == nil {
47
+ if endOfDay {
48
+ return date.AddDate(0, 0, 1).Add(-time.Nanosecond), nil
49
+ }
50
+ return date, nil
51
+ }
52
+ return time.Parse(time.RFC3339, value)
53
+ }
54
+
55
+ func parseUsageFilterQuery(req *http.Request, anchor time.Time) (service.UsageFilter, error) {
56
+ if req == nil {
57
+ return service.UsageFilter{}, nil
58
+ }
59
+
60
+ rangeValue := strings.TrimSpace(req.URL.Query().Get("range"))
61
+ if rangeValue == "" {
62
+ rangeValue = "all"
63
+ }
64
+
65
+ filter := service.UsageFilter{Range: rangeValue, Limit: service.DefaultUsageEventsLimit, Page: 1, PageSize: service.DefaultUsageEventsLimit}
66
+ query := req.URL.Query()
67
+ if pageValue := strings.TrimSpace(query.Get("page")); pageValue != "" {
68
+ page, err := strconv.Atoi(pageValue)
69
+ if err != nil || page < 1 {
70
+ return service.UsageFilter{}, fmt.Errorf("invalid page %q", pageValue)
71
+ }
72
+ filter.Page = page
73
+ }
74
+ pageSizeValue := strings.TrimSpace(query.Get("page_size"))
75
+ if pageSizeValue == "" {
76
+ pageSizeValue = strings.TrimSpace(query.Get("limit"))
77
+ }
78
+ if pageSizeValue != "" {
79
+ pageSize, err := strconv.Atoi(pageSizeValue)
80
+ if err != nil {
81
+ return service.UsageFilter{}, fmt.Errorf("invalid page_size %q", pageSizeValue)
82
+ }
83
+ if _, ok := allowedUsageEventsPageSizes[pageSize]; !ok {
84
+ return service.UsageFilter{}, fmt.Errorf("invalid page_size %q", pageSizeValue)
85
+ }
86
+ filter.PageSize = pageSize
87
+ filter.Limit = pageSize
88
+ }
89
+ filter.Offset = (filter.Page - 1) * filter.PageSize
90
+ filter.Model = strings.TrimSpace(query.Get("model"))
91
+ filter.Source = strings.TrimSpace(query.Get("source"))
92
+ filter.AuthIndex = strings.TrimSpace(query.Get("auth_index"))
93
+ filter.Result = strings.TrimSpace(query.Get("result"))
94
+ if filter.Result != "" && filter.Result != "success" && filter.Result != "failed" {
95
+ return service.UsageFilter{}, fmt.Errorf("invalid result %q", filter.Result)
96
+ }
97
+ switch rangeValue {
98
+ case "all":
99
+ return filter, nil
100
+ case "today":
101
+ localAnchor := anchor.In(time.Local)
102
+ localStart := time.Date(localAnchor.Year(), localAnchor.Month(), localAnchor.Day(), 0, 0, 0, 0, time.Local)
103
+ startTime := localStart.UTC()
104
+ endTime := localStart.AddDate(0, 0, 1).Add(-time.Nanosecond).UTC()
105
+ filter.StartTime = &startTime
106
+ filter.EndTime = &endTime
107
+ return filter, nil
108
+ case "custom":
109
+ startValue := strings.TrimSpace(req.URL.Query().Get("start"))
110
+ endValue := strings.TrimSpace(req.URL.Query().Get("end"))
111
+ if startValue == "" || endValue == "" {
112
+ return service.UsageFilter{}, fmt.Errorf("custom range requires start and end")
113
+ }
114
+ startTime, err := parseCustomUsageRangeBoundary(startValue, false)
115
+ if err != nil {
116
+ return service.UsageFilter{}, fmt.Errorf("invalid start: %w", err)
117
+ }
118
+ endTime, err := parseCustomUsageRangeBoundary(endValue, true)
119
+ if err != nil {
120
+ return service.UsageFilter{}, fmt.Errorf("invalid end: %w", err)
121
+ }
122
+ startTime = startTime.UTC()
123
+ endTime = endTime.UTC()
124
+ if startTime.After(endTime) {
125
+ return service.UsageFilter{}, fmt.Errorf("custom range start must be before end")
126
+ }
127
+ filter.StartTime = &startTime
128
+ filter.EndTime = &endTime
129
+ return filter, nil
130
+ default:
131
+ duration, ok := presetUsageRangeDurations[rangeValue]
132
+ if !ok {
133
+ return service.UsageFilter{}, fmt.Errorf("unsupported usage range %q", rangeValue)
134
+ }
135
+ endTime := anchor.UTC()
136
+ startTime := endTime.Add(-duration)
137
+ filter.StartTime = &startTime
138
+ filter.EndTime = &endTime
139
+ return filter, nil
140
+ }
141
+ }
internal/api/usage_filter_test.go ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http/httptest"
5
+ "testing"
6
+ "time"
7
+ )
8
+
9
+ func TestParseUsageFilterQueryPresetRange(t *testing.T) {
10
+ req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=24h", nil)
11
+ anchor := time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC)
12
+
13
+ filter, err := parseUsageFilterQuery(req, anchor)
14
+ if err != nil {
15
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
16
+ }
17
+ if filter.Range != "24h" {
18
+ t.Fatalf("expected range to be preserved, got %+v", filter)
19
+ }
20
+ if filter.StartTime == nil || filter.EndTime == nil {
21
+ t.Fatalf("expected preset range to resolve concrete times, got %+v", filter)
22
+ }
23
+ if !filter.EndTime.Equal(anchor) {
24
+ t.Fatalf("expected preset range end to use anchor time, got %+v", filter)
25
+ }
26
+ if !filter.StartTime.Equal(anchor.Add(-24 * time.Hour)) {
27
+ t.Fatalf("expected preset range start to subtract 24h, got %+v", filter)
28
+ }
29
+ }
30
+
31
+ func TestParseUsageFilterQueryTodayRangeUsesLocalDayBoundary(t *testing.T) {
32
+ previousLocal := time.Local
33
+ location, err := time.LoadLocation("Asia/Shanghai")
34
+ if err != nil {
35
+ t.Fatalf("load location: %v", err)
36
+ }
37
+ t.Cleanup(func() { time.Local = previousLocal })
38
+ time.Local = location
39
+
40
+ req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=today", nil)
41
+ anchor := time.Date(2026, 4, 22, 12, 34, 56, 0, time.UTC)
42
+
43
+ filter, err := parseUsageFilterQuery(req, anchor)
44
+ if err != nil {
45
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
46
+ }
47
+ if filter.Range != "today" {
48
+ t.Fatalf("expected today range to be preserved, got %+v", filter)
49
+ }
50
+ if filter.StartTime == nil || filter.EndTime == nil {
51
+ t.Fatalf("expected today range to resolve concrete times, got %+v", filter)
52
+ }
53
+ expectedStart := time.Date(2026, 4, 22, 0, 0, 0, 0, location).UTC()
54
+ expectedEnd := time.Date(2026, 4, 23, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC()
55
+ if !filter.StartTime.Equal(expectedStart) {
56
+ t.Fatalf("expected today start %s, got %s", expectedStart, *filter.StartTime)
57
+ }
58
+ if !filter.EndTime.Equal(expectedEnd) {
59
+ t.Fatalf("expected today end %s, got %s", expectedEnd, *filter.EndTime)
60
+ }
61
+ }
62
+
63
+ func TestParseUsageFilterQueryTodayRangeUsesLocalDSTBoundary(t *testing.T) {
64
+ previousLocal := time.Local
65
+ location, err := time.LoadLocation("America/New_York")
66
+ if err != nil {
67
+ t.Fatalf("load location: %v", err)
68
+ }
69
+ t.Cleanup(func() { time.Local = previousLocal })
70
+ time.Local = location
71
+
72
+ req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=today", nil)
73
+ anchor := time.Date(2026, 3, 8, 12, 0, 0, 0, location)
74
+
75
+ filter, err := parseUsageFilterQuery(req, anchor)
76
+ if err != nil {
77
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
78
+ }
79
+ if filter.StartTime == nil || filter.EndTime == nil {
80
+ t.Fatalf("expected today range to resolve concrete times, got %+v", filter)
81
+ }
82
+ expectedStart := time.Date(2026, 3, 8, 0, 0, 0, 0, location).UTC()
83
+ expectedEnd := time.Date(2026, 3, 9, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC()
84
+ if !filter.StartTime.Equal(expectedStart) {
85
+ t.Fatalf("expected DST today start %s, got %s", expectedStart, *filter.StartTime)
86
+ }
87
+ if !filter.EndTime.Equal(expectedEnd) {
88
+ t.Fatalf("expected DST today end %s, got %s", expectedEnd, *filter.EndTime)
89
+ }
90
+ }
91
+
92
+ func TestParseUsageFilterQueryCustomRange(t *testing.T) {
93
+ req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-20T00:00:00Z&end=2026-04-21T23:59:59Z", nil)
94
+
95
+ filter, err := parseUsageFilterQuery(req, time.Time{})
96
+ if err != nil {
97
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
98
+ }
99
+ if filter.StartTime == nil || filter.EndTime == nil {
100
+ t.Fatalf("expected custom range bounds, got %+v", filter)
101
+ }
102
+ if !filter.StartTime.Equal(time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)) {
103
+ t.Fatalf("unexpected custom start: %+v", filter)
104
+ }
105
+ if !filter.EndTime.Equal(time.Date(2026, 4, 21, 23, 59, 59, 0, time.UTC)) {
106
+ t.Fatalf("unexpected custom end: %+v", filter)
107
+ }
108
+ }
109
+
110
+ func TestParseUsageFilterQueryCustomDateRangeUsesLocalDayBoundary(t *testing.T) {
111
+ previousLocal := time.Local
112
+ location, err := time.LoadLocation("Asia/Shanghai")
113
+ if err != nil {
114
+ t.Fatalf("load location: %v", err)
115
+ }
116
+ t.Cleanup(func() { time.Local = previousLocal })
117
+ time.Local = location
118
+
119
+ req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-20&end=2026-04-21", nil)
120
+
121
+ filter, err := parseUsageFilterQuery(req, time.Time{})
122
+ if err != nil {
123
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
124
+ }
125
+ if filter.StartTime == nil || filter.EndTime == nil {
126
+ t.Fatalf("expected custom date range bounds, got %+v", filter)
127
+ }
128
+ expectedStart := time.Date(2026, 4, 20, 0, 0, 0, 0, location).UTC()
129
+ expectedEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC()
130
+ if !filter.StartTime.Equal(expectedStart) {
131
+ t.Fatalf("expected custom date start %s, got %s", expectedStart, *filter.StartTime)
132
+ }
133
+ if !filter.EndTime.Equal(expectedEnd) {
134
+ t.Fatalf("expected custom date end %s, got %s", expectedEnd, *filter.EndTime)
135
+ }
136
+ }
137
+
138
+ func TestParseUsageFilterQueryRejectsInvalidCustomRange(t *testing.T) {
139
+ req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-21T00:00:00Z&end=2026-04-20T23:59:59Z", nil)
140
+
141
+ _, err := parseUsageFilterQuery(req, time.Time{})
142
+ if err == nil {
143
+ t.Fatal("expected invalid custom range error")
144
+ }
145
+ }
146
+
147
+ func TestParseUsageFilterQueryDefaultsEventsPagination(t *testing.T) {
148
+ req := httptest.NewRequest("GET", "/api/v1/usage/events?range=all", nil)
149
+
150
+ filter, err := parseUsageFilterQuery(req, time.Time{})
151
+ if err != nil {
152
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
153
+ }
154
+ if filter.Page != 1 || filter.PageSize != 100 || filter.Offset != 0 {
155
+ t.Fatalf("expected default pagination, got %+v", filter)
156
+ }
157
+ }
158
+
159
+ func TestParseUsageFilterQueryAcceptsEventsPaginationAndFilters(t *testing.T) {
160
+ req := httptest.NewRequest("GET", "/api/v1/usage/events?page=3&page_size=100&model=%20claude-sonnet%20&source=%20source-a%20&auth_index=%202%20", nil)
161
+
162
+ filter, err := parseUsageFilterQuery(req, time.Time{})
163
+ if err != nil {
164
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
165
+ }
166
+ if filter.Page != 3 || filter.PageSize != 100 || filter.Offset != 200 {
167
+ t.Fatalf("expected page 3/page size 100 offset 200, got %+v", filter)
168
+ }
169
+ if filter.Model != "claude-sonnet" || filter.Source != "source-a" || filter.AuthIndex != "2" {
170
+ t.Fatalf("expected trimmed server-side filters, got %+v", filter)
171
+ }
172
+ }
173
+
174
+ func TestParseUsageFilterQueryUsesLimitAsPageSizeAlias(t *testing.T) {
175
+ req := httptest.NewRequest("GET", "/api/v1/usage/events?limit=20", nil)
176
+
177
+ filter, err := parseUsageFilterQuery(req, time.Time{})
178
+ if err != nil {
179
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
180
+ }
181
+ if filter.Page != 1 || filter.PageSize != 20 || filter.Offset != 0 {
182
+ t.Fatalf("expected limit alias to set page size, got %+v", filter)
183
+ }
184
+ }
185
+
186
+ func TestParseUsageFilterQueryPrefersPageSizeOverLimit(t *testing.T) {
187
+ req := httptest.NewRequest("GET", "/api/v1/usage/events?page_size=50&limit=20", nil)
188
+
189
+ filter, err := parseUsageFilterQuery(req, time.Time{})
190
+ if err != nil {
191
+ t.Fatalf("parseUsageFilterQuery returned error: %v", err)
192
+ }
193
+ if filter.PageSize != 50 {
194
+ t.Fatalf("expected page_size to win over limit, got %+v", filter)
195
+ }
196
+ }
197
+
198
+ func TestParseUsageFilterQueryRejectsInvalidEventsPagination(t *testing.T) {
199
+ tests := []string{
200
+ "/api/v1/usage/events?page=0",
201
+ "/api/v1/usage/events?page_size=25",
202
+ }
203
+ for _, path := range tests {
204
+ req := httptest.NewRequest("GET", path, nil)
205
+ if _, err := parseUsageFilterQuery(req, time.Time{}); err == nil {
206
+ t.Fatalf("expected pagination error for %s", path)
207
+ }
208
+ }
209
+ }
internal/api/usage_identities.go ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+ "time"
7
+
8
+ "cpa-usage-keeper/internal/models"
9
+ "cpa-usage-keeper/internal/redact"
10
+ "cpa-usage-keeper/internal/service"
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ type usageIdentitiesResponse struct {
15
+ Identities []usageIdentityResponse `json:"identities"`
16
+ }
17
+
18
+ type usageIdentityResponse struct {
19
+ ID uint `json:"id"`
20
+ Name string `json:"name"`
21
+ AuthType models.UsageIdentityAuthType `json:"auth_type"`
22
+ AuthTypeName string `json:"auth_type_name"`
23
+ Identity string `json:"identity"`
24
+ Type string `json:"type"`
25
+ Provider string `json:"provider"`
26
+ TotalRequests int64 `json:"total_requests"`
27
+ SuccessCount int64 `json:"success_count"`
28
+ FailureCount int64 `json:"failure_count"`
29
+ InputTokens int64 `json:"input_tokens"`
30
+ OutputTokens int64 `json:"output_tokens"`
31
+ ReasoningTokens int64 `json:"reasoning_tokens"`
32
+ CachedTokens int64 `json:"cached_tokens"`
33
+ TotalTokens int64 `json:"total_tokens"`
34
+ LastAggregatedUsageEventID uint `json:"last_aggregated_usage_event_id"`
35
+ FirstUsedAt *time.Time `json:"first_used_at,omitempty"`
36
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
37
+ StatsUpdatedAt *time.Time `json:"stats_updated_at,omitempty"`
38
+ IsDeleted bool `json:"is_deleted"`
39
+ CreatedAt time.Time `json:"created_at"`
40
+ UpdatedAt time.Time `json:"updated_at"`
41
+ DeletedAt *time.Time `json:"deleted_at,omitempty"`
42
+ }
43
+
44
+ func registerUsageIdentityRoutes(router gin.IRoutes, usageIdentityProvider service.UsageIdentityProvider) {
45
+ router.GET("/usage/identities", func(c *gin.Context) {
46
+ if usageIdentityProvider == nil {
47
+ c.JSON(http.StatusOK, usageIdentitiesResponse{Identities: []usageIdentityResponse{}})
48
+ return
49
+ }
50
+
51
+ items, err := usageIdentityProvider.ListUsageIdentities(c.Request.Context())
52
+ if err != nil {
53
+ writeInternalError(c, "list usage identities failed", err)
54
+ return
55
+ }
56
+
57
+ response := make([]usageIdentityResponse, 0, len(items))
58
+ for _, item := range items {
59
+ response = append(response, mapUsageIdentityResponse(item))
60
+ }
61
+ c.JSON(http.StatusOK, usageIdentitiesResponse{Identities: response})
62
+ })
63
+ }
64
+
65
+ func mapUsageIdentityResponse(item models.UsageIdentity) usageIdentityResponse {
66
+ identity := item.Identity
67
+ name := item.Name
68
+ identityType := item.Type
69
+ provider := item.Provider
70
+ if item.AuthType == models.UsageIdentityAuthTypeAIProvider {
71
+ identity = redact.APIKeyDisplayName(item.Identity)
72
+ identityType = safeAIProviderDisplayValue(item.Type, item.Identity, item.AuthTypeName)
73
+ provider = safeAIProviderDisplayValue(item.Provider, item.Identity, firstNonEmptyString(identityType, identity))
74
+ name = safeAIProviderDisplayValue(item.Name, item.Identity, firstNonEmptyString(provider, identityType, identity))
75
+ }
76
+
77
+ return usageIdentityResponse{
78
+ ID: item.ID,
79
+ Name: name,
80
+ AuthType: item.AuthType,
81
+ AuthTypeName: item.AuthTypeName,
82
+ Identity: identity,
83
+ Type: identityType,
84
+ Provider: provider,
85
+ TotalRequests: item.TotalRequests,
86
+ SuccessCount: item.SuccessCount,
87
+ FailureCount: item.FailureCount,
88
+ InputTokens: item.InputTokens,
89
+ OutputTokens: item.OutputTokens,
90
+ ReasoningTokens: item.ReasoningTokens,
91
+ CachedTokens: item.CachedTokens,
92
+ TotalTokens: item.TotalTokens,
93
+ LastAggregatedUsageEventID: item.LastAggregatedUsageEventID,
94
+ FirstUsedAt: item.FirstUsedAt,
95
+ LastUsedAt: item.LastUsedAt,
96
+ StatsUpdatedAt: item.StatsUpdatedAt,
97
+ IsDeleted: item.IsDeleted,
98
+ CreatedAt: item.CreatedAt,
99
+ UpdatedAt: item.UpdatedAt,
100
+ DeletedAt: item.DeletedAt,
101
+ }
102
+ }
103
+
104
+ func safeAIProviderDisplayValue(value, rawIdentity, fallback string) string {
105
+ trimmed := strings.TrimSpace(value)
106
+ if trimmed == "" {
107
+ return fallback
108
+ }
109
+ if isSensitiveUsageIdentityValue(trimmed, rawIdentity) {
110
+ return fallback
111
+ }
112
+ return trimmed
113
+ }
114
+
115
+ func isSensitiveUsageIdentityValue(value, rawIdentity string) bool {
116
+ trimmed := strings.TrimSpace(value)
117
+ if trimmed == "" {
118
+ return false
119
+ }
120
+ if raw := strings.TrimSpace(rawIdentity); raw != "" && strings.Contains(trimmed, raw) {
121
+ return true
122
+ }
123
+ lower := strings.ToLower(trimmed)
124
+ return strings.Contains(lower, "sk-") || strings.Contains(lower, "aiza") || strings.Contains(lower, "cr_") || strings.Contains(lower, "cr-")
125
+ }
internal/api/usage_identities_test.go ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+ "time"
9
+
10
+ "cpa-usage-keeper/internal/models"
11
+ "cpa-usage-keeper/internal/redact"
12
+ )
13
+
14
+ type usageIdentitiesStub struct {
15
+ items []models.UsageIdentity
16
+ err error
17
+ }
18
+
19
+ func (s usageIdentitiesStub) ListUsageIdentities(context.Context) ([]models.UsageIdentity, error) {
20
+ return s.items, s.err
21
+ }
22
+
23
+ func TestUsageIdentitiesRouteReturnsMetadataStatsAndDeletedRows(t *testing.T) {
24
+ firstUsedAt := time.Date(2026, 5, 4, 8, 0, 0, 0, time.UTC)
25
+ lastUsedAt := time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC)
26
+ statsUpdatedAt := time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC)
27
+ createdAt := time.Date(2026, 5, 3, 8, 0, 0, 0, time.UTC)
28
+ updatedAt := time.Date(2026, 5, 4, 10, 30, 0, 0, time.UTC)
29
+ deletedAt := time.Date(2026, 5, 4, 11, 0, 0, 0, time.UTC)
30
+
31
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{
32
+ {
33
+ ID: 1,
34
+ Name: "Claude Desktop",
35
+ AuthType: models.UsageIdentityAuthTypeAuthFile,
36
+ AuthTypeName: "oauth",
37
+ Identity: "2",
38
+ Type: "auth-file",
39
+ Provider: "anthropic",
40
+ TotalRequests: 10,
41
+ SuccessCount: 8,
42
+ FailureCount: 2,
43
+ InputTokens: 100,
44
+ OutputTokens: 200,
45
+ ReasoningTokens: 30,
46
+ CachedTokens: 40,
47
+ TotalTokens: 370,
48
+ LastAggregatedUsageEventID: 99,
49
+ FirstUsedAt: &firstUsedAt,
50
+ LastUsedAt: &lastUsedAt,
51
+ StatsUpdatedAt: &statsUpdatedAt,
52
+ CreatedAt: createdAt,
53
+ UpdatedAt: updatedAt,
54
+ },
55
+ {
56
+ ID: 2,
57
+ Name: "Deleted Provider",
58
+ AuthType: models.UsageIdentityAuthTypeAIProvider,
59
+ AuthTypeName: "apikey",
60
+ Identity: "sk-deleted-provider-secret",
61
+ Type: "openai",
62
+ Provider: "OpenAI",
63
+ IsDeleted: true,
64
+ DeletedAt: &deletedAt,
65
+ CreatedAt: createdAt,
66
+ UpdatedAt: updatedAt,
67
+ },
68
+ }})
69
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/identities", nil)
70
+ resp := httptest.NewRecorder()
71
+
72
+ router.ServeHTTP(resp, req)
73
+
74
+ body := resp.Body.String()
75
+ if resp.Code != http.StatusOK {
76
+ t.Fatalf("expected status 200, got %d: %s", resp.Code, body)
77
+ }
78
+ if !contains(body, `"identities":[`) || !contains(body, `"id":1`) || !contains(body, `"identity":"2"`) {
79
+ t.Fatalf("expected auth file identity row in response, got %s", body)
80
+ }
81
+ for _, expected := range []string{
82
+ `"name":"Claude Desktop"`,
83
+ `"auth_type":1`,
84
+ `"auth_type_name":"oauth"`,
85
+ `"type":"auth-file"`,
86
+ `"provider":"anthropic"`,
87
+ `"total_requests":10`,
88
+ `"success_count":8`,
89
+ `"failure_count":2`,
90
+ `"input_tokens":100`,
91
+ `"output_tokens":200`,
92
+ `"reasoning_tokens":30`,
93
+ `"cached_tokens":40`,
94
+ `"total_tokens":370`,
95
+ `"last_aggregated_usage_event_id":99`,
96
+ `"first_used_at":"2026-05-04T08:00:00Z"`,
97
+ `"last_used_at":"2026-05-04T09:00:00Z"`,
98
+ `"stats_updated_at":"2026-05-04T10:00:00Z"`,
99
+ `"is_deleted":true`,
100
+ `"deleted_at":"2026-05-04T11:00:00Z"`,
101
+ } {
102
+ if !contains(body, expected) {
103
+ t.Fatalf("expected %s in response body: %s", expected, body)
104
+ }
105
+ }
106
+ }
107
+
108
+ func TestUsageIdentitiesRouteMasksAIProviderIdentity(t *testing.T) {
109
+ rawLookupKey := "sk-live-secret-value"
110
+ rawPrefix := "sk-live-prefix"
111
+ maskedLookupKey := redact.APIKeyDisplayName(rawLookupKey)
112
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{
113
+ {ID: 1, Name: rawPrefix, AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: rawLookupKey, Type: "openai " + rawLookupKey, Provider: "OpenAI " + rawPrefix},
114
+ }})
115
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/identities", nil)
116
+ resp := httptest.NewRecorder()
117
+
118
+ router.ServeHTTP(resp, req)
119
+
120
+ body := resp.Body.String()
121
+ if resp.Code != http.StatusOK {
122
+ t.Fatalf("expected status 200, got %d: %s", resp.Code, body)
123
+ }
124
+ if contains(body, rawLookupKey) || contains(body, rawPrefix) {
125
+ t.Fatalf("expected raw AI provider lookup values to be hidden, got %s", body)
126
+ }
127
+ if !contains(body, `"identity":"`+maskedLookupKey+`"`) {
128
+ t.Fatalf("expected masked AI provider identity %q in response body: %s", maskedLookupKey, body)
129
+ }
130
+ }
131
+
132
+ func TestUsageIdentityReplacesLegacyMetadataRoutes(t *testing.T) {
133
+ router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{})
134
+ for _, path := range []string{"/api/v1/auth-files", "/api/v1/provider-metadata"} {
135
+ req := httptest.NewRequest(http.MethodGet, path, nil)
136
+ resp := httptest.NewRecorder()
137
+
138
+ router.ServeHTTP(resp, req)
139
+
140
+ if resp.Code != http.StatusNotFound {
141
+ t.Fatalf("expected %s to return 404, got %d: %s", path, resp.Code, resp.Body.String())
142
+ }
143
+ }
144
+ }
internal/api/usage_overview.go ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "net/http"
5
+ "time"
6
+
7
+ "cpa-usage-keeper/internal/cpa"
8
+ "cpa-usage-keeper/internal/redact"
9
+ "cpa-usage-keeper/internal/service"
10
+ "github.com/gin-gonic/gin"
11
+ )
12
+
13
+ type usageOverviewResponse struct {
14
+ Usage usageOverviewPayload `json:"usage"`
15
+ Summary usageOverviewSummary `json:"summary"`
16
+ Series usageOverviewSeries `json:"series"`
17
+ HourlySeries usageOverviewSeries `json:"hourly_series"`
18
+ DailySeries usageOverviewSeries `json:"daily_series"`
19
+ ServiceHealth usageOverviewServiceHealth `json:"service_health"`
20
+ Timezone string `json:"timezone"`
21
+ RangeStart *time.Time `json:"range_start,omitempty"`
22
+ RangeEnd *time.Time `json:"range_end,omitempty"`
23
+ }
24
+
25
+ type usageOverviewPayload struct {
26
+ TotalRequests int64 `json:"total_requests"`
27
+ SuccessCount int64 `json:"success_count"`
28
+ FailureCount int64 `json:"failure_count"`
29
+ TotalTokens int64 `json:"total_tokens"`
30
+ APIs map[string]usageOverviewAPISnapshot `json:"apis"`
31
+ RequestsByDay map[string]int64 `json:"requests_by_day"`
32
+ RequestsByHour map[string]int64 `json:"requests_by_hour"`
33
+ TokensByDay map[string]int64 `json:"tokens_by_day"`
34
+ TokensByHour map[string]int64 `json:"tokens_by_hour"`
35
+ }
36
+
37
+ type usageOverviewSummary struct {
38
+ RequestCount int64 `json:"request_count"`
39
+ TokenCount int64 `json:"token_count"`
40
+ WindowMinutes int64 `json:"window_minutes"`
41
+ RPM float64 `json:"rpm"`
42
+ TPM float64 `json:"tpm"`
43
+ TotalCost float64 `json:"total_cost"`
44
+ CostAvailable bool `json:"cost_available"`
45
+ CachedTokens int64 `json:"cached_tokens"`
46
+ ReasoningTokens int64 `json:"reasoning_tokens"`
47
+ }
48
+
49
+ type usageOverviewSeries struct {
50
+ Requests map[string]int64 `json:"requests"`
51
+ Tokens map[string]int64 `json:"tokens"`
52
+ RPM map[string]float64 `json:"rpm"`
53
+ TPM map[string]float64 `json:"tpm"`
54
+ Cost map[string]float64 `json:"cost"`
55
+ InputTokens map[string]int64 `json:"input_tokens"`
56
+ OutputTokens map[string]int64 `json:"output_tokens"`
57
+ CachedTokens map[string]int64 `json:"cached_tokens"`
58
+ ReasoningTokens map[string]int64 `json:"reasoning_tokens"`
59
+ Models map[string]usageOverviewSeriesLine `json:"models"`
60
+ }
61
+
62
+ type usageOverviewSeriesLine struct {
63
+ Requests map[string]int64 `json:"requests"`
64
+ Tokens map[string]int64 `json:"tokens"`
65
+ RPM map[string]float64 `json:"rpm"`
66
+ TPM map[string]float64 `json:"tpm"`
67
+ Cost map[string]float64 `json:"cost"`
68
+ InputTokens map[string]int64 `json:"input_tokens"`
69
+ OutputTokens map[string]int64 `json:"output_tokens"`
70
+ CachedTokens map[string]int64 `json:"cached_tokens"`
71
+ ReasoningTokens map[string]int64 `json:"reasoning_tokens"`
72
+ }
73
+
74
+ type usageOverviewServiceHealth struct {
75
+ TotalSuccess int64 `json:"total_success"`
76
+ TotalFailure int64 `json:"total_failure"`
77
+ SuccessRate float64 `json:"success_rate"`
78
+ Rows int `json:"rows"`
79
+ Columns int `json:"columns"`
80
+ BucketSeconds int64 `json:"bucket_seconds"`
81
+ WindowStart time.Time `json:"window_start"`
82
+ WindowEnd time.Time `json:"window_end"`
83
+ BlockDetails []usageOverviewServiceHealthBlock `json:"block_details"`
84
+ }
85
+
86
+ type usageOverviewServiceHealthBlock struct {
87
+ StartTime time.Time `json:"start_time"`
88
+ EndTime time.Time `json:"end_time"`
89
+ Success int64 `json:"success"`
90
+ Failure int64 `json:"failure"`
91
+ Rate float64 `json:"rate"`
92
+ }
93
+
94
+ type usageOverviewAPISnapshot struct {
95
+ DisplayName string `json:"display_name,omitempty"`
96
+ TotalRequests int64 `json:"total_requests"`
97
+ SuccessCount int64 `json:"success_count"`
98
+ FailureCount int64 `json:"failure_count"`
99
+ TotalTokens int64 `json:"total_tokens"`
100
+ Models map[string]usageOverviewModelSnapshot `json:"models"`
101
+ }
102
+
103
+ type usageOverviewModelSnapshot struct {
104
+ TotalRequests int64 `json:"total_requests"`
105
+ SuccessCount int64 `json:"success_count"`
106
+ FailureCount int64 `json:"failure_count"`
107
+ TotalTokens int64 `json:"total_tokens"`
108
+ }
109
+
110
+ func registerUsageOverviewRoute(router gin.IRoutes, usageProvider service.UsageProvider) {
111
+ router.GET("/usage/overview", func(c *gin.Context) {
112
+ if usageProvider == nil {
113
+ c.JSON(http.StatusOK, usageOverviewResponse{
114
+ Usage: buildUsageOverviewPayload(nil),
115
+ Summary: usageOverviewSummary{},
116
+ Series: emptyUsageOverviewSeries(),
117
+ HourlySeries: emptyUsageOverviewSeries(),
118
+ DailySeries: emptyUsageOverviewSeries(),
119
+ ServiceHealth: usageOverviewServiceHealth{BlockDetails: []usageOverviewServiceHealthBlock{}},
120
+ Timezone: time.Local.String(),
121
+ })
122
+ return
123
+ }
124
+
125
+ filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC())
126
+ if err != nil {
127
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
128
+ return
129
+ }
130
+
131
+ overview, err := usageProvider.GetUsageOverview(c.Request.Context(), filter)
132
+ if err != nil {
133
+ writeInternalError(c, "get usage overview failed", err)
134
+ return
135
+ }
136
+
137
+ var usage *cpa.StatisticsSnapshot
138
+ if overview != nil {
139
+ usage = overview.Usage
140
+ }
141
+ redactedUsage := redact.UsageSnapshot(usage)
142
+ c.JSON(http.StatusOK, usageOverviewResponse{
143
+ Usage: buildUsageOverviewPayload(redactedUsage),
144
+ Summary: buildUsageOverviewSummary(overview),
145
+ Series: buildUsageOverviewSeries(overview),
146
+ HourlySeries: buildUsageOverviewHourlySeries(overview),
147
+ DailySeries: buildUsageOverviewDailySeries(overview),
148
+ ServiceHealth: buildUsageOverviewServiceHealth(overview),
149
+ Timezone: time.Local.String(),
150
+ RangeStart: filter.StartTime,
151
+ RangeEnd: filter.EndTime,
152
+ })
153
+ })
154
+ }
155
+
156
+ func buildUsageOverviewPayload(snapshot *cpa.StatisticsSnapshot) usageOverviewPayload {
157
+ if snapshot == nil {
158
+ return usageOverviewPayload{
159
+ APIs: map[string]usageOverviewAPISnapshot{},
160
+ RequestsByDay: map[string]int64{},
161
+ RequestsByHour: map[string]int64{},
162
+ TokensByDay: map[string]int64{},
163
+ TokensByHour: map[string]int64{},
164
+ }
165
+ }
166
+
167
+ payload := usageOverviewPayload{
168
+ TotalRequests: snapshot.TotalRequests,
169
+ SuccessCount: snapshot.SuccessCount,
170
+ FailureCount: snapshot.FailureCount,
171
+ TotalTokens: snapshot.TotalTokens,
172
+ RequestsByDay: cloneInt64Map(snapshot.RequestsByDay),
173
+ RequestsByHour: cloneInt64Map(snapshot.RequestsByHour),
174
+ TokensByDay: cloneInt64Map(snapshot.TokensByDay),
175
+ TokensByHour: cloneInt64Map(snapshot.TokensByHour),
176
+ APIs: map[string]usageOverviewAPISnapshot{},
177
+ }
178
+
179
+ for apiName, apiSnapshot := range snapshot.APIs {
180
+ payloadAPI := usageOverviewAPISnapshot{
181
+ DisplayName: apiSnapshot.DisplayName,
182
+ TotalRequests: apiSnapshot.TotalRequests,
183
+ SuccessCount: apiSnapshot.SuccessCount,
184
+ FailureCount: apiSnapshot.FailureCount,
185
+ TotalTokens: apiSnapshot.TotalTokens,
186
+ Models: map[string]usageOverviewModelSnapshot{},
187
+ }
188
+ for modelName, modelSnapshot := range apiSnapshot.Models {
189
+ payloadAPI.Models[modelName] = usageOverviewModelSnapshot{
190
+ TotalRequests: modelSnapshot.TotalRequests,
191
+ SuccessCount: modelSnapshot.SuccessCount,
192
+ FailureCount: modelSnapshot.FailureCount,
193
+ TotalTokens: modelSnapshot.TotalTokens,
194
+ }
195
+ }
196
+ payload.APIs[apiName] = payloadAPI
197
+ }
198
+
199
+ return payload
200
+ }
201
+
202
+ func buildUsageOverviewSummary(overview *service.UsageOverviewSnapshot) usageOverviewSummary {
203
+ if overview == nil {
204
+ return usageOverviewSummary{}
205
+ }
206
+ return usageOverviewSummary{
207
+ RequestCount: overview.Summary.RequestCount,
208
+ TokenCount: overview.Summary.TokenCount,
209
+ WindowMinutes: overview.Summary.WindowMinutes,
210
+ RPM: overview.Summary.RPM,
211
+ TPM: overview.Summary.TPM,
212
+ TotalCost: overview.Summary.TotalCost,
213
+ CostAvailable: overview.Summary.CostAvailable,
214
+ CachedTokens: overview.Summary.CachedTokens,
215
+ ReasoningTokens: overview.Summary.ReasoningTokens,
216
+ }
217
+ }
218
+
219
+ func emptyUsageOverviewSeries() usageOverviewSeries {
220
+ return usageOverviewSeries{
221
+ Requests: map[string]int64{},
222
+ Tokens: map[string]int64{},
223
+ RPM: map[string]float64{},
224
+ TPM: map[string]float64{},
225
+ Cost: map[string]float64{},
226
+ InputTokens: map[string]int64{},
227
+ OutputTokens: map[string]int64{},
228
+ CachedTokens: map[string]int64{},
229
+ ReasoningTokens: map[string]int64{},
230
+ Models: map[string]usageOverviewSeriesLine{},
231
+ }
232
+ }
233
+
234
+ func mapUsageOverviewSeriesLine(series service.UsageOverviewSeries) usageOverviewSeriesLine {
235
+ return usageOverviewSeriesLine{
236
+ Requests: cloneInt64Map(series.Requests),
237
+ Tokens: cloneInt64Map(series.Tokens),
238
+ RPM: cloneFloat64Map(series.RPM),
239
+ TPM: cloneFloat64Map(series.TPM),
240
+ Cost: cloneFloat64Map(series.Cost),
241
+ InputTokens: cloneInt64Map(series.InputTokens),
242
+ OutputTokens: cloneInt64Map(series.OutputTokens),
243
+ CachedTokens: cloneInt64Map(series.CachedTokens),
244
+ ReasoningTokens: cloneInt64Map(series.ReasoningTokens),
245
+ }
246
+ }
247
+
248
+ func mapUsageOverviewSeries(series service.UsageOverviewSeries) usageOverviewSeries {
249
+ models := make(map[string]usageOverviewSeriesLine, len(series.Models))
250
+ for model, modelSeries := range series.Models {
251
+ models[model] = mapUsageOverviewSeriesLine(modelSeries)
252
+ }
253
+ return usageOverviewSeries{
254
+ Requests: cloneInt64Map(series.Requests),
255
+ Tokens: cloneInt64Map(series.Tokens),
256
+ RPM: cloneFloat64Map(series.RPM),
257
+ TPM: cloneFloat64Map(series.TPM),
258
+ Cost: cloneFloat64Map(series.Cost),
259
+ InputTokens: cloneInt64Map(series.InputTokens),
260
+ OutputTokens: cloneInt64Map(series.OutputTokens),
261
+ CachedTokens: cloneInt64Map(series.CachedTokens),
262
+ ReasoningTokens: cloneInt64Map(series.ReasoningTokens),
263
+ Models: models,
264
+ }
265
+ }
266
+
267
+ func buildUsageOverviewSeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries {
268
+ if overview == nil {
269
+ return emptyUsageOverviewSeries()
270
+ }
271
+ return mapUsageOverviewSeries(overview.Series)
272
+ }
273
+
274
+ func buildUsageOverviewHourlySeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries {
275
+ if overview == nil {
276
+ return emptyUsageOverviewSeries()
277
+ }
278
+ return mapUsageOverviewSeries(overview.HourlySeries)
279
+ }
280
+
281
+ func buildUsageOverviewDailySeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries {
282
+ if overview == nil {
283
+ return emptyUsageOverviewSeries()
284
+ }
285
+ return mapUsageOverviewSeries(overview.DailySeries)
286
+ }
287
+
288
+ func buildUsageOverviewServiceHealth(overview *service.UsageOverviewSnapshot) usageOverviewServiceHealth {
289
+ if overview == nil {
290
+ return usageOverviewServiceHealth{BlockDetails: []usageOverviewServiceHealthBlock{}}
291
+ }
292
+ blocks := make([]usageOverviewServiceHealthBlock, 0, len(overview.Health.BlockDetails))
293
+ for _, block := range overview.Health.BlockDetails {
294
+ blocks = append(blocks, usageOverviewServiceHealthBlock{
295
+ StartTime: block.StartTime,
296
+ EndTime: block.EndTime,
297
+ Success: block.Success,
298
+ Failure: block.Failure,
299
+ Rate: block.Rate,
300
+ })
301
+ }
302
+ return usageOverviewServiceHealth{
303
+ TotalSuccess: overview.Health.TotalSuccess,
304
+ TotalFailure: overview.Health.TotalFailure,
305
+ SuccessRate: overview.Health.SuccessRate,
306
+ Rows: overview.Health.Rows,
307
+ Columns: overview.Health.Columns,
308
+ BucketSeconds: overview.Health.BucketSeconds,
309
+ WindowStart: overview.Health.WindowStart,
310
+ WindowEnd: overview.Health.WindowEnd,
311
+ BlockDetails: blocks,
312
+ }
313
+ }
314
+
315
+ func cloneInt64Map(source map[string]int64) map[string]int64 {
316
+ if len(source) == 0 {
317
+ return map[string]int64{}
318
+ }
319
+ cloned := make(map[string]int64, len(source))
320
+ for key, value := range source {
321
+ cloned[key] = value
322
+ }
323
+ return cloned
324
+ }
325
+
326
+ func cloneFloat64Map(source map[string]float64) map[string]float64 {
327
+ if len(source) == 0 {
328
+ return map[string]float64{}
329
+ }
330
+ cloned := make(map[string]float64, len(source))
331
+ for key, value := range source {
332
+ cloned[key] = value
333
+ }
334
+ return cloned
335
+ }
internal/api/usage_overview_test.go ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+ "time"
9
+
10
+ "cpa-usage-keeper/internal/cpa"
11
+ "cpa-usage-keeper/internal/service"
12
+ )
13
+
14
+ type usageFilterStub struct {
15
+ usage *cpa.StatisticsSnapshot
16
+ overview *service.UsageOverviewSnapshot
17
+ err error
18
+ lastFilter service.UsageFilter
19
+ filterCalls int
20
+ overviewCalls int
21
+ }
22
+
23
+ func (s *usageFilterStub) GetUsageWithFilter(_ context.Context, filter service.UsageFilter) (*cpa.StatisticsSnapshot, error) {
24
+ s.lastFilter = filter
25
+ s.filterCalls++
26
+ return s.usage, s.err
27
+ }
28
+
29
+ func (s *usageFilterStub) GetUsageOverview(_ context.Context, filter service.UsageFilter) (*service.UsageOverviewSnapshot, error) {
30
+ s.lastFilter = filter
31
+ s.overviewCalls++
32
+ return s.overview, s.err
33
+ }
34
+
35
+ func (s *usageFilterStub) ListUsageEvents(context.Context, service.UsageFilter) (*service.UsageEventsPage, error) {
36
+ return nil, s.err
37
+ }
38
+
39
+ func (s *usageFilterStub) ListUsageEventFilterOptions(context.Context, service.UsageFilter) (*service.UsageEventFilterOptions, error) {
40
+ return nil, s.err
41
+ }
42
+
43
+ func (s *usageFilterStub) ListUsageCredentialStats(context.Context, service.UsageFilter) ([]service.UsageCredentialStat, error) {
44
+ return nil, s.err
45
+ }
46
+
47
+ func (s *usageFilterStub) GetUsageAnalysis(context.Context, service.UsageFilter) (*service.UsageAnalysisSnapshot, error) {
48
+ return nil, s.err
49
+ }
50
+
51
+ func mustParseTime(t *testing.T, value string) time.Time {
52
+ t.Helper()
53
+ parsed, err := time.Parse(time.RFC3339, value)
54
+ if err != nil {
55
+ t.Fatalf("time.Parse returned error: %v", err)
56
+ }
57
+ return parsed
58
+ }
59
+
60
+ func TestUsageOverviewResponseIncludesResolvedRangeAndTimezone(t *testing.T) {
61
+ previousLocal := time.Local
62
+ location, err := time.LoadLocation("Asia/Shanghai")
63
+ if err != nil {
64
+ t.Fatalf("load location: %v", err)
65
+ }
66
+ t.Cleanup(func() { time.Local = previousLocal })
67
+ time.Local = location
68
+
69
+ provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{}}
70
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
71
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=custom&start=2026-04-20&end=2026-04-21", nil)
72
+ resp := httptest.NewRecorder()
73
+
74
+ router.ServeHTTP(resp, req)
75
+
76
+ if resp.Code != http.StatusOK {
77
+ t.Fatalf("expected status 200, got %d", resp.Code)
78
+ }
79
+ expectedStart := time.Date(2026, 4, 20, 0, 0, 0, 0, location).UTC().Format(time.RFC3339Nano)
80
+ expectedEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC().Format(time.RFC3339Nano)
81
+ body := resp.Body.String()
82
+ if !contains(body, `"timezone":"Asia/Shanghai"`) || !contains(body, `"range_start":"`+expectedStart+`"`) || !contains(body, `"range_end":"`+expectedEnd+`"`) {
83
+ t.Fatalf("expected overview response to include resolved range and timezone, got %s", body)
84
+ }
85
+ }
86
+
87
+ func TestUsageOverviewReturnsFilteredSnapshot(t *testing.T) {
88
+ provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{
89
+ Usage: &cpa.StatisticsSnapshot{
90
+ TotalRequests: 1,
91
+ SuccessCount: 1,
92
+ TotalTokens: 20,
93
+ RequestsByHour: map[string]int64{
94
+ "2026-04-22T11:00:00Z": 1,
95
+ },
96
+ TokensByHour: map[string]int64{
97
+ "2026-04-22T11:00:00Z": 20,
98
+ },
99
+ APIs: map[string]cpa.APISnapshot{
100
+ "provider-a": {
101
+ TotalRequests: 1,
102
+ SuccessCount: 1,
103
+ TotalTokens: 20,
104
+ Models: map[string]cpa.ModelSnapshot{
105
+ "claude-sonnet": {
106
+ TotalRequests: 1,
107
+ SuccessCount: 1,
108
+ TotalTokens: 20,
109
+ },
110
+ },
111
+ },
112
+ },
113
+ },
114
+ Summary: service.UsageOverviewSummary{
115
+ RequestCount: 1,
116
+ TokenCount: 20,
117
+ WindowMinutes: 1440,
118
+ RPM: 1.0 / 1440.0,
119
+ TPM: 20.0 / 1440.0,
120
+ TotalCost: 0.123,
121
+ CostAvailable: true,
122
+ CachedTokens: 2,
123
+ ReasoningTokens: 3,
124
+ },
125
+ Series: service.UsageOverviewSeries{
126
+ Requests: map[string]int64{"2026-04-22T11:00:00Z": 1},
127
+ Tokens: map[string]int64{"2026-04-22T11:00:00Z": 20},
128
+ RPM: map[string]float64{"2026-04-22T11:00:00Z": 1.0 / 60.0},
129
+ TPM: map[string]float64{"2026-04-22T11:00:00Z": 20.0 / 60.0},
130
+ Cost: map[string]float64{"2026-04-22T11:00:00Z": 0.123},
131
+ InputTokens: map[string]int64{"2026-04-22T11:00:00Z": 11},
132
+ OutputTokens: map[string]int64{"2026-04-22T11:00:00Z": 7},
133
+ CachedTokens: map[string]int64{"2026-04-22T11:00:00Z": 2},
134
+ ReasoningTokens: map[string]int64{"2026-04-22T11:00:00Z": 3},
135
+ },
136
+ Health: service.UsageOverviewHealth{
137
+ TotalSuccess: 1,
138
+ TotalFailure: 0,
139
+ SuccessRate: 100,
140
+ BlockDetails: []service.UsageOverviewHealthBlock{{
141
+ StartTime: mustParseTime(t, "2026-04-22T11:00:00Z"),
142
+ EndTime: mustParseTime(t, "2026-04-22T11:15:00Z"),
143
+ Success: 1,
144
+ Failure: 0,
145
+ Rate: 1,
146
+ }},
147
+ },
148
+ }}
149
+ router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "")
150
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=24h", nil)
151
+ resp := httptest.NewRecorder()
152
+
153
+ router.ServeHTTP(resp, req)
154
+
155
+ if resp.Code != http.StatusOK {
156
+ t.Fatalf("expected status 200, got %d", resp.Code)
157
+ }
158
+ body := resp.Body.String()
159
+ if !contains(body, `"usage":`) || !contains(body, `"total_requests":1`) {
160
+ t.Fatalf("unexpected response body: %s", body)
161
+ }
162
+ if !contains(body, `"summary":{"request_count":1,"token_count":20`) {
163
+ t.Fatalf("expected backend summary in response body: %s", body)
164
+ }
165
+ if !contains(body, `"cost_available":true`) {
166
+ t.Fatalf("expected backend cost availability in response body: %s", body)
167
+ }
168
+ if !contains(body, `"series":{"requests":{"2026-04-22T11:00:00Z":1}`) {
169
+ t.Fatalf("expected backend series in response body: %s", body)
170
+ }
171
+ if !contains(body, `"input_tokens":{"2026-04-22T11:00:00Z":11}`) ||
172
+ !contains(body, `"output_tokens":{"2026-04-22T11:00:00Z":7}`) ||
173
+ !contains(body, `"cached_tokens":{"2026-04-22T11:00:00Z":2}`) ||
174
+ !contains(body, `"reasoning_tokens":{"2026-04-22T11:00:00Z":3}`) {
175
+ t.Fatalf("expected token breakdown series in response body: %s", body)
176
+ }
177
+ if !contains(body, `"service_health":{"total_success":1,"total_failure":0,"success_rate":100`) ||
178
+ !contains(body, `"block_details":[{"start_time":"2026-04-22T11:00:00Z","end_time":"2026-04-22T11:15:00Z","success":1,"failure":0,"rate":1}]`) {
179
+ t.Fatalf("expected service health in response body: %s", body)
180
+ }
181
+ if contains(body, `"details":`) {
182
+ t.Fatalf("expected overview response to omit request details: %s", body)
183
+ }
184
+ if provider.filterCalls != 0 {
185
+ t.Fatalf("expected GetUsageWithFilter not to be called, got %d", provider.filterCalls)
186
+ }
187
+ if provider.overviewCalls != 1 {
188
+ t.Fatalf("expected GetUsageOverview to be called once, got %d", provider.overviewCalls)
189
+ }
190
+ if provider.lastFilter.Range != "24h" {
191
+ t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter)
192
+ }
193
+ if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil {
194
+ t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter)
195
+ }
196
+ }
internal/api/usage_resolution.go ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "cpa-usage-keeper/internal/models"
5
+ "cpa-usage-keeper/internal/service"
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ func loadUsageResolutionData(
10
+ c *gin.Context,
11
+ usageIdentityProvider service.UsageIdentityProvider,
12
+ ) ([]models.UsageIdentity, error) {
13
+ if usageIdentityProvider == nil {
14
+ return []models.UsageIdentity{}, nil
15
+ }
16
+ return usageIdentityProvider.ListUsageIdentities(c.Request.Context())
17
+ }
internal/api/usage_source_resolution.go ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package api
2
+
3
+ import (
4
+ "strconv"
5
+ "strings"
6
+
7
+ "cpa-usage-keeper/internal/models"
8
+ "cpa-usage-keeper/internal/redact"
9
+ )
10
+
11
+ type usageSourceResolver struct {
12
+ authIdentities map[string]models.UsageIdentity
13
+ providerIdentities map[string]models.UsageIdentity
14
+ providerRawByKey map[string]string
15
+ }
16
+
17
+ func newUsageSourceResolver(identities []models.UsageIdentity) usageSourceResolver {
18
+ authIdentities := make(map[string]models.UsageIdentity, len(identities))
19
+ providerIdentities := make(map[string]models.UsageIdentity, len(identities))
20
+ providerRawByKey := make(map[string]string, len(identities))
21
+ for _, identity := range identities {
22
+ key := strings.TrimSpace(identity.Identity)
23
+ if key == "" {
24
+ continue
25
+ }
26
+ switch identity.AuthType {
27
+ case models.UsageIdentityAuthTypeAuthFile:
28
+ authIdentities[key] = identity
29
+ case models.UsageIdentityAuthTypeAIProvider:
30
+ providerIdentities[key] = identity
31
+ resolved := usageSourceResolutionFromIdentity(identity, key)
32
+ if resolved.SourceKey != "" {
33
+ providerRawByKey[resolved.SourceKey] = key
34
+ }
35
+ }
36
+ }
37
+
38
+ return usageSourceResolver{
39
+ authIdentities: authIdentities,
40
+ providerIdentities: providerIdentities,
41
+ providerRawByKey: providerRawByKey,
42
+ }
43
+ }
44
+
45
+ type usageSourceResolution struct {
46
+ DisplayName string
47
+ SourceType string
48
+ SourceKey string
49
+ }
50
+
51
+ func usageSourceResolutionFromIdentity(item models.UsageIdentity, fallbackIdentity string) usageSourceResolution {
52
+ identityType := safeAIProviderDisplayValue(item.Type, fallbackIdentity, "")
53
+ displayName := firstNonEmptyString(
54
+ safeAIProviderDisplayValue(item.Name, fallbackIdentity, ""),
55
+ safeAIProviderDisplayValue(item.Provider, fallbackIdentity, ""),
56
+ identityType,
57
+ redact.APIKeyDisplayName(fallbackIdentity),
58
+ )
59
+ sourceKey := "provider:" + uintToString(item.ID)
60
+ if item.ID == 0 {
61
+ sourceKey = "provider:" + redact.APIKeyDisplayName(fallbackIdentity)
62
+ }
63
+ return usageSourceResolution{
64
+ DisplayName: displayName,
65
+ SourceType: identityType,
66
+ SourceKey: sourceKey,
67
+ }
68
+ }
69
+
70
+ func (r usageSourceResolver) rawSourceForPublicValue(value string) string {
71
+ trimmed := strings.TrimSpace(value)
72
+ if trimmed == "" {
73
+ return ""
74
+ }
75
+ if raw, ok := r.providerRawByKey[trimmed]; ok {
76
+ return raw
77
+ }
78
+ return trimmed
79
+ }
80
+
81
+ func (r usageSourceResolver) resolve(rawSource string, authIndex string) usageSourceResolution {
82
+ normalizedSource := strings.TrimSpace(rawSource)
83
+ if normalizedSource != "" {
84
+ if item, ok := r.providerIdentities[normalizedSource]; ok {
85
+ return usageSourceResolutionFromIdentity(item, normalizedSource)
86
+ }
87
+ }
88
+
89
+ normalizedAuthIndex := strings.TrimSpace(authIndex)
90
+ if normalizedAuthIndex != "" {
91
+ if identity, ok := r.authIdentities[normalizedAuthIndex]; ok {
92
+ displayName := firstNonEmptyString(identity.Name, normalizedAuthIndex)
93
+ return usageSourceResolution{
94
+ DisplayName: displayName,
95
+ SourceType: firstNonEmptyString(identity.Type, identity.Provider),
96
+ SourceKey: "auth:" + normalizedAuthIndex,
97
+ }
98
+ }
99
+ }
100
+
101
+ if normalizedSource == "" {
102
+ return usageSourceResolution{DisplayName: "-", SourceKey: "raw:-"}
103
+ }
104
+ if looksLikeEmail(normalizedSource) {
105
+ return usageSourceResolution{
106
+ DisplayName: normalizedSource,
107
+ SourceKey: "email:" + normalizedSource,
108
+ }
109
+ }
110
+ if inferredProvider := inferUsageProviderType(normalizedSource); inferredProvider != "" {
111
+ return usageSourceResolution{
112
+ DisplayName: inferredProvider,
113
+ SourceType: inferredProvider,
114
+ SourceKey: "provider:fallback:" + inferredProvider,
115
+ }
116
+ }
117
+ masked := redact.APIKeyDisplayName(normalizedSource)
118
+ return usageSourceResolution{
119
+ DisplayName: masked,
120
+ SourceKey: "raw:" + masked,
121
+ }
122
+ }
123
+
124
+ func uintToString(value uint) string {
125
+ return strconv.FormatUint(uint64(value), 10)
126
+ }
127
+
128
+ func firstNonEmptyString(values ...string) string {
129
+ for _, value := range values {
130
+ trimmed := strings.TrimSpace(value)
131
+ if trimmed != "" {
132
+ return trimmed
133
+ }
134
+ }
135
+ return ""
136
+ }
137
+
138
+ func looksLikeEmail(value string) bool {
139
+ trimmed := strings.TrimSpace(value)
140
+ if trimmed == "" {
141
+ return false
142
+ }
143
+ atIndex := strings.Index(trimmed, "@")
144
+ return atIndex > 0 && atIndex < len(trimmed)-1 && strings.Contains(trimmed[atIndex+1:], ".")
145
+ }
146
+
147
+ func inferUsageProviderType(source string) string {
148
+ value := strings.ToLower(strings.TrimSpace(source))
149
+ switch {
150
+ case value == "":
151
+ return ""
152
+ case strings.Contains(value, "ampcode"):
153
+ return "ampcode"
154
+ case strings.HasPrefix(value, "sk-ant-") || strings.Contains(value, "anthropic") || strings.Contains(value, "claude"):
155
+ return "claude"
156
+ case strings.HasPrefix(value, "sk-proj-") || strings.HasPrefix(value, "sk-") || strings.Contains(value, "openai") || strings.Contains(value, "gpt"):
157
+ return "openai"
158
+ case strings.HasPrefix(value, "aiza") || strings.Contains(value, "gemini"):
159
+ return "gemini"
160
+ case strings.Contains(value, "vertex"):
161
+ return "vertex"
162
+ case strings.Contains(value, "codex"):
163
+ return "codex"
164
+ default:
165
+ return ""
166
+ }
167
+ }
internal/app/app.go ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "sync"
9
+
10
+ "cpa-usage-keeper/internal/api"
11
+ "cpa-usage-keeper/internal/auth"
12
+ "cpa-usage-keeper/internal/config"
13
+ "cpa-usage-keeper/internal/cpa"
14
+ "cpa-usage-keeper/internal/logging"
15
+ "cpa-usage-keeper/internal/poller"
16
+ "cpa-usage-keeper/internal/repository"
17
+ "cpa-usage-keeper/internal/service"
18
+ webui "cpa-usage-keeper/web"
19
+ "github.com/gin-gonic/gin"
20
+ "github.com/sirupsen/logrus"
21
+ "gorm.io/gorm"
22
+ )
23
+
24
+ type Runner interface {
25
+ Run(ctx context.Context) error
26
+ Status() poller.Status
27
+ SyncNow(ctx context.Context) error
28
+ }
29
+
30
+ type Options struct {
31
+ EnvFile string
32
+ }
33
+
34
+ type App struct {
35
+ Config *config.Config
36
+ DB *gorm.DB
37
+ Router *gin.Engine
38
+ Poller Runner
39
+ Maintenance *StorageCleanupRunner
40
+ MetadataSync *MetadataSyncRunner
41
+ BackupMaintenance *DatabaseBackupRunner
42
+ LogCloser io.Closer
43
+
44
+ backgroundCancel context.CancelFunc
45
+ backgroundWG sync.WaitGroup
46
+ }
47
+
48
+ func New() (*App, error) {
49
+ return NewWithOptions(Options{})
50
+ }
51
+
52
+ func NewWithOptions(options Options) (*App, error) {
53
+ cfg, err := config.Load(config.LoadOptions{EnvFile: options.EnvFile})
54
+ if err != nil {
55
+ return nil, err
56
+ }
57
+
58
+ return NewWithConfig(*cfg)
59
+ }
60
+
61
+ func NewWithConfig(cfg config.Config) (*App, error) {
62
+ logCloser, err := logging.Configure(cfg)
63
+ if err != nil {
64
+ return nil, err
65
+ }
66
+
67
+ db, err := repository.OpenDatabase(cfg)
68
+ if err != nil {
69
+ _ = logCloser.Close()
70
+ return nil, err
71
+ }
72
+
73
+ syncService := service.NewSyncService(db, cfg)
74
+ backgroundPoller := poller.NewRedisDrain(syncService, poller.RedisDrainConfig{
75
+ IdleInterval: cfg.RedisQueueIdleInterval,
76
+ ErrorBackoff: cfg.RedisQueueErrorBackoff,
77
+ })
78
+ var backupMaintenance *DatabaseBackupRunner
79
+ if cfg.BackupEnabled {
80
+ sqlDB, err := db.DB()
81
+ if err != nil {
82
+ _ = closeGormDB(db)
83
+ _ = logCloser.Close()
84
+ return nil, err
85
+ }
86
+ backupStore := newDatabaseBackupStore(sqlDB, cfg.BackupDir)
87
+ backupMaintenance = NewDatabaseBackupRunner(backupStore, backupStore, cfg.BackupInterval, cfg.BackupRetentionDays)
88
+ }
89
+
90
+ usageService := service.NewUsageService(db)
91
+ usageIdentityService := service.NewUsageIdentityService(db)
92
+ pricingModelsClient := cpa.NewClient(cfg.CPABaseURL, cfg.CPAManagementKey, cfg.RequestTimeout)
93
+ pricingService := service.NewPricingService(db, pricingModelsClient)
94
+ sessionManager := auth.NewSessionManager(cfg.AuthSessionTTL)
95
+ authHandler := api.NewAuthHandler(api.AuthConfig{
96
+ Enabled: cfg.AuthEnabled,
97
+ LoginPassword: cfg.LoginPassword,
98
+ SessionTTL: cfg.AuthSessionTTL,
99
+ BasePath: cfg.AppBasePath,
100
+ }, sessionManager)
101
+
102
+ return &App{
103
+ Config: &cfg,
104
+ DB: db,
105
+ Poller: backgroundPoller,
106
+ Maintenance: NewStorageCleanupRunner(syncService),
107
+ MetadataSync: NewMetadataSyncRunner(syncService, cfg.MetadataSyncInterval),
108
+ BackupMaintenance: backupMaintenance,
109
+ LogCloser: logCloser,
110
+ Router: api.NewRouter(
111
+ webui.Static,
112
+ newManualSyncRunner(backgroundPoller, syncService),
113
+ usageService,
114
+ pricingService,
115
+ api.AuthConfig{
116
+ Enabled: cfg.AuthEnabled,
117
+ LoginPassword: cfg.LoginPassword,
118
+ SessionTTL: cfg.AuthSessionTTL,
119
+ BasePath: cfg.AppBasePath,
120
+ },
121
+ authHandler,
122
+ cfg.AppBasePath,
123
+ usageIdentityService,
124
+ ),
125
+ }, nil
126
+ }
127
+
128
+ func closeGormDB(db *gorm.DB) error {
129
+ if db == nil {
130
+ return nil
131
+ }
132
+ sqlDB, err := db.DB()
133
+ if err != nil {
134
+ return err
135
+ }
136
+ return sqlDB.Close()
137
+ }
138
+
139
+ func (a *App) Close() error {
140
+ if a == nil {
141
+ return nil
142
+ }
143
+
144
+ a.stopBackgroundTasks()
145
+
146
+ var closeErr error
147
+ if a.DB != nil {
148
+ closeErr = errors.Join(closeErr, closeGormDB(a.DB))
149
+ a.DB = nil
150
+ }
151
+ if a.LogCloser != nil {
152
+ closeErr = errors.Join(closeErr, a.LogCloser.Close())
153
+ a.LogCloser = nil
154
+ }
155
+ return closeErr
156
+ }
157
+
158
+ func (a *App) Run() error {
159
+ if a == nil || a.Router == nil || a.Config == nil {
160
+ return fmt.Errorf("application is not initialized")
161
+ }
162
+
163
+ ctx := a.startBackgroundContext()
164
+ defer a.stopBackgroundTasks()
165
+ if a.Poller != nil {
166
+ a.startBackgroundTask(func() {
167
+ if err := a.Poller.Run(ctx); err != nil {
168
+ logrus.Errorf("poller stopped: %v", err)
169
+ }
170
+ })
171
+ }
172
+ if a.Maintenance != nil {
173
+ a.startBackgroundTask(func() {
174
+ if err := a.Maintenance.Run(ctx); err != nil {
175
+ logrus.Errorf("maintenance cleanup stopped: %v", err)
176
+ }
177
+ })
178
+ }
179
+ if a.MetadataSync != nil {
180
+ a.startBackgroundTask(func() {
181
+ if err := a.MetadataSync.Run(ctx); err != nil {
182
+ logrus.Errorf("metadata sync stopped: %v", err)
183
+ }
184
+ })
185
+ }
186
+ if a.BackupMaintenance != nil {
187
+ a.startBackgroundTask(func() {
188
+ if err := a.BackupMaintenance.Run(ctx); err != nil {
189
+ logrus.Errorf("database backup stopped: %v", err)
190
+ }
191
+ })
192
+ }
193
+
194
+ return a.Router.Run(":" + a.Config.AppPort)
195
+ }
196
+
197
+ func (a *App) startBackgroundContext() context.Context {
198
+ ctx, cancel := context.WithCancel(context.Background())
199
+ a.backgroundCancel = cancel
200
+ return ctx
201
+ }
202
+
203
+ func (a *App) startBackgroundTask(run func()) {
204
+ a.backgroundWG.Add(1)
205
+ go func() {
206
+ defer a.backgroundWG.Done()
207
+ run()
208
+ }()
209
+ }
210
+
211
+ func (a *App) stopBackgroundTasks() {
212
+ if a.backgroundCancel != nil {
213
+ a.backgroundCancel()
214
+ a.backgroundCancel = nil
215
+ }
216
+ a.backgroundWG.Wait()
217
+ }
internal/app/app_test.go ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "testing"
7
+ "time"
8
+
9
+ "cpa-usage-keeper/internal/config"
10
+ "cpa-usage-keeper/internal/poller"
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/sirupsen/logrus"
13
+ )
14
+
15
+ func TestAppCloseClosesDatabase(t *testing.T) {
16
+ app, err := NewWithConfig(testAppConfig(t))
17
+ if err != nil {
18
+ t.Fatalf("NewWithConfig returned error: %v", err)
19
+ }
20
+ sqlDB, err := app.DB.DB()
21
+ if err != nil {
22
+ t.Fatalf("load sql db: %v", err)
23
+ }
24
+
25
+ if err := app.Close(); err != nil {
26
+ t.Fatalf("Close returned error: %v", err)
27
+ }
28
+
29
+ if err := sqlDB.Ping(); err == nil {
30
+ t.Fatal("expected database ping to fail after app close")
31
+ }
32
+ }
33
+
34
+ func TestNewWithConfigBuildsRedisDrainAndRouter(t *testing.T) {
35
+ app, err := NewWithConfig(testAppConfig(t))
36
+ if err != nil {
37
+ t.Fatalf("NewWithConfig returned error: %v", err)
38
+ }
39
+ defer app.Close()
40
+ if app.Poller == nil {
41
+ t.Fatal("expected poller to be initialized")
42
+ }
43
+ if app.Router == nil {
44
+ t.Fatal("expected router to be initialized")
45
+ }
46
+ if app.LogCloser == nil {
47
+ t.Fatal("expected log closer to be initialized")
48
+ }
49
+ if app.BackupMaintenance == nil {
50
+ t.Fatal("expected database backup runner to be initialized")
51
+ }
52
+ if app.MetadataSync == nil {
53
+ t.Fatal("expected metadata sync runner to be initialized")
54
+ }
55
+ }
56
+
57
+ func TestNewWithConfigSkipsBackupRunnerWhenDisabled(t *testing.T) {
58
+ cfg := testAppConfig(t)
59
+ cfg.BackupEnabled = false
60
+ app, err := NewWithConfig(cfg)
61
+ if err != nil {
62
+ t.Fatalf("NewWithConfig returned error: %v", err)
63
+ }
64
+ defer app.Close()
65
+ if app.BackupMaintenance != nil {
66
+ t.Fatal("expected database backup runner to be skipped when backups are disabled")
67
+ }
68
+ }
69
+
70
+ func TestNewWithConfigSelectsRedisDrain(t *testing.T) {
71
+ app, err := NewWithConfig(testAppConfig(t))
72
+ if err != nil {
73
+ t.Fatalf("NewWithConfig returned error: %v", err)
74
+ }
75
+ defer app.Close()
76
+ if _, ok := app.Poller.(*poller.RedisDrain); !ok {
77
+ t.Fatalf("expected redis to use redis drain, got %T", app.Poller)
78
+ }
79
+ if app.Maintenance == nil {
80
+ t.Fatal("expected maintenance cleanup runner to be initialized")
81
+ }
82
+ }
83
+
84
+ func TestNewWithConfigCreatesIndependentMaintenanceRunner(t *testing.T) {
85
+ app, err := NewWithConfig(testAppConfig(t))
86
+ if err != nil {
87
+ t.Fatalf("NewWithConfig returned error: %v", err)
88
+ }
89
+ defer app.Close()
90
+ if app.Poller == nil {
91
+ t.Fatal("expected sync poller to be initialized")
92
+ }
93
+ if app.Maintenance == nil {
94
+ t.Fatal("expected independent maintenance runner to be initialized")
95
+ }
96
+ }
97
+
98
+ func TestRunStartsPollerAndMaintenanceIndependently(t *testing.T) {
99
+ cfg := testAppConfig(t)
100
+ cfg.AppPort = "invalid-port"
101
+ pollerStarted := make(chan struct{})
102
+ maintenanceStarted := make(chan struct{})
103
+ metadataStarted := make(chan struct{})
104
+ backupStarted := make(chan struct{})
105
+ maintenance := NewStorageCleanupRunner(&maintenanceSyncStub{})
106
+ maintenance.sleep = func(context.Context, time.Duration) bool {
107
+ close(maintenanceStarted)
108
+ return false
109
+ }
110
+ metadataRunner := NewMetadataSyncRunner(&metadataSyncStub{}, time.Second)
111
+ metadataRunner.sleep = func(context.Context, time.Duration) bool {
112
+ close(metadataStarted)
113
+ return false
114
+ }
115
+ backupRunner := NewDatabaseBackupRunner(&databaseBackupWriterStub{}, nil, time.Second, 0)
116
+ backupRunner.sleep = func(context.Context, time.Duration) bool {
117
+ close(backupStarted)
118
+ return false
119
+ }
120
+ app := &App{
121
+ Config: &cfg,
122
+ Router: gin.New(),
123
+ Poller: &appRunStub{started: pollerStarted},
124
+ Maintenance: maintenance,
125
+ MetadataSync: metadataRunner,
126
+ BackupMaintenance: backupRunner,
127
+ }
128
+
129
+ if err := app.Run(); err == nil {
130
+ t.Fatal("expected Run to return an error for invalid port")
131
+ }
132
+ select {
133
+ case <-pollerStarted:
134
+ case <-time.After(time.Second):
135
+ t.Fatal("expected poller runner to start")
136
+ }
137
+ select {
138
+ case <-maintenanceStarted:
139
+ case <-time.After(time.Second):
140
+ t.Fatal("expected maintenance runner to start")
141
+ }
142
+ select {
143
+ case <-metadataStarted:
144
+ case <-time.After(time.Second):
145
+ t.Fatal("expected metadata sync runner to start")
146
+ }
147
+ select {
148
+ case <-backupStarted:
149
+ case <-time.After(time.Second):
150
+ t.Fatal("expected database backup runner to start")
151
+ }
152
+ }
153
+
154
+ func TestRunCancelsBackgroundTasksWhenRouterStops(t *testing.T) {
155
+ cfg := testAppConfig(t)
156
+ cfg.AppPort = "invalid-port"
157
+ backupStarted := make(chan struct{})
158
+ backupCanceled := make(chan struct{})
159
+ backupRunner := NewDatabaseBackupRunner(&databaseBackupWriterStub{}, nil, time.Second, 0)
160
+ backupRunner.sleep = func(ctx context.Context, _ time.Duration) bool {
161
+ close(backupStarted)
162
+ <-ctx.Done()
163
+ close(backupCanceled)
164
+ return false
165
+ }
166
+ app := &App{
167
+ Config: &cfg,
168
+ Router: gin.New(),
169
+ BackupMaintenance: backupRunner,
170
+ }
171
+
172
+ if err := app.Run(); err == nil {
173
+ t.Fatal("expected Run to return an error for invalid port")
174
+ }
175
+ select {
176
+ case <-backupStarted:
177
+ case <-time.After(time.Second):
178
+ t.Fatal("expected database backup runner to start")
179
+ }
180
+ select {
181
+ case <-backupCanceled:
182
+ case <-time.After(time.Second):
183
+ t.Fatal("expected database backup runner context to be canceled")
184
+ }
185
+ }
186
+
187
+ type appRunStub struct {
188
+ started chan struct{}
189
+ }
190
+
191
+ func (s *appRunStub) Run(context.Context) error {
192
+ close(s.started)
193
+ return nil
194
+ }
195
+
196
+ func (s *appRunStub) Status() poller.Status {
197
+ return poller.Status{}
198
+ }
199
+
200
+ func (s *appRunStub) SyncNow(context.Context) error {
201
+ return nil
202
+ }
203
+
204
+ func captureAppInfoLogs(t *testing.T) *bytes.Buffer {
205
+ t.Helper()
206
+ var logs bytes.Buffer
207
+ previousOutput := logrus.StandardLogger().Out
208
+ previousFormatter := logrus.StandardLogger().Formatter
209
+ previousLevel := logrus.GetLevel()
210
+ logrus.SetOutput(&logs)
211
+ logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true})
212
+ logrus.SetLevel(logrus.InfoLevel)
213
+ t.Cleanup(func() {
214
+ logrus.SetOutput(previousOutput)
215
+ logrus.SetFormatter(previousFormatter)
216
+ logrus.SetLevel(previousLevel)
217
+ })
218
+ return &logs
219
+ }
220
+
221
+ func testAppConfig(t *testing.T) config.Config {
222
+ t.Helper()
223
+ return config.Config{
224
+ AppPort: "8080",
225
+ CPABaseURL: "https://cpa.example.com",
226
+ CPAManagementKey: "secret",
227
+ RedisQueueIdleInterval: time.Second,
228
+ RedisQueueErrorBackoff: 10 * time.Second,
229
+ MetadataSyncInterval: 30 * time.Second,
230
+ SQLitePath: t.TempDir() + "/app.db",
231
+ BackupEnabled: true,
232
+ BackupDir: t.TempDir() + "/backups",
233
+ BackupRetentionDays: 7,
234
+ RequestTimeout: 5 * time.Second,
235
+ LogLevel: "info",
236
+ LogFileEnabled: false,
237
+ LogRetentionDays: 7,
238
+ }
239
+ }
internal/app/backup_runner.go ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "os"
8
+ "sync"
9
+ "time"
10
+
11
+ "cpa-usage-keeper/internal/backup"
12
+ "github.com/sirupsen/logrus"
13
+ )
14
+
15
+ type DatabaseBackupWriter interface {
16
+ WriteDatabase(context.Context, time.Time) (string, error)
17
+ }
18
+
19
+ type DatabaseBackupCleaner interface {
20
+ Cleanup(retentionDays int, now time.Time) (int, error)
21
+ }
22
+
23
+ type DatabaseBackupHistory interface {
24
+ LastBackupAt() (time.Time, bool, error)
25
+ }
26
+
27
+ type databaseBackupStore struct {
28
+ db *sql.DB
29
+ dir string
30
+ writer *backup.Writer
31
+ }
32
+
33
+ func newDatabaseBackupStore(db *sql.DB, dir string) *databaseBackupStore {
34
+ return &databaseBackupStore{db: db, dir: dir, writer: backup.NewWriter(dir)}
35
+ }
36
+
37
+ func (s *databaseBackupStore) WriteDatabase(ctx context.Context, backupAt time.Time) (string, error) {
38
+ return s.writer.WriteDatabase(ctx, s.db, backupAt)
39
+ }
40
+
41
+ func (s *databaseBackupStore) Cleanup(retentionDays int, now time.Time) (int, error) {
42
+ return s.writer.Cleanup(retentionDays, now)
43
+ }
44
+
45
+ func (s *databaseBackupStore) LastBackupAt() (time.Time, bool, error) {
46
+ files, err := backup.ListFiles(s.dir)
47
+ if err != nil {
48
+ return time.Time{}, false, err
49
+ }
50
+ var latest time.Time
51
+ for _, file := range files {
52
+ info, err := os.Stat(file)
53
+ if err != nil {
54
+ return time.Time{}, false, err
55
+ }
56
+ if info.ModTime().After(latest) {
57
+ latest = info.ModTime()
58
+ }
59
+ }
60
+ return latest, !latest.IsZero(), nil
61
+ }
62
+
63
+ type DatabaseBackupRunner struct {
64
+ writer DatabaseBackupWriter
65
+ cleaner DatabaseBackupCleaner
66
+ history DatabaseBackupHistory
67
+ interval time.Duration
68
+ retentionDays int
69
+ lastBackupAt time.Time
70
+ retryDelay time.Duration
71
+ retryAttempts int
72
+ pendingRetry bool
73
+ now func() time.Time
74
+ sleep func(context.Context, time.Duration) bool
75
+
76
+ mu sync.Mutex
77
+ running bool
78
+ }
79
+
80
+ func NewDatabaseBackupRunner(writer DatabaseBackupWriter, cleaner DatabaseBackupCleaner, interval time.Duration, retentionDays int) *DatabaseBackupRunner {
81
+ history, _ := writer.(DatabaseBackupHistory)
82
+ return &DatabaseBackupRunner{
83
+ writer: writer,
84
+ cleaner: cleaner,
85
+ history: history,
86
+ interval: interval,
87
+ retentionDays: retentionDays,
88
+ retryDelay: 15 * time.Minute,
89
+ now: time.Now,
90
+ sleep: maintenanceSleepContext,
91
+ }
92
+ }
93
+
94
+ func (r *DatabaseBackupRunner) Run(ctx context.Context) error {
95
+ if err := r.validate(); err != nil {
96
+ return err
97
+ }
98
+ logrus.Info("database backup task started")
99
+ r.setRunning(true)
100
+ defer r.setRunning(false)
101
+
102
+ for {
103
+ now := r.now()
104
+ delay := r.nextDelay(now)
105
+ if delay < 0 {
106
+ delay = 0
107
+ }
108
+ if !r.sleep(ctx, delay) {
109
+ return nil
110
+ }
111
+ backupAt := r.now()
112
+ if _, err := r.writer.WriteDatabase(ctx, backupAt); err != nil {
113
+ logrus.WithError(err).Error("database backup failed")
114
+ r.retryAttempts++
115
+ r.pendingRetry = r.retryAttempts <= 3
116
+ } else {
117
+ r.lastBackupAt = backupAt
118
+ r.retryAttempts = 0
119
+ r.pendingRetry = false
120
+ }
121
+ r.cleanup(backupAt)
122
+ }
123
+ }
124
+
125
+ func (r *DatabaseBackupRunner) nextDelay(now time.Time) time.Duration {
126
+ if r.pendingRetry {
127
+ return r.retryDelay
128
+ }
129
+ if r.usesDailySchedule() {
130
+ return r.nextDailyDelay(now)
131
+ }
132
+ lastBackupAt, ok := r.lastBackupAtFromHistory()
133
+ if !ok {
134
+ return 0
135
+ }
136
+ return lastBackupAt.Add(r.interval).Sub(now)
137
+ }
138
+
139
+ func (r *DatabaseBackupRunner) cleanup(now time.Time) {
140
+ if r.cleaner == nil || r.retentionDays <= 0 {
141
+ return
142
+ }
143
+ if _, err := r.cleaner.Cleanup(r.retentionDays, now); err != nil {
144
+ logrus.WithError(err).Error("database backup cleanup failed")
145
+ }
146
+ }
147
+
148
+ func (r *DatabaseBackupRunner) nextDailyDelay(now time.Time) time.Duration {
149
+ localNow := now.In(time.Local)
150
+ nextBackupAt := nextDailyBackupAt(localNow)
151
+ if lastBackupAt, ok := r.lastBackupAtFromHistory(); ok {
152
+ lastLocalBackup := lastBackupAt.In(time.Local)
153
+ lastBackupDay := time.Date(lastLocalBackup.Year(), lastLocalBackup.Month(), lastLocalBackup.Day(), 4, 0, 0, 0, time.Local)
154
+ candidate := lastBackupDay.AddDate(0, 0, r.dailyScheduleDays())
155
+ if localNow.Before(candidate) {
156
+ nextBackupAt = candidate
157
+ }
158
+ }
159
+ return nextBackupAt.Sub(localNow)
160
+ }
161
+
162
+ func (r *DatabaseBackupRunner) usesDailySchedule() bool {
163
+ return r.interval >= 24*time.Hour && r.interval%(24*time.Hour) == 0
164
+ }
165
+
166
+ func (r *DatabaseBackupRunner) dailyScheduleDays() int {
167
+ return int(r.interval / (24 * time.Hour))
168
+ }
169
+
170
+ func (r *DatabaseBackupRunner) lastBackupAtFromHistory() (time.Time, bool) {
171
+ lastBackupAt := r.lastBackupAt
172
+ if r.history != nil {
173
+ storedBackupAt, ok, err := r.history.LastBackupAt()
174
+ if err != nil {
175
+ logrus.WithError(err).Error("load last database backup time failed")
176
+ } else if ok && storedBackupAt.After(lastBackupAt) {
177
+ lastBackupAt = storedBackupAt
178
+ }
179
+ }
180
+ return lastBackupAt, !lastBackupAt.IsZero()
181
+ }
182
+
183
+ func nextDailyBackupAt(now time.Time) time.Time {
184
+ localNow := now.In(time.Local)
185
+ backupAt := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 4, 0, 0, 0, time.Local)
186
+ if !localNow.Before(backupAt) {
187
+ backupAt = backupAt.AddDate(0, 0, 1)
188
+ }
189
+ return backupAt
190
+ }
191
+
192
+ func (r *DatabaseBackupRunner) validate() error {
193
+ if r == nil {
194
+ return fmt.Errorf("database backup runner is nil")
195
+ }
196
+ if r.writer == nil {
197
+ return fmt.Errorf("database backup writer is nil")
198
+ }
199
+ if r.interval <= 0 {
200
+ return fmt.Errorf("database backup interval must be positive")
201
+ }
202
+ if r.retryDelay <= 0 {
203
+ r.retryDelay = 15 * time.Minute
204
+ }
205
+ if r.now == nil {
206
+ r.now = time.Now
207
+ }
208
+ if r.sleep == nil {
209
+ r.sleep = maintenanceSleepContext
210
+ }
211
+ return nil
212
+ }
213
+
214
+ func (r *DatabaseBackupRunner) setRunning(running bool) {
215
+ r.mu.Lock()
216
+ defer r.mu.Unlock()
217
+ r.running = running
218
+ }
internal/app/backup_runner_test.go ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+ )
10
+
11
+ func TestNextDailyBackupAtUsesLocal0400(t *testing.T) {
12
+ previousLocal := time.Local
13
+ time.Local = time.FixedZone("Test/Local", 8*60*60)
14
+ t.Cleanup(func() { time.Local = previousLocal })
15
+
16
+ before := time.Date(2026, 4, 16, 3, 30, 0, 0, time.Local)
17
+ if got := nextDailyBackupAt(before); !got.Equal(time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local)) {
18
+ t.Fatalf("expected same-day 04:00 backup, got %s", got)
19
+ }
20
+ after := time.Date(2026, 4, 16, 4, 1, 0, 0, time.Local)
21
+ if got := nextDailyBackupAt(after); !got.Equal(time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local)) {
22
+ t.Fatalf("expected next-day 04:00 backup, got %s", got)
23
+ }
24
+ }
25
+
26
+ func TestDatabaseBackupRunnerRunsAtScheduledTime(t *testing.T) {
27
+ writer := &databaseBackupWriterStub{}
28
+ cleaner := &databaseBackupCleanerStub{}
29
+ runner := NewDatabaseBackupRunner(writer, cleaner, 24*time.Hour, 30)
30
+ now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local)
31
+ runner.now = func() time.Time { return now }
32
+ sleepCalls := 0
33
+ runner.sleep = func(context.Context, time.Duration) bool {
34
+ sleepCalls++
35
+ if sleepCalls == 1 {
36
+ return true
37
+ }
38
+ return false
39
+ }
40
+
41
+ if err := runner.Run(context.Background()); err != nil {
42
+ t.Fatalf("Run returned error: %v", err)
43
+ }
44
+ if writer.calls != 1 {
45
+ t.Fatalf("expected one database backup, got %d", writer.calls)
46
+ }
47
+ if cleaner.calls != 1 {
48
+ t.Fatalf("expected one backup cleanup, got %d", cleaner.calls)
49
+ }
50
+ if cleaner.retentionDays != 30 {
51
+ t.Fatalf("expected cleanup retention 30, got %d", cleaner.retentionDays)
52
+ }
53
+ }
54
+
55
+ func TestDatabaseBackupRunnerWaitsUntilNext0400AfterDailyScheduleWithoutTodaysBackup(t *testing.T) {
56
+ writer := &databaseBackupWriterStub{}
57
+ runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0)
58
+ now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local)
59
+ writer.lastBackupAt = now.AddDate(0, 0, -1)
60
+ runner.now = func() time.Time { return now }
61
+ var delay time.Duration
62
+ runner.sleep = func(_ context.Context, d time.Duration) bool {
63
+ delay = d
64
+ return false
65
+ }
66
+
67
+ if err := runner.Run(context.Background()); err != nil {
68
+ t.Fatalf("Run returned error: %v", err)
69
+ }
70
+ expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now)
71
+ if delay != expected {
72
+ t.Fatalf("expected next 04:00 backup delay %s, got %s", expected, delay)
73
+ }
74
+ }
75
+
76
+ func TestDatabaseBackupRunnerWaitsUntilTomorrowAfterTodaysDailyBackup(t *testing.T) {
77
+ writer := &databaseBackupWriterStub{}
78
+ runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0)
79
+ now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local)
80
+ writer.lastBackupAt = time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local)
81
+ runner.now = func() time.Time { return now }
82
+ var delay time.Duration
83
+ runner.sleep = func(_ context.Context, d time.Duration) bool {
84
+ delay = d
85
+ return false
86
+ }
87
+
88
+ if err := runner.Run(context.Background()); err != nil {
89
+ t.Fatalf("Run returned error: %v", err)
90
+ }
91
+ expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now)
92
+ if delay != expected {
93
+ t.Fatalf("expected next daily backup delay %s, got %s", expected, delay)
94
+ }
95
+ }
96
+
97
+ func TestDatabaseBackupRunnerUsesDailyScheduleForMultipleOf24Hours(t *testing.T) {
98
+ writer := &databaseBackupWriterStub{}
99
+ runner := NewDatabaseBackupRunner(writer, nil, 48*time.Hour, 0)
100
+ now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local)
101
+ writer.lastBackupAt = time.Date(2026, 4, 15, 10, 0, 0, 0, time.Local)
102
+ runner.now = func() time.Time { return now }
103
+ var delay time.Duration
104
+ runner.sleep = func(_ context.Context, d time.Duration) bool {
105
+ delay = d
106
+ return false
107
+ }
108
+
109
+ if err := runner.Run(context.Background()); err != nil {
110
+ t.Fatalf("Run returned error: %v", err)
111
+ }
112
+ expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now)
113
+ if delay != expected {
114
+ t.Fatalf("expected next 48h daily schedule delay %s, got %s", expected, delay)
115
+ }
116
+ }
117
+
118
+ func TestDatabaseBackupRunnerUsesExistingBackupForIntervalSchedule(t *testing.T) {
119
+ writer := &databaseBackupWriterStub{}
120
+ runner := NewDatabaseBackupRunner(writer, nil, 10*time.Second, 0)
121
+ now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local)
122
+ writer.lastBackupAt = now.Add(-4 * time.Second)
123
+ runner.now = func() time.Time { return now }
124
+ var delays []time.Duration
125
+ sleepCalls := 0
126
+ runner.sleep = func(_ context.Context, d time.Duration) bool {
127
+ sleepCalls++
128
+ delays = append(delays, d)
129
+ return sleepCalls == 1
130
+ }
131
+
132
+ if err := runner.Run(context.Background()); err != nil {
133
+ t.Fatalf("Run returned error: %v", err)
134
+ }
135
+ if len(delays) == 0 || delays[0] != 6*time.Second {
136
+ t.Fatalf("expected first interval delay 6s, got %+v", delays)
137
+ }
138
+ if writer.calls != 1 {
139
+ t.Fatalf("expected one database backup, got %d", writer.calls)
140
+ }
141
+ }
142
+
143
+ func TestDatabaseBackupRunnerRunsImmediatelyWhenIntervalBackupIsExpired(t *testing.T) {
144
+ writer := &databaseBackupWriterStub{}
145
+ runner := NewDatabaseBackupRunner(writer, nil, 10*time.Second, 0)
146
+ now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local)
147
+ writer.lastBackupAt = now.Add(-11 * time.Second)
148
+ runner.now = func() time.Time { return now }
149
+ var delays []time.Duration
150
+ sleepCalls := 0
151
+ runner.sleep = func(_ context.Context, d time.Duration) bool {
152
+ sleepCalls++
153
+ delays = append(delays, d)
154
+ return sleepCalls == 1
155
+ }
156
+
157
+ if err := runner.Run(context.Background()); err != nil {
158
+ t.Fatalf("Run returned error: %v", err)
159
+ }
160
+ if len(delays) == 0 || delays[0] != 0 {
161
+ t.Fatalf("expected immediate backup for expired interval, got delays %+v", delays)
162
+ }
163
+ }
164
+
165
+ func TestDatabaseBackupRunnerCleansAfterBackupFailure(t *testing.T) {
166
+ logs := captureAppInfoLogs(t)
167
+ writer := &databaseBackupWriterStub{err: errors.New("disk full")}
168
+ cleaner := &databaseBackupCleanerStub{}
169
+ runner := NewDatabaseBackupRunner(writer, cleaner, time.Second, 30)
170
+ runner.now = func() time.Time { return time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local) }
171
+ sleepCalls := 0
172
+ runner.sleep = func(context.Context, time.Duration) bool {
173
+ sleepCalls++
174
+ return sleepCalls == 1
175
+ }
176
+
177
+ if err := runner.Run(context.Background()); err != nil {
178
+ t.Fatalf("Run returned error: %v", err)
179
+ }
180
+ if writer.calls != 1 {
181
+ t.Fatalf("expected one database backup attempt, got %d", writer.calls)
182
+ }
183
+ if cleaner.calls != 1 {
184
+ t.Fatalf("expected cleanup after failed backup, got %d calls", cleaner.calls)
185
+ }
186
+ content := logs.String()
187
+ if !strings.Contains(content, "level=error") || !strings.Contains(content, "msg=\"database backup failed\"") {
188
+ t.Fatalf("expected database backup failure error log, got %q", content)
189
+ }
190
+ }
191
+
192
+ func TestDatabaseBackupRunnerRetriesDailyBackupAfterFailure(t *testing.T) {
193
+ writer := &databaseBackupWriterStub{err: errors.New("temporary failure")}
194
+ runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0)
195
+ now := time.Date(2026, 4, 16, 3, 59, 0, 0, time.Local)
196
+ runner.now = func() time.Time { return now }
197
+ var delays []time.Duration
198
+ sleepCalls := 0
199
+ runner.sleep = func(_ context.Context, d time.Duration) bool {
200
+ delays = append(delays, d)
201
+ sleepCalls++
202
+ switch sleepCalls {
203
+ case 1:
204
+ now = time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local)
205
+ case 2:
206
+ now = time.Date(2026, 4, 16, 4, 15, 0, 0, time.Local)
207
+ case 3:
208
+ now = time.Date(2026, 4, 16, 4, 30, 0, 0, time.Local)
209
+ case 4:
210
+ now = time.Date(2026, 4, 16, 4, 45, 0, 0, time.Local)
211
+ default:
212
+ return false
213
+ }
214
+ return true
215
+ }
216
+
217
+ if err := runner.Run(context.Background()); err != nil {
218
+ t.Fatalf("Run returned error: %v", err)
219
+ }
220
+ expectedDelays := []time.Duration{time.Minute, 15 * time.Minute, 15 * time.Minute, 15 * time.Minute, 23*time.Hour + 15*time.Minute}
221
+ if len(delays) != len(expectedDelays) {
222
+ t.Fatalf("expected delays %+v, got %+v", expectedDelays, delays)
223
+ }
224
+ for i, expected := range expectedDelays {
225
+ if delays[i] != expected {
226
+ t.Fatalf("expected delay %d to be %s, got %s", i, expected, delays[i])
227
+ }
228
+ }
229
+ if writer.calls != 4 {
230
+ t.Fatalf("expected initial attempt plus 3 retries, got %d attempts", writer.calls)
231
+ }
232
+ }
233
+
234
+ type databaseBackupWriterStub struct {
235
+ calls int
236
+ err error
237
+ lastBackupAt time.Time
238
+ }
239
+
240
+ func (s *databaseBackupWriterStub) WriteDatabase(_ context.Context, backupAt time.Time) (string, error) {
241
+ s.calls++
242
+ if s.err == nil {
243
+ s.lastBackupAt = backupAt
244
+ }
245
+ return "/tmp/database.db", s.err
246
+ }
247
+
248
+ func (s *databaseBackupWriterStub) LastBackupAt() (time.Time, bool, error) {
249
+ return s.lastBackupAt, !s.lastBackupAt.IsZero(), nil
250
+ }
251
+
252
+ type databaseBackupCleanerStub struct {
253
+ calls int
254
+ retentionDays int
255
+ now time.Time
256
+ err error
257
+ }
258
+
259
+ func (s *databaseBackupCleanerStub) Cleanup(retentionDays int, now time.Time) (int, error) {
260
+ s.calls++
261
+ s.retentionDays = retentionDays
262
+ s.now = now
263
+ return 0, s.err
264
+ }
internal/app/maintenance.go ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "sync"
7
+ "time"
8
+
9
+ "github.com/sirupsen/logrus"
10
+ )
11
+
12
+ type StorageCleanupSyncer interface {
13
+ CleanupStorage(ctx context.Context) error
14
+ }
15
+
16
+ type StorageCleanupRunner struct {
17
+ syncer StorageCleanupSyncer
18
+ now func() time.Time
19
+ sleep func(context.Context, time.Duration) bool
20
+
21
+ mu sync.Mutex
22
+ running bool
23
+ }
24
+
25
+ func NewStorageCleanupRunner(syncer StorageCleanupSyncer) *StorageCleanupRunner {
26
+ return &StorageCleanupRunner{
27
+ syncer: syncer,
28
+ now: time.Now,
29
+ sleep: maintenanceSleepContext,
30
+ }
31
+ }
32
+
33
+ // Run 每天按项目本地时区 03:00 执行一次统一存储清理,失败只记录日志,不终止后台任务。
34
+ func (r *StorageCleanupRunner) Run(ctx context.Context) error {
35
+ if err := r.validate(); err != nil {
36
+ return err
37
+ }
38
+ logrus.Info("storage cleanup task started")
39
+ r.setRunning(true)
40
+ defer r.setRunning(false)
41
+
42
+ for {
43
+ now := r.now()
44
+ delay := nextDailyCleanupAt(now).Sub(now)
45
+ if delay < 0 {
46
+ delay = 0
47
+ }
48
+ if !r.sleep(ctx, delay) {
49
+ return nil
50
+ }
51
+ if err := r.syncer.CleanupStorage(ctx); err != nil {
52
+ logrus.WithError(err).Error("storage cleanup failed")
53
+ }
54
+ }
55
+ }
56
+
57
+ // nextDailyCleanupAt 用 time.Local 计算下一次 03:00,因此 TZ 同时控制业务日期边界和清理触发时间。
58
+ func nextDailyCleanupAt(now time.Time) time.Time {
59
+ localNow := now.In(time.Local)
60
+ cleanupAt := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 3, 0, 0, 0, time.Local)
61
+ if !localNow.Before(cleanupAt) {
62
+ cleanupAt = cleanupAt.AddDate(0, 0, 1)
63
+ }
64
+ return cleanupAt
65
+ }
66
+
67
+ func (r *StorageCleanupRunner) validate() error {
68
+ if r == nil {
69
+ return fmt.Errorf("storage cleanup runner is nil")
70
+ }
71
+ if r.syncer == nil {
72
+ return fmt.Errorf("storage cleanup syncer is nil")
73
+ }
74
+ if r.now == nil {
75
+ r.now = time.Now
76
+ }
77
+ if r.sleep == nil {
78
+ r.sleep = maintenanceSleepContext
79
+ }
80
+ return nil
81
+ }
82
+
83
+ func maintenanceSleepContext(ctx context.Context, d time.Duration) bool {
84
+ timer := time.NewTimer(d)
85
+ defer timer.Stop()
86
+ select {
87
+ case <-ctx.Done():
88
+ return false
89
+ case <-timer.C:
90
+ return true
91
+ }
92
+ }
93
+
94
+ func (r *StorageCleanupRunner) setRunning(running bool) {
95
+ r.mu.Lock()
96
+ defer r.mu.Unlock()
97
+ r.running = running
98
+ }
internal/app/maintenance_test.go ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/sirupsen/logrus"
11
+ )
12
+
13
+ type maintenanceSyncStub struct {
14
+ cleanupCalls int
15
+ }
16
+
17
+ func (s *maintenanceSyncStub) CleanupStorage(context.Context) error {
18
+ s.cleanupCalls++
19
+ return nil
20
+ }
21
+
22
+ func TestNextDailyCleanupAtUsesLocalThreeAM(t *testing.T) {
23
+ previousLocal := time.Local
24
+ location, err := time.LoadLocation("Asia/Shanghai")
25
+ if err != nil {
26
+ t.Fatalf("load location: %v", err)
27
+ }
28
+ time.Local = location
29
+ t.Cleanup(func() { time.Local = previousLocal })
30
+
31
+ before := nextDailyCleanupAt(time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC))
32
+ if !before.Equal(time.Date(2026, 4, 26, 19, 0, 0, 0, time.UTC)) {
33
+ t.Fatalf("expected same local day 03:00 cleanup, got %s", before)
34
+ }
35
+ after := nextDailyCleanupAt(time.Date(2026, 4, 26, 20, 30, 0, 0, time.UTC))
36
+ if !after.Equal(time.Date(2026, 4, 27, 19, 0, 0, 0, time.UTC)) {
37
+ t.Fatalf("expected next local day 03:00 cleanup, got %s", after)
38
+ }
39
+ }
40
+
41
+ func TestStorageCleanupRunnerLogsTaskStart(t *testing.T) {
42
+ logs := captureMaintenanceInfoLogs(t)
43
+ syncer := &maintenanceSyncStub{}
44
+ runner := NewStorageCleanupRunner(syncer)
45
+ runner.now = func() time.Time { return time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC) }
46
+ runner.sleep = func(context.Context, time.Duration) bool { return false }
47
+
48
+ if err := runner.Run(context.Background()); err != nil {
49
+ t.Fatalf("cleanup runner returned error: %v", err)
50
+ }
51
+
52
+ content := logs.String()
53
+ if !strings.Contains(content, "level=info") || !strings.Contains(content, "msg=\"storage cleanup task started\"") {
54
+ t.Fatalf("expected storage cleanup start info log, got %q", content)
55
+ }
56
+ }
57
+
58
+ func TestStorageCleanupRunnerRunsAtScheduledTime(t *testing.T) {
59
+ syncer := &maintenanceSyncStub{}
60
+ runner := NewStorageCleanupRunner(syncer)
61
+ previousLocal := time.Local
62
+ location, err := time.LoadLocation("Asia/Shanghai")
63
+ if err != nil {
64
+ t.Fatalf("load location: %v", err)
65
+ }
66
+ time.Local = location
67
+ t.Cleanup(func() { time.Local = previousLocal })
68
+ runner.now = func() time.Time { return time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC) }
69
+ ctx, cancel := context.WithCancel(context.Background())
70
+ calls := 0
71
+ runner.sleep = func(_ context.Context, d time.Duration) bool {
72
+ calls++
73
+ if calls == 1 {
74
+ if d != 30*time.Minute {
75
+ t.Fatalf("expected cleanup sleep until local 03:00, got %s", d)
76
+ }
77
+ return true
78
+ }
79
+ cancel()
80
+ return false
81
+ }
82
+
83
+ if err := runner.Run(ctx); err != nil {
84
+ t.Fatalf("cleanup runner returned error: %v", err)
85
+ }
86
+
87
+ if syncer.cleanupCalls != 1 {
88
+ t.Fatalf("expected cleanup loop to run once, got %d", syncer.cleanupCalls)
89
+ }
90
+ }
91
+
92
+ func captureMaintenanceInfoLogs(t *testing.T) *bytes.Buffer {
93
+ t.Helper()
94
+ var logs bytes.Buffer
95
+ previousOutput := logrus.StandardLogger().Out
96
+ previousFormatter := logrus.StandardLogger().Formatter
97
+ previousLevel := logrus.GetLevel()
98
+ logrus.SetOutput(&logs)
99
+ logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true})
100
+ logrus.SetLevel(logrus.InfoLevel)
101
+ t.Cleanup(func() {
102
+ logrus.SetOutput(previousOutput)
103
+ logrus.SetFormatter(previousFormatter)
104
+ logrus.SetLevel(previousLevel)
105
+ })
106
+ return &logs
107
+ }
internal/app/manual_sync.go ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "cpa-usage-keeper/internal/poller"
8
+ )
9
+
10
+ type manualSyncRunner struct {
11
+ redis Runner
12
+ metadata MetadataSyncer
13
+ }
14
+
15
+ type manualSyncStageError struct {
16
+ message string
17
+ err error
18
+ }
19
+
20
+ func (e manualSyncStageError) Error() string {
21
+ return fmt.Sprintf("%s: %v", e.message, e.err)
22
+ }
23
+
24
+ func (e manualSyncStageError) Unwrap() error {
25
+ return e.err
26
+ }
27
+
28
+ func (e manualSyncStageError) UserMessage() string {
29
+ return e.message
30
+ }
31
+
32
+ func newManualSyncRunner(redis Runner, metadata MetadataSyncer) *manualSyncRunner {
33
+ return &manualSyncRunner{redis: redis, metadata: metadata}
34
+ }
35
+
36
+ func (r *manualSyncRunner) Status() poller.Status {
37
+ if r == nil || r.redis == nil {
38
+ return poller.Status{}
39
+ }
40
+ return r.redis.Status()
41
+ }
42
+
43
+ func (r *manualSyncRunner) SyncNow(ctx context.Context) error {
44
+ if r == nil {
45
+ return fmt.Errorf("manual sync runner is nil")
46
+ }
47
+ if r.redis == nil {
48
+ return fmt.Errorf("manual redis syncer is nil")
49
+ }
50
+ if err := r.redis.SyncNow(ctx); err != nil {
51
+ return manualSyncStageError{message: "redis sync failed", err: err}
52
+ }
53
+ if r.metadata == nil {
54
+ return fmt.Errorf("manual metadata syncer is nil")
55
+ }
56
+ if err := r.metadata.SyncMetadata(ctx); err != nil {
57
+ return manualSyncStageError{message: "metadata sync failed", err: err}
58
+ }
59
+ return nil
60
+ }
internal/app/manual_sync_test.go ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "reflect"
7
+ "testing"
8
+
9
+ "cpa-usage-keeper/internal/poller"
10
+ )
11
+
12
+ type manualRedisSyncStub struct {
13
+ status poller.Status
14
+ err error
15
+ calls int
16
+ order *[]string
17
+ }
18
+
19
+ func (s *manualRedisSyncStub) Run(context.Context) error {
20
+ return nil
21
+ }
22
+
23
+ func (s *manualRedisSyncStub) Status() poller.Status {
24
+ return s.status
25
+ }
26
+
27
+ func (s *manualRedisSyncStub) SyncNow(context.Context) error {
28
+ s.calls++
29
+ if s.order != nil {
30
+ *s.order = append(*s.order, "redis")
31
+ }
32
+ return s.err
33
+ }
34
+
35
+ type manualMetadataSyncStub struct {
36
+ err error
37
+ calls int
38
+ order *[]string
39
+ }
40
+
41
+ func (s *manualMetadataSyncStub) SyncMetadata(context.Context) error {
42
+ s.calls++
43
+ if s.order != nil {
44
+ *s.order = append(*s.order, "metadata")
45
+ }
46
+ return s.err
47
+ }
48
+
49
+ func TestManualSyncRunnerRunsRedisThenMetadata(t *testing.T) {
50
+ var order []string
51
+ redis := &manualRedisSyncStub{status: poller.Status{LastStatus: "completed"}, order: &order}
52
+ metadata := &manualMetadataSyncStub{order: &order}
53
+ runner := newManualSyncRunner(redis, metadata)
54
+
55
+ if err := runner.SyncNow(context.Background()); err != nil {
56
+ t.Fatalf("SyncNow returned error: %v", err)
57
+ }
58
+ if !reflect.DeepEqual(order, []string{"redis", "metadata"}) {
59
+ t.Fatalf("expected redis then metadata sync, got %v", order)
60
+ }
61
+ if runner.Status().LastStatus != "completed" {
62
+ t.Fatalf("expected status to delegate to redis runner, got %+v", runner.Status())
63
+ }
64
+ }
65
+
66
+ func TestManualSyncRunnerReturnsRedisErrorWithoutMetadata(t *testing.T) {
67
+ redisErr := errors.New("redis failed")
68
+ redis := &manualRedisSyncStub{err: redisErr}
69
+ metadata := &manualMetadataSyncStub{}
70
+ runner := newManualSyncRunner(redis, metadata)
71
+
72
+ err := runner.SyncNow(context.Background())
73
+ if !errors.Is(err, redisErr) {
74
+ t.Fatalf("expected redis error, got %v", err)
75
+ }
76
+ if err == nil || err.Error() != "redis sync failed: redis failed" {
77
+ t.Fatalf("expected redis-specific sync error, got %v", err)
78
+ }
79
+ if metadata.calls != 0 {
80
+ t.Fatalf("expected metadata sync not to run after redis failure, got %d calls", metadata.calls)
81
+ }
82
+ }
83
+
84
+ func TestManualSyncRunnerReturnsMetadataError(t *testing.T) {
85
+ metadataErr := errors.New("metadata failed")
86
+ redis := &manualRedisSyncStub{}
87
+ metadata := &manualMetadataSyncStub{err: metadataErr}
88
+ runner := newManualSyncRunner(redis, metadata)
89
+
90
+ err := runner.SyncNow(context.Background())
91
+ if !errors.Is(err, metadataErr) {
92
+ t.Fatalf("expected metadata error, got %v", err)
93
+ }
94
+ if err == nil || err.Error() != "metadata sync failed: metadata failed" {
95
+ t.Fatalf("expected metadata-specific sync error, got %v", err)
96
+ }
97
+ if redis.calls != 1 || metadata.calls != 1 {
98
+ t.Fatalf("expected redis and metadata to run once, got redis=%d metadata=%d", redis.calls, metadata.calls)
99
+ }
100
+ }
internal/app/metadata_sync.go ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "sync"
7
+ "time"
8
+
9
+ "github.com/sirupsen/logrus"
10
+ )
11
+
12
+ type MetadataSyncer interface {
13
+ SyncMetadata(ctx context.Context) error
14
+ }
15
+
16
+ type MetadataSyncRunner struct {
17
+ syncer MetadataSyncer
18
+ interval time.Duration
19
+ sleep func(context.Context, time.Duration) bool
20
+
21
+ mu sync.Mutex
22
+ running bool
23
+ }
24
+
25
+ func NewMetadataSyncRunner(syncer MetadataSyncer, interval time.Duration) *MetadataSyncRunner {
26
+ return &MetadataSyncRunner{
27
+ syncer: syncer,
28
+ interval: interval,
29
+ sleep: maintenanceSleepContext,
30
+ }
31
+ }
32
+
33
+ // Run 启动独立 metadata 同步任务:启动后立即执行一次,之后按固定间隔刷新 auth files 和 provider metadata。
34
+ func (r *MetadataSyncRunner) Run(ctx context.Context) error {
35
+ if err := r.validate(); err != nil {
36
+ return err
37
+ }
38
+ logrus.Info("metadata sync task started")
39
+ r.setRunning(true)
40
+ defer r.setRunning(false)
41
+
42
+ delay := time.Duration(0)
43
+ for {
44
+ if !r.sleep(ctx, delay) {
45
+ return nil
46
+ }
47
+ if err := r.syncer.SyncMetadata(ctx); err != nil {
48
+ logrus.WithError(err).Error("metadata sync failed")
49
+ }
50
+ delay = r.interval
51
+ }
52
+ }
53
+
54
+ func (r *MetadataSyncRunner) validate() error {
55
+ if r == nil {
56
+ return fmt.Errorf("metadata sync runner is nil")
57
+ }
58
+ if r.syncer == nil {
59
+ return fmt.Errorf("metadata syncer is nil")
60
+ }
61
+ if r.interval <= 0 {
62
+ return fmt.Errorf("metadata sync interval must be positive")
63
+ }
64
+ if r.sleep == nil {
65
+ r.sleep = maintenanceSleepContext
66
+ }
67
+ return nil
68
+ }
69
+
70
+ func (r *MetadataSyncRunner) setRunning(running bool) {
71
+ r.mu.Lock()
72
+ defer r.mu.Unlock()
73
+ r.running = running
74
+ }