mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 07:19:33 +08:00
基本功能实现
This commit is contained in:
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# =========================
|
||||
# Go .gitignore (lean)
|
||||
# =========================
|
||||
|
||||
# Binaries and build artifacts
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.a
|
||||
*.o
|
||||
*.out
|
||||
*.test
|
||||
|
||||
# Build outputs
|
||||
/bin/
|
||||
/dist/
|
||||
/build/
|
||||
/out/
|
||||
|
||||
# Go workspace files
|
||||
/go.work
|
||||
/go.work.sum
|
||||
|
||||
# Coverage
|
||||
coverage.out
|
||||
cover.out
|
||||
*.coverprofile
|
||||
|
||||
# Vendor (enable if you do not commit vendor/)
|
||||
# /vendor/
|
||||
|
||||
# IDE / editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Vim / Emacs
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
# Data
|
||||
/data/
|
||||
/picture/
|
||||
/config.yaml
|
||||
/bing_daily_image.db
|
||||
/req.txt
|
||||
/BingDailyImage
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -o BingDailyImage .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/BingDailyImage .
|
||||
RUN mkdir -p data
|
||||
COPY --from=builder /app/config.example.yaml ./data/config.yaml
|
||||
COPY --from=builder /app/web ./web
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./BingDailyImage"]
|
||||
82
README.md
Normal file
82
README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# BingDailyImage
|
||||
|
||||
必应每日一图抓取、存储、多分辨率管理与公共 API 服务。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **自动抓取**:每日定时抓取 Bing 每日一图,支持 UHD 探测降级。
|
||||
- **补抓能力**:支持手动或 API 触发抓取最近 N 天(默认 8 天)的图片。
|
||||
- **多分辨率管理**:自动生成 UHD, 1920x1080, 1366x768 等分辨率,支持 WebP 和 JPG 格式。
|
||||
- **灵活存储**:支持本地磁盘、S3 对象存储、WebDAV 存储。
|
||||
- **数据库支持**:支持 SQLite, MySQL, PostgreSQL。
|
||||
- **公共 API**:提供今日图片、随机图片、指定日期图片的纯图及元数据接口。
|
||||
- **管理后台**:内置极简管理后台,支持 Token 管理、任务控制、配置查看。
|
||||
- **行为模式**:支持 `local`(服务转发)和 `redirect`(302 跳转至公网 URL)两种模式。
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 1. 配置
|
||||
|
||||
复制示例配置文件到 `data` 目录并根据需要修改:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
cp config.example.yaml data/config.yaml
|
||||
```
|
||||
|
||||
所有生成的数据(图片、数据库)及配置文件现在统一存放在 `./data` 目录下。
|
||||
|
||||
特别注意修改 `admin.password_bcrypt`(默认密码为 `admin123`)。
|
||||
|
||||
### 2. 运行
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
项目启动后会自动执行一次抓取任务,并根据 `cron.daily_spec` 设置定时任务。
|
||||
|
||||
### 3. 访问
|
||||
|
||||
- 管理后台:`http://localhost:8080/`
|
||||
- 今日图片:`http://localhost:8080/api/v1/image/today`
|
||||
- 今日元数据:`http://localhost:8080/api/v1/image/today/meta`
|
||||
- API 文档 (Swagger):`http://localhost:8080/swagger/index.html`
|
||||
|
||||
## API 文档 (v1)
|
||||
|
||||
### 公共接口 (无需 Token)
|
||||
|
||||
- `GET /api/v1/image/today`:返回今日图片
|
||||
- `GET /api/v1/image/today/meta`:返回今日图片元数据
|
||||
- `GET /api/v1/image/random`:返回随机图片
|
||||
- `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片
|
||||
- **查询参数**:
|
||||
- `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD`
|
||||
- `format`:格式 (jpg, webp),默认 `jpg`
|
||||
|
||||
### 管理接口 (需 Bearer Token)
|
||||
|
||||
- `POST /api/v1/admin/login`:登录获取 Token
|
||||
- `GET /api/v1/admin/tokens`:Token 列表
|
||||
- `POST /api/v1/admin/fetch`:手动触发抓取
|
||||
- `POST /api/v1/admin/cleanup`:手动触发清理
|
||||
|
||||
## 存储模式区别
|
||||
|
||||
- **local 模式**:接口直接返回图片的二进制流,图片存储对外部不可见。
|
||||
- **redirect 模式**:接口返回 302 重定向到图片的 `PublicURL`(通常在 S3 或 WebDAV 配置了 `public_url_prefix` 时使用)。
|
||||
|
||||
## 开发与构建
|
||||
|
||||
```bash
|
||||
# 构建二进制
|
||||
go build -o BingDailyImage .
|
||||
|
||||
# 构建 Docker 镜像
|
||||
docker build -t bing-daily-image .
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
848
docs/docs.go
Normal file
848
docs/docs.go
Normal file
@@ -0,0 +1,848 @@
|
||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/admin/cleanup": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "立即启动旧图片清理任务",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "手动触发清理",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/config": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "获取服务的当前运行配置 (脱敏)",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取当前配置",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.Config"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "在线更新服务配置并保存",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新配置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "配置对象",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.Config"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.Config"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/fetch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "立即启动抓取 Bing 任务",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "手动触发抓取",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "抓取天数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ManualFetchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/login": {
|
||||
"post": {
|
||||
"description": "使用密码登录并获取临时 Token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "管理员登录",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "登录请求",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Token"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/tokens": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "获取所有已创建的 API Token 列表",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取 Token 列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "创建一个新的 API Token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "创建 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "创建请求",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateTokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/tokens/{id}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "永久删除指定的 API Token",
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "删除 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Token ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "启用或禁用指定的 API Token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新 Token 状态",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Token ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "更新请求",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.UpdateTokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/date/{date}": {
|
||||
"get": {
|
||||
"description": "根据日期返回图片流或重定向 (yyyy-mm-dd)",
|
||||
"produces": [
|
||||
"image/jpeg",
|
||||
"image/webp"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取指定日期图片",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "日期 (yyyy-mm-dd)",
|
||||
"name": "date",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "UHD",
|
||||
"description": "分辨率",
|
||||
"name": "variant",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "jpg",
|
||||
"description": "格式",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/date/{date}/meta": {
|
||||
"get": {
|
||||
"description": "根据日期获取图片元数据 (yyyy-mm-dd)",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取指定日期图片元数据",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "日期 (yyyy-mm-dd)",
|
||||
"name": "date",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/random": {
|
||||
"get": {
|
||||
"description": "随机返回一张已抓取的图片流或重定向",
|
||||
"produces": [
|
||||
"image/jpeg",
|
||||
"image/webp"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取随机图片",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "UHD",
|
||||
"description": "分辨率",
|
||||
"name": "variant",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "jpg",
|
||||
"description": "格式",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/random/meta": {
|
||||
"get": {
|
||||
"description": "随机获取一张已抓取图片的元数据",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取随机图片元数据",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/today": {
|
||||
"get": {
|
||||
"description": "根据参数返回今日必应图片流或重定向",
|
||||
"produces": [
|
||||
"image/jpeg",
|
||||
"image/webp"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取今日图片",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "UHD",
|
||||
"description": "分辨率 (UHD, 1920x1080, 1366x768)",
|
||||
"name": "variant",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "jpg",
|
||||
"description": "格式 (jpg, webp)",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/today/meta": {
|
||||
"get": {
|
||||
"description": "获取今日必应图片的标题、版权等元数据",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取今日图片元数据",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/images": {
|
||||
"get": {
|
||||
"description": "分页获取已抓取的图片元数据列表",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取图片列表",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"description": "限制数量",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"config.APIConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"description": "local | redirect",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.AdminConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"passwordBcrypt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.Config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"admin": {
|
||||
"$ref": "#/definitions/config.AdminConfig"
|
||||
},
|
||||
"api": {
|
||||
"$ref": "#/definitions/config.APIConfig"
|
||||
},
|
||||
"cron": {
|
||||
"$ref": "#/definitions/config.CronConfig"
|
||||
},
|
||||
"db": {
|
||||
"$ref": "#/definitions/config.DBConfig"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/config.FeatureConfig"
|
||||
},
|
||||
"log": {
|
||||
"$ref": "#/definitions/config.LogConfig"
|
||||
},
|
||||
"retention": {
|
||||
"$ref": "#/definitions/config.RetentionConfig"
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/definitions/config.ServerConfig"
|
||||
},
|
||||
"storage": {
|
||||
"$ref": "#/definitions/config.StorageConfig"
|
||||
},
|
||||
"token": {
|
||||
"$ref": "#/definitions/config.TokenConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.CronConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dailySpec": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.DBConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dsn": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "sqlite/mysql/postgres",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.FeatureConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"writeDailyFiles": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.LocalConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"root": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.LogConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.RetentionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.S3Config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"bucket": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"forcePathStyle": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"publicURLPrefix": {
|
||||
"type": "string"
|
||||
},
|
||||
"region": {
|
||||
"type": "string"
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.ServerConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.StorageConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"local": {
|
||||
"$ref": "#/definitions/config.LocalConfig"
|
||||
},
|
||||
"s3": {
|
||||
"$ref": "#/definitions/config.S3Config"
|
||||
},
|
||||
"type": {
|
||||
"description": "local/s3/webdav",
|
||||
"type": "string"
|
||||
},
|
||||
"webDAV": {
|
||||
"$ref": "#/definitions/config.WebDAVConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.TokenConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"defaultTTL": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.WebDAVConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicURLPrefix": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.CreateTokenRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"expires_at": {
|
||||
"description": "optional",
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"description": "optional, e.g. 168h",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.LoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ManualFetchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"n": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.UpdateTokenRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.Token": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BearerAuth": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "1.0",
|
||||
Host: "localhost:8080",
|
||||
BasePath: "/api/v1",
|
||||
Schemes: []string{},
|
||||
Title: "BingDailyImage API",
|
||||
Description: "必应每日一图抓取、存储、管理与公共 API 服务。",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
824
docs/swagger.json
Normal file
824
docs/swagger.json
Normal file
@@ -0,0 +1,824 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "必应每日一图抓取、存储、管理与公共 API 服务。",
|
||||
"title": "BingDailyImage API",
|
||||
"contact": {},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/admin/cleanup": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "立即启动旧图片清理任务",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "手动触发清理",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/config": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "获取服务的当前运行配置 (脱敏)",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取当前配置",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.Config"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "在线更新服务配置并保存",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新配置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "配置对象",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.Config"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/config.Config"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/fetch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "立即启动抓取 Bing 任务",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "手动触发抓取",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "抓取天数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ManualFetchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/login": {
|
||||
"post": {
|
||||
"description": "使用密码登录并获取临时 Token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "管理员登录",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "登录请求",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Token"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/tokens": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "获取所有已创建的 API Token 列表",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取 Token 列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "创建一个新的 API Token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "创建 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "创建请求",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateTokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/tokens/{id}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "永久删除指定的 API Token",
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "删除 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Token ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "启用或禁用指定的 API Token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "更新 Token 状态",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Token ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "更新请求",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.UpdateTokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/date/{date}": {
|
||||
"get": {
|
||||
"description": "根据日期返回图片流或重定向 (yyyy-mm-dd)",
|
||||
"produces": [
|
||||
"image/jpeg",
|
||||
"image/webp"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取指定日期图片",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "日期 (yyyy-mm-dd)",
|
||||
"name": "date",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "UHD",
|
||||
"description": "分辨率",
|
||||
"name": "variant",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "jpg",
|
||||
"description": "格式",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/date/{date}/meta": {
|
||||
"get": {
|
||||
"description": "根据日期获取图片元数据 (yyyy-mm-dd)",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取指定日期图片元数据",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "日期 (yyyy-mm-dd)",
|
||||
"name": "date",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/random": {
|
||||
"get": {
|
||||
"description": "随机返回一张已抓取的图片流或重定向",
|
||||
"produces": [
|
||||
"image/jpeg",
|
||||
"image/webp"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取随机图片",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "UHD",
|
||||
"description": "分辨率",
|
||||
"name": "variant",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "jpg",
|
||||
"description": "格式",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/random/meta": {
|
||||
"get": {
|
||||
"description": "随机获取一张已抓取图片的元数据",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取随机图片元数据",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/today": {
|
||||
"get": {
|
||||
"description": "根据参数返回今日必应图片流或重定向",
|
||||
"produces": [
|
||||
"image/jpeg",
|
||||
"image/webp"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取今日图片",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "UHD",
|
||||
"description": "分辨率 (UHD, 1920x1080, 1366x768)",
|
||||
"name": "variant",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "jpg",
|
||||
"description": "格式 (jpg, webp)",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/image/today/meta": {
|
||||
"get": {
|
||||
"description": "获取今日必应图片的标题、版权等元数据",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取今日图片元数据",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/images": {
|
||||
"get": {
|
||||
"description": "分页获取已抓取的图片元数据列表",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"image"
|
||||
],
|
||||
"summary": "获取图片列表",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"description": "限制数量",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"config.APIConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"description": "local | redirect",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.AdminConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"passwordBcrypt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.Config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"admin": {
|
||||
"$ref": "#/definitions/config.AdminConfig"
|
||||
},
|
||||
"api": {
|
||||
"$ref": "#/definitions/config.APIConfig"
|
||||
},
|
||||
"cron": {
|
||||
"$ref": "#/definitions/config.CronConfig"
|
||||
},
|
||||
"db": {
|
||||
"$ref": "#/definitions/config.DBConfig"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/config.FeatureConfig"
|
||||
},
|
||||
"log": {
|
||||
"$ref": "#/definitions/config.LogConfig"
|
||||
},
|
||||
"retention": {
|
||||
"$ref": "#/definitions/config.RetentionConfig"
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/definitions/config.ServerConfig"
|
||||
},
|
||||
"storage": {
|
||||
"$ref": "#/definitions/config.StorageConfig"
|
||||
},
|
||||
"token": {
|
||||
"$ref": "#/definitions/config.TokenConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.CronConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dailySpec": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.DBConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dsn": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "sqlite/mysql/postgres",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.FeatureConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"writeDailyFiles": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.LocalConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"root": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.LogConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.RetentionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.S3Config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"bucket": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"forcePathStyle": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"publicURLPrefix": {
|
||||
"type": "string"
|
||||
},
|
||||
"region": {
|
||||
"type": "string"
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.ServerConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.StorageConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"local": {
|
||||
"$ref": "#/definitions/config.LocalConfig"
|
||||
},
|
||||
"s3": {
|
||||
"$ref": "#/definitions/config.S3Config"
|
||||
},
|
||||
"type": {
|
||||
"description": "local/s3/webdav",
|
||||
"type": "string"
|
||||
},
|
||||
"webDAV": {
|
||||
"$ref": "#/definitions/config.WebDAVConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.TokenConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"defaultTTL": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config.WebDAVConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicURLPrefix": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.CreateTokenRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"expires_at": {
|
||||
"description": "optional",
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"description": "optional, e.g. 168h",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.LoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ManualFetchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"n": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.UpdateTokenRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.Token": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BearerAuth": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
532
docs/swagger.yaml
Normal file
532
docs/swagger.yaml
Normal file
@@ -0,0 +1,532 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
config.APIConfig:
|
||||
properties:
|
||||
mode:
|
||||
description: local | redirect
|
||||
type: string
|
||||
type: object
|
||||
config.AdminConfig:
|
||||
properties:
|
||||
passwordBcrypt:
|
||||
type: string
|
||||
type: object
|
||||
config.Config:
|
||||
properties:
|
||||
admin:
|
||||
$ref: '#/definitions/config.AdminConfig'
|
||||
api:
|
||||
$ref: '#/definitions/config.APIConfig'
|
||||
cron:
|
||||
$ref: '#/definitions/config.CronConfig'
|
||||
db:
|
||||
$ref: '#/definitions/config.DBConfig'
|
||||
feature:
|
||||
$ref: '#/definitions/config.FeatureConfig'
|
||||
log:
|
||||
$ref: '#/definitions/config.LogConfig'
|
||||
retention:
|
||||
$ref: '#/definitions/config.RetentionConfig'
|
||||
server:
|
||||
$ref: '#/definitions/config.ServerConfig'
|
||||
storage:
|
||||
$ref: '#/definitions/config.StorageConfig'
|
||||
token:
|
||||
$ref: '#/definitions/config.TokenConfig'
|
||||
type: object
|
||||
config.CronConfig:
|
||||
properties:
|
||||
dailySpec:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
config.DBConfig:
|
||||
properties:
|
||||
dsn:
|
||||
type: string
|
||||
type:
|
||||
description: sqlite/mysql/postgres
|
||||
type: string
|
||||
type: object
|
||||
config.FeatureConfig:
|
||||
properties:
|
||||
writeDailyFiles:
|
||||
type: boolean
|
||||
type: object
|
||||
config.LocalConfig:
|
||||
properties:
|
||||
root:
|
||||
type: string
|
||||
type: object
|
||||
config.LogConfig:
|
||||
properties:
|
||||
level:
|
||||
type: string
|
||||
type: object
|
||||
config.RetentionConfig:
|
||||
properties:
|
||||
days:
|
||||
type: integer
|
||||
type: object
|
||||
config.S3Config:
|
||||
properties:
|
||||
accessKey:
|
||||
type: string
|
||||
bucket:
|
||||
type: string
|
||||
endpoint:
|
||||
type: string
|
||||
forcePathStyle:
|
||||
type: boolean
|
||||
publicURLPrefix:
|
||||
type: string
|
||||
region:
|
||||
type: string
|
||||
secretKey:
|
||||
type: string
|
||||
type: object
|
||||
config.ServerConfig:
|
||||
properties:
|
||||
baseURL:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
type: object
|
||||
config.StorageConfig:
|
||||
properties:
|
||||
local:
|
||||
$ref: '#/definitions/config.LocalConfig'
|
||||
s3:
|
||||
$ref: '#/definitions/config.S3Config'
|
||||
type:
|
||||
description: local/s3/webdav
|
||||
type: string
|
||||
webDAV:
|
||||
$ref: '#/definitions/config.WebDAVConfig'
|
||||
type: object
|
||||
config.TokenConfig:
|
||||
properties:
|
||||
defaultTTL:
|
||||
type: string
|
||||
type: object
|
||||
config.WebDAVConfig:
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
publicURLPrefix:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
handlers.CreateTokenRequest:
|
||||
properties:
|
||||
expires_at:
|
||||
description: optional
|
||||
type: string
|
||||
expires_in:
|
||||
description: optional, e.g. 168h
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
handlers.LoginRequest:
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
required:
|
||||
- password
|
||||
type: object
|
||||
handlers.ManualFetchRequest:
|
||||
properties:
|
||||
"n":
|
||||
type: integer
|
||||
type: object
|
||||
handlers.UpdateTokenRequest:
|
||||
properties:
|
||||
disabled:
|
||||
type: boolean
|
||||
type: object
|
||||
model.Token:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
expires_at:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
host: localhost:8080
|
||||
info:
|
||||
contact: {}
|
||||
description: 必应每日一图抓取、存储、管理与公共 API 服务。
|
||||
title: BingDailyImage API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/admin/cleanup:
|
||||
post:
|
||||
description: 立即启动旧图片清理任务
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 手动触发清理
|
||||
tags:
|
||||
- admin
|
||||
/admin/config:
|
||||
get:
|
||||
description: 获取服务的当前运行配置 (脱敏)
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/config.Config'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 获取当前配置
|
||||
tags:
|
||||
- admin
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 在线更新服务配置并保存
|
||||
parameters:
|
||||
- description: 配置对象
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/config.Config'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/config.Config'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 更新配置
|
||||
tags:
|
||||
- admin
|
||||
/admin/fetch:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 立即启动抓取 Bing 任务
|
||||
parameters:
|
||||
- description: 抓取天数
|
||||
in: body
|
||||
name: request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ManualFetchRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 手动触发抓取
|
||||
tags:
|
||||
- admin
|
||||
/admin/login:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 使用密码登录并获取临时 Token
|
||||
parameters:
|
||||
- description: 登录请求
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.LoginRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.Token'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: 管理员登录
|
||||
tags:
|
||||
- admin
|
||||
/admin/tokens:
|
||||
get:
|
||||
description: 获取所有已创建的 API Token 列表
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.Token'
|
||||
type: array
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 获取 Token 列表
|
||||
tags:
|
||||
- admin
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 创建一个新的 API Token
|
||||
parameters:
|
||||
- description: 创建请求
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.CreateTokenRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.Token'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 创建 Token
|
||||
tags:
|
||||
- admin
|
||||
/admin/tokens/{id}:
|
||||
delete:
|
||||
description: 永久删除指定的 API Token
|
||||
parameters:
|
||||
- description: Token ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 删除 Token
|
||||
tags:
|
||||
- admin
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 启用或禁用指定的 API Token
|
||||
parameters:
|
||||
- description: Token ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 更新请求
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.UpdateTokenRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 更新 Token 状态
|
||||
tags:
|
||||
- admin
|
||||
/image/date/{date}:
|
||||
get:
|
||||
description: 根据日期返回图片流或重定向 (yyyy-mm-dd)
|
||||
parameters:
|
||||
- description: 日期 (yyyy-mm-dd)
|
||||
in: path
|
||||
name: date
|
||||
required: true
|
||||
type: string
|
||||
- default: UHD
|
||||
description: 分辨率
|
||||
in: query
|
||||
name: variant
|
||||
type: string
|
||||
- default: jpg
|
||||
description: 格式
|
||||
in: query
|
||||
name: format
|
||||
type: string
|
||||
produces:
|
||||
- image/jpeg
|
||||
- image/webp
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: 获取指定日期图片
|
||||
tags:
|
||||
- image
|
||||
/image/date/{date}/meta:
|
||||
get:
|
||||
description: 根据日期获取图片元数据 (yyyy-mm-dd)
|
||||
parameters:
|
||||
- description: 日期 (yyyy-mm-dd)
|
||||
in: path
|
||||
name: date
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: 获取指定日期图片元数据
|
||||
tags:
|
||||
- image
|
||||
/image/random:
|
||||
get:
|
||||
description: 随机返回一张已抓取的图片流或重定向
|
||||
parameters:
|
||||
- default: UHD
|
||||
description: 分辨率
|
||||
in: query
|
||||
name: variant
|
||||
type: string
|
||||
- default: jpg
|
||||
description: 格式
|
||||
in: query
|
||||
name: format
|
||||
type: string
|
||||
produces:
|
||||
- image/jpeg
|
||||
- image/webp
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: 获取随机图片
|
||||
tags:
|
||||
- image
|
||||
/image/random/meta:
|
||||
get:
|
||||
description: 随机获取一张已抓取图片的元数据
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: 获取随机图片元数据
|
||||
tags:
|
||||
- image
|
||||
/image/today:
|
||||
get:
|
||||
description: 根据参数返回今日必应图片流或重定向
|
||||
parameters:
|
||||
- default: UHD
|
||||
description: 分辨率 (UHD, 1920x1080, 1366x768)
|
||||
in: query
|
||||
name: variant
|
||||
type: string
|
||||
- default: jpg
|
||||
description: 格式 (jpg, webp)
|
||||
in: query
|
||||
name: format
|
||||
type: string
|
||||
produces:
|
||||
- image/jpeg
|
||||
- image/webp
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: 获取今日图片
|
||||
tags:
|
||||
- image
|
||||
/image/today/meta:
|
||||
get:
|
||||
description: 获取今日必应图片的标题、版权等元数据
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: 获取今日图片元数据
|
||||
tags:
|
||||
- image
|
||||
/images:
|
||||
get:
|
||||
description: 分页获取已抓取的图片元数据列表
|
||||
parameters:
|
||||
- default: 30
|
||||
description: 限制数量
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
summary: 获取图片列表
|
||||
tags:
|
||||
- image
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
88
go.mod
Normal file
88
go.mod
Normal file
@@ -0,0 +1,88 @@
|
||||
module BingDailyImage
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/aws/aws-sdk-go v1.55.8 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/chai2010/webp v1.4.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.11.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/studio-b12/gowebdav v0.12.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/swaggo/gin-swagger v1.6.1 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
)
|
||||
233
go.sum
Normal file
233
go.sum
Normal file
@@ -0,0 +1,233 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
|
||||
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
102
internal/bootstrap/bootstrap.go
Normal file
102
internal/bootstrap/bootstrap.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/cron"
|
||||
"BingDailyImage/internal/http"
|
||||
"BingDailyImage/internal/repo"
|
||||
"BingDailyImage/internal/service/fetcher"
|
||||
"BingDailyImage/internal/storage"
|
||||
"BingDailyImage/internal/storage/local"
|
||||
"BingDailyImage/internal/storage/s3"
|
||||
"BingDailyImage/internal/storage/webdav"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Init 初始化应用各项服务
|
||||
func Init() *gin.Engine {
|
||||
// 0. 确保数据目录存在
|
||||
_ = os.MkdirAll("data/picture", 0755)
|
||||
|
||||
// 1. 初始化配置
|
||||
if err := config.Init(""); err != nil {
|
||||
log.Fatalf("Failed to initialize config: %v", err)
|
||||
}
|
||||
cfg := config.GetConfig()
|
||||
|
||||
// 2. 初始化日志
|
||||
util.InitLogger(cfg.Log.Level)
|
||||
|
||||
// 3. 初始化数据库
|
||||
if err := repo.InitDB(); err != nil {
|
||||
util.Logger.Fatal("Failed to initialize database")
|
||||
}
|
||||
|
||||
// 4. 初始化存储
|
||||
var s storage.Storage
|
||||
var err error
|
||||
switch cfg.Storage.Type {
|
||||
case "s3":
|
||||
s, err = s3.NewS3Storage(
|
||||
cfg.Storage.S3.Endpoint,
|
||||
cfg.Storage.S3.Region,
|
||||
cfg.Storage.S3.Bucket,
|
||||
cfg.Storage.S3.AccessKey,
|
||||
cfg.Storage.S3.SecretKey,
|
||||
cfg.Storage.S3.PublicURLPrefix,
|
||||
cfg.Storage.S3.ForcePathStyle,
|
||||
)
|
||||
case "webdav":
|
||||
s, err = webdav.NewWebDAVStorage(
|
||||
cfg.Storage.WebDAV.URL,
|
||||
cfg.Storage.WebDAV.Username,
|
||||
cfg.Storage.WebDAV.Password,
|
||||
cfg.Storage.WebDAV.PublicURLPrefix,
|
||||
)
|
||||
default: // local
|
||||
s, err = local.NewLocalStorage(cfg.Storage.Local.Root)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
util.Logger.Fatal("Failed to initialize storage", zap.Error(err))
|
||||
}
|
||||
storage.GlobalStorage = s
|
||||
|
||||
// 5. 初始化定时任务
|
||||
cron.InitCron()
|
||||
|
||||
// 6. 启动时执行一次抓取 (可选,这里我们默认执行一次以确保有数据)
|
||||
go func() {
|
||||
f := fetcher.NewFetcher()
|
||||
f.Fetch(context.Background(), config.BingFetchN)
|
||||
}()
|
||||
|
||||
// 7. 设置路由
|
||||
return http.SetupRouter()
|
||||
}
|
||||
|
||||
// LogWelcomeInfo 输出欢迎信息和快速跳转地址
|
||||
func LogWelcomeInfo() {
|
||||
cfg := config.GetConfig()
|
||||
port := cfg.Server.Port
|
||||
baseURL := cfg.Server.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
|
||||
fmt.Println("\n---------------------------------------------------------")
|
||||
fmt.Println(" BingDailyImage 服务已启动!")
|
||||
fmt.Printf(" - 首页地址: %s/\n", baseURL)
|
||||
fmt.Printf(" - 管理后台: %s/admin\n", baseURL)
|
||||
fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL)
|
||||
fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL)
|
||||
fmt.Println("---------------------------------------------------------")
|
||||
}
|
||||
192
internal/config/config.go
Normal file
192
internal/config/config.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
API APIConfig `mapstructure:"api"`
|
||||
Cron CronConfig `mapstructure:"cron"`
|
||||
Retention RetentionConfig `mapstructure:"retention"`
|
||||
DB DBConfig `mapstructure:"db"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Admin AdminConfig `mapstructure:"admin"`
|
||||
Token TokenConfig `mapstructure:"token"`
|
||||
Feature FeatureConfig `mapstructure:"feature"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
}
|
||||
|
||||
type APIConfig struct {
|
||||
Mode string `mapstructure:"mode"` // local | redirect
|
||||
}
|
||||
|
||||
type CronConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DailySpec string `mapstructure:"daily_spec"`
|
||||
}
|
||||
|
||||
type RetentionConfig struct {
|
||||
Days int `mapstructure:"days"`
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
Type string `mapstructure:"type"` // sqlite/mysql/postgres
|
||||
DSN string `mapstructure:"dsn"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Type string `mapstructure:"type"` // local/s3/webdav
|
||||
Local LocalConfig `mapstructure:"local"`
|
||||
S3 S3Config `mapstructure:"s3"`
|
||||
WebDAV WebDAVConfig `mapstructure:"webdav"`
|
||||
}
|
||||
|
||||
type LocalConfig struct {
|
||||
Root string `mapstructure:"root"`
|
||||
}
|
||||
|
||||
type S3Config struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
Region string `mapstructure:"region"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
||||
}
|
||||
|
||||
type WebDAVConfig struct {
|
||||
URL string `mapstructure:"url"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
PublicURLPrefix string `mapstructure:"public_url_prefix"`
|
||||
}
|
||||
|
||||
type AdminConfig struct {
|
||||
PasswordBcrypt string `mapstructure:"password_bcrypt"`
|
||||
}
|
||||
|
||||
type TokenConfig struct {
|
||||
DefaultTTL string `mapstructure:"default_ttl"`
|
||||
}
|
||||
|
||||
type FeatureConfig struct {
|
||||
WriteDailyFiles bool `mapstructure:"write_daily_files"`
|
||||
}
|
||||
|
||||
// Bing 默认配置 (内置)
|
||||
const (
|
||||
BingMkt = "zh-CN"
|
||||
BingFetchN = 8
|
||||
BingAPIBase = "https://www.bing.com/HPImageArchive.aspx"
|
||||
)
|
||||
|
||||
var (
|
||||
GlobalConfig *Config
|
||||
configLock sync.RWMutex
|
||||
v *viper.Viper
|
||||
)
|
||||
|
||||
func Init(configPath string) error {
|
||||
v = viper.New()
|
||||
if configPath != "" {
|
||||
v.SetConfigFile(configPath)
|
||||
} else {
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath("./data")
|
||||
v.AddConfigPath(".")
|
||||
}
|
||||
|
||||
v.SetDefault("server.port", 8080)
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("api.mode", "local")
|
||||
v.SetDefault("cron.enabled", true)
|
||||
v.SetDefault("cron.daily_spec", "0 10 * * *")
|
||||
v.SetDefault("retention.days", 30)
|
||||
v.SetDefault("db.type", "sqlite")
|
||||
v.SetDefault("db.dsn", "data/bing_daily_image.db")
|
||||
v.SetDefault("storage.type", "local")
|
||||
v.SetDefault("storage.local.root", "data/picture")
|
||||
v.SetDefault("token.default_ttl", "168h")
|
||||
v.SetDefault("feature.write_daily_files", true)
|
||||
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return err
|
||||
}
|
||||
// 如果文件不存在,我们使用默认值并尝试创建一个默认配置文件
|
||||
fmt.Println("Config file not found, creating default config at ./data/config.yaml")
|
||||
if err := v.SafeWriteConfigAs("./data/config.yaml"); err != nil {
|
||||
fmt.Printf("Warning: Failed to create default config file: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
GlobalConfig = &cfg
|
||||
|
||||
v.OnConfigChange(func(e fsnotify.Event) {
|
||||
fmt.Println("Config file changed:", e.Name)
|
||||
var newCfg Config
|
||||
if err := v.Unmarshal(&newCfg); err == nil {
|
||||
configLock.Lock()
|
||||
GlobalConfig = &newCfg
|
||||
configLock.Unlock()
|
||||
}
|
||||
})
|
||||
v.WatchConfig()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
configLock.RLock()
|
||||
defer configLock.RUnlock()
|
||||
return GlobalConfig
|
||||
}
|
||||
|
||||
func SaveConfig(cfg *Config) error {
|
||||
v.Set("server", cfg.Server)
|
||||
v.Set("log", cfg.Log)
|
||||
v.Set("api", cfg.API)
|
||||
v.Set("cron", cfg.Cron)
|
||||
v.Set("retention", cfg.Retention)
|
||||
v.Set("db", cfg.DB)
|
||||
v.Set("storage", cfg.Storage)
|
||||
v.Set("admin", cfg.Admin)
|
||||
v.Set("token", cfg.Token)
|
||||
v.Set("feature", cfg.Feature)
|
||||
return v.WriteConfig()
|
||||
}
|
||||
|
||||
func GetRawViper() *viper.Viper {
|
||||
return v
|
||||
}
|
||||
|
||||
func GetTokenTTL() time.Duration {
|
||||
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
|
||||
if err != nil {
|
||||
return 168 * time.Hour
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
21
internal/config/config_test.go
Normal file
21
internal/config/config_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
err := Init("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init config: %v", err)
|
||||
}
|
||||
|
||||
cfg := GetConfig()
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Expected port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
|
||||
if cfg.DB.Type != "sqlite" {
|
||||
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type)
|
||||
}
|
||||
}
|
||||
47
internal/cron/cron.go
Normal file
47
internal/cron/cron.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/service/fetcher"
|
||||
"BingDailyImage/internal/service/image"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var GlobalCron *cron.Cron
|
||||
|
||||
func InitCron() {
|
||||
cfg := config.GetConfig()
|
||||
if !cfg.Cron.Enabled {
|
||||
util.Logger.Info("Cron is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
c := cron.New()
|
||||
|
||||
// 每日抓取任务
|
||||
_, err := c.AddFunc(cfg.Cron.DailySpec, func() {
|
||||
util.Logger.Info("Running scheduled daily fetch")
|
||||
f := fetcher.NewFetcher()
|
||||
if err := f.Fetch(context.Background(), 1); err != nil {
|
||||
util.Logger.Error("Scheduled fetch failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 抓取后顺便清理
|
||||
if err := image.CleanupOldImages(context.Background()); err != nil {
|
||||
util.Logger.Error("Scheduled cleanup failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
util.Logger.Fatal("Failed to setup cron", zap.Error(err))
|
||||
}
|
||||
|
||||
c.Start()
|
||||
GlobalCron = c
|
||||
util.Logger.Info("Cron service started", zap.String("spec", cfg.Cron.DailySpec))
|
||||
}
|
||||
243
internal/http/handlers/admin.go
Normal file
243
internal/http/handlers/admin.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/service/fetcher"
|
||||
"BingDailyImage/internal/service/image"
|
||||
"BingDailyImage/internal/service/token"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// AdminLogin 管理员登录
|
||||
// @Summary 管理员登录
|
||||
// @Description 使用密码登录并获取临时 Token
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LoginRequest true "登录请求"
|
||||
// @Success 200 {object} model.Token
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /admin/login [post]
|
||||
func AdminLogin(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
t, err := token.Login(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
// ListTokens 获取 Token 列表
|
||||
// @Summary 获取 Token 列表
|
||||
// @Description 获取所有已创建的 API Token 列表
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.Token
|
||||
// @Router /admin/tokens [get]
|
||||
func ListTokens(c *gin.Context) {
|
||||
tokens, err := token.ListTokens()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ExpiresAt string `json:"expires_at"` // optional
|
||||
ExpiresIn string `json:"expires_in"` // optional, e.g. 168h
|
||||
}
|
||||
|
||||
// CreateToken 创建 Token
|
||||
// @Summary 创建 Token
|
||||
// @Description 创建一个新的 API Token
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body CreateTokenRequest true "创建请求"
|
||||
// @Success 200 {object} model.Token
|
||||
// @Router /admin/tokens [post]
|
||||
func CreateToken(c *gin.Context) {
|
||||
var req CreateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(config.GetTokenTTL())
|
||||
if req.ExpiresAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
|
||||
if err == nil {
|
||||
expiresAt = t
|
||||
}
|
||||
} else if req.ExpiresIn != "" {
|
||||
d, err := time.ParseDuration(req.ExpiresIn)
|
||||
if err == nil {
|
||||
expiresAt = time.Now().Add(d)
|
||||
}
|
||||
}
|
||||
|
||||
t, err := token.CreateToken(req.Name, expiresAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
type UpdateTokenRequest struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// UpdateToken 更新 Token 状态
|
||||
// @Summary 更新 Token 状态
|
||||
// @Description 启用或禁用指定的 API Token
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Token ID"
|
||||
// @Param request body UpdateTokenRequest true "更新请求"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/tokens/{id} [patch]
|
||||
func UpdateToken(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, _ := strconv.ParseUint(idStr, 10, 32)
|
||||
var req UpdateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := token.UpdateToken(uint(id), req.Disabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// DeleteToken 删除 Token
|
||||
// @Summary 删除 Token
|
||||
// @Description 永久删除指定的 API Token
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/tokens/{id} [delete]
|
||||
func DeleteToken(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, _ := strconv.ParseUint(idStr, 10, 32)
|
||||
if err := token.DeleteToken(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetConfig 获取当前配置
|
||||
// @Summary 获取当前配置
|
||||
// @Description 获取服务的当前运行配置 (脱敏)
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} config.Config
|
||||
// @Router /admin/config [get]
|
||||
func GetConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, config.GetConfig())
|
||||
}
|
||||
|
||||
// UpdateConfig 更新配置
|
||||
// @Summary 更新配置
|
||||
// @Description 在线更新服务配置并保存
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body config.Config true "配置对象"
|
||||
// @Success 200 {object} config.Config
|
||||
// @Router /admin/config [put]
|
||||
func UpdateConfig(c *gin.Context) {
|
||||
var cfg config.Config
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(&cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("reload") == "true" {
|
||||
// 实际上 viper 会 watch config,但这里可以触发一些重新初始化逻辑
|
||||
// 这里暂不实现复杂的 reload
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config.GetConfig())
|
||||
}
|
||||
|
||||
type ManualFetchRequest struct {
|
||||
N int `json:"n"`
|
||||
}
|
||||
|
||||
// ManualFetch 手动触发抓取
|
||||
// @Summary 手动触发抓取
|
||||
// @Description 立即启动抓取 Bing 任务
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ManualFetchRequest false "抓取天数"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/fetch [post]
|
||||
func ManualFetch(c *gin.Context) {
|
||||
var req ManualFetchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.N = config.BingFetchN
|
||||
}
|
||||
if req.N <= 0 {
|
||||
req.N = config.BingFetchN
|
||||
}
|
||||
|
||||
f := fetcher.NewFetcher()
|
||||
go func() {
|
||||
f.Fetch(context.Background(), req.N)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "task started"})
|
||||
}
|
||||
|
||||
// ManualCleanup 手动触发清理
|
||||
// @Summary 手动触发清理
|
||||
// @Description 立即启动旧图片清理任务
|
||||
// @Tags admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /admin/cleanup [post]
|
||||
func ManualCleanup(c *gin.Context) {
|
||||
go func() {
|
||||
image.CleanupOldImages(context.Background())
|
||||
}()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "task started"})
|
||||
}
|
||||
223
internal/http/handlers/image.go
Normal file
223
internal/http/handlers/image.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/service/image"
|
||||
"BingDailyImage/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetToday 获取今日图片
|
||||
// @Summary 获取今日图片
|
||||
// @Description 根据参数返回今日必应图片流或重定向
|
||||
// @Tags image
|
||||
// @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768)" default(UHD)
|
||||
// @Param format query string false "格式 (jpg, webp)" default(jpg)
|
||||
// @Produce image/jpeg,image/webp
|
||||
// @Success 200 {file} binary
|
||||
// @Router /image/today [get]
|
||||
func GetToday(c *gin.Context) {
|
||||
img, err := image.GetTodayImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
}
|
||||
|
||||
// GetTodayMeta 获取今日图片元数据
|
||||
// @Summary 获取今日图片元数据
|
||||
// @Description 获取今日必应图片的标题、版权等元数据
|
||||
// @Tags image
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /image/today/meta [get]
|
||||
func GetTodayMeta(c *gin.Context) {
|
||||
img, err := image.GetTodayImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
// GetRandom 获取随机图片
|
||||
// @Summary 获取随机图片
|
||||
// @Description 随机返回一张已抓取的图片流或重定向
|
||||
// @Tags image
|
||||
// @Param variant query string false "分辨率" default(UHD)
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg,image/webp
|
||||
// @Success 200 {file} binary
|
||||
// @Router /image/random [get]
|
||||
func GetRandom(c *gin.Context) {
|
||||
img, err := image.GetRandomImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
}
|
||||
|
||||
// GetRandomMeta 获取随机图片元数据
|
||||
// @Summary 获取随机图片元数据
|
||||
// @Description 随机获取一张已抓取图片的元数据
|
||||
// @Tags image
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /image/random/meta [get]
|
||||
func GetRandomMeta(c *gin.Context) {
|
||||
img, err := image.GetRandomImage()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
// GetByDate 获取指定日期图片
|
||||
// @Summary 获取指定日期图片
|
||||
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
|
||||
// @Tags image
|
||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||
// @Param variant query string false "分辨率" default(UHD)
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg,image/webp
|
||||
// @Success 200 {file} binary
|
||||
// @Router /image/date/{date} [get]
|
||||
func GetByDate(c *gin.Context) {
|
||||
date := c.Param("date")
|
||||
img, err := image.GetImageByDate(date)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
}
|
||||
|
||||
// GetByDateMeta 获取指定日期图片元数据
|
||||
// @Summary 获取指定日期图片元数据
|
||||
// @Description 根据日期获取图片元数据 (yyyy-mm-dd)
|
||||
// @Tags image
|
||||
// @Param date path string true "日期 (yyyy-mm-dd)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /image/date/{date}/meta [get]
|
||||
func GetByDateMeta(c *gin.Context) {
|
||||
date := c.Param("date")
|
||||
img, err := image.GetImageByDate(date)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
// ListImages 获取图片列表
|
||||
// @Summary 获取图片列表
|
||||
// @Description 分页获取已抓取的图片元数据列表
|
||||
// @Tags image
|
||||
// @Param limit query int false "限制数量" default(30)
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Router /images [get]
|
||||
func ListImages(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "30")
|
||||
var limit int
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
|
||||
images, err := image.GetImageList(limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := []gin.H{}
|
||||
for _, img := range images {
|
||||
result = append(result, formatMeta(&img))
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func handleImageResponse(c *gin.Context, img *model.Image) {
|
||||
variant := c.DefaultQuery("variant", "UHD")
|
||||
format := c.DefaultQuery("format", "jpg")
|
||||
|
||||
var selected *model.ImageVariant
|
||||
for _, v := range img.Variants {
|
||||
if v.Variant == variant && v.Format == format {
|
||||
selected = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selected == nil && len(img.Variants) > 0 {
|
||||
// 回退逻辑
|
||||
selected = &img.Variants[0]
|
||||
}
|
||||
|
||||
if selected == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "variant not found"})
|
||||
return
|
||||
}
|
||||
|
||||
mode := config.GetConfig().API.Mode
|
||||
if mode == "redirect" {
|
||||
if selected.PublicURL != "" {
|
||||
c.Redirect(http.StatusFound, selected.PublicURL)
|
||||
} else {
|
||||
// 兜底重定向到原始 Bing (如果可能,但由于 URLBase 只有一部分,这里可能不工作)
|
||||
// 这里我们更倾向于 local 转发,如果 PublicURL 为空
|
||||
serveLocal(c, selected.StorageKey)
|
||||
}
|
||||
} else {
|
||||
serveLocal(c, selected.StorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
func serveLocal(c *gin.Context, key string) {
|
||||
reader, contentType, err := storage.GlobalStorage.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get image"})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
if contentType != "" {
|
||||
c.Header("Content-Type", contentType)
|
||||
}
|
||||
io.Copy(c.Writer, reader)
|
||||
}
|
||||
|
||||
func formatMeta(img *model.Image) gin.H {
|
||||
cfg := config.GetConfig()
|
||||
variants := []gin.H{}
|
||||
for _, v := range img.Variants {
|
||||
url := v.PublicURL
|
||||
if cfg.API.Mode == "local" || url == "" {
|
||||
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format)
|
||||
}
|
||||
variants = append(variants, gin.H{
|
||||
"variant": v.Variant,
|
||||
"format": v.Format,
|
||||
"size": v.Size,
|
||||
"url": url,
|
||||
"storage_key": v.StorageKey,
|
||||
})
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"date": img.Date,
|
||||
"title": img.Title,
|
||||
"copyright": img.Copyright,
|
||||
"quiz": img.Quiz,
|
||||
"variants": variants,
|
||||
}
|
||||
}
|
||||
38
internal/http/middleware/auth.go
Normal file
38
internal/http/middleware/auth.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"BingDailyImage/internal/service/token"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
t, err := token.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("token", t)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
63
internal/http/router.go
Normal file
63
internal/http/router.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
_ "BingDailyImage/docs"
|
||||
"BingDailyImage/internal/http/handlers"
|
||||
"BingDailyImage/internal/http/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// Swagger
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 静态文件
|
||||
r.Static("/static", "./static")
|
||||
r.StaticFile("/", "./web/index.html")
|
||||
r.StaticFile("/admin", "./web/index.html")
|
||||
r.StaticFile("/login", "./web/index.html")
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// 公共接口
|
||||
img := api.Group("/image")
|
||||
{
|
||||
img.GET("/today", handlers.GetToday)
|
||||
img.GET("/today/meta", handlers.GetTodayMeta)
|
||||
img.GET("/random", handlers.GetRandom)
|
||||
img.GET("/random/meta", handlers.GetRandomMeta)
|
||||
img.GET("/date/:date", handlers.GetByDate)
|
||||
img.GET("/date/:date/meta", handlers.GetByDateMeta)
|
||||
}
|
||||
api.GET("/images", handlers.ListImages)
|
||||
|
||||
// 管理接口
|
||||
admin := api.Group("/admin")
|
||||
{
|
||||
admin.POST("/login", handlers.AdminLogin)
|
||||
|
||||
// 需要验证的接口
|
||||
authorized := admin.Group("/")
|
||||
authorized.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
authorized.GET("/tokens", handlers.ListTokens)
|
||||
authorized.POST("/tokens", handlers.CreateToken)
|
||||
authorized.PATCH("/tokens/:id", handlers.UpdateToken)
|
||||
authorized.DELETE("/tokens/:id", handlers.DeleteToken)
|
||||
|
||||
authorized.GET("/config", handlers.GetConfig)
|
||||
authorized.PUT("/config", handlers.UpdateConfig)
|
||||
|
||||
authorized.POST("/fetch", handlers.ManualFetch)
|
||||
authorized.POST("/cleanup", handlers.ManualCleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
41
internal/model/models.go
Normal file
41
internal/model/models.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD
|
||||
Title string `json:"title"`
|
||||
Copyright string `json:"copyright"`
|
||||
URLBase string `json:"urlbase"`
|
||||
Quiz string `json:"quiz"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
Variants []ImageVariant `gorm:"foreignKey:ImageID" json:"variants"`
|
||||
}
|
||||
|
||||
type ImageVariant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ImageID uint `gorm:"uniqueIndex:idx_image_variant_format" json:"image_id"`
|
||||
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
|
||||
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
|
||||
StorageKey string `json:"storage_key"`
|
||||
PublicURL string `json:"public_url"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Token string `gorm:"uniqueIndex;type:varchar(64)" json:"token"`
|
||||
Name string `json:"name"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Disabled bool `gorm:"default:false" json:"disabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
51
internal/repo/db.go
Normal file
51
internal/repo/db.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/util"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB() error {
|
||||
cfg := config.GetConfig()
|
||||
var dialector gorm.Dialector
|
||||
|
||||
switch cfg.DB.Type {
|
||||
case "mysql":
|
||||
dialector = mysql.Open(cfg.DB.DSN)
|
||||
case "postgres":
|
||||
dialector = postgres.Open(cfg.DB.DSN)
|
||||
case "sqlite":
|
||||
dialector = sqlite.Open(cfg.DB.DSN)
|
||||
default:
|
||||
return fmt.Errorf("unsupported db type: %s", cfg.DB.Type)
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, gormConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 迁移
|
||||
if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
DB = db
|
||||
util.Logger.Info("Database initialized successfully", zap.String("type", cfg.DB.Type))
|
||||
return nil
|
||||
}
|
||||
243
internal/service/fetcher/fetcher.go
Normal file
243
internal/service/fetcher/fetcher.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/repo"
|
||||
"BingDailyImage/internal/storage"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
"github.com/disintegration/imaging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type BingResponse struct {
|
||||
Images []BingImage `json:"images"`
|
||||
}
|
||||
|
||||
type BingImage struct {
|
||||
Startdate string `json:"startdate"`
|
||||
Fullstartdate string `json:"fullstartdate"`
|
||||
Enddate string `json:"enddate"`
|
||||
URL string `json:"url"`
|
||||
URLBase string `json:"urlbase"`
|
||||
Copyright string `json:"copyright"`
|
||||
CopyrightLink string `json:"copyrightlink"`
|
||||
Title string `json:"title"`
|
||||
Quiz string `json:"quiz"`
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewFetcher() *Fetcher {
|
||||
return &Fetcher{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Fetch(ctx context.Context, n int) error {
|
||||
util.Logger.Info("Starting fetch task", zap.Int("n", n))
|
||||
url := fmt.Sprintf("%s?format=js&idx=0&n=%d&uhd=1&mkt=%s", config.BingAPIBase, n, config.BingMkt)
|
||||
resp, err := f.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var bingResp BingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, bingImg := range bingResp.Images {
|
||||
if err := f.processImage(ctx, bingImg); err != nil {
|
||||
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
util.Logger.Info("Fetch task completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
|
||||
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
|
||||
|
||||
// 幂等检查
|
||||
var existing model.Image
|
||||
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil {
|
||||
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr))
|
||||
return nil
|
||||
}
|
||||
|
||||
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title))
|
||||
|
||||
// UHD 探测
|
||||
imgURL, variantName := f.probeUHD(bingImg.URLBase)
|
||||
|
||||
imgData, err := f.downloadImage(imgURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解码图片用于缩放
|
||||
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建 DB 记录
|
||||
dbImg := model.Image{
|
||||
Date: dateStr,
|
||||
Title: bingImg.Title,
|
||||
Copyright: bingImg.Copyright,
|
||||
URLBase: bingImg.URLBase,
|
||||
Quiz: bingImg.Quiz,
|
||||
}
|
||||
|
||||
if err := repo.DB.Create(&dbImg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存各种分辨率
|
||||
variants := []struct {
|
||||
name string
|
||||
width int
|
||||
height int
|
||||
}{
|
||||
{variantName, 0, 0}, // 原图 (UHD 或 1080p)
|
||||
{"1920x1080", 1920, 1080},
|
||||
{"1366x768", 1366, 768},
|
||||
}
|
||||
|
||||
for _, v := range variants {
|
||||
// 如果是探测到的最高清版本,且我们已经有了数据,直接使用
|
||||
var currentImgData []byte
|
||||
if v.width == 0 {
|
||||
currentImgData = imgData
|
||||
} else {
|
||||
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
|
||||
buf := new(bytes.Buffer)
|
||||
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 90}); err != nil {
|
||||
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
currentImgData = buf.Bytes()
|
||||
}
|
||||
|
||||
// 保存 JPG
|
||||
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil {
|
||||
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
|
||||
}
|
||||
|
||||
// 保存 WebP (可选或默认)
|
||||
webpBuf := new(bytes.Buffer)
|
||||
var webpImg image.Image
|
||||
if v.width == 0 {
|
||||
webpImg = srcImg
|
||||
} else {
|
||||
webpImg = imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
|
||||
}
|
||||
if err := webp.Encode(webpBuf, webpImg, &webp.Options{Quality: 80}); err == nil {
|
||||
if err := f.saveVariant(ctx, &dbImg, v.name, "webp", webpBuf.Bytes()); err != nil {
|
||||
util.Logger.Error("Failed to save webp variant", zap.String("variant", v.name), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存今日额外文件
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
|
||||
f.saveDailyFiles(srcImg, imgData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) probeUHD(urlBase string) (string, string) {
|
||||
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
|
||||
resp, err := f.httpClient.Head(uhdURL)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
return uhdURL, "UHD"
|
||||
}
|
||||
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
|
||||
}
|
||||
|
||||
func (f *Fetcher) downloadImage(url string) ([]byte, error) {
|
||||
resp, err := f.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error {
|
||||
key := fmt.Sprintf("%s/%s_%s.%s", img.Date, img.Date, variant, format)
|
||||
contentType := "image/jpeg"
|
||||
if format == "webp" {
|
||||
contentType = "image/webp"
|
||||
}
|
||||
|
||||
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vRecord := model.ImageVariant{
|
||||
ImageID: img.ID,
|
||||
Variant: variant,
|
||||
Format: format,
|
||||
StorageKey: stored.Key,
|
||||
PublicURL: stored.PublicURL,
|
||||
Size: int64(len(data)),
|
||||
}
|
||||
|
||||
return repo.DB.Create(&vRecord).Error
|
||||
}
|
||||
|
||||
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
|
||||
util.Logger.Info("Saving daily files")
|
||||
localRoot := config.GetConfig().Storage.Local.Root
|
||||
if config.GetConfig().Storage.Type != "local" {
|
||||
// 如果不是本地存储,保存在临时目录或指定缓存目录
|
||||
localRoot = "static"
|
||||
}
|
||||
os.MkdirAll(filepath.Join(localRoot, "static"), 0755)
|
||||
|
||||
// daily.webp (quality 80)
|
||||
webpPath := filepath.Join(localRoot, "static", "daily.webp")
|
||||
fWebp, _ := os.Create(webpPath)
|
||||
if fWebp != nil {
|
||||
webp.Encode(fWebp, srcImg, &webp.Options{Quality: 80})
|
||||
fWebp.Close()
|
||||
}
|
||||
|
||||
// daily.jpeg (quality 95)
|
||||
jpegPath := filepath.Join(localRoot, "static", "daily.jpeg")
|
||||
fJpeg, _ := os.Create(jpegPath)
|
||||
if fJpeg != nil {
|
||||
jpeg.Encode(fJpeg, srcImg, &jpeg.Options{Quality: 95})
|
||||
fJpeg.Close()
|
||||
}
|
||||
|
||||
// original.jpeg (quality 100)
|
||||
originalPath := filepath.Join(localRoot, "static", "original.jpeg")
|
||||
os.WriteFile(originalPath, originalData, 0644)
|
||||
}
|
||||
92
internal/service/image/image_service.go
Normal file
92
internal/service/image/image_service.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/repo"
|
||||
"BingDailyImage/internal/storage"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func CleanupOldImages(ctx context.Context) error {
|
||||
days := config.GetConfig().Retention.Days
|
||||
if days <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
|
||||
|
||||
var images []model.Image
|
||||
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, img := range images {
|
||||
util.Logger.Info("Deleting old image", zap.String("date", img.Date))
|
||||
for _, v := range img.Variants {
|
||||
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
|
||||
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// 删除 DB 记录 (级联删除由代码处理,或者 GORM 会处理已加载的关联吗?)
|
||||
// 简单起见,手动删除关联
|
||||
repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{})
|
||||
repo.DB.Delete(&img)
|
||||
}
|
||||
|
||||
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetTodayImage() (*model.Image, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var img model.Image
|
||||
err := repo.DB.Where("date = ?", today).Preload("Variants").First(&img).Error
|
||||
if err != nil {
|
||||
// 如果今天没有,尝试获取最近的一张
|
||||
err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error
|
||||
}
|
||||
return &img, err
|
||||
}
|
||||
|
||||
func GetRandomImage() (*model.Image, error) {
|
||||
var img model.Image
|
||||
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
||||
// 简单起见,先查总数再 Offset
|
||||
var count int64
|
||||
repo.DB.Model(&model.Image{}).Count(&count)
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("no images found")
|
||||
}
|
||||
|
||||
// 这种方法不适合海量数据,但对于 30 天的数据没问题
|
||||
err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error
|
||||
if err != nil {
|
||||
// 适配 MySQL
|
||||
err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error
|
||||
}
|
||||
return &img, err
|
||||
}
|
||||
|
||||
func GetImageByDate(date string) (*model.Image, error) {
|
||||
var img model.Image
|
||||
err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error
|
||||
return &img, err
|
||||
}
|
||||
|
||||
func GetImageList(limit int) ([]model.Image, error) {
|
||||
var images []model.Image
|
||||
db := repo.DB.Order("date desc").Preload("Variants")
|
||||
if limit > 0 {
|
||||
db = db.Limit(limit)
|
||||
}
|
||||
err := db.Find(&images).Error
|
||||
return images, err
|
||||
}
|
||||
69
internal/service/token/token_service.go
Normal file
69
internal/service/token/token_service.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/model"
|
||||
"BingDailyImage/internal/repo"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func GenerateTokenString() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func CreateToken(name string, expiresAt time.Time) (*model.Token, error) {
|
||||
tString := GenerateTokenString()
|
||||
t := &model.Token{
|
||||
Token: tString,
|
||||
Name: name,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := repo.DB.Create(t).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func ValidateToken(tokenStr string) (*model.Token, error) {
|
||||
var t model.Token
|
||||
if err := repo.DB.Where("token = ? AND disabled = ?", tokenStr, false).First(&t).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func Login(password string) (*model.Token, error) {
|
||||
cfg := config.GetConfig()
|
||||
err := bcrypt.CompareHashAndPassword([]byte(cfg.Admin.PasswordBcrypt), []byte(password))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid password")
|
||||
}
|
||||
|
||||
ttl := config.GetTokenTTL()
|
||||
return CreateToken("login-token", time.Now().Add(ttl))
|
||||
}
|
||||
|
||||
func ListTokens() ([]model.Token, error) {
|
||||
var tokens []model.Token
|
||||
err := repo.DB.Order("id desc").Find(&tokens).Error
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func UpdateToken(id uint, disabled bool) error {
|
||||
return repo.DB.Model(&model.Token{}).Where("id = ?", id).Update("disabled", disabled).Error
|
||||
}
|
||||
|
||||
func DeleteToken(id uint) error {
|
||||
return repo.DB.Delete(&model.Token{}, id).Error
|
||||
}
|
||||
65
internal/storage/local/local.go
Normal file
65
internal/storage/local/local.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"BingDailyImage/internal/storage"
|
||||
)
|
||||
|
||||
type LocalStorage struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewLocalStorage(root string) (*LocalStorage, error) {
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LocalStorage{root: root}, nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
||||
path := filepath.Join(l.root, key)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, r)
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
return storage.StoredObject{
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
Size: n,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
path := filepath.Join(l.root, key)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// 这里很难从文件扩展名以外的地方获得 contentType,除非存储时记录
|
||||
// 简单处理
|
||||
return f, "", nil
|
||||
}
|
||||
|
||||
func (l *LocalStorage) Delete(ctx context.Context, key string) error {
|
||||
path := filepath.Join(l.root, key)
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (l *LocalStorage) PublicURL(key string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
95
internal/storage/s3/s3.go
Normal file
95
internal/storage/s3/s3.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"BingDailyImage/internal/storage"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
)
|
||||
|
||||
type S3Storage struct {
|
||||
session *session.Session
|
||||
client *s3.S3
|
||||
bucket string
|
||||
publicURLPrefix string
|
||||
}
|
||||
|
||||
func NewS3Storage(endpoint, region, bucket, accessKey, secretKey, publicURLPrefix string, forcePathStyle bool) (*S3Storage, error) {
|
||||
config := &aws.Config{
|
||||
Region: aws.String(region),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
Endpoint: aws.String(endpoint),
|
||||
S3ForcePathStyle: aws.Bool(forcePathStyle),
|
||||
}
|
||||
sess, err := session.NewSession(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &S3Storage{
|
||||
session: sess,
|
||||
client: s3.New(sess),
|
||||
bucket: bucket,
|
||||
publicURLPrefix: publicURLPrefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
||||
uploader := s3manager.NewUploader(s.session)
|
||||
output, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: r,
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
publicURL := ""
|
||||
if s.publicURLPrefix != "" {
|
||||
publicURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(s.publicURLPrefix, "/"), key)
|
||||
} else {
|
||||
publicURL = output.Location
|
||||
}
|
||||
|
||||
return storage.StoredObject{
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
PublicURL: publicURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
output, err := s.client.GetObjectWithContext(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return output.Body, aws.StringValue(output.ContentType), nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *S3Storage) PublicURL(key string) (string, bool) {
|
||||
if s.publicURLPrefix != "" {
|
||||
return fmt.Sprintf("%s/%s", strings.TrimSuffix(s.publicURLPrefix, "/"), key), true
|
||||
}
|
||||
// 也可以生成签名 URL,但这里简单处理
|
||||
return "", false
|
||||
}
|
||||
27
internal/storage/storage.go
Normal file
27
internal/storage/storage.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
type StoredObject struct {
|
||||
Key string
|
||||
ContentType string
|
||||
Size int64
|
||||
PublicURL string
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Put(ctx context.Context, key string, r io.Reader, contentType string) (StoredObject, error)
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, string, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
PublicURL(key string) (string, bool)
|
||||
}
|
||||
|
||||
var GlobalStorage Storage
|
||||
|
||||
func InitStorage() error {
|
||||
// 实际初始化在 main.go 中根据配置调用对应的初始化函数
|
||||
return nil
|
||||
}
|
||||
74
internal/storage/webdav/webdav.go
Normal file
74
internal/storage/webdav/webdav.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"BingDailyImage/internal/storage"
|
||||
|
||||
"github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
type WebDAVStorage struct {
|
||||
client *gowebdav.Client
|
||||
publicURLPrefix string
|
||||
}
|
||||
|
||||
func NewWebDAVStorage(url, username, password, publicURLPrefix string) (*WebDAVStorage, error) {
|
||||
client := gowebdav.NewClient(url, username, password)
|
||||
if err := client.Connect(); err != nil {
|
||||
// 有些 webdav 不支持 Connect,我们可以忽略错误或者做简单的探测
|
||||
}
|
||||
return &WebDAVStorage{
|
||||
client: client,
|
||||
publicURLPrefix: publicURLPrefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) Put(ctx context.Context, key string, r io.Reader, contentType string) (storage.StoredObject, error) {
|
||||
// 确保目录存在
|
||||
dir := path.Dir(key)
|
||||
if dir != "." && dir != "/" {
|
||||
if err := w.client.MkdirAll(dir, 0755); err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
}
|
||||
|
||||
err := w.client.WriteStream(key, r, 0644)
|
||||
if err != nil {
|
||||
return storage.StoredObject{}, err
|
||||
}
|
||||
|
||||
publicURL := ""
|
||||
if w.publicURLPrefix != "" {
|
||||
publicURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(w.publicURLPrefix, "/"), key)
|
||||
}
|
||||
|
||||
return storage.StoredObject{
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
PublicURL: publicURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) Get(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||||
reader, err := w.client.ReadStream(key)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return reader, "", nil
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) Delete(ctx context.Context, key string) error {
|
||||
return w.client.Remove(key)
|
||||
}
|
||||
|
||||
func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
|
||||
if w.publicURLPrefix != "" {
|
||||
return fmt.Sprintf("%s/%s", strings.TrimSuffix(w.publicURLPrefix, "/"), key), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
38
internal/util/logger.go
Normal file
38
internal/util/logger.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var Logger *zap.Logger
|
||||
|
||||
func InitLogger(level string) {
|
||||
var zapLevel zapcore.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
zapLevel = zap.DebugLevel
|
||||
case "info":
|
||||
zapLevel = zap.InfoLevel
|
||||
case "warn":
|
||||
zapLevel = zap.WarnLevel
|
||||
case "error":
|
||||
zapLevel = zap.ErrorLevel
|
||||
default:
|
||||
zapLevel = zap.InfoLevel
|
||||
}
|
||||
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(encoderConfig),
|
||||
zapcore.AddSync(os.Stdout),
|
||||
zapLevel,
|
||||
)
|
||||
|
||||
Logger = zap.New(core, zap.AddCaller())
|
||||
}
|
||||
36
main.go
Normal file
36
main.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"BingDailyImage/internal/bootstrap"
|
||||
"BingDailyImage/internal/config"
|
||||
"BingDailyImage/internal/util"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// @title BingDailyImage API
|
||||
// @version 1.0
|
||||
// @description 必应每日一图抓取、存储、管理与公共 API 服务。
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
func main() {
|
||||
// 1. 初始化
|
||||
r := bootstrap.Init()
|
||||
|
||||
// 2. 输出欢迎信息
|
||||
bootstrap.LogWelcomeInfo()
|
||||
|
||||
// 3. 启动服务
|
||||
cfg := config.GetConfig()
|
||||
util.Logger.Info("Server starting", zap.Int("port", cfg.Server.Port))
|
||||
if err := r.Run(fmt.Sprintf(":%d", cfg.Server.Port)); err != nil {
|
||||
util.Logger.Fatal("Server failed to start", zap.Error(err))
|
||||
}
|
||||
}
|
||||
362
web/index.html
Normal file
362
web/index.html
Normal file
@@ -0,0 +1,362 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BingDailyImage - 必应每日一图</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--danger-color: #dc3545;
|
||||
--bg-color: #f8f9fa;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #333;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--bg-color); color: var(--text-color); line-height: 1.6; }
|
||||
nav { background: #333; color: white; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 1000; }
|
||||
nav a { color: white; text-decoration: none; margin-left: 1.5rem; font-weight: 500; }
|
||||
nav .logo { font-size: 1.5rem; font-weight: bold; margin-left: 0; }
|
||||
|
||||
.container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Gallery Styles */
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; }
|
||||
.gallery-item { position: relative; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); background: #eee; aspect-ratio: 16 / 9; transition: transform 0.3s ease; }
|
||||
.gallery-item:hover { transform: translateY(-5px); }
|
||||
.gallery-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.gallery-item .overlay {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||||
color: white; padding: 20px 15px 10px;
|
||||
opacity: 0; transition: opacity 0.3s ease;
|
||||
}
|
||||
.gallery-item:hover .overlay { opacity: 1; }
|
||||
.gallery-item .title { font-weight: bold; font-size: 1.1rem; margin-bottom: 5px; }
|
||||
.gallery-item .copyright { font-size: 0.85rem; opacity: 0.9; }
|
||||
.gallery-item .date { position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.5); padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; }
|
||||
|
||||
/* Info Section */
|
||||
.info-card { background: var(--card-bg); padding: 2rem; border-radius: 12px; margin-bottom: 2rem; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
|
||||
code { background: #f1f1f1; padding: 2px 5px; border-radius: 4px; color: #e83e8c; }
|
||||
pre { background: #f1f1f1; padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
||||
|
||||
/* Admin/Login Form Styles */
|
||||
.form-card { max-width: 400px; margin: 5rem auto; background: white; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
|
||||
.form-card h2 { margin-top: 0; text-align: center; }
|
||||
input, select { width: 100%; padding: 12px; margin: 10px 0 20px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
|
||||
button { width: 100%; padding: 12px; border: none; border-radius: 6px; background: var(--primary-color); color: white; font-size: 1rem; cursor: pointer; transition: background 0.2s; }
|
||||
button:hover { background: #0069d9; }
|
||||
button.danger { background: var(--danger-color); }
|
||||
button.danger:hover { background: #c82333; }
|
||||
button.secondary { background: #6c757d; margin-top: 10px; }
|
||||
|
||||
/* Admin Panel Styles */
|
||||
.admin-grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
||||
@media (min-width: 768px) { .admin-grid { grid-template-columns: 2fr 1fr; } }
|
||||
.admin-card { background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
|
||||
.status-badge { padding: 2px 8px; border-radius: 12px; font-size: 0.8rem; }
|
||||
.status-enabled { background: #d4edda; color: #155724; }
|
||||
.status-disabled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.loading { text-align: center; padding: 3rem; font-size: 1.2rem; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<a href="/" class="logo">BingDailyImage</a>
|
||||
<div>
|
||||
<a href="/">首页</a>
|
||||
<a href="/admin">管理</a>
|
||||
<span id="nav-logout-container" class="hidden">
|
||||
<a href="#" onclick="logout()">退出</a>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="content-home" class="container hidden">
|
||||
<div class="info-card">
|
||||
<h2>使用说明</h2>
|
||||
<p>这是一个自动抓取必应每日一图并提供多分辨率管理的工具。您可以直接通过以下接口获取图片:</p>
|
||||
<ul>
|
||||
<li>今日图片:<code>/api/v1/image/today</code></li>
|
||||
<li>今日元数据:<code>/api/v1/image/today/meta</code></li>
|
||||
<li>随机图片:<code>/api/v1/image/random</code></li>
|
||||
<li>指定日期:<code>/api/v1/image/date/2026-01-26</code></li>
|
||||
</ul>
|
||||
<p>参数支持:<code>variant=UHD|1920x1080|1366x768</code>,<code>format=jpg|webp</code>。</p>
|
||||
</div>
|
||||
<div id="gallery" class="gallery">
|
||||
<div class="loading">正在加载图片...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content-login" class="container hidden">
|
||||
<div class="form-card">
|
||||
<h2>管理员登录</h2>
|
||||
<input type="password" id="login-password" placeholder="请输入管理员密码" onkeypress="if(event.keyCode==13) login()">
|
||||
<button onclick="login()">登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content-admin" class="container hidden">
|
||||
<div class="admin-grid">
|
||||
<div class="left-col">
|
||||
<div class="admin-card">
|
||||
<h3>Token 管理</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>过期时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="token-list"></tbody>
|
||||
</table>
|
||||
<h4 style="margin-top: 2rem;">创建新 Token</h4>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="token-name" placeholder="Token 名称" style="margin: 0;">
|
||||
<button onclick="createToken()" style="width: auto; padding: 0 20px;">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h3>配置文件</h3>
|
||||
<pre id="config-view"></pre>
|
||||
<button class="secondary" onclick="loadConfig()">刷新配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-col">
|
||||
<div class="admin-card">
|
||||
<h3>任务控制</h3>
|
||||
<button onclick="triggerFetch()" style="margin-bottom: 10px;">手动抓取 (最近8天)</button>
|
||||
<button class="secondary" onclick="triggerCleanup()" style="margin-bottom: 10px;">手动清理旧图</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h3>今日图预览</h3>
|
||||
<div id="today-preview" style="text-align: center;">
|
||||
<div class="loading" style="padding: 1rem;">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let token = localStorage.getItem('bing_token');
|
||||
|
||||
// 简易路由实现
|
||||
function router() {
|
||||
const path = window.location.pathname;
|
||||
const homeSection = document.getElementById('content-home');
|
||||
const loginSection = document.getElementById('content-login');
|
||||
const adminSection = document.getElementById('content-admin');
|
||||
const logoutNav = document.getElementById('nav-logout-container');
|
||||
|
||||
// 隐藏所有
|
||||
[homeSection, loginSection, adminSection].forEach(s => s.classList.add('hidden'));
|
||||
logoutNav.classList.add('hidden');
|
||||
|
||||
if (path === '/' || path === '') {
|
||||
homeSection.classList.remove('hidden');
|
||||
loadGallery();
|
||||
} else if (path === '/login') {
|
||||
if (token) {
|
||||
window.history.pushState({}, '', '/admin');
|
||||
router();
|
||||
return;
|
||||
}
|
||||
loginSection.classList.remove('hidden');
|
||||
} else if (path === '/admin') {
|
||||
if (!token) {
|
||||
window.history.pushState({}, '', '/login');
|
||||
router();
|
||||
return;
|
||||
}
|
||||
adminSection.classList.remove('hidden');
|
||||
logoutNav.classList.remove('hidden');
|
||||
loadAdminData();
|
||||
} else {
|
||||
// 404 默认首页
|
||||
window.history.pushState({}, '', '/');
|
||||
router();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理导航点击
|
||||
document.querySelectorAll('nav a').forEach(a => {
|
||||
if (a.onclick) return;
|
||||
a.addEventListener('click', e => {
|
||||
if (a.getAttribute('href').startsWith('http')) return;
|
||||
e.preventDefault();
|
||||
const href = a.getAttribute('href');
|
||||
window.history.pushState({}, '', href);
|
||||
router();
|
||||
});
|
||||
});
|
||||
|
||||
// 监听后退/前进
|
||||
window.addEventListener('popstate', router);
|
||||
|
||||
async function login() {
|
||||
const password = document.getElementById('login-password').value;
|
||||
try {
|
||||
const resp = await fetch('/api/v1/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.token) {
|
||||
token = data.token;
|
||||
localStorage.setItem('bing_token', token);
|
||||
window.history.pushState({}, '', '/admin');
|
||||
router();
|
||||
} else {
|
||||
alert('登录失败: ' + (data.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('网络请求失败');
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('bing_token');
|
||||
token = null;
|
||||
window.history.pushState({}, '', '/');
|
||||
router();
|
||||
}
|
||||
|
||||
// 画廊功能
|
||||
async function loadGallery() {
|
||||
const gallery = document.getElementById('gallery');
|
||||
try {
|
||||
const resp = await fetch('/api/v1/images?limit=30');
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.length === 0) {
|
||||
gallery.innerHTML = '<div class="loading">暂无图片,请稍后再试。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
gallery.innerHTML = data.map(img => `
|
||||
<div class="gallery-item">
|
||||
<div class="date">${img.date}</div>
|
||||
<img src="${img.variants.find(v => v.variant === '1920x1080')?.url || img.variants[0].url}" alt="${img.title}" loading="lazy">
|
||||
<div class="overlay">
|
||||
<div class="title">${img.title}</div>
|
||||
<div class="copyright">${img.copyright}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
gallery.innerHTML = '<div class="loading">加载失败,请刷新重试。</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员数据
|
||||
function loadAdminData() {
|
||||
loadTokens();
|
||||
loadConfig();
|
||||
loadTodayPreview();
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
const resp = await fetch('/api/v1/admin/tokens', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (resp.status === 401) return logout();
|
||||
const data = await resp.json();
|
||||
const tbody = document.getElementById('token-list');
|
||||
tbody.innerHTML = data.map(t => `
|
||||
<tr>
|
||||
<td>${t.name}</td>
|
||||
<td>${new Date(t.expires_at).toLocaleString()}</td>
|
||||
<td><span class="status-badge ${t.disabled ? 'status-disabled' : 'status-enabled'}">${t.disabled ? '禁用' : '启用'}</span></td>
|
||||
<td>
|
||||
<button style="width:auto; padding:5px 10px; margin-right:5px;" onclick="toggleToken(${t.id}, ${!t.disabled})">${t.disabled ? '启用' : '禁用'}</button>
|
||||
<button class="danger" style="width:auto; padding:5px 10px;" onclick="deleteToken(${t.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const resp = await fetch('/api/v1/admin/config', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
const data = await resp.json();
|
||||
document.getElementById('config-view').innerText = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
async function loadTodayPreview() {
|
||||
const resp = await fetch('/api/v1/image/today/meta');
|
||||
const data = await resp.json();
|
||||
const container = document.getElementById('today-preview');
|
||||
container.innerHTML = `
|
||||
<p><strong>${data.date}</strong></p>
|
||||
<p>${data.title}</p>
|
||||
<img src="${data.variants[0].url}" style="max-width:100%; border-radius:8px;">
|
||||
`;
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
const name = document.getElementById('token-name').value;
|
||||
if (!name) return alert('请输入名称');
|
||||
await fetch('/api/v1/admin/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
document.getElementById('token-name').value = '';
|
||||
loadTokens();
|
||||
}
|
||||
|
||||
async function toggleToken(id, disabled) {
|
||||
await fetch('/api/v1/admin/tokens/' + id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ disabled })
|
||||
});
|
||||
loadTokens();
|
||||
}
|
||||
|
||||
async function deleteToken(id) {
|
||||
if (!confirm('确定删除?')) return;
|
||||
await fetch('/api/v1/admin/tokens/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
loadTokens();
|
||||
}
|
||||
|
||||
async function triggerFetch() {
|
||||
await fetch('/api/v1/admin/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ n: 8 })
|
||||
});
|
||||
alert('抓取任务已启动');
|
||||
}
|
||||
|
||||
async function triggerCleanup() {
|
||||
await fetch('/api/v1/admin/cleanup', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
alert('清理任务已启动');
|
||||
}
|
||||
|
||||
// 初始化
|
||||
router();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user