This commit is contained in:
ryan
2026-06-08 08:19:22 +08:00
parent 4b72419a96
commit 9d0f9f0576
42 changed files with 1412 additions and 809 deletions
@@ -68,7 +68,7 @@ func TestListAuthSources(t *testing.T) {
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("GET", "/api/v1/admin/auth-sources", nil)
@@ -105,7 +105,7 @@ func TestCreateAuthSource(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("create successfully", func(t *testing.T) {
@@ -175,7 +175,7 @@ func TestUpdateAuthSource(t *testing.T) {
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("update keep client secret", func(t *testing.T) {
@@ -252,7 +252,7 @@ func TestToggleAuthSource(t *testing.T) {
}
dbConn.Create(&source)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("cannot activate without credentials", func(t *testing.T) {
@@ -318,7 +318,7 @@ func TestDeleteAuthSource(t *testing.T) {
}
dbConn.Create(&externalAccount)
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("DELETE", "/api/v1/admin/auth-sources/1", nil)
@@ -62,7 +62,7 @@ func TestCreateSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("create successfully", func(t *testing.T) {
@@ -124,7 +124,7 @@ func TestListSystemConfigs(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("list all seeded configurations", func(t *testing.T) {
@@ -171,7 +171,7 @@ func TestGetSystemConfig(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("get existing configuration", func(t *testing.T) {
@@ -210,7 +210,7 @@ func TestUpdateSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("update successfully", func(t *testing.T) {
@@ -263,7 +263,7 @@ func TestDeleteSystemConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("delete successfully", func(t *testing.T) {
+6 -6
View File
@@ -61,7 +61,7 @@ func TestListTaskTypes(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/types", nil)
@@ -99,7 +99,7 @@ func TestDispatchTask(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("dispatch valid task successfully", func(t *testing.T) {
@@ -156,7 +156,7 @@ func TestListTaskExecutions(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
@@ -245,7 +245,7 @@ func TestGetTaskExecution(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
@@ -308,7 +308,7 @@ func TestRetryTask(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
@@ -415,7 +415,7 @@ func TestRetryTaskMaxRetryExceeded(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
+4
View File
@@ -20,4 +20,8 @@ const (
userNotFound = "用户不存在"
cannotDisable = "不能禁用管理员用户"
updateUserFailed = "更新用户状态失败"
usernameExists = "用户名已存在"
usernameRequired = "用户名不能为空"
passwordTooShort = "密码长度不能少于 8 位"
createUserFailed = "创建用户失败"
)
+103 -21
View File
@@ -23,9 +23,9 @@ import (
"github.com/gin-gonic/gin"
"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/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
@@ -38,23 +38,15 @@ type listUsersRequest struct {
}
type user struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
TrustLevel model.TrustLevel `json:"trust_level"`
PayScore int64 `json:"pay_score"`
TotalReceive decimal.Decimal `json:"total_receive"`
TotalPayment decimal.Decimal `json:"total_payment"`
TotalTransfer decimal.Decimal `json:"total_transfer"`
TotalCommunity decimal.Decimal `json:"total_community"`
CommunityBalance decimal.Decimal `json:"community_balance"`
AvailableBalance decimal.Decimal `json:"available_balance"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// listUsersResponse 用户列表响应
@@ -76,6 +68,7 @@ type listUsersResponse struct {
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users [get]
// ListUsers 获取用户列表
func ListUsers(c *gin.Context) {
var req listUsersRequest
if err := c.ShouldBindQuery(&req); err != nil {
@@ -105,9 +98,7 @@ func ListUsers(c *gin.Context) {
offset := (req.Page - 1) * req.PageSize
if err := query.
Select("id, username, nickname, avatar_url, trust_level, pay_score, " +
"total_receive, total_payment, total_transfer, total_community, " +
"community_balance, available_balance, is_active, is_admin, " +
Select("id, username, nickname, avatar_url, is_active, is_admin, " +
"last_login_at, created_at, updated_at").
Order("id DESC").
Offset(offset).
@@ -185,3 +176,94 @@ func UpdateUserStatus(c *gin.Context) {
c.JSON(http.StatusOK, util.OKNil())
}
// createUserRequest 创建用户请求
type createUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=64"`
Nickname string `json:"nickname" binding:"omitempty,max=64"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
}
// CreateUser 创建用户
// @Summary 创建用户
// @Description 创建一个本地密码登录的新用户,需要管理员权限
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body user.createUserRequest true "创建用户参数"
// @Success 200 {object} util.ResponseAny{data=user.user} "创建成功"
// @Failure 400 {object} util.ResponseAny "参数错误或用户名已存在"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/admin/users [post]
func CreateUser(c *gin.Context) {
var req createUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
req.Username = strings.TrimSpace(req.Username)
req.Nickname = strings.TrimSpace(req.Nickname)
req.Password = strings.TrimSpace(req.Password)
if req.Username == "" {
c.JSON(http.StatusBadRequest, util.Err(usernameRequired))
return
}
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, util.Err(passwordTooShort))
return
}
ctx := c.Request.Context()
var count int64
if err := db.DB(ctx).Table("users").Where("username = ?", req.Username).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if count > 0 {
c.JSON(http.StatusBadRequest, util.Err(usernameExists))
return
}
newUser := model.User{
ID: idgen.NextUint64ID(),
Username: req.Username,
Nickname: req.Nickname,
IsActive: req.IsActive,
IsAdmin: req.IsAdmin,
LastLoginAt: time.Time{},
}
if newUser.Nickname == "" {
newUser.Nickname = req.Username
}
if err := newUser.SetEncryptedPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
res := user{
ID: newUser.ID,
Username: newUser.Username,
Nickname: newUser.Nickname,
AvatarUrl: newUser.AvatarUrl,
IsActive: newUser.IsActive,
IsAdmin: newUser.IsAdmin,
LastLoginAt: newUser.LastLoginAt,
CreatedAt: newUser.CreatedAt,
UpdatedAt: newUser.UpdatedAt,
}
c.JSON(http.StatusOK, util.OK(res))
}
+124 -28
View File
@@ -29,7 +29,6 @@ import (
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/testhelper"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
func setupTestRouter(authUser *model.User) *gin.Engine {
@@ -46,6 +45,7 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
})
adminGroup.GET("/users", ListUsers)
adminGroup.POST("/users", CreateUser)
adminGroup.PUT("/users/:id/status", UpdateUserStatus)
return r
}
@@ -57,34 +57,28 @@ func TestListUsers(t *testing.T) {
// Seed users
users := []model.User{
{
ID: 1001,
Username: "alice",
Nickname: "Alice Nickname",
IsActive: true,
IsAdmin: false,
AvailableBalance: decimal.NewFromFloat(100.0),
LastLoginAt: time.Now(),
SignKey: "alice_sign_key",
ID: 1001,
Username: "alice",
Nickname: "Alice Nickname",
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
},
{
ID: 1002,
Username: "bob",
Nickname: "Bob Nickname",
IsActive: true,
IsAdmin: false,
AvailableBalance: decimal.NewFromFloat(50.0),
LastLoginAt: time.Now(),
SignKey: "bob_sign_key",
ID: 1002,
Username: "bob",
Nickname: "Bob Nickname",
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
},
{
ID: 1003,
Username: "charlie",
Nickname: "Charlie Nickname",
IsActive: false,
IsAdmin: true,
AvailableBalance: decimal.NewFromFloat(9999.0),
LastLoginAt: time.Now(),
SignKey: "charlie_sign_key",
ID: 1003,
Username: "charlie",
Nickname: "Charlie Nickname",
IsActive: false,
IsAdmin: true,
LastLoginAt: time.Now(),
},
}
@@ -94,7 +88,7 @@ func TestListUsers(t *testing.T) {
}
}
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true, SignKey: "charlie_sign_key"}
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("basic pagination list", func(t *testing.T) {
@@ -185,14 +179,12 @@ func TestUpdateUserStatus(t *testing.T) {
Username: "alice",
IsActive: true,
IsAdmin: false,
SignKey: "alice_sign_key",
}
adminUser := model.User{
ID: 1002,
Username: "bob",
IsActive: true,
IsAdmin: true,
SignKey: "bob_sign_key",
}
dbConn.Create(&regularUser)
@@ -252,3 +244,107 @@ func TestUpdateUserStatus(t *testing.T) {
}
})
}
func TestCreateUser(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true}
router := setupTestRouter(adminUser)
t.Run("create user successfully", func(t *testing.T) {
payload := createUserRequest{
Username: "newuser",
Password: "newpassword123",
Nickname: "New Nickname",
IsActive: true,
IsAdmin: false,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Errorf("expected empty error message, got '%s'", resp.ErrorMsg)
}
dataBytes, _ := json.Marshal(resp.Data)
var resUser user
if err := json.Unmarshal(dataBytes, &resUser); err != nil {
t.Fatalf("failed to parse response data: %v", err)
}
if resUser.Username != "newuser" || resUser.Nickname != "New Nickname" || !resUser.IsActive || resUser.IsAdmin {
t.Errorf("unexpected user values: %+v", resUser)
}
// Verify in DB
var dbUser model.User
if err := dbConn.Where("username = ?", "newuser").First(&dbUser).Error; err != nil {
t.Fatalf("failed to find user in db: %v", err)
}
if !dbUser.CheckPassword("newpassword123") {
t.Error("password was not hashed correctly")
}
})
t.Run("create user with duplicate username", func(t *testing.T) {
// Create the first user
existing := model.User{
ID: 2001,
Username: "dupuser",
Nickname: "Dup User",
}
dbConn.Create(&existing)
payload := createUserRequest{
Username: "dupuser",
Password: "password123",
Nickname: "Another Nick",
IsActive: true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg != usernameExists {
t.Errorf("expected error '%s', got '%s'", usernameExists, resp.ErrorMsg)
}
})
t.Run("validation error - password too short", func(t *testing.T) {
payload := createUserRequest{
Username: "shortpass",
Password: "123",
IsActive: true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
}
})
}
-4
View File
@@ -712,7 +712,6 @@ func TestCallbackBind(t *testing.T) {
ID: 777,
Username: "existing_member",
Nickname: "Existing Member",
SignKey: "key",
IsActive: true,
LastLoginAt: time.Now(),
}
@@ -841,7 +840,6 @@ func TestCallbackBind(t *testing.T) {
ID: 888,
Username: "another_member",
Nickname: "Another Member",
SignKey: "key2",
IsActive: true,
LastLoginAt: time.Now(),
}
@@ -929,7 +927,6 @@ func TestExternalAccountsListAndDelete(t *testing.T) {
ID: 555,
Username: "account_holder",
IsActive: true,
SignKey: "key5",
})
dbConn.Create(&model.AuthSource{
@@ -1000,7 +997,6 @@ func TestLoginRequiredAndRiskChecks(t *testing.T) {
ID: 1122,
Username: "risk_tester",
IsActive: true,
SignKey: "keysign",
})
// Enable risk checks
+16 -37
View File
@@ -23,48 +23,25 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
type BasicUserInfo struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
TrustLevel model.TrustLevel `json:"trust_level"`
AvatarUrl string `json:"avatar_url"`
TotalReceive decimal.Decimal `json:"total_receive"`
TotalPayment decimal.Decimal `json:"total_payment"`
TotalTransfer decimal.Decimal `json:"total_transfer"`
TotalCommunity decimal.Decimal `json:"total_community"`
CommunityBalance decimal.Decimal `json:"community_balance"`
AvailableBalance decimal.Decimal `json:"available_balance"`
PendingBalance decimal.Decimal `json:"pending_balance"`
PayScore int64 `json:"pay_score"`
IsAdmin bool `json:"is_admin"`
RemainQuota decimal.Decimal `json:"remain_quota"`
PayLevel string `json:"pay_level"`
DailyLimit *int64 `json:"daily_limit"`
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password"`
}
func BuildBasicUserInfo(user *model.User) BasicUserInfo {
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
return BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
TrustLevel: user.TrustLevel,
AvatarUrl: user.AvatarUrl,
TotalReceive: user.TotalReceive,
TotalPayment: user.TotalPayment,
TotalTransfer: user.TotalTransfer,
TotalCommunity: user.TotalCommunity,
CommunityBalance: user.CommunityBalance,
AvailableBalance: user.AvailableBalance,
PendingBalance: user.PendingBalance,
PayScore: user.PayScore,
IsAdmin: user.IsAdmin,
RemainQuota: decimal.NewFromInt(-1),
PayLevel: "Free",
DailyLimit: nil,
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
AvatarUrl: user.AvatarUrl,
IsAdmin: user.IsAdmin,
NeedChangePassword: needChange,
}
}
@@ -79,10 +56,12 @@ func BuildBasicUserInfo(user *model.User) BasicUserInfo {
// @Router /api/v1/oauth/user-info [get]
func UserInfo(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, UserObjKey)
session := sessions.Default(c)
needChange := session.Get("need_change_password") == true
c.JSON(
http.StatusOK,
util.OK(BuildBasicUserInfo(user)),
util.OK(BuildBasicUserInfo(user, needChange)),
)
}
+1 -1
View File
@@ -255,7 +255,7 @@ func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
func buildCallbackResult(user *model.User, status string) OAuthCallbackResult {
result := OAuthCallbackResult{Status: status}
if user != nil {
info := BuildBasicUserInfo(user)
info := BuildBasicUserInfo(user, false)
result.User = &info
}
return result
+29 -30
View File
@@ -35,7 +35,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
@@ -73,20 +72,20 @@ func UploadFile(c *gin.Context) {
header, err := c.FormFile("file")
if err != nil {
response.RespondFailure(c, ErrNoFileSelected)
c.JSON(http.StatusOK, util.Err(ErrNoFileSelected))
return
}
file, err := header.Open()
if err != nil {
response.RespondFailure(c, ErrOpenFileFailed)
c.JSON(http.StatusOK, util.Err(ErrOpenFileFailed))
return
}
defer file.Close()
// 校验大小
if header.Size > maxUploadSize {
response.RespondFailure(c, "文件大小不能超过 32MB")
c.JSON(http.StatusOK, util.Err("文件大小不能超过 32MB"))
return
}
@@ -109,7 +108,7 @@ func UploadFile(c *gin.Context) {
}
}
if !allowed {
response.RespondFailure(c, ErrUnsupportedFormat)
c.JSON(http.StatusOK, util.Err(ErrUnsupportedFormat))
return
}
}
@@ -119,7 +118,7 @@ func UploadFile(c *gin.Context) {
var buf bytes.Buffer
size, err := io.Copy(&buf, io.TeeReader(file, hashWriter))
if err != nil {
response.RespondFailure(c, ErrProcessFileFailed)
c.JSON(http.StatusOK, util.Err(ErrProcessFileFailed))
return
}
@@ -152,15 +151,15 @@ func UploadFile(c *gin.Context) {
}
if err := db.DB(ctx).Create(&newUpload).Error; err != nil {
response.RespondFailure(c, ErrSaveUploadRecordFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed))
return
}
logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath)
response.RespondSuccess(c, newUpload)
c.JSON(http.StatusOK, util.OK(newUpload))
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
response.RespondFailure(c, "文件校验失败")
c.JSON(http.StatusOK, util.Err("文件校验失败"))
return
}
@@ -169,7 +168,7 @@ func UploadFile(c *gin.Context) {
var meta model.UploadMetadata
if metadataStr != "" {
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
response.RespondFailure(c, "元数据 JSON 格式不合法")
c.JSON(http.StatusOK, util.Err("元数据 JSON 格式不合法"))
return
}
}
@@ -192,7 +191,7 @@ func UploadFile(c *gin.Context) {
err = storage.PutObject(ctx, fullKey, bytes.NewReader(buf.Bytes()), size, mimeType)
if err != nil {
logger.ErrorF(ctx, "S3 存储上传失败: %v", err)
response.RespondFailure(c, ErrSaveFileFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
return
}
} else {
@@ -200,14 +199,14 @@ func UploadFile(c *gin.Context) {
localDir := filepath.Join("uploads", time.Now().Format("2006/01/02"))
if err := os.MkdirAll(localDir, 0755); err != nil {
logger.ErrorF(ctx, "创建本地上传目录失败: %v", err)
response.RespondFailure(c, ErrSaveFileFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
return
}
localPath := filepath.Join(localDir, fmt.Sprintf("%d.%s", id, ext))
if err := os.WriteFile(localPath, buf.Bytes(), 0644); err != nil {
logger.ErrorF(ctx, "本地磁盘写入文件失败: %v", err)
response.RespondFailure(c, ErrSaveFileFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
return
}
// 统一使用相对路径,方便将来环境移植或备份
@@ -235,11 +234,11 @@ func UploadFile(c *gin.Context) {
if storageDriver == "local" {
_ = os.Remove(subPath)
}
response.RespondFailure(c, ErrSaveUploadRecordFailed)
c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed))
return
}
response.RespondSuccess(c, newUpload)
c.JSON(http.StatusOK, util.OK(newUpload))
}
// DownloadFile 通用单文件下载接口
@@ -259,7 +258,7 @@ func DownloadFile(c *gin.Context) {
idStr := c.Param("id")
uploadID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的文件 ID")
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
return
}
@@ -269,7 +268,7 @@ func DownloadFile(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
return
}
response.RespondFailure(c, "查询文件记录失败")
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
return
}
@@ -317,7 +316,7 @@ func BatchDownloadFiles(c *gin.Context) {
var req batchDownloadRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.RespondFailure(c, "参数绑定失败,请传入有效的文件 ID 数组")
c.JSON(http.StatusOK, util.Err("参数绑定失败,请传入有效的文件 ID 数组"))
return
}
@@ -326,7 +325,7 @@ func BatchDownloadFiles(c *gin.Context) {
for _, idStr := range req.IDs {
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, fmt.Sprintf("无效的 ID 值: %s", idStr))
c.JSON(http.StatusOK, util.Err(fmt.Sprintf("无效的 ID 值: %s", idStr)))
return
}
ids = append(ids, id)
@@ -335,12 +334,12 @@ func BatchDownloadFiles(c *gin.Context) {
// 查库获取所有匹配且正常的文件记录
var uploads []model.Upload
if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil {
response.RespondFailure(c, "检索文件记录失败")
c.JSON(http.StatusOK, util.Err("检索文件记录失败"))
return
}
if len(uploads) == 0 {
response.RespondFailure(c, "没有找到任何有效的文件记录进行打包")
c.JSON(http.StatusOK, util.Err("没有找到任何有效的文件记录进行打包"))
return
}
@@ -436,7 +435,7 @@ func ListMyFiles(c *gin.Context) {
var req listMyFilesRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.RespondFailure(c, "参数错误")
c.JSON(http.StatusOK, util.Err("参数错误"))
return
}
if req.Page <= 0 {
@@ -461,23 +460,23 @@ func ListMyFiles(c *gin.Context) {
var total int64
if err := query.Count(&total).Error; err != nil {
response.RespondFailure(c, "查询文件数量失败")
c.JSON(http.StatusOK, util.Err("查询文件数量失败"))
return
}
var items []model.Upload
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil {
response.RespondFailure(c, "查询文件列表失败")
c.JSON(http.StatusOK, util.Err("查询文件列表失败"))
return
}
response.RespondSuccess(c, listMyFilesResponse{
c.JSON(http.StatusOK, util.OK(listMyFilesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
Items: items,
})
}))
}
// DeleteFile 软删除文件记录
@@ -498,7 +497,7 @@ func DeleteFile(c *gin.Context) {
idStr := c.Param("id")
uploadID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的文件 ID")
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
return
}
@@ -508,7 +507,7 @@ func DeleteFile(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
return
}
response.RespondFailure(c, "查询文件记录失败")
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
return
}
@@ -519,11 +518,11 @@ func DeleteFile(c *gin.Context) {
}
if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil {
response.RespondFailure(c, "删除文件失败")
c.JSON(http.StatusOK, util.Err("删除文件失败"))
return
}
response.RespondSuccess(c, nil)
c.JSON(http.StatusOK, util.OKNil())
}
func min(a, b int) int {
+9 -10
View File
@@ -39,9 +39,8 @@ import (
)
type testResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
ErrorMsg string `json:"error_msg"`
Data json.RawMessage `json:"data"`
}
func setupTestRouter(authUser *model.User) *gin.Engine {
@@ -161,8 +160,8 @@ func TestUploadFile(t *testing.T) {
t.Fatalf("failed to unmarshal response: %v", err)
}
if !resp.Success {
t.Fatalf("expected success response, got failure: %s", resp.Message)
if resp.ErrorMsg != "" {
t.Fatalf("expected success response, got failure: %s", resp.ErrorMsg)
}
// Verify database record
@@ -212,7 +211,7 @@ func TestUploadFile(t *testing.T) {
var resp testResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Success || !strings.Contains(resp.Message, ErrUnsupportedFormat) {
if resp.ErrorMsg == "" || !strings.Contains(resp.ErrorMsg, ErrUnsupportedFormat) {
t.Errorf("expected unsupported format error, got: %v", resp)
}
})
@@ -249,8 +248,8 @@ func TestUploadFile(t *testing.T) {
var resp2 testResponse
json.Unmarshal(w2.Body.Bytes(), &resp2)
if !resp2.Success {
t.Fatalf("second upload was unsuccessful: %s", resp2.Message)
if resp2.ErrorMsg != "" {
t.Fatalf("second upload was unsuccessful: %s", resp2.ErrorMsg)
}
var uploadRecord2 model.Upload
@@ -306,8 +305,8 @@ func TestUploadFile(t *testing.T) {
var resp testResponse
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Success {
t.Fatalf("local upload failed: %s", resp.Message)
if resp.ErrorMsg != "" {
t.Fatalf("local upload failed: %s", resp.ErrorMsg)
}
var localRecord model.Upload
+22 -21
View File
@@ -17,12 +17,12 @@ limitations under the License.
package user
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
@@ -46,17 +46,18 @@ type tokenResponse struct {
// @Success 200 {object} util.ResponseAny{data=[]model.AccessToken} "令牌列表"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Router /api/v1/user/access-tokens [get]
// ListAccessTokens 获取当前用户的 AccessToken 列表
func ListAccessTokens(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
var tokens []model.AccessToken
if err := db.DB(ctx).Where("user_id = ?", currUser.ID).Order("created_at desc").Find(&tokens).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccess(c, tokens)
c.JSON(http.StatusOK, util.OK(tokens))
}
// CreateAccessToken 创建一个新的 AccessToken
@@ -76,13 +77,13 @@ func CreateAccessToken(c *gin.Context) {
var req createTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.RespondFailure(c, "参数绑定失败")
c.JSON(http.StatusOK, util.Err("参数绑定失败"))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
response.RespondFailure(c, "令牌名称不能为空")
c.JSON(http.StatusOK, util.Err("令牌名称不能为空"))
return
}
@@ -94,19 +95,19 @@ func CreateAccessToken(c *gin.Context) {
var count int64
if err := db.DB(ctx).Model(&model.AccessToken{}).Where("user_id = ?", currUser.ID).Count(&count).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if int(count) >= maxLimit {
response.RespondFailure(c, "已达到访问令牌最大创建数量限制")
c.JSON(http.StatusOK, util.Err("已达到访问令牌最大创建数量限制"))
return
}
// 生成 Token
tokenStr, err := model.GenerateTokenString()
if err != nil {
response.RespondFailure(c, "生成令牌失败")
c.JSON(http.StatusOK, util.Err("生成令牌失败"))
return
}
@@ -121,14 +122,14 @@ func CreateAccessToken(c *gin.Context) {
}
if err := db.DB(ctx).Create(&tokenRecord).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccess(c, tokenResponse{
c.JSON(http.StatusOK, util.OK(tokenResponse{
Token: tokenStr,
Record: tokenRecord,
})
}))
}
// DeleteAccessToken 删除一个 AccessToken
@@ -148,22 +149,22 @@ func DeleteAccessToken(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的令牌ID")
c.JSON(http.StatusOK, util.Err("无效的令牌ID"))
return
}
tx := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).Delete(&model.AccessToken{})
if tx.Error != nil {
response.RespondFailure(c, tx.Error.Error())
c.JSON(http.StatusOK, util.Err(tx.Error.Error()))
return
}
if tx.RowsAffected == 0 {
response.RespondFailure(c, "令牌不存在或无权操作")
c.JSON(http.StatusOK, util.Err("令牌不存在或无权操作"))
return
}
response.RespondSuccess(c, "删除成功")
c.JSON(http.StatusOK, util.OK("删除成功"))
}
// RotateAccessToken 轮换一个 AccessToken
@@ -183,20 +184,20 @@ func RotateAccessToken(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.RespondFailure(c, "无效的令牌ID")
c.JSON(http.StatusOK, util.Err("无效的令牌ID"))
return
}
var tokenRecord model.AccessToken
if err := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).First(&tokenRecord).Error; err != nil {
response.RespondFailure(c, "令牌不存在或无权操作")
c.JSON(http.StatusOK, util.Err("令牌不存在或无权操作"))
return
}
// 生成新的 Token
newTokenStr, err := model.GenerateTokenString()
if err != nil {
response.RespondFailure(c, "生成令牌失败")
c.JSON(http.StatusOK, util.Err("生成令牌失败"))
return
}
@@ -208,12 +209,12 @@ func RotateAccessToken(c *gin.Context) {
tokenRecord.LastUsedAt = nil // 轮换后重置使用时间
if err := db.DB(ctx).Save(&tokenRecord).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccess(c, tokenResponse{
c.JSON(http.StatusOK, util.OK(tokenResponse{
Token: newTokenStr,
Record: tokenRecord,
})
}))
}
+112 -23
View File
@@ -2,6 +2,7 @@ package user
import (
"context"
"net/http"
"strings"
"time"
@@ -10,7 +11,6 @@ import (
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/common/bind"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
@@ -75,7 +75,7 @@ func setLoginSession(c *gin.Context, user *model.User) error {
// @Router /api/v1/user/login [post]
func Login(c *gin.Context) {
if !isPasswordLoginEnabled() {
response.RespondFailure(c, "管理员关闭了密码登录")
c.JSON(http.StatusOK, util.Err("管理员关闭了密码登录"))
return
}
var req loginRequest
@@ -84,36 +84,56 @@ func Login(c *gin.Context) {
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" {
response.RespondFailure(c, "无效的参数")
c.JSON(http.StatusOK, util.Err("无效的参数"))
return
}
var user model.User
ctx := c.Request.Context()
if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
response.RespondFailure(c, "用户名或密码错误")
c.JSON(http.StatusOK, util.Err("用户名或密码错误"))
return
}
if !user.IsActive {
response.RespondFailure(c, common.BannedAccount)
c.JSON(http.StatusOK, util.Err(common.BannedAccount))
return
}
// 判定是否是明文密码存储
isPlaintext := !(strings.HasPrefix(user.Password, "$2a$") || strings.HasPrefix(user.Password, "$2b$") || strings.HasPrefix(user.Password, "$2y$"))
if !user.CheckPassword(req.Password) {
response.RespondFailure(c, "用户名或密码错误")
c.JSON(http.StatusOK, util.Err("用户名或密码错误"))
return
}
session := sessions.Default(c)
needChangePassword := false
// 如果是以明文密码登录,在数据库中置换为加密密码
if isPlaintext {
if err := user.SetEncryptedPassword(req.Password); err == nil {
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
c.JSON(http.StatusOK, util.Err("升级密码安全算法失败,请重试"))
return
}
needChangePassword = true
session.Set("need_change_password", true)
_ = session.Save()
}
}
user.LastLoginAt = time.Now()
if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if err := setLoginSession(c, &user); err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试"))
return
}
response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user))
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
}
// Register 用户注册
@@ -129,7 +149,7 @@ func Login(c *gin.Context) {
// @Router /api/v1/user/register [post]
func Register(c *gin.Context) {
if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
response.RespondFailure(c, "管理员关闭了注册")
c.JSON(http.StatusOK, util.Err("管理员关闭了注册"))
return
}
@@ -144,22 +164,22 @@ func Register(c *gin.Context) {
req.DisplayName = strings.TrimSpace(req.DisplayName)
if req.Username == "" || req.Password == "" {
response.RespondFailure(c, "无效的参数")
c.JSON(http.StatusOK, util.Err("无效的参数"))
return
}
if len(req.Password) < 8 {
response.RespondFailure(c, "密码长度不能少于 8 位")
c.JSON(http.StatusOK, util.Err("密码长度不能少于 8 位"))
return
}
ctx := c.Request.Context()
var count int64
if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if count > 0 {
response.RespondFailure(c, "用户名已存在")
c.JSON(http.StatusOK, util.Err("用户名已存在"))
return
}
@@ -167,9 +187,6 @@ func Register(c *gin.Context) {
Username: req.Username,
Nickname: req.Nickname,
AvatarUrl: "",
TrustLevel: model.TrustLevelNewUser,
PayScore: 0,
SignKey: util.GenerateUniqueIDSimple(),
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
@@ -181,21 +198,21 @@ func Register(c *gin.Context) {
user.Nickname = req.Username
}
if err := user.SetPassword(req.Password); err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if err := db.DB(ctx).Create(&user).Error; err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if err := setLoginSession(c, &user); err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
c.JSON(http.StatusOK, util.Err("无法保存会话信息,请重试"))
return
}
response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user))
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, false)))
}
// Logout 用户退出登录
@@ -212,8 +229,80 @@ func Logout(c *gin.Context) {
session.Options(util.GetSessionOptions(-1))
session.Clear()
if err := session.Save(); err != nil {
response.RespondFailure(c, err.Error())
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
response.RespondSuccessMessage(c, "")
c.JSON(http.StatusOK, util.OK(""))
}
type changePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword 修改用户密码
// @Summary 修改用户密码
// @Description 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。
// @Tags user
// @Accept json
// @Produce json
// @Param request body user.changePasswordRequest true "修改密码请求参数"
// @Success 200 {object} util.ResponseAny{data=string} "修改密码成功"
// @Failure 400 {object} util.ResponseAny "原密码错误或新密码不符合要求"
// @Failure 401 {object} util.ResponseAny "请先登录"
// @Router /api/v1/user/change-password [post]
func ChangePassword(c *gin.Context) {
var req changePasswordRequest
if !bind.JSON(c, &req) {
return
}
req.OldPassword = strings.TrimSpace(req.OldPassword)
req.NewPassword = strings.TrimSpace(req.NewPassword)
if req.OldPassword == "" || req.NewPassword == "" {
c.JSON(http.StatusOK, util.Err("无效的参数"))
return
}
if len(req.NewPassword) < 8 {
c.JSON(http.StatusOK, util.Err("新密码长度不能少于 8 位"))
return
}
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if userObj == nil {
c.JSON(http.StatusUnauthorized, util.Err("请先登录"))
return
}
ctx := c.Request.Context()
var dbUser model.User
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
c.JSON(http.StatusOK, util.Err("未找到该用户"))
return
}
// 校验旧密码
if !dbUser.CheckPassword(req.OldPassword) {
c.JSON(http.StatusOK, util.Err("原密码不正确"))
return
}
// 加密并更新为新密码
if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
c.JSON(http.StatusOK, util.Err("密码加密失败,请重试"))
return
}
if err := db.DB(ctx).Model(&dbUser).Update("password", dbUser.Password).Error; err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
// 清除 Session 中修改密码提示状态
session := sessions.Default(c)
session.Delete("need_change_password")
_ = session.Save()
c.JSON(http.StatusOK, util.OK("密码修改成功"))
}
+4 -3
View File
@@ -4,10 +4,11 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common/response"
"github.com/linux-do/credit/internal/util"
)
// DecodeJSONBody decodes JSON reader to target
@@ -32,7 +33,7 @@ func IDParam(c *gin.Context) (uint, bool) {
func IDParamByName(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
response.RespondBadRequest(c, "")
c.JSON(http.StatusBadRequest, util.Err("参数错误"))
return 0, false
}
return uint(id), true
@@ -41,7 +42,7 @@ func IDParamByName(c *gin.Context, name string) (uint, bool) {
// JSON binds JSON body of context request to target
func JSON(c *gin.Context, target any) bool {
if err := DecodeJSONBody(c.Request.Body, target); err != nil {
response.RespondBadRequest(c, "")
c.JSON(http.StatusBadRequest, util.Err("参数错误"))
return false
}
return true
-82
View File
@@ -1,82 +0,0 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
const invalidParamsMessage = "参数错误"
// RespondSuccess sends a successful response with data
func RespondSuccess(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
// RespondSuccessWithExtras sends a successful response with data and extra fields
func RespondSuccessWithExtras(c *gin.Context, data any, extras gin.H) {
payload := gin.H{
"success": true,
"message": "",
"data": data,
}
for key, value := range extras {
payload[key] = value
}
c.JSON(http.StatusOK, payload)
}
// RespondSuccessMessage sends a successful response with a custom message
func RespondSuccessMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
})
}
// RespondFailure sends a failed response with http.StatusOK and a failure message
func RespondFailure(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": message,
})
}
// RespondBadRequest sends a bad request response (400)
func RespondBadRequest(c *gin.Context, message string) {
if message == "" {
message = invalidParamsMessage
}
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": message,
})
}
// RespondUnauthorized sends an unauthorized response (401)
func RespondUnauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": message,
})
}
// RespondForbidden sends a forbidden response (403)
func RespondForbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": message,
})
}
// RespondErrorWithStatus sends a response with target HTTP status code and a message
func RespondErrorWithStatus(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{
"success": false,
"message": message,
})
}
+36
View File
@@ -19,11 +19,13 @@ package migrator
import (
"context"
"log"
"time"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
)
func Migrate() {
@@ -46,6 +48,8 @@ func Migrate() {
// 初始化系统配置数据
initSystemConfigs()
// 初始化默认管理员用户
initDefaultAdmin()
}
// initSystemConfigs 初始化系统配置数据
@@ -113,3 +117,35 @@ func initSystemConfigs() {
log.Printf("[PostgreSQL] initialized %d default system configs\n", len(defaultConfigs))
}
}
// initDefaultAdmin 初始化默认管理员用户
func initDefaultAdmin() {
tx := db.DB(context.Background())
var count int64
if err := tx.Model(&model.User{}).Where("username = ?", "admin").Count(&count).Error; err != nil {
log.Printf("[PostgreSQL] failed to check default admin user: %v\n", err)
return
}
if count > 0 {
return
}
adminUser := model.User{
ID: idgen.NextUint64ID(),
Username: "admin",
Password: "12345678", // 密码使用明文存储
Nickname: "Administrator",
AvatarUrl: "",
IsActive: true,
IsAdmin: true,
LastLoginAt: time.Now(),
}
if err := tx.Create(&adminUser).Error; err != nil {
log.Printf("[PostgreSQL] failed to create default admin user: %v\n", err)
} else {
log.Printf("[PostgreSQL] default admin user created successfully (username: admin, password: 12345678)\n")
}
}
+30 -124
View File
@@ -17,41 +17,26 @@ limitations under the License.
package model
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type TrustLevel uint8
const (
TrustLevelNewUser TrustLevel = iota
TrustLevelBasicUser
TrustLevelUser
TrustLevelActiveUser
TrustLevelLeader
)
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
type OAuthUserInfo struct {
Id uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarUrl string `json:"avatar_url"`
TrustLevel TrustLevel `json:"trust_level"`
Id uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarUrl string `json:"avatar_url"`
}
// GetID 获取用户 ID
@@ -68,49 +53,25 @@ func (u *OAuthUserInfo) GetID() uint64 {
return 0
}
// UserGamificationScoreResponse API响应
type UserGamificationScoreResponse struct {
User struct {
GamificationScore int64 `json:"gamification_score"`
} `json:"user"`
}
// LeaderboardResponse 排行榜 API 响应
type LeaderboardResponse struct {
Users []LeaderboardUser `json:"users"`
}
// LeaderboardUser 排行榜用户信息
type LeaderboardUser struct {
ID uint64 `json:"id"`
Username string `json:"username"`
TotalScore int64 `json:"total_score"`
}
type User struct {
ID uint64 `json:"id" gorm:"primaryKey;index:idx_users_active_bal_id,priority:3"`
Username string `json:"username" gorm:"size:64;uniqueIndex"`
Password string `json:"password,omitempty" gorm:"size:255"`
Nickname string `json:"nickname" gorm:"size:255"`
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
TrustLevel TrustLevel `json:"trust_level" gorm:"index"`
PayScore int64 `json:"pay_score" gorm:"default:0;index"`
SignKey string `json:"sign_key" gorm:"size:64;uniqueIndex;not null"`
TotalReceive decimal.Decimal `json:"total_receive" gorm:"type:numeric(20,2);default:0"`
TotalPayment decimal.Decimal `json:"total_payment" gorm:"type:numeric(20,2);default:0"`
TotalTransfer decimal.Decimal `json:"total_transfer" gorm:"type:numeric(20,2);default:0"`
TotalCommunity decimal.Decimal `json:"total_community" gorm:"type:numeric(20,2);default:0"`
CommunityBalance decimal.Decimal `json:"community_balance" gorm:"type:numeric(20,2);default:0"`
AvailableBalance decimal.Decimal `json:"available_balance" gorm:"type:numeric(20,2);default:0;index:idx_users_active_bal_id,priority:2"`
PendingBalance decimal.Decimal `json:"pending_balance" gorm:"type:numeric(20,2);default:0"`
IsActive bool `json:"is_active" gorm:"default:true;index:idx_users_active_bal_id,priority:1"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
ID uint64 `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"size:64;uniqueIndex"`
Password string `json:"password,omitempty" gorm:"size:255"`
Nickname string `json:"nickname" gorm:"size:255"`
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
IsActive bool `json:"is_active" gorm:"default:true;index"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func (u *User) SetPassword(password string) error {
u.Password = password
return nil
}
func (u *User) SetEncryptedPassword(password string) error {
if password == "" {
u.Password = ""
return nil
@@ -127,7 +88,11 @@ func (u *User) CheckPassword(password string) bool {
if u.Password == "" || password == "" {
return false
}
return util.CheckPasswordHash(u.Password, password)
isBcrypt := strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
if isBcrypt {
return util.CheckPasswordHash(u.Password, password)
}
return u.Password == password
}
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
@@ -137,70 +102,13 @@ func (u *User) GetByID(tx *gorm.DB, id uint64) error {
return nil
}
// GetByIDs 批量查询用户
func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) {
var users []User
if err := tx.Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (u *User) GetUserGamificationScore(ctx context.Context) (*UserGamificationScoreResponse, error) {
if u.Username == "dev_user" {
var response UserGamificationScoreResponse
response.User.GamificationScore = 12345
return &response, nil
}
url := fmt.Sprintf("https://linux.do/u/%s.json", u.Username)
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取用户积分失败,状态码: %d", resp.StatusCode)
}
var response UserGamificationScoreResponse
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("解析用户积分响应失败: %w", err)
}
return &response, nil
}
// GetLeaderboard 获取排行榜数据
func GetLeaderboard(ctx context.Context, page int) (*LeaderboardResponse, error) {
url := fmt.Sprintf("https://linux.do/leaderboard/1.json?period=all_time&page=%d", page)
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取排行榜失败,状态码: %d", resp.StatusCode)
}
var response LeaderboardResponse
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("解析排行榜响应失败: %w", err)
}
return &response, nil
}
// UpdateFromOAuthInfo 根据 OAuth 信息更新用户数据
func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
u.Username = oauthInfo.Username
u.Nickname = oauthInfo.Name
u.AvatarUrl = oauthInfo.AvatarUrl
u.IsActive = oauthInfo.Active
u.TrustLevel = oauthInfo.TrustLevel
u.LastLoginAt = time.Now()
if oauthInfo.Username == "dev_user" {
u.IsAdmin = true
}
}
// CheckActive 检查用户账户是否激活,未激活则返回错误
@@ -220,10 +128,8 @@ func (u *User) CreateUser(tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
Nickname: oauthInfo.Name,
AvatarUrl: oauthInfo.AvatarUrl,
IsActive: oauthInfo.Active,
TrustLevel: oauthInfo.TrustLevel,
SignKey: util.GenerateUniqueIDSimple(),
LastLoginAt: now,
IsAdmin: oauthInfo.Username == "dev_user",
IsAdmin: false,
}
if err := tx.Create(&newUser).Error; err != nil {
return err
+3
View File
@@ -117,6 +117,7 @@ func Serve() {
apiV1Router.GET("/oauth/logout", oauth.Logout)
apiV1Router.POST("/oauth/callback", oauth.Callback)
apiV1Router.GET("/oauth/user-info", oauth.LoginRequired(), oauth.UserInfo)
apiV1Router.GET("/user-info", oauth.LoginRequired(), oauth.UserInfo)
apiV1Router.GET("/oauth/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts)
apiV1Router.POST("/oauth/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount)
@@ -127,6 +128,7 @@ func Serve() {
userRouter.POST("/register", user.Register)
userRouter.GET("/logout", user.Logout)
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
// Access Token
tokenRouter := userRouter.Group("/access-tokens")
@@ -171,6 +173,7 @@ func Serve() {
// Users
adminRouter.GET("/users", admin_user.ListUsers)
adminRouter.POST("/users", admin_user.CreateUser)
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
// System Config