Files
infinite-canvas/service/auth.go
T
HouYunFei 2181b3b885 feat(admin): 添加模型算力点配置和扣费功能
- 在管理后台设置页面添加模型算力点配置表格
- 实现后端AI接口调用时按模型扣除用户算力点
- 添加算力点余额检查和不足时的错误处理
- 在credit_logs中记录AI调用消费流水
- 更新前端远程调用后刷新用户信息显示
- 文档中补充模型算力点配置说明
2026-05-25 14:01:04 +08:00

557 lines
15 KiB
Go

package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/basketikun/infinite-canvas/config"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type TokenClaims struct {
UserID string `json:"userId"`
Username string `json:"username"`
Role model.UserRole `json:"role"`
jwt.RegisteredClaims
}
type userExtra struct {
LinuxDo any `json:"linuxDo,omitempty"`
}
func EnsureDefaultAdmin() error {
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
return nil
}
WarnDefaultSecurityConfig()
hasAdmin, err := repository.HasAdmin()
if err != nil || hasAdmin {
return err
}
hash, err := hashPassword(config.Cfg.AdminPassword)
if err != nil {
return err
}
_, err = repository.SaveUser(model.User{
ID: newID("user"),
Username: strings.TrimSpace(config.Cfg.AdminUsername),
Password: hash,
Role: model.UserRoleAdmin,
AffCode: newAffCode(),
Status: model.UserStatusActive,
CreatedAt: now(),
UpdatedAt: now(),
})
return err
}
func Register(username string, password string) (model.AuthSession, error) {
username = strings.TrimSpace(username)
if strings.ContainsAny(username, " \t\r\n") {
return model.AuthSession{}, safeMessageError{message: "用户名不能包含空格"}
}
if username == "" || password == "" {
return model.AuthSession{}, safeMessageError{message: "用户名和密码不能为空"}
}
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
if err != nil {
return model.AuthSession{}, err
}
return model.AuthSession{}, safeMessageError{message: "用户名已存在"}
}
hash, err := hashPassword(password)
if err != nil {
return model.AuthSession{}, err
}
user, err := repository.SaveUser(model.User{
ID: newID("user"),
Username: username,
Password: hash,
Role: model.UserRoleUser,
AffCode: newAffCode(),
Status: model.UserStatusActive,
CreatedAt: now(),
UpdatedAt: now(),
})
if err != nil {
return model.AuthSession{}, err
}
return newSession(user)
}
func Login(username string, password string) (model.AuthSession, error) {
user, ok, err := repository.GetUserByUsername(strings.TrimSpace(username))
if err != nil {
return model.AuthSession{}, err
}
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
return model.AuthSession{}, safeMessageError{message: "用户名或密码错误"}
}
if user.Status == model.UserStatusBan {
return model.AuthSession{}, safeMessageError{message: "账号已被禁用"}
}
normalizeUserDefaults(&user)
user.LastLoginAt = now()
user.UpdatedAt = now()
user, err = repository.SaveUser(user)
if err != nil {
return model.AuthSession{}, err
}
return newSession(user)
}
func LinuxDoAuthorizeURL(r *http.Request, redirect string) (string, error) {
settings, err := repository.GetSettings()
if err != nil {
return "", err
}
settings = normalizeSettings(settings)
linuxDo := settings.Private.Auth.LinuxDo
if !settings.Public.Auth.LinuxDo.Enabled {
return "", safeMessageError{message: "Linux.do 登录未开启"}
}
if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" {
return "", safeMessageError{message: "Linux.do 登录未配置"}
}
values := url.Values{}
values.Set("client_id", linuxDo.ClientID)
values.Set("redirect_uri", linuxDoRedirectURI(r))
values.Set("response_type", "code")
values.Set("scope", "read")
values.Set("state", base64.RawURLEncoding.EncodeToString([]byte(redirect)))
return config.Cfg.LinuxDoAuthorizeURL + "?" + values.Encode(), nil
}
func LoginWithLinuxDo(r *http.Request, code string, state string) (model.AuthSession, string, error) {
redirect := decodeState(state)
settings, err := repository.GetSettings()
if err != nil {
return model.AuthSession{}, redirect, err
}
settings = normalizeSettings(settings)
linuxDo := settings.Private.Auth.LinuxDo
if !settings.Public.Auth.LinuxDo.Enabled {
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 登录未开启"}
}
token, err := linuxDoAccessToken(r, code, linuxDo)
if err != nil {
return model.AuthSession{}, redirect, err
}
profile, err := linuxDoProfile(token)
if err != nil {
return model.AuthSession{}, redirect, err
}
linuxDoID := fmt.Sprint(profile.ID)
if strings.TrimSpace(linuxDoID) == "" || linuxDoID == "0" {
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 用户信息无效"}
}
user, ok, err := repository.GetUserByLinuxDoID(linuxDoID)
if err != nil {
return model.AuthSession{}, redirect, err
}
if !ok {
user = model.User{
ID: newID("user"),
Username: linuxDoUsername(profile.Username, linuxDoID),
DisplayName: strings.TrimSpace(profile.Name),
AvatarURL: linuxDoAvatar(profile.AvatarTemplate),
Role: model.UserRoleUser,
AffCode: newAffCode(),
LinuxDoID: linuxDoID,
Status: model.UserStatusActive,
CreatedAt: now(),
}
} else if user.Status == model.UserStatusBan {
return model.AuthSession{}, redirect, safeMessageError{message: "账号已被禁用"}
}
user.DisplayName = firstNonEmpty(profile.Name, user.DisplayName)
user.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL)
user.LastLoginAt = now()
user.UpdatedAt = now()
extra, _ := json.Marshal(userExtra{LinuxDo: profile})
user.Extra = string(extra)
user, err = repository.SaveUser(user)
if err != nil {
return model.AuthSession{}, redirect, err
}
session, err := newSession(user)
return session, redirect, err
}
func ParseToken(tokenText string) (TokenClaims, error) {
claims := TokenClaims{}
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("登录状态无效")
}
return []byte(config.Cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
return TokenClaims{}, errors.New("登录状态无效")
}
return claims, nil
}
func CurrentAuthUser(tokenText string) (model.AuthUser, bool) {
claims, err := ParseToken(tokenText)
if err != nil {
return model.AuthUser{}, false
}
user, ok, err := repository.GetUserByID(claims.UserID)
if err != nil || !ok {
return model.AuthUser{}, false
}
if user.Status == model.UserStatusBan {
return model.AuthUser{}, false
}
return model.PublicUser(user), true
}
func ListUsers(q model.Query) (model.UserList, error) {
users, total, err := repository.ListUsers(q)
if err != nil {
return model.UserList{}, err
}
for i := range users {
users[i].Password = ""
normalizeUserDefaults(&users[i])
}
return model.UserList{Items: users, Total: int(total)}, nil
}
func SaveUser(user model.User, password string) (model.User, error) {
user.Username = strings.TrimSpace(user.Username)
if strings.ContainsAny(user.Username, " \t\r\n") {
return user, safeMessageError{message: "用户名不能包含空格"}
}
if user.Username == "" {
return user, safeMessageError{message: "用户名不能为空"}
}
if user.Role == "" || user.Role == model.UserRoleGuest {
user.Role = model.UserRoleUser
}
if user.Status == "" {
user.Status = model.UserStatusActive
}
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
return user, err
} else if ok && saved.ID != user.ID {
return user, safeMessageError{message: "用户名已存在"}
}
isCreate := user.ID == ""
if isCreate {
user.ID = newID("user")
user.AffCode = newAffCode()
user.CreatedAt = now()
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
return user, err
} else if ok {
user.CreatedAt = saved.CreatedAt
user.Password = saved.Password
user.AvatarURL = saved.AvatarURL
user.Credits = saved.Credits
user.Extra = saved.Extra
if user.AffCode == "" {
user.AffCode = saved.AffCode
}
if user.AffCode == "" {
user.AffCode = newAffCode()
}
if user.LinuxDoID == "" {
user.LinuxDoID = saved.LinuxDoID
}
user.LastLoginAt = saved.LastLoginAt
}
if password != "" {
hash, err := hashPassword(password)
if err != nil {
return user, err
}
user.Password = hash
}
if isCreate && user.Password == "" {
return user, safeMessageError{message: "密码不能为空"}
}
user.UpdatedAt = now()
user, err := repository.SaveUser(user)
user.Password = ""
return user, err
}
func AdjustUserCredits(id string, credits int) (model.User, error) {
user, ok, err := repository.GetUserByID(id)
if err != nil || !ok {
if err != nil {
return user, err
}
return user, safeMessageError{message: "用户不存在"}
}
oldCredits := user.Credits
user.Credits = credits
user.UpdatedAt = now()
user, err = repository.SaveUser(user)
if err == nil && oldCredits != credits {
_, err = repository.SaveCreditLog(model.CreditLog{
ID: newID("credit"),
UserID: user.ID,
Type: model.CreditLogTypeAdminAdjust,
Amount: credits - oldCredits,
Balance: credits,
Remark: "后台手动调整",
CreatedAt: now(),
})
}
user.Password = ""
return user, err
}
func EnsureUserCredits(userID string, credits int) error {
if credits <= 0 {
return nil
}
ok, err := repository.HasUserCredits(userID, credits)
if err != nil {
return err
}
if !ok {
return safeMessageError{message: "算力点不足"}
}
return nil
}
func ConsumeUserCredits(userID string, modelName string, credits int, path string) error {
if credits <= 0 {
return nil
}
user, ok, err := repository.ConsumeUserCredits(userID, credits, now())
if err != nil {
return err
}
if !ok {
return safeMessageError{message: "算力点不足"}
}
extra, _ := json.Marshal(map[string]string{"model": modelName, "path": path})
_, err = repository.SaveCreditLog(model.CreditLog{
ID: newID("credit"),
UserID: userID,
Type: model.CreditLogTypeAIConsume,
Amount: -credits,
Balance: user.Credits,
Remark: "调用模型 " + modelName,
Extra: string(extra),
CreatedAt: now(),
})
return err
}
func ListCreditLogs(q model.Query) (model.CreditLogList, error) {
logs, total, err := repository.ListCreditLogs(q)
if err != nil {
return model.CreditLogList{}, err
}
return model.CreditLogList{Items: logs, Total: int(total)}, nil
}
func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
if log.ID == "" {
log.ID = newID("credit")
log.CreatedAt = now()
}
return repository.SaveCreditLog(log)
}
func DeleteCreditLog(id string) error {
return repository.DeleteCreditLog(id)
}
func DeleteUser(id string) error {
return repository.DeleteUser(id)
}
func GuestUser() model.AuthUser {
return model.AuthUser{ID: "", Username: "guest", Role: model.UserRoleGuest}
}
func newSession(user model.User) (model.AuthSession, error) {
token, err := newToken(user)
if err != nil {
return model.AuthSession{}, err
}
return model.AuthSession{Token: token, User: model.PublicUser(user)}, nil
}
func newToken(user model.User) (string, error) {
expireHours := config.Cfg.JWTExpireHours
if expireHours <= 0 {
expireHours = 168
}
claims := TokenClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user.ID,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Cfg.JWTSecret))
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
func now() string {
return time.Now().Format(time.RFC3339)
}
func newID(prefix string) string {
return prefix + "-" + uuid.NewString()
}
func newAffCode() string {
return strings.ToUpper(strings.ReplaceAll(uuid.NewString()[:8], "-", ""))
}
func normalizeUserDefaults(user *model.User) {
if user.Status == "" {
user.Status = model.UserStatusActive
}
if user.AffCode == "" {
user.AffCode = newAffCode()
}
}
type linuxDoTokenResponse struct {
AccessToken string `json:"access_token"`
}
type linuxDoUserResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
AvatarTemplate string `json:"avatar_template"`
}
func linuxDoAccessToken(r *http.Request, code string, setting model.PrivateLinuxDoAuthSetting) (string, error) {
values := url.Values{}
values.Set("client_id", setting.ClientID)
values.Set("client_secret", setting.ClientSecret)
values.Set("grant_type", "authorization_code")
values.Set("code", code)
values.Set("redirect_uri", linuxDoRedirectURI(r))
req, _ := http.NewRequest(http.MethodPost, config.Cfg.LinuxDoTokenURL, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var payload linuxDoTokenResponse
if err := doLinuxDoJSON(req, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.AccessToken) == "" {
return "", safeMessageError{message: "Linux.do 登录失败"}
}
return payload.AccessToken, nil
}
func linuxDoRedirectURI(r *http.Request) string {
return RequestOrigin(r) + "/api/auth/linux-do/callback"
}
func linuxDoProfile(token string) (linuxDoUserResponse, error) {
req, _ := http.NewRequest(http.MethodGet, config.Cfg.LinuxDoUserInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+token)
var payload linuxDoUserResponse
err := doLinuxDoJSON(req, &payload)
return payload, err
}
func doLinuxDoJSON(req *http.Request, payload any) error {
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return safeMessageError{message: "Linux.do 登录失败"}
}
return json.NewDecoder(bytes.NewReader(body)).Decode(payload)
}
func linuxDoUsername(username string, id string) string {
base := strings.TrimSpace(username)
if base == "" {
base = "linuxdo-" + id
}
if _, ok, err := repository.GetUserByUsername(base); err != nil || !ok {
return base
}
return base + "-" + id
}
func linuxDoAvatar(template string) string {
if strings.TrimSpace(template) == "" {
return ""
}
if strings.HasPrefix(template, "//") {
template = "https:" + template
}
if strings.HasPrefix(template, "/") {
template = "https://linux.do" + template
}
return strings.ReplaceAll(template, "{size}", "120")
}
func decodeState(state string) string {
data, err := base64.RawURLEncoding.DecodeString(state)
if err != nil {
return "/"
}
redirect := string(data)
if !strings.HasPrefix(redirect, "/") {
return "/"
}
return redirect
}
func RequestOrigin(r *http.Request) string {
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if host == "" {
host = r.Host
}
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if proto == "" {
proto = "http"
}
return proto + "://" + host
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func WarnDefaultSecurityConfig() {
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
}
}