swagger

移除 merchant

swagger

改造首页内容为通用后台管理系统定位

- 修改首页标题从 'LINUX DO Credit' 改为 'Modern Platform'
- 更新副标题为 '为二次开发而生'
- 更新首页描述为通用平台的特点
- 更新首页特性标签为 '开箱即用、高度可扩展、工业级基建'
- 修改展示卡片为技术栈和二次开发相关
- 更新开发者示例代码为通用的注册和 API Key 获取示例
- 更新页脚品牌名为 'Modern Platform'
- 调整页脚导航链接为通用平台相关内容

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

裁剪
移除 /api/v1/user/pay-key 相关代码

- 删除后端 UpdatePayKey 处理器函数和 UpdatePayKeyRequest 结构体
- 删除 User 模型中的 PayKey 字段
- 删除 User.VerifyPayKey 方法
- 删除 EncryptPayKeyFailed 错误常量
- 删除 /api/v1/user/pay-key PUT 路由
- 删除 OAuth 返回中的 IsPayKey 字段
- 删除前端 UserService.updatePayKey 方法
- 删除前端所有支付密钥 UI 和逻辑
- 更新相关的导出和注释

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

api 修正

系统配置

前端裁剪

后端裁剪

init
This commit is contained in:
ryan
2026-06-07 15:32:14 +08:00
parent 8a782525de
commit 48d414e197
180 changed files with 1538 additions and 27463 deletions
-28
View File
@@ -1,28 +0,0 @@
/*
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package upload
// 封面类型
const (
CoverTypeCover = "cover"
CoverTypeHeterotypic = "heterotypic"
)
// 最大文件大小
const (
MaxFileSize = 2 * 1024 * 1024 // 2MB
)
-233
View File
@@ -1,233 +0,0 @@
/*
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package upload
import (
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/storage"
"github.com/linux-do/credit/internal/util"
_ "golang.org/x/image/webp"
"gorm.io/gorm"
)
// UploadResponse 上传响应
type UploadResponse struct {
ID uint64 `json:"id,string"`
}
// UploadRedEnvelopeCover 上传红包封面
// @Tags upload
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "图片文件"
// @Param type formData string true "封面类型 (cover/heterotypic)"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/upload/redenvelope/cover [post]
func UploadRedEnvelopeCover(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrNoFileSelected))
return
}
// 获取封面类型
coverType := c.PostForm("type")
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
return
}
// 验证文件大小
if file.Size > int64(MaxFileSize) {
c.JSON(http.StatusBadRequest, util.Err(ErrFileTooLarge))
return
}
// 打开上传的文件
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrOpenFileFailed))
return
}
defer src.Close()
// 验证文件确实是图片并获取尺寸
_, format, err := image.DecodeConfig(src)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidImage))
return
}
// 验证图片类型
norm := strings.ToLower(format)
if norm == "jpeg" {
norm = "jpg"
}
var sc model.SystemConfig
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeyUploadAllowedExtensions); err != nil || strings.TrimSpace(sc.Value) == "" {
c.JSON(http.StatusInternalServerError, util.Err(ErrUploadExtensionsNotConfigured))
return
}
v := strings.ToLower(strings.ReplaceAll(sc.Value, " ", ""))
v = strings.ReplaceAll(v, "jpeg", "jpg")
if !strings.Contains(","+v+",", ","+norm+",") {
c.JSON(http.StatusBadRequest, util.Err(ErrUnsupportedFormat))
return
}
// 重置文件指针
src.Close()
src, _ = file.Open()
defer src.Close()
// 计算文件 MD5 以避免重复上传
hash := md5.New()
if _, err := io.Copy(hash, src); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrProcessFileFailed))
return
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
// 重置文件指针
src.Close()
src, _ = file.Open()
defer src.Close()
// 生成安全的文件名: 用户ID_类型_MD5.扩展名
// 使用完整 MD5 实现去重,同一用户上传相同图片会命中已有文件
safeExt := "." + format
if format == "jpeg" {
safeExt = ".jpg"
}
filename := fmt.Sprintf("%s%s", md5Sum, safeExt)
// 构建 S3 object key: {prefix}{type}/{date}/{userID}/{filename}
now := time.Now()
objectPath := fmt.Sprintf("%s/%s/%d/%s", coverType, now.Format("2006/01/02"), currentUser.ID, filename)
s3Key := storage.BuildKey(objectPath)
// validate S3 key
if err := ValidateS3Key(s3Key); err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidFilePath))
return
}
// Content type for S3
contentType := "image/" + format
var recordID uint64
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var existing model.Upload
if err := tx.Where("file_path = ?", s3Key).First(&existing).Error; err == nil {
recordID = existing.ID
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// Upload to S3
if err := storage.PutObject(c.Request.Context(), s3Key, src, file.Size, contentType); err != nil {
return errors.New(ErrSaveFileFailed)
}
upload := model.Upload{
ID: idgen.NextUint64ID(),
UserID: currentUser.ID,
FilePath: s3Key,
FileSize: file.Size,
Type: coverType,
Status: model.UploadStatusPending,
}
if err := tx.Create(&upload).Error; err != nil {
// 如果数据库保存失败,尝试删除已上传的文件以避免垃圾数据
_ = storage.DeleteObject(c.Request.Context(), s3Key)
return errors.New(ErrSaveUploadRecordFailed)
}
recordID = upload.ID
return nil
}); err != nil {
if err.Error() == ErrSaveFileFailed {
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveFileFailed))
return
}
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveUploadRecordFailed))
return
}
c.JSON(http.StatusOK, util.OK(UploadResponse{
ID: recordID,
}))
}
// ListRedEnvelopeCovers 获取用户历史红包封面
// @Tags redenvelope
// @Produce json
// @Param type query string true "封面类型 (cover/heterotypic)"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/covers [get]
func ListRedEnvelopeCovers(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
coverType := c.Query("type")
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
return
}
var uploads []model.Upload
if err := db.DB(c.Request.Context()).
Where("user_id = ? AND status = ? AND type = ?",
currentUser.ID, model.UploadStatusUsed, coverType).
Order("created_at DESC").
Limit(20).
Find(&uploads).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrQueryHistoryCoverFailed))
return
}
var results []UploadResponse
for _, u := range uploads {
results = append(results, UploadResponse{
ID: u.ID,
})
}
c.JSON(http.StatusOK, util.OK(results))
}