commit c6e5e655f9ced502d0909cab89d8bba9eb58e481 Author: hxuanyu <2252193204@qq.com> Date: Mon Jan 26 21:53:34 2026 +0800 基本功能实现 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..895b26c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e045812 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3acbdda --- /dev/null +++ b/README.md @@ -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 diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..226787f --- /dev/null +++ b/docs/docs.go @@ -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) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..c8cd5e9 --- /dev/null +++ b/docs/swagger.json @@ -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" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..c21adf0 --- /dev/null +++ b/docs/swagger.yaml @@ -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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26e2136 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..409d8ef --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..37c1c2a --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -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("---------------------------------------------------------") +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d104c05 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..943057a --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/cron/cron.go b/internal/cron/cron.go new file mode 100644 index 0000000..2fee623 --- /dev/null +++ b/internal/cron/cron.go @@ -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)) +} diff --git a/internal/http/handlers/admin.go b/internal/http/handlers/admin.go new file mode 100644 index 0000000..39271f9 --- /dev/null +++ b/internal/http/handlers/admin.go @@ -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"}) +} diff --git a/internal/http/handlers/image.go b/internal/http/handlers/image.go new file mode 100644 index 0000000..6811315 --- /dev/null +++ b/internal/http/handlers/image.go @@ -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, + } +} diff --git a/internal/http/middleware/auth.go b/internal/http/middleware/auth.go new file mode 100644 index 0000000..5cc8b85 --- /dev/null +++ b/internal/http/middleware/auth.go @@ -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() + } +} diff --git a/internal/http/router.go b/internal/http/router.go new file mode 100644 index 0000000..3f689d6 --- /dev/null +++ b/internal/http/router.go @@ -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 +} diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..a64662e --- /dev/null +++ b/internal/model/models.go @@ -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"` +} diff --git a/internal/repo/db.go b/internal/repo/db.go new file mode 100644 index 0000000..3fce0f1 --- /dev/null +++ b/internal/repo/db.go @@ -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 +} diff --git a/internal/service/fetcher/fetcher.go b/internal/service/fetcher/fetcher.go new file mode 100644 index 0000000..943e4c5 --- /dev/null +++ b/internal/service/fetcher/fetcher.go @@ -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) +} diff --git a/internal/service/image/image_service.go b/internal/service/image/image_service.go new file mode 100644 index 0000000..99986f9 --- /dev/null +++ b/internal/service/image/image_service.go @@ -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 +} diff --git a/internal/service/token/token_service.go b/internal/service/token/token_service.go new file mode 100644 index 0000000..b266a53 --- /dev/null +++ b/internal/service/token/token_service.go @@ -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 +} diff --git a/internal/storage/local/local.go b/internal/storage/local/local.go new file mode 100644 index 0000000..889bb60 --- /dev/null +++ b/internal/storage/local/local.go @@ -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 +} diff --git a/internal/storage/s3/s3.go b/internal/storage/s3/s3.go new file mode 100644 index 0000000..d350c0f --- /dev/null +++ b/internal/storage/s3/s3.go @@ -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 +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..fb62dfb --- /dev/null +++ b/internal/storage/storage.go @@ -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 +} diff --git a/internal/storage/webdav/webdav.go b/internal/storage/webdav/webdav.go new file mode 100644 index 0000000..e80484f --- /dev/null +++ b/internal/storage/webdav/webdav.go @@ -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 +} diff --git a/internal/util/logger.go b/internal/util/logger.go new file mode 100644 index 0000000..b8b6035 --- /dev/null +++ b/internal/util/logger.go @@ -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()) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f6f4969 --- /dev/null +++ b/main.go @@ -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)) + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..958a968 --- /dev/null +++ b/web/index.html @@ -0,0 +1,362 @@ + + +
+ + +