tokenFactory/controller/user.go

2216 lines
66 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// RegisterRequest 用户注册请求体:关闭短信注册时邮箱必填;开启短信注册时邮箱与手机号二选一(至少填其一);开启邮箱验证且填写了邮箱时需验证码;开启短信且填写了手机号时需短信验证码。邮箱/手机号占用仅与未注销用户冲突。
type RegisterRequest struct {
Username string `json:"username" validate:"required,max=20"`
Password string `json:"password" validate:"required,min=8,max=20"`
Email string `json:"email" validate:"omitempty,email,max=50"`
VerificationCode string `json:"verification_code"`
AffCode string `json:"aff_code"`
Phone string `json:"phone"`
SMSCode string `json:"sms_verification_code"`
}
func ApplyStudent(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
common.ApiError(c, err)
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorMsg(c, "管理员账号无需申请学员身份")
return
}
if user.IsStudent == 1 && user.StudentStatus == common.StudentStatusApproved {
common.ApiErrorMsg(c, "你已经是学员")
return
}
if user.StudentStatus == common.StudentStatusPending {
common.ApiErrorMsg(c, "学员申请正在审批中")
return
}
now := time.Now()
user.IsStudent = 0
user.StudentStatus = common.StudentStatusPending
user.StudentApplied = &now
user.StudentApprovedAt = nil
user.StudentApprovedBy = 0
if err := user.Update(false); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "学员申请已提交,请等待管理员审批",
})
}
func Login(c *gin.Context) {
if !common.PasswordLoginEnabled {
common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled)
return
}
var loginRequest LoginRequest
err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
username := loginRequest.Username
password := loginRequest.Password
if username == "" || password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
user := model.User{
Username: username,
Password: password,
}
err = user.ValidateAndFill()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
// 检查是否启用2FA
if model.IsTwoFAEnabled(user.Id) {
// 设置pending session等待2FA验证
session := sessions.Default(c)
session.Set("pending_username", user.Username)
session.Set("pending_user_id", user.Id)
err := session.Save()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
return
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.T(c, i18n.MsgUserRequire2FA),
"success": true,
"data": map[string]interface{}{
"require_2fa": true,
},
})
return
}
setupLogin(&user, c)
}
// setup session & cookies and then return user info
func setupLogin(user *model.User, c *gin.Context) {
session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
session.Set("role", user.Role)
session.Set("status", user.Status)
session.Set("group", user.Group)
err := session.Save()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
return
}
model.TouchUserLastLogin(user.Id)
requireAdminInitialSetup := user.CreatedBy == common.UserCreatedByAdmin && !user.AdminInitialSetupCompleted
adminSetupPhoneRequired := requireAdminInitialSetup && strings.TrimSpace(user.Phone) == ""
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
"data": map[string]any{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"group": user.Group,
"is_distributor": user.IsDistributor,
"require_admin_initial_setup": requireAdminInitialSetup,
"admin_setup_phone_required": adminSetupPhoneRequired,
},
})
}
func Logout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
})
}
// Register 处理用户名密码注册:未开启短信时邮箱必填;开启短信时邮箱与手机至少填其一;短信与邮箱验证码仅在对应字段填写时校验;邮箱与手机号是否与已占用冲突仅检查未软删用户。
func Register(c *gin.Context) {
if !common.RegisterEnabled {
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
return
}
if !common.PasswordRegisterEnabled {
common.ApiErrorI18n(c, i18n.MsgUserPasswordRegisterDisabled)
return
}
var req RegisterRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
req.Username = strings.TrimSpace(req.Username)
req.Email = strings.TrimSpace(req.Email)
req.Phone = common.NormalizePhone(req.Phone)
req.SMSCode = strings.TrimSpace(req.SMSCode)
if !common.SMSVerificationEnabled && req.Email == "" {
common.ApiErrorI18n(c, i18n.MsgUserEmailEmpty)
return
}
if err := common.Validate.Struct(&req); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
return
}
if common.SMSVerificationEnabled {
if req.Email == "" && req.Phone == "" {
common.ApiErrorI18n(c, i18n.MsgUserRegisterEmailOrPhoneRequired)
return
}
if req.Phone != "" {
if !common.ValidateMainlandChinaPhone(req.Phone) {
common.ApiError(c, fmt.Errorf("手机号格式无效,请输入 11 位中国大陆手机号"))
return
}
if common.IsSMSPhoneBlacklisted(req.Phone) {
common.ApiError(c, fmt.Errorf("该手机号已被加入短信黑名单"))
return
}
if len(strings.TrimSpace(req.SMSCode)) != 6 {
common.ApiError(c, fmt.Errorf("请输入 6 位短信验证码"))
return
}
if !common.VerifyAndConsumeSMSCode(req.Phone, req.SMSCode) {
common.ApiError(c, fmt.Errorf("短信验证码错误或已过期"))
return
}
if model.IsPhoneAlreadyTaken(req.Phone) {
common.ApiError(c, fmt.Errorf("手机号已被占用"))
return
}
} else {
req.Phone = ""
req.SMSCode = ""
}
} else {
req.Phone = ""
req.SMSCode = ""
}
if common.EmailVerificationEnabled && req.Email != "" {
if req.VerificationCode == "" {
common.ApiErrorI18n(c, i18n.MsgUserEmailVerificationRequired)
return
}
if !common.VerifyCodeWithKey(req.Email, req.VerificationCode, common.EmailVerificationPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
}
}
nameTaken, err := model.IsUsernameTakenUnscoped(req.Username)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
common.SysLog(fmt.Sprintf("IsUsernameTakenUnscoped error: %v", err))
return
}
if nameTaken {
common.ApiErrorI18n(c, i18n.MsgUserUsernameTaken)
return
}
if req.Email != "" {
emailTaken, err := model.IsEmailTakenByActiveUser(req.Email)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
common.SysLog(fmt.Sprintf("IsEmailTakenByActiveUser error: %v", err))
return
}
if emailTaken {
common.ApiErrorI18n(c, i18n.MsgUserEmailTaken)
return
}
}
affCode := req.AffCode // this code is the inviter's code, not the user's own code
inviterId, _ := model.GetUserIdByAffCode(affCode)
cleanUser := model.User{
Username: req.Username,
Password: req.Password,
DisplayName: req.Username,
InviterId: inviterId,
Role: common.RoleCommonUser, // 明确设置角色为普通用户
Phone: req.Phone,
Email: req.Email,
}
if err := cleanUser.Insert(inviterId); err != nil {
common.ApiError(c, err)
return
}
// 获取插入后的用户ID
var insertedUser model.User
if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil {
common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed)
return
}
// 生成默认令牌
if constant.GenerateDefaultToken {
key, err := common.GenerateKey()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserDefaultTokenFailed)
common.SysLog("failed to generate token key: " + err.Error())
return
}
// 生成默认令牌
token := model.Token{
UserId: insertedUser.Id, // 使用插入后的用户ID
Name: cleanUser.Username + "的初始令牌",
Key: key,
CreatedTime: common.GetTimestamp(),
AccessedTime: common.GetTimestamp(),
ExpiredTime: -1, // 永不过期
RemainQuota: 500000, // 示例额度
UnlimitedQuota: true,
ModelLimitsEnabled: false,
}
if setting.DefaultUseAutoGroup {
token.Group = "auto"
}
if err := token.Insert(); err != nil {
common.ApiErrorI18n(c, i18n.MsgCreateDefaultTokenErr)
return
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func GetAllUsers(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
studentView := strings.TrimSpace(c.Query("student_view"))
tag := strings.TrimSpace(c.Query("tag"))
users, total, err := model.GetAllUsers(pageInfo, studentView, tag)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(users)
common.ApiSuccess(c, pageInfo)
return
}
func SearchUsers(c *gin.Context) {
keyword := c.Query("keyword")
group := c.Query("group")
studentView := strings.TrimSpace(c.Query("student_view"))
tag := strings.TrimSpace(c.Query("tag"))
pageInfo := common.GetPageQuery(c)
users, total, err := model.SearchUsers(keyword, group, studentView, tag, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(users)
common.ApiSuccess(c, pageInfo)
return
}
func GetUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
user, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if myRole <= user.Role && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user,
})
return
}
// AdminCheckPhoneAvailable 管理端校验手机号是否未被他人占用:新建时不传 exclude_id编辑用户时传 exclude_id 为当前用户 ID。
func AdminCheckPhoneAvailable(c *gin.Context) {
phone := c.Query("phone")
excludeStr := strings.TrimSpace(c.Query("exclude_id"))
excludeID := 0
if excludeStr != "" {
var convErr error
excludeID, convErr = strconv.Atoi(excludeStr)
if convErr != nil || excludeID < 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
}
normalized := common.NormalizePhone(phone)
if normalized == "" {
common.ApiSuccess(c, gin.H{"available": true})
return
}
if !common.ValidateMainlandChinaPhone(normalized) {
common.ApiSuccess(c, gin.H{"available": true})
return
}
var taken bool
if excludeID == 0 {
taken = model.IsPhoneAlreadyTaken(normalized)
} else {
taken = model.IsPhoneTakenByOtherUser(normalized, excludeID)
}
common.ApiSuccess(c, gin.H{"available": !taken})
}
// UserSelfCheckPhoneAvailable 当前登录用户校验欲使用的手机号是否与他人冲突exclude 固定为本人,不可伪造)。
func UserSelfCheckPhoneAvailable(c *gin.Context) {
id := c.GetInt("id")
if id <= 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
phone := c.Query("phone")
normalized := common.NormalizePhone(phone)
if normalized == "" {
common.ApiSuccess(c, gin.H{"available": true})
return
}
if !common.ValidateMainlandChinaPhone(normalized) {
common.ApiSuccess(c, gin.H{"available": true})
return
}
taken := model.IsPhoneTakenByOtherUser(normalized, id)
common.ApiSuccess(c, gin.H{"available": !taken})
}
// isPhoneUniqueConstraintError 判断数据库错误是否为手机号唯一约束冲突。
func isPhoneUniqueConstraintError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
if !strings.Contains(msg, "duplicate") && !strings.Contains(msg, "unique constraint") {
return false
}
return strings.Contains(msg, "phone")
}
// GenerateAccessToken godoc
// @Summary 生成当前用户 AccessToken
// @Description 生成并返回当前登录用户的 access_token用于在 Authorization 请求头中进行接口鉴权
// @Tags 用户
// @Produce json
// @Security ApiKeyAuth
// @Security ApiUserID
// @Success 200 {object} map[string]interface{} "success + data{access_token}"
// @Router /user/token [get]
func GenerateAccessToken(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
common.ApiError(c, err)
return
}
// get rand int 28-32
randI := common.GetRandomInt(4)
key, err := common.GenerateRandomKey(29 + randI)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgGenerateFailed)
common.SysLog("failed to generate key: " + err.Error())
return
}
user.SetAccessToken(key)
if model.DB.Where("access_token = ?", user.AccessToken).First(user).RowsAffected != 0 {
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate)
return
}
if err := user.Update(false); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user.AccessToken,
})
return
}
type TransferAffQuotaRequest struct {
Quota int `json:"quota" binding:"required"`
}
func TransferAffQuota(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
common.ApiError(c, err)
return
}
if !model.UserIsDistributor(user) {
common.ApiErrorMsg(c, "仅分销商可划转邀请收益")
return
}
tran := TransferAffQuotaRequest{}
if err := c.ShouldBindJSON(&tran); err != nil {
common.ApiError(c, err)
return
}
err = user.TransferAffQuotaToQuota(tran.Quota)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserTransferFailed, map[string]any{"Error": err.Error()})
return
}
common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil)
}
func GetAffCode(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
common.ApiError(c, err)
return
}
if !model.UserIsDistributor(user) {
common.ApiErrorMsg(c, "仅分销商可使用邀请链接")
return
}
if user.AffCode == "" {
user.EnsureAffCode()
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user.AffCode,
})
return
}
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
userRole := c.GetInt("role")
user, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
// 计算用户权限信息
permissions := calculateUserPermissions(userRole)
// 获取用户设置并提取sidebar_modules
userSetting := user.GetSetting()
requireAdminInitialSetup := user.CreatedBy == common.UserCreatedByAdmin && !user.AdminInitialSetupCompleted
adminSetupPhoneRequired := requireAdminInitialSetup && strings.TrimSpace(user.Phone) == ""
// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"email": user.Email,
"phone": user.Phone,
"github_id": user.GitHubId,
"discord_id": user.DiscordId,
"oidc_id": user.OidcId,
"wechat_id": user.WeChatId,
"telegram_id": user.TelegramId,
"group": user.Group,
"quota": user.Quota,
"used_quota": user.UsedQuota,
"request_count": user.RequestCount,
"aff_code": user.AffCode,
"aff_count": user.AffCount,
"aff_quota": user.AffQuota,
"aff_history_quota": user.AffHistoryQuota,
"distributor_commission_bps": user.DistributorCommissionBps,
"inviter_id": user.InviterId,
"linux_do_id": user.LinuxDOId,
"setting": user.Setting,
"stripe_customer": user.StripeCustomer,
"supplier_id": user.SupplierID,
"is_distributor": user.IsDistributor,
"is_student": user.IsStudent,
"student_status": user.StudentStatus,
"student_applied_at": user.StudentApplied,
"student_approved_at": user.StudentApprovedAt,
"student_approved_by": user.StudentApprovedBy,
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
"permissions": permissions, // 新增权限字段
"require_admin_initial_setup": requireAdminInitialSetup,
"admin_setup_phone_required": adminSetupPhoneRequired,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responseData,
})
return
}
// AdminInitialSetupRequest 管理员代建用户首次登录补全信息(改密;未预留手机时须绑定手机号)。
type AdminInitialSetupRequest struct {
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
Phone string `json:"phone"`
}
// CompleteAdminInitialSetup 管理员创建的账号首次登录后提交:修改密码;若创建时未填手机号则必须绑定且不可与他人重复。
func CompleteAdminInitialSetup(c *gin.Context) {
id := c.GetInt("id")
var req AdminInitialSetupRequest
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
req.NewPassword = strings.TrimSpace(req.NewPassword)
req.ConfirmPassword = strings.TrimSpace(req.ConfirmPassword)
if len(req.NewPassword) < 8 || len(req.NewPassword) > 20 {
common.ApiErrorMsg(c, "新密码长度须在 820 位之间")
return
}
if req.NewPassword != req.ConfirmPassword {
common.ApiErrorMsg(c, "两次输入的密码不一致")
return
}
user, err := model.GetUserById(id, true)
if err != nil {
common.ApiError(c, err)
return
}
if user.CreatedBy != common.UserCreatedByAdmin || user.AdminInitialSetupCompleted {
common.ApiErrorMsg(c, "当前账号无需执行此操作")
return
}
var phoneNorm string
if strings.TrimSpace(user.Phone) == "" {
var valErr error
phoneNorm, valErr = model.NormalizeAndValidateAdminUserPhone(req.Phone, user.Id)
if valErr != nil {
common.ApiError(c, valErr)
return
}
}
user.Password = req.NewPassword
user.AdminInitialSetupCompleted = true
if phoneNorm != "" {
user.Phone = phoneNorm
}
if err := user.Update(true); err != nil {
if isPhoneUniqueConstraintError(err) {
common.ApiErrorMsg(c, "手机号已被占用")
return
}
common.ApiError(c, err)
return
}
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
}
// 计算用户权限的辅助函数
func calculateUserPermissions(userRole int) map[string]interface{} {
permissions := map[string]interface{}{}
// 根据用户角色计算权限
if userRole == common.RoleRootUser {
// 超级管理员不需要边栏设置功能
permissions["sidebar_settings"] = false
permissions["sidebar_modules"] = map[string]interface{}{}
} else if userRole == common.RoleAdminUser {
// 管理员可以设置边栏,但不包含系统设置功能
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": map[string]interface{}{
"setting": false, // 管理员不能访问系统设置
},
}
} else {
// 普通用户、分销商:仅个人功能,不含管理后台
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": false,
}
}
return permissions
}
// 根据用户角色生成默认的边栏配置
func generateDefaultSidebarConfig(userRole int) string {
defaultConfig := map[string]interface{}{}
// 聊天区域 - 所有用户都可以访问
defaultConfig["chat"] = map[string]interface{}{
"enabled": true,
"playground": true,
"chat": true,
}
// 控制台区域 - 所有用户都可以访问
defaultConfig["console"] = map[string]interface{}{
"enabled": true,
"detail": true,
"token": true,
"log": true,
"midjourney": true,
"task": true,
}
// 个人中心区域 - 所有用户都可以访问
defaultConfig["personal"] = map[string]interface{}{
"enabled": true,
"topup": true,
"personal": true,
}
// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": false, // 管理员不能访问系统设置
}
} else if userRole == common.RoleRootUser {
// 超级管理员可以访问所有功能
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": true,
}
}
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
}
return string(configBytes)
}
func GetUserModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
id = c.GetInt("id")
}
user, err := model.GetUserCache(id)
if err != nil {
common.ApiError(c, err)
return
}
groups := service.GetUserUsableGroups(user.Group)
var models []string
for group := range groups {
for _, g := range model.GetGroupEnabledModels(group) {
if !common.StringsContains(models, g) {
models = append(models, g)
}
}
}
// scene=playground 时返回结构化模型列表:
// - 展示口径与 /pricing 完全一致模型必须已配置定价ratio_setting.ModelHasConfiguredPricing
// 且至少存在一个 (模型, 可见渠道) 在 model_test_results 中满足
// ManualDisplayResponseTime>0 或 (LastTestSuccess && LastResponseTime>0)
// 不再使用 model_test_results 全表 last_test_success=1 的模糊名字匹配(口径偏宽且与定价页不一致)。
// - 在此基础上再叠加「该模型在用户可用分组下的 abilities 已 enabled」的用户视角过滤
// 也即同时通过 GetGroupEnabledModels 与 CollectPricingShowableModelNames 两层门禁。
// - vendor: 模型类型类型选项仅由「通过判定后的」items 中出现的 vendor_id 推导。
// - tested_success 在返回项中恒为 true因已按 pricing 同源条件过滤)。
if c.Query("scene") == "playground" {
type playgroundChannelOption struct {
ID int `json:"id"`
Name string `json:"name"`
ChannelNo string `json:"channel_no,omitempty"`
RouteSlug string `json:"route_slug,omitempty"`
SupplierType string `json:"supplier_type,omitempty"`
}
type playgroundModelItem struct {
ModelName string `json:"model_name"`
VendorID int `json:"vendor_id"`
Vendor string `json:"vendor"`
Tags string `json:"tags"`
TestedSuccess bool `json:"tested_success"`
ChannelOptions []playgroundChannelOption `json:"channel_options,omitempty"`
}
modelRows := make([]struct {
ModelName string `gorm:"column:model_name"`
VendorID int `gorm:"column:vendor_id"`
Tags string `gorm:"column:tags"`
NameRule int `gorm:"column:name_rule"`
}, 0)
modelVendorIDByName := make(map[string]int, len(models))
modelTagsByName := make(map[string]string, len(models))
if len(models) > 0 {
// 与“模型广场”一致:按模型元数据(model_meta)中的规则(精确/前缀/后缀/包含)做归属映射
if err := model.DB.Model(&model.Model{}).
Select("model_name", "vendor_id", "tags", "name_rule").
Where("status = ?", 1).
Find(&modelRows).Error; err != nil {
common.ApiError(c, err)
return
}
rulePriority := func(rule int) int {
switch rule {
case model.NameRuleExact:
return 0
case model.NameRulePrefix:
return 1
case model.NameRuleSuffix:
return 2
case model.NameRuleContains:
return 3
default:
return 9
}
}
matchRule := func(pattern, target string, rule int) bool {
switch rule {
case model.NameRuleExact:
return target == pattern
case model.NameRulePrefix:
return strings.HasPrefix(target, pattern)
case model.NameRuleSuffix:
return strings.HasSuffix(target, pattern)
case model.NameRuleContains:
return strings.Contains(target, pattern)
default:
return false
}
}
for _, targetModelName := range models {
bestIdx := -1
for i := range modelRows {
row := modelRows[i]
if !matchRule(row.ModelName, targetModelName, row.NameRule) {
continue
}
if bestIdx < 0 {
bestIdx = i
continue
}
cur := modelRows[bestIdx]
curPriority := rulePriority(cur.NameRule)
newPriority := rulePriority(row.NameRule)
if newPriority < curPriority {
bestIdx = i
continue
}
if newPriority == curPriority && len(row.ModelName) > len(cur.ModelName) {
bestIdx = i
}
}
if bestIdx >= 0 {
row := modelRows[bestIdx]
modelVendorIDByName[targetModelName] = row.VendorID
modelTagsByName[targetModelName] = strings.TrimSpace(row.Tags)
}
}
}
// 先列出分组内每个已启用模型名 + 元数据 vendor再用 CollectPricingShowableModelNames 与 /pricing 同口径过滤,只返回与定价页一致的可展示模型,并据此推导「模型类型」选项
playgroundNameRows := make([]struct {
ModelName string
VendorID int
}, 0, len(models))
for _, name := range models {
vid := 0
if v, ok := modelVendorIDByName[name]; ok {
vid = v
}
playgroundNameRows = append(playgroundNameRows, struct {
ModelName string
VendorID int
}{ModelName: name, VendorID: vid})
}
// 与 /pricing 完全一致地过滤:仅保留定价页当前可展示的模型集合中的项。
// 之前操练场是按 model_test_results 全表 last_test_success=1 的模糊名字匹配做判定,
// 与 /pricing 的「(模型,可见渠道) 严格匹配 + testMs>0 + ManualDisplayResponseTime 兜底 + ModelHasConfiguredPricing」口径不一致
// 导致诸如「最近一次单测失败但运营手动覆盖了展示耗时」「定价未配但偶然有过成功单测」等场景两端展示差异。
pricingShowable := CollectPricingShowableModelNames()
filteredNameRows := make([]struct {
ModelName string
VendorID int
}, 0, len(playgroundNameRows))
for i := range playgroundNameRows {
if !pricingShowable[playgroundNameRows[i].ModelName] {
continue
}
filteredNameRows = append(filteredNameRows, playgroundNameRows[i])
}
vendorIDSet := make(map[int]struct{})
for i := range filteredNameRows {
if filteredNameRows[i].VendorID > 0 {
vendorIDSet[filteredNameRows[i].VendorID] = struct{}{}
}
}
vendorIDs := make([]int, 0, len(vendorIDSet))
for id := range vendorIDSet {
vendorIDs = append(vendorIDs, id)
}
vendorNameByID := make(map[int]string)
// 操练场按 vendor_id 筛选须与下拉的 id 一一对应;不限制 status避免元数据有 vendor_id 但库中已禁用时名称为空导致前端「按类型无数据」
if len(vendorIDs) > 0 {
vendorRows := make([]struct {
Id int `gorm:"column:id"`
Name string `gorm:"column:name"`
}, 0)
if err := model.DB.Model(&model.Vendor{}).
Select("id", "name").
Where("id IN ?", vendorIDs).
Find(&vendorRows).Error; err != nil {
common.ApiError(c, err)
return
}
for i := range vendorRows {
vendorNameByID[vendorRows[i].Id] = vendorRows[i].Name
}
}
// 按用户可用分组 + 模型统计可选渠道channels.id供操练场前端做模型下的渠道联动下拉。
modelChannelIDSet := make(map[string]map[int]struct{}, len(filteredNameRows))
for i := range filteredNameRows {
modelName := filteredNameRows[i].ModelName
if modelName == "" {
continue
}
if _, ok := modelChannelIDSet[modelName]; !ok {
modelChannelIDSet[modelName] = make(map[int]struct{})
}
for group := range groups {
channelIDs := model.ListChannelIDsForGroupModel(group, modelName)
for _, channelID := range channelIDs {
ch, chErr := model.CacheGetChannel(channelID)
if chErr != nil || ch == nil || ch.Status != common.ChannelStatusEnabled {
continue
}
modelChannelIDSet[modelName][channelID] = struct{}{}
}
}
}
channelMeta := make(map[int]playgroundChannelOption)
for _, idSet := range modelChannelIDSet {
for channelID := range idSet {
if _, ok := channelMeta[channelID]; ok {
continue
}
ch, chErr := model.CacheGetChannel(channelID)
if chErr != nil || ch == nil || ch.Status != common.ChannelStatusEnabled {
continue
}
channelMeta[channelID] = playgroundChannelOption{
ID: ch.Id,
Name: strings.TrimSpace(ch.Name),
ChannelNo: strings.TrimSpace(ch.ChannelNo),
RouteSlug: strings.TrimSpace(ch.RouteSlug),
SupplierType: strings.TrimSpace(ch.SupplierType),
}
}
}
// 返回项均为单测成功的模型;有元数据则带 vendor并附可选渠道列表
items := make([]playgroundModelItem, 0, len(filteredNameRows))
for i := range filteredNameRows {
modelName := filteredNameRows[i].ModelName
vendorID := filteredNameRows[i].VendorID
vendorName := vendorNameByID[vendorID]
channelOptions := make([]playgroundChannelOption, 0)
for channelID := range modelChannelIDSet[modelName] {
meta, ok := channelMeta[channelID]
if !ok {
continue
}
channelOptions = append(channelOptions, meta)
}
sort.Slice(channelOptions, func(i, j int) bool {
if channelOptions[i].Name == channelOptions[j].Name {
return channelOptions[i].ID < channelOptions[j].ID
}
return strings.Compare(channelOptions[i].Name, channelOptions[j].Name) < 0
})
items = append(items, playgroundModelItem{
ModelName: modelName,
VendorID: vendorID,
Vendor: vendorName,
Tags: modelTagsByName[modelName],
TestedSuccess: true,
ChannelOptions: channelOptions,
})
}
// 与模型广场PricingVendors 仅基于当前模型集推导供应商)一致:类型选项只含本页 items 中实际出现过的 vendor_id不用 GetVendors 全量,避免多一个「幽灵类型」、按类型筛选与 vendor_id 对不上导致列表全空
// playgroundVendorOption 为操练场「模型类型」下拉中的一项,与 items[].vendor_id 一一可对应
type playgroundVendorOption struct {
id int
name string
}
playgroundVendorOptions := make([]playgroundVendorOption, 0, len(vendorIDSet)+1)
for id := range vendorIDSet {
nm := strings.TrimSpace(vendorNameByID[id])
if nm == "" {
nm = fmt.Sprintf("未关联#%d", id)
}
playgroundVendorOptions = append(playgroundVendorOptions, playgroundVendorOption{
id: id,
name: nm,
})
}
sort.Slice(playgroundVendorOptions, func(i, j int) bool {
if playgroundVendorOptions[i].name == playgroundVendorOptions[j].name {
return playgroundVendorOptions[i].id < playgroundVendorOptions[j].id
}
return strings.Compare(playgroundVendorOptions[i].name, playgroundVendorOptions[j].name) < 0
})
var hasUnassignedModel bool
for i := range filteredNameRows {
if filteredNameRows[i].VendorID == 0 {
hasUnassignedModel = true
break
}
}
vendorOptions := make([]map[string]interface{}, 0, len(playgroundVendorOptions)+1)
for i := range playgroundVendorOptions {
vendorOptions = append(vendorOptions, map[string]interface{}{
"id": playgroundVendorOptions[i].id,
"name": playgroundVendorOptions[i].name,
})
}
if hasUnassignedModel {
vendorOptions = append(vendorOptions, map[string]interface{}{
"id": 0,
"name": "未知模型类型",
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"vendor_options": vendorOptions,
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": models,
})
return
}
func UpdateUser(c *gin.Context) {
var updatedUser model.User
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
if err != nil || updatedUser.Id == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if updatedUser.Password == "" {
updatedUser.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := common.Validate.Struct(&updatedUser); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
return
}
originUser, err := model.GetUserById(updatedUser.Id, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
return
}
if updatedUser.Password == "$I_LOVE_U" {
updatedUser.Password = "" // rollback to what it should be
}
updatePassword := updatedUser.Password != ""
if err := updatedUser.Edit(updatePassword); err != nil {
if isPhoneUniqueConstraintError(err) {
common.ApiErrorMsg(c, "手机号已被占用")
return
}
common.ApiError(c, err)
return
}
// Update user tags metadata if tags changed
if updatedUser.Tags != "" {
tags := model.GetUserTagsList(updatedUser.Tags)
if len(tags) > 0 {
model.UpsertUserTags(tags)
}
}
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
}
// 构建字段级变更描述
var changes []service.FieldChange
if updatePassword {
tmpPwd := "******"
changes = append(changes, service.FieldChange{Field: "密码", OldValue: tmpPwd, NewValue: tmpPwd})
}
if originUser.Email != updatedUser.Email {
changes = append(changes, service.FieldChange{Field: "邮箱", OldValue: originUser.Email, NewValue: updatedUser.Email})
}
if originUser.Phone != updatedUser.Phone {
changes = append(changes, service.FieldChange{Field: "手机号", OldValue: originUser.Phone, NewValue: updatedUser.Phone})
}
if originUser.DisplayName != updatedUser.DisplayName {
changes = append(changes, service.FieldChange{Field: "显示名称", OldValue: originUser.DisplayName, NewValue: updatedUser.DisplayName})
}
if originUser.Group != updatedUser.Group {
changes = append(changes, service.FieldChange{Field: "分组", OldValue: originUser.Group, NewValue: updatedUser.Group})
}
if originUser.Quota != updatedUser.Quota {
changes = append(changes, service.FieldChange{Field: "额度", OldValue: logger.LogQuota(originUser.Quota), NewValue: logger.LogQuota(updatedUser.Quota)})
}
if originUser.Role != updatedUser.Role {
changes = append(changes, service.FieldChange{Field: "角色", OldValue: roleLabel(originUser.Role), NewValue: roleLabel(updatedUser.Role)})
}
if originUser.Status != updatedUser.Status {
changes = append(changes, service.FieldChange{Field: "状态", OldValue: statusLabel(originUser.Status), NewValue: statusLabel(updatedUser.Status)})
}
if originUser.Remark != updatedUser.Remark {
changes = append(changes, service.FieldChange{Field: "备注", OldValue: originUser.Remark, NewValue: updatedUser.Remark})
}
if originUser.Tags != updatedUser.Tags {
changes = append(changes, service.FieldChange{Field: "标签", OldValue: originUser.Tags, NewValue: updatedUser.Tags})
}
if originUser.IsDistributor != updatedUser.IsDistributor {
changes = append(changes, service.FieldChange{Field: "分销商", OldValue: distributorLabel(originUser.IsDistributor), NewValue: distributorLabel(updatedUser.IsDistributor)})
}
if originUser.IsStudent != updatedUser.IsStudent || originUser.StudentStatus != updatedUser.StudentStatus {
var oldStudent, newStudent string
if originUser.IsStudent == 1 {
oldStudent = "是(" + studentStatusLabel(originUser.StudentStatus) + ")"
} else {
oldStudent = "否"
}
if updatedUser.IsStudent == 1 {
newStudent = "是(" + studentStatusLabel(updatedUser.StudentStatus) + ")"
} else {
newStudent = "否"
}
changes = append(changes, service.FieldChange{Field: "学员", OldValue: oldStudent, NewValue: newStudent})
}
if originUser.DistributorCommissionBps != updatedUser.DistributorCommissionBps {
changes = append(changes, service.FieldChange{Field: "分销佣金比例", OldValue: fmt.Sprintf("%d‱", originUser.DistributorCommissionBps), NewValue: fmt.Sprintf("%d‱", updatedUser.DistributorCommissionBps)})
}
service.RecordUpdateWithDiff(c, "user", updatedUser.Id, updatedUser.Username, "更新用户: "+updatedUser.Username, changes, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func AdminClearUserBinding(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
bindingType := strings.ToLower(strings.TrimSpace(c.Param("binding_type")))
if bindingType == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
user, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if myRole <= user.Role && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
return
}
if err := user.ClearBinding(bindingType); err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("admin cleared %s binding for user %s", bindingType, user.Username))
service.RecordDeleteOperation(c, "user_binding", user.Id, user.Username, "清除用户绑定["+bindingType+"]: "+user.Username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "success",
})
}
func UpdateSelf(c *gin.Context) {
var requestData map[string]interface{}
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
// 检查是否是用户设置更新请求 (sidebar_modules 或 language)
// 注意:这两类请求只改 setting 这一列。历史实现走 user.Update() 会触及全行(且 GetUserById(_, false)
// 不带 password 列,叠加旧版 Select("*") 时直接把密码哈希擦成空串)。改成单列 Update 后既避免误擦其他
// 字段,也减少不必要的索引/缓存写入。
if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
currentSetting := user.GetSetting()
if sidebarModulesStr, ok := sidebarModules.(string); ok {
currentSetting.SidebarModules = sidebarModulesStr
}
user.SetSetting(currentSetting)
if err := model.DB.Model(&model.User{}).Where("id = ?", userId).Update("setting", user.Setting).Error; err != nil {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
return
}
_ = model.InvalidateUserCache(userId)
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
return
}
if language, langExists := requestData["language"]; langExists {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
currentSetting := user.GetSetting()
if langStr, ok := language.(string); ok {
currentSetting.Language = langStr
}
user.SetSetting(currentSetting)
if err := model.DB.Model(&model.User{}).Where("id = ?", userId).Update("setting", user.Setting).Error; err != nil {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
return
}
_ = model.InvalidateUserCache(userId)
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
return
}
// 原有的用户信息更新逻辑
var user model.User
requestDataBytes, err := json.Marshal(requestData)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
err = json.Unmarshal(requestDataBytes, &user)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if user.Password == "" {
user.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := common.Validate.Struct(&user); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidInput)
return
}
if user.Password == "$I_LOVE_U" {
user.Password = "" // rollback to what it should be
}
// 必须以数据库完整行为基础再合并请求字段;仅用 JSON 解出的局部 User 会含大量零值,
// 若直接传入 Update() 会用 Select("*") 把角色/状态/用户名等全部覆盖掉。
userId := c.GetInt("id")
current, err := model.GetUserById(userId, true)
if err != nil {
common.ApiError(c, err)
return
}
merged := *current
if _, ok := requestData["username"]; ok {
if s, ok := requestData["username"].(string); ok {
merged.Username = strings.TrimSpace(s)
}
}
if _, ok := requestData["display_name"]; ok {
if s, ok := requestData["display_name"].(string); ok {
merged.DisplayName = strings.TrimSpace(s)
}
}
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, userId)
if err != nil {
common.ApiError(c, err)
return
}
if updatePassword {
merged.Password = user.Password
}
if err := merged.Update(updatePassword); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func checkUpdatePassword(originalPassword string, newPassword string, userId int) (updatePassword bool, err error) {
var currentUser *model.User
currentUser, err = model.GetUserById(userId, true)
if err != nil {
return
}
// 密码不为空,需要验证原密码
// 支持第一次账号绑定时原密码为空的情况
if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) && currentUser.Password != "" {
err = fmt.Errorf("原密码错误")
return
}
if newPassword == "" {
return
}
updatePassword = true
return
}
func DeleteUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
originUser, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
err = model.HardDeleteUserById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
service.RecordDeleteOperation(c, "user", id, originUser.Username, "删除用户: "+originUser.Username, "")
}
func DeleteSelf(c *gin.Context) {
id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
return
}
err := model.DeleteUserById(id)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func CreateUser(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
user.Username = strings.TrimSpace(user.Username)
user.Email = strings.TrimSpace(user.Email)
if err != nil || user.Username == "" || user.Password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if err := common.Validate.Struct(&user); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
return
}
if user.DisplayName == "" {
user.DisplayName = user.Username
}
if user.Role == common.RoleDistributorUser {
user.Role = common.RoleCommonUser
user.IsDistributor = common.DistributorFlagYes
}
myRole := c.GetInt("role")
if user.Role >= myRole {
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
return
}
normalizedPhone, err := model.NormalizeAndValidateAdminUserPhone(user.Phone, 0)
if err != nil {
common.ApiError(c, err)
return
}
normalizedEmail, err := model.NormalizeAndValidateAdminUserEmail(user.Email, 0)
if err != nil {
common.ApiError(c, err)
return
}
// Even for admin users, we cannot fully trust them!
cleanUser := model.User{
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
Role: user.Role,
IsDistributor: user.IsDistributor,
CreatedBy: common.UserCreatedByAdmin,
Phone: normalizedPhone,
Email: normalizedEmail,
Remark: user.Remark,
AdminInitialSetupCompleted: false,
}
if err := cleanUser.Insert(0); err != nil {
if isPhoneUniqueConstraintError(err) {
common.ApiErrorMsg(c, "手机号已被占用")
return
}
common.ApiError(c, err)
return
}
service.RecordCreateOperation(c, "user", cleanUser.Id, cleanUser.Username, "创建用户: "+cleanUser.Username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type ManageRequest struct {
Id int `json:"id"`
Action string `json:"action"`
RewardQuota int `json:"reward_quota,omitempty"`
}
// ManageUser 管理员对用户启用/禁用、删除、提升/降级身份;分销商资格使用 is_distributor 与 set_distributor / unset_distributor。
func ManageUser(c *gin.Context) {
var req ManageRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
user := model.User{
Id: req.Id,
}
// Fill attributes
model.DB.Unscoped().Where(&user).First(&user)
if user.Id == 0 {
common.ApiErrorI18n(c, i18n.MsgUserNotExists)
return
}
myRole := c.GetInt("role")
if myRole <= user.Role && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
// 记录变更前的原始值
oldStatus := user.Status
oldRole := user.Role
oldIsDistributor := user.IsDistributor
oldIsStudent := user.IsStudent
oldStudentStatus := user.StudentStatus
beforeAdminDemote := false
switch req.Action {
case "disable":
user.Status = common.UserStatusDisabled
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDisableRootUser)
return
}
case "enable":
user.Status = common.UserStatusEnabled
case "delete":
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
return
}
if err := user.Delete(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "promote":
// 仅超级管理员可将普通用户(含已开通分销商)提升为管理员;开通分销请使用 set_distributor
switch user.Role {
case common.RoleCommonUser:
if myRole != common.RoleRootUser {
common.ApiErrorMsg(c, "仅超级管理员可提升为管理员;为普通用户开通分销请使用「设为分销商」")
return
}
user.Role = common.RoleAdminUser
user.IsDistributor = common.DistributorFlagNo
case common.RoleDistributorUser:
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
return
}
user.Role = common.RoleAdminUser
user.IsDistributor = common.DistributorFlagNo
case common.RoleAdminUser, common.RoleRootUser:
common.ApiErrorI18n(c, i18n.MsgUserCannotPromoteFurther)
return
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
case "demote":
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDemoteRootUser)
return
}
switch user.Role {
case common.RoleAdminUser:
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
user.Role = common.RoleCommonUser
user.IsDistributor = common.DistributorFlagNo
beforeAdminDemote = true
case common.RoleDistributorUser:
user.Role = common.RoleCommonUser
user.IsDistributor = common.DistributorFlagNo
case common.RoleCommonUser:
common.ApiErrorMsg(c, "已是普通用户;取消分销资格请使用「取消分销商」")
return
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
case "set_distributor":
if myRole != common.RoleAdminUser && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorMsg(c, "管理员账号无需开通分销商")
return
}
if model.UserIsDistributor(&user) {
common.ApiErrorMsg(c, "该用户已是分销商")
return
}
user.IsDistributor = common.DistributorFlagYes
case "unset_distributor":
if myRole != common.RoleAdminUser && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorMsg(c, "管理员账号无分销商标记")
return
}
if !model.UserIsDistributor(&user) {
common.ApiErrorMsg(c, "该用户不是分销商")
return
}
user.IsDistributor = common.DistributorFlagNo
case "approve_student":
if myRole != common.RoleAdminUser && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorMsg(c, "管理员账号不支持学员审批")
return
}
if user.IsStudent == 1 && user.StudentStatus == common.StudentStatusApproved {
common.ApiErrorMsg(c, "该用户已经是学员")
return
}
now := time.Now()
user.IsStudent = 1
user.StudentStatus = common.StudentStatusApproved
user.StudentApprovedAt = &now
user.StudentApprovedBy = c.GetInt("id")
if user.StudentApplied == nil {
user.StudentApplied = &now
}
case "reject_student":
if myRole != common.RoleAdminUser && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorMsg(c, "管理员账号不支持学员审批")
return
}
if user.StudentStatus != common.StudentStatusPending {
common.ApiErrorMsg(c, "该用户当前不在待审批状态")
return
}
user.IsStudent = 0
user.StudentStatus = common.StudentStatusRejected
user.StudentApprovedAt = nil
user.StudentApprovedBy = 0
case "unset_student":
if myRole != common.RoleAdminUser && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorMsg(c, "管理员账号不支持学员身份操作")
return
}
if user.IsStudent != 1 || user.StudentStatus != common.StudentStatusApproved {
common.ApiErrorMsg(c, "该用户不是学员")
return
}
user.IsStudent = 0
user.StudentStatus = common.StudentStatusNone
case "set_student":
if myRole != common.RoleAdminUser && myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if user.Role >= common.RoleAdminUser {
common.ApiErrorMsg(c, "管理员账号不支持学员身份操作")
return
}
if user.IsStudent == 1 && user.StudentStatus == common.StudentStatusApproved {
common.ApiErrorMsg(c, "该用户已经是学员")
return
}
now := time.Now()
user.IsStudent = 1
user.StudentStatus = common.StudentStatusApproved
user.StudentApprovedAt = &now
user.StudentApprovedBy = c.GetInt("id")
if user.StudentApplied == nil {
user.StudentApplied = &now
}
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if err := user.Update(false); err != nil {
common.ApiError(c, err)
return
}
if req.Action == "approve_student" || req.Action == "set_student" {
rewardQuota := common.StudentApprovalRewardQuota
if req.RewardQuota > 0 {
rewardQuota = req.RewardQuota
}
if rewardQuota > 0 {
if err := model.IncreaseUserQuota(user.Id, rewardQuota, true); err != nil {
common.ApiError(c, err)
return
}
actionLabel := "管理员审批学员申请"
if req.Action == "set_student" {
actionLabel = "管理员指定学员身份"
}
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("%s赠送 %s", actionLabel, logger.LogQuota(rewardQuota)))
}
}
// 记录操作日志(字段级变更描述)
manageActionMap := map[string]string{
"disable": "禁用用户",
"enable": "启用用户",
"delete": "删除用户",
"promote": "提升用户角色",
"demote": "降级用户角色",
"set_distributor": "设为分销商",
"unset_distributor": "取消分销商",
"approve_student": "审批通过学员",
"reject_student": "拒绝学员申请",
"unset_student": "取消学员身份",
"set_student": "设置学员身份",
}
if label, ok := manageActionMap[req.Action]; ok {
var changes []service.FieldChange
// 根据实际变更字段生成 diff
if oldStatus != user.Status {
changes = append(changes, service.FieldChange{Field: "状态", OldValue: statusLabel(oldStatus), NewValue: statusLabel(user.Status)})
}
if oldRole != user.Role {
changes = append(changes, service.FieldChange{Field: "角色", OldValue: roleLabel(oldRole), NewValue: roleLabel(user.Role)})
}
if oldIsDistributor != user.IsDistributor {
changes = append(changes, service.FieldChange{Field: "分销商", OldValue: distributorLabel(oldIsDistributor), NewValue: distributorLabel(user.IsDistributor)})
}
if oldIsStudent != user.IsStudent || oldStudentStatus != user.StudentStatus {
var oldLabel, newLabel string
if oldIsStudent == 1 {
oldLabel = "是(" + studentStatusLabel(oldStudentStatus) + ")"
} else {
oldLabel = "否"
}
if user.IsStudent == 1 {
newLabel = "是(" + studentStatusLabel(user.StudentStatus) + ")"
} else {
newLabel = "否"
}
changes = append(changes, service.FieldChange{Field: "学员", OldValue: oldLabel, NewValue: newLabel})
}
service.RecordUpdateWithDiff(c, "user", user.Id, user.Username, label+": "+user.Username, changes, "")
}
switch req.Action {
case "set_distributor":
service.NotifyDistributorRoleGranted(user.Id)
case "unset_distributor":
service.NotifyDistributorRoleRevoked(user.Id)
case "demote":
if beforeAdminDemote {
service.NotifyUserDemotedFromAdmin(user.Id)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"role": user.Role,
"status": user.Status,
"is_distributor": user.IsDistributor,
"is_student": user.IsStudent,
"student_status": user.StudentStatus,
},
})
return
}
type emailBindRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
// phoneBindRequest 用户自助绑定/修改手机号请求体。
type phoneBindRequest struct {
Phone string `json:"phone"`
SMSCode string `json:"sms_verification_code"`
}
// PhoneBind 已登录用户通过短信验证码绑定或修改手机号。
func PhoneBind(c *gin.Context) {
var req phoneBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if !common.SMSVerificationEnabled {
common.ApiErrorMsg(c, "短信验证码功能未启用")
return
}
userID := c.GetInt("id")
if userID <= 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
req.Phone = strings.TrimSpace(req.Phone)
req.SMSCode = strings.TrimSpace(req.SMSCode)
phoneNorm, valErr := model.NormalizeAndValidateAdminUserPhone(req.Phone, userID)
if valErr != nil {
common.ApiError(c, valErr)
return
}
if phoneNorm == "" {
common.ApiErrorMsg(c, "请输入手机号")
return
}
if len(req.SMSCode) != 6 {
common.ApiErrorMsg(c, "请输入 6 位短信验证码")
return
}
if !common.VerifyAndConsumeSMSCode(phoneNorm, req.SMSCode) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
}
user, err := model.GetUserById(userID, false)
if err != nil {
common.ApiError(c, err)
return
}
user.Phone = phoneNorm
if err := user.Update(false); err != nil {
if isPhoneUniqueConstraintError(err) {
common.ApiErrorMsg(c, "手机号已被占用")
return
}
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
func EmailBind(c *gin.Context) {
var req emailBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiError(c, errors.New("invalid request body"))
return
}
email := req.Email
code := req.Code
if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{
Id: id.(int),
}
err := user.FillUserById()
if err != nil {
common.ApiError(c, err)
return
}
user.Email = email
// no need to check if this email already taken, because we have used verification code to check it
err = user.Update(false)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type topUpRequest struct {
Key string `json:"key"`
}
var topUpLocks sync.Map
var topUpCreateLock sync.Mutex
type topUpTryLock struct {
ch chan struct{}
}
func newTopUpTryLock() *topUpTryLock {
return &topUpTryLock{ch: make(chan struct{}, 1)}
}
func (l *topUpTryLock) TryLock() bool {
select {
case l.ch <- struct{}{}:
return true
default:
return false
}
}
func (l *topUpTryLock) Unlock() {
select {
case <-l.ch:
default:
}
}
func getTopUpLock(userID int) *topUpTryLock {
if v, ok := topUpLocks.Load(userID); ok {
return v.(*topUpTryLock)
}
topUpCreateLock.Lock()
defer topUpCreateLock.Unlock()
if v, ok := topUpLocks.Load(userID); ok {
return v.(*topUpTryLock)
}
l := newTopUpTryLock()
topUpLocks.Store(userID, l)
return l
}
func TopUp(c *gin.Context) {
id := c.GetInt("id")
lock := getTopUpLock(id)
if !lock.TryLock() {
common.ApiErrorI18n(c, i18n.MsgUserTopUpProcessing)
return
}
defer lock.Unlock()
req := topUpRequest{}
err := c.ShouldBindJSON(&req)
if err != nil {
common.ApiError(c, err)
return
}
quota, err := model.Redeem(req.Key, id)
if err != nil {
if errors.Is(err, model.ErrRedeemFailed) {
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
return
}
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": quota,
})
}
type UpdateUserSettingRequest struct {
QuotaWarningType string `json:"notify_type"`
QuotaWarningThreshold float64 `json:"quota_warning_threshold"`
WebhookUrl string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
BarkUrl string `json:"bark_url,omitempty"`
GotifyUrl string `json:"gotify_url,omitempty"`
GotifyToken string `json:"gotify_token,omitempty"`
GotifyPriority int `json:"gotify_priority,omitempty"`
UpstreamModelUpdateNotifyEnabled *bool `json:"upstream_model_update_notify_enabled,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
func UpdateUserSetting(c *gin.Context) {
var req UpdateUserSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
common.ApiErrorI18n(c, i18n.MsgSettingInvalidType)
return
}
// 验证预警阈值
if req.QuotaWarningThreshold <= 0 {
common.ApiErrorI18n(c, i18n.MsgQuotaThresholdGtZero)
return
}
// 如果是webhook类型,验证webhook地址
if req.QuotaWarningType == dto.NotifyTypeWebhook {
if req.WebhookUrl == "" {
common.ApiErrorI18n(c, i18n.MsgSettingWebhookEmpty)
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil {
common.ApiErrorI18n(c, i18n.MsgSettingWebhookInvalid)
return
}
}
// 如果是邮件类型,验证邮箱地址
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
// 验证邮箱格式
if !strings.Contains(req.NotificationEmail, "@") {
common.ApiErrorI18n(c, i18n.MsgSettingEmailInvalid)
return
}
}
// 如果是Bark类型验证Bark URL
if req.QuotaWarningType == dto.NotifyTypeBark {
if req.BarkUrl == "" {
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlEmpty)
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
common.ApiErrorI18n(c, i18n.MsgSettingBarkUrlInvalid)
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
return
}
}
// 如果是Gotify类型验证Gotify URL和Token
if req.QuotaWarningType == dto.NotifyTypeGotify {
if req.GotifyUrl == "" {
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlEmpty)
return
}
if req.GotifyToken == "" {
common.ApiErrorI18n(c, i18n.MsgSettingGotifyTokenEmpty)
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
common.ApiErrorI18n(c, i18n.MsgSettingGotifyUrlInvalid)
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
common.ApiErrorI18n(c, i18n.MsgSettingUrlMustHttp)
return
}
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
common.ApiError(c, err)
return
}
existingSettings := user.GetSetting()
upstreamModelUpdateNotifyEnabled := existingSettings.UpstreamModelUpdateNotifyEnabled
if user.Role >= common.RoleAdminUser && req.UpstreamModelUpdateNotifyEnabled != nil {
upstreamModelUpdateNotifyEnabled = *req.UpstreamModelUpdateNotifyEnabled
}
// 构建设置
settings := dto.UserSetting{
NotifyType: req.QuotaWarningType,
QuotaWarningThreshold: req.QuotaWarningThreshold,
UpstreamModelUpdateNotifyEnabled: upstreamModelUpdateNotifyEnabled,
AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel,
RecordIpLog: req.RecordIpLog,
}
// 如果是webhook类型,添加webhook相关设置
if req.QuotaWarningType == dto.NotifyTypeWebhook {
settings.WebhookUrl = req.WebhookUrl
if req.WebhookSecret != "" {
settings.WebhookSecret = req.WebhookSecret
}
}
// 如果提供了通知邮箱,添加到设置中
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
settings.NotificationEmail = req.NotificationEmail
}
// 如果是Bark类型添加Bark URL到设置中
if req.QuotaWarningType == dto.NotifyTypeBark {
settings.BarkUrl = req.BarkUrl
}
// 如果是Gotify类型添加Gotify配置到设置中
if req.QuotaWarningType == dto.NotifyTypeGotify {
settings.GotifyUrl = req.GotifyUrl
settings.GotifyToken = req.GotifyToken
// Gotify优先级范围0-10超出范围则使用默认值5
if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
settings.GotifyPriority = 5
} else {
settings.GotifyPriority = req.GotifyPriority
}
}
// 更新用户设置
user.SetSetting(settings)
if err := user.Update(false); err != nil {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
return
}
common.ApiSuccessI18n(c, i18n.MsgSettingSaved, nil)
}
func GetUserTags(c *gin.Context) {
merged := make([]string, 0, 32)
seen := make(map[string]struct{}, 32)
appendTag := func(name string) {
tag := strings.TrimSpace(name)
if tag == "" {
return
}
if _, ok := seen[tag]; ok {
return
}
seen[tag] = struct{}{}
merged = append(merged, tag)
}
dbTags, err := model.GetAllUserTagNames()
if err != nil {
common.ApiError(c, err)
return
}
for _, tag := range dbTags {
appendTag(tag)
}
var allTagCSVs []string
if err := model.DB.Model(&model.User{}).Where("tags <> ?", "").Pluck("tags", &allTagCSVs).Error; err != nil {
common.ApiError(c, err)
return
}
for _, csv := range allTagCSVs {
for _, tag := range model.GetUserTagsList(csv) {
appendTag(tag)
}
}
if err := model.UpsertUserTags(merged); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, merged)
}
// --- 操作日志字段标签辅助函数 ---
func roleLabel(role int) string {
switch role {
case common.RoleCommonUser:
return "普通用户"
case common.RoleDistributorUser:
return "分销商(旧)"
case common.RoleAdminUser:
return "管理员"
case common.RoleRootUser:
return "超级管理员"
case common.RoleGuestUser:
return "访客"
default:
return fmt.Sprintf("未知(%d)", role)
}
}
func statusLabel(status int) string {
switch status {
case common.UserStatusEnabled:
return "启用"
case common.UserStatusDisabled:
return "禁用"
default:
return fmt.Sprintf("未知(%d)", status)
}
}
func distributorLabel(v int) string {
if v == common.DistributorFlagYes {
return "是"
}
return "否"
}
func studentStatusLabel(v int) string {
switch v {
case common.StudentStatusNone:
return "未申请"
case common.StudentStatusPending:
return "待审批"
case common.StudentStatusApproved:
return "已通过"
case common.StudentStatusRejected:
return "已拒绝"
default:
return fmt.Sprintf("未知(%d)", v)
}
}