🎨 重构用户端前端为vue开发,完善基础类和角色相关接口
This commit is contained in:
194
server/api/v1/app/auth.go
Normal file
194
server/api/v1/app/auth.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/middleware"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/common/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AuthApi struct{}
|
||||
|
||||
// Register 前台用户注册
|
||||
// @Tags App.Auth
|
||||
// @Summary 前台用户注册
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.RegisterRequest true "用户注册信息"
|
||||
// @Success 200 {object} response.Response{msg=string} "注册成功"
|
||||
// @Router /app/auth/register [post]
|
||||
func (a *AuthApi) Register(c *gin.Context) {
|
||||
var req request.RegisterRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
err = authService.Register(&req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("注册失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMessage("注册成功", c)
|
||||
}
|
||||
|
||||
// Login 前台用户登录
|
||||
// @Tags App.Auth
|
||||
// @Summary 前台用户登录
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.LoginRequest true "用户登录信息"
|
||||
// @Success 200 {object} response.Response{data=response.LoginResponse,msg=string} "登录成功"
|
||||
// @Router /app/auth/login [post]
|
||||
func (a *AuthApi) Login(c *gin.Context) {
|
||||
var req request.LoginRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取客户端 IP
|
||||
ip := c.ClientIP()
|
||||
|
||||
result, err := authService.Login(&req, ip)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("登录失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(result, c)
|
||||
}
|
||||
|
||||
// Logout 前台用户登出
|
||||
// @Tags App.Auth
|
||||
// @Summary 前台用户登出
|
||||
// @Security ApiKeyAuth
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// @Success 200 {object} response.Response{msg=string} "登出成功"
|
||||
// @Router /app/auth/logout [post]
|
||||
func (a *AuthApi) Logout(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
token := middleware.GetToken(c)
|
||||
|
||||
err := authService.Logout(userID, token)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("登出失败", zap.Error(err))
|
||||
response.FailWithMessage("登出失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMessage("登出成功", c)
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
// @Tags App.Auth
|
||||
// @Summary 刷新 Token
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.RefreshTokenRequest true "刷新Token请求"
|
||||
// @Success 200 {object} response.Response{data=response.LoginResponse,msg=string} "刷新成功"
|
||||
// @Router /app/auth/refresh [post]
|
||||
func (a *AuthApi) RefreshToken(c *gin.Context) {
|
||||
var req request.RefreshTokenRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := authService.RefreshToken(&req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("刷新 Token 失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(result, c)
|
||||
}
|
||||
|
||||
// GetUserInfo 获取当前登录用户信息
|
||||
// @Tags App.Auth
|
||||
// @Summary 获取当前登录用户信息
|
||||
// @Security ApiKeyAuth
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// @Success 200 {object} response.Response{data=response.AppUserResponse,msg=string} "获取成功"
|
||||
// @Router /app/auth/userinfo [get]
|
||||
func (a *AuthApi) GetUserInfo(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
result, err := authService.GetUserInfo(userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取用户信息失败", zap.Error(err))
|
||||
response.FailWithMessage("获取用户信息失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(result, c)
|
||||
}
|
||||
|
||||
// UpdateProfile 更新用户信息
|
||||
// @Tags App.Auth
|
||||
// @Summary 更新用户信息
|
||||
// @Security ApiKeyAuth
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.UpdateProfileRequest true "用户信息"
|
||||
// @Success 200 {object} response.Response{msg=string} "更新成功"
|
||||
// @Router /app/user/profile [put]
|
||||
func (a *AuthApi) UpdateProfile(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
var req request.UpdateProfileRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
err = authService.UpdateProfile(userID, &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("更新用户信息失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMessage("更新成功", c)
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
// @Tags App.Auth
|
||||
// @Summary 修改密码
|
||||
// @Security ApiKeyAuth
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.ChangePasswordRequest true "密码信息"
|
||||
// @Success 200 {object} response.Response{msg=string} "修改成功"
|
||||
// @Router /app/user/change-password [post]
|
||||
func (a *AuthApi) ChangePassword(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
var req request.ChangePasswordRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
err = authService.ChangePassword(userID, &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("修改密码失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMessage("修改成功", c)
|
||||
}
|
||||
392
server/api/v1/app/character.go
Normal file
392
server/api/v1/app/character.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/middleware"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/common/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type CharacterApi struct{}
|
||||
|
||||
// GetPublicCharacterList 获取公开角色卡列表(无需鉴权)
|
||||
// @Summary 获取公开角色卡列表
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(20)
|
||||
// @Param keyword query string false "关键词"
|
||||
// @Param sortBy query string false "排序方式: newest, popular, mostChats, mostLikes"
|
||||
// @Success 200 {object} response.Response{data=response.CharacterListResponse}
|
||||
// @Router /app/character/public [get]
|
||||
func (ca *CharacterApi) GetPublicCharacterList(c *gin.Context) {
|
||||
var req request.CharacterListRequest
|
||||
req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
req.Keyword = c.Query("keyword")
|
||||
req.SortBy = c.Query("sortBy")
|
||||
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
|
||||
// 尝试获取当前用户ID(如果已登录)
|
||||
userID := middleware.GetOptionalAppUserID(c)
|
||||
|
||||
list, err := characterService.GetPublicCharacterList(req, userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取公开角色卡列表失败", zap.Error(err))
|
||||
response.FailWithMessage("获取列表失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(list, c)
|
||||
}
|
||||
|
||||
// GetMyCharacterList 获取我的角色卡列表
|
||||
// @Summary 获取我的角色卡列表
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(20)
|
||||
// @Param keyword query string false "关键词"
|
||||
// @Param sortBy query string false "排序方式"
|
||||
// @Success 200 {object} response.Response{data=response.CharacterListResponse}
|
||||
// @Router /app/character/my [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (ca *CharacterApi) GetMyCharacterList(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
var req request.CharacterListRequest
|
||||
req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
req.Keyword = c.Query("keyword")
|
||||
req.SortBy = c.Query("sortBy")
|
||||
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
|
||||
list, err := characterService.GetMyCharacterList(req, userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取我的角色卡列表失败", zap.Error(err))
|
||||
response.FailWithMessage("获取列表失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(list, c)
|
||||
}
|
||||
|
||||
// GetCharacterDetail 获取角色卡详情
|
||||
// @Summary 获取角色卡详情
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "角色卡ID"
|
||||
// @Success 200 {object} response.Response{data=response.CharacterResponse}
|
||||
// @Router /app/character/:id [get]
|
||||
func (ca *CharacterApi) GetCharacterDetail(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
response.FailWithMessage("无效的ID", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取当前用户ID(如果已登录)
|
||||
userID := middleware.GetOptionalAppUserID(c)
|
||||
|
||||
character, err := characterService.GetCharacterDetail(uint(id), userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取角色卡详情失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(character, c)
|
||||
}
|
||||
|
||||
// CreateCharacter 创建角色卡
|
||||
// @Summary 创建角色卡
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param data body request.CreateCharacterRequest true "角色卡信息"
|
||||
// @Success 200 {object} response.Response{data=response.CharacterResponse}
|
||||
// @Router /app/character [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (ca *CharacterApi) CreateCharacter(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
var req request.CreateCharacterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
character, err := characterService.CreateCharacter(req, userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("创建角色卡失败", zap.Error(err))
|
||||
response.FailWithMessage("创建失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(character, c)
|
||||
}
|
||||
|
||||
// UpdateCharacter 更新角色卡
|
||||
// @Summary 更新角色卡
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param data body request.UpdateCharacterRequest true "角色卡信息"
|
||||
// @Success 200 {object} response.Response{data=response.CharacterResponse}
|
||||
// @Router /app/character [put]
|
||||
// @Security ApiKeyAuth
|
||||
func (ca *CharacterApi) UpdateCharacter(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
var req request.UpdateCharacterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
character, err := characterService.UpdateCharacter(req, userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("更新角色卡失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(character, c)
|
||||
}
|
||||
|
||||
// DeleteCharacter 删除角色卡
|
||||
// @Summary 删除角色卡
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "角色卡ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /app/character/:id [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (ca *CharacterApi) DeleteCharacter(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
response.FailWithMessage("无效的ID", c)
|
||||
return
|
||||
}
|
||||
|
||||
err = characterService.DeleteCharacter(uint(id), userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("删除角色卡失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMessage("删除成功", c)
|
||||
}
|
||||
|
||||
// ToggleFavorite 切换收藏状态
|
||||
// @Summary 切换收藏状态
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param data body request.CharacterActionRequest true "角色卡ID"
|
||||
// @Success 200 {object} response.Response{data=map[string]bool}
|
||||
// @Router /app/character/favorite [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (ca *CharacterApi) ToggleFavorite(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
var req request.CharacterActionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
isFavorited, err := characterService.ToggleFavorite(req.CharacterID, userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("切换收藏失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(gin.H{"isFavorited": isFavorited}, c)
|
||||
}
|
||||
|
||||
// LikeCharacter 点赞角色卡
|
||||
// @Summary 点赞角色卡
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param data body request.CharacterActionRequest true "角色卡ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /app/character/like [post]
|
||||
func (ca *CharacterApi) LikeCharacter(c *gin.Context) {
|
||||
var req request.CharacterActionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
err := characterService.LikeCharacter(req.CharacterID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("点赞失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMessage("点赞成功", c)
|
||||
}
|
||||
|
||||
// ExportCharacter 导出角色卡为 JSON
|
||||
// @Summary 导出角色卡为 JSON
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "角色卡ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /app/character/:id/export [get]
|
||||
func (ca *CharacterApi) ExportCharacter(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
response.FailWithMessage("无效的ID", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取当前用户ID(如果已登录)
|
||||
userID := middleware.GetOptionalAppUserID(c)
|
||||
|
||||
exportData, err := characterService.ExportCharacter(uint(id), userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("导出角色卡失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置下载文件头
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Header("Content-Disposition", "attachment; filename=character.json")
|
||||
c.JSON(200, exportData)
|
||||
}
|
||||
|
||||
// ExportCharacterAsPNG 导出角色卡为 PNG
|
||||
// @Summary 导出角色卡为 PNG
|
||||
// @Tags 角色卡
|
||||
// @Accept json
|
||||
// @Produce octet-stream
|
||||
// @Param id path int true "角色卡ID"
|
||||
// @Success 200 {file} binary
|
||||
// @Router /app/character/:id/export/png [get]
|
||||
func (ca *CharacterApi) ExportCharacterAsPNG(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
response.FailWithMessage("无效的ID", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取当前用户ID(如果已登录)
|
||||
userID := middleware.GetOptionalAppUserID(c)
|
||||
|
||||
pngData, err := characterService.ExportCharacterAsPNG(uint(id), userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("导出 PNG 角色卡失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置下载文件头
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Content-Disposition", "attachment; filename=character.png")
|
||||
c.Data(200, "image/png", pngData)
|
||||
}
|
||||
|
||||
// ImportCharacter 导入角色卡
|
||||
// @Summary 导入角色卡(支持 PNG 和 JSON)
|
||||
// @Tags 角色卡
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "角色卡文件(PNG 或 JSON)"
|
||||
// @Param isPublic formData bool false "是否公开"
|
||||
// @Success 200 {object} response.Response{data=response.CharacterResponse}
|
||||
// @Router /app/character/import [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (ca *CharacterApi) ImportCharacter(c *gin.Context) {
|
||||
userID := middleware.GetAppUserID(c)
|
||||
|
||||
// 获取上传的文件
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取上传文件失败", zap.Error(err))
|
||||
response.FailWithMessage("请上传文件", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件大小(最大 10MB)
|
||||
maxSize := int64(10 * 1024 * 1024)
|
||||
if file.Size > maxSize {
|
||||
response.FailWithMessage("文件大小不能超过 10MB", c)
|
||||
return
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info("接收到文件上传",
|
||||
zap.String("filename", file.Filename),
|
||||
zap.Int64("size", file.Size))
|
||||
|
||||
// 读取文件内容
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("打开文件失败", zap.Error(err))
|
||||
response.FailWithMessage("读取文件失败", c)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 使用 io.ReadAll 读取完整文件内容
|
||||
fileData, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("读取文件内容失败", zap.Error(err))
|
||||
response.FailWithMessage("读取文件内容失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info("文件读取成功", zap.Int("bytes", len(fileData)))
|
||||
|
||||
// 获取是否公开参数
|
||||
isPublic := c.DefaultPostForm("isPublic", "false") == "true"
|
||||
|
||||
// 导入角色卡
|
||||
character, err := characterService.ImportCharacter(fileData, file.Filename, userID, isPublic)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("导入角色卡失败",
|
||||
zap.Error(err),
|
||||
zap.String("filename", file.Filename))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info("角色卡导入成功",
|
||||
zap.Uint("characterId", character.ID),
|
||||
zap.String("name", character.Name))
|
||||
|
||||
response.OkWithData(character, c)
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
package app
|
||||
|
||||
import "git.echol.cn/loser/st/server/service"
|
||||
|
||||
type ApiGroup struct {
|
||||
AuthApi
|
||||
CharacterApi
|
||||
}
|
||||
|
||||
var (
|
||||
authService = service.ServiceGroupApp.AppServiceGroup.AuthService
|
||||
characterService = service.ServiceGroupApp.AppServiceGroup.CharacterService
|
||||
)
|
||||
|
||||
@@ -41,8 +41,8 @@ func RegisterTables() {
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 PostgreSQL 扩展(pgvector)
|
||||
InitPgSQLExtensions()
|
||||
// 初始化 PostgreSQL 扩展(仅创建 pgvector 扩展)
|
||||
InitPgSQLExtension()
|
||||
|
||||
db := global.GVA_DB
|
||||
err := db.AutoMigrate(
|
||||
@@ -100,6 +100,9 @@ func RegisterTables() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 创建向量索引(必须在 AutoMigrate 之后)
|
||||
CreateVectorIndexes()
|
||||
|
||||
err = bizModel()
|
||||
|
||||
if err != nil {
|
||||
|
||||
48
server/initialize/gorm_pgsql_extension.go
Normal file
48
server/initialize/gorm_pgsql_extension.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// InitPgSQLExtension 初始化 PostgreSQL 扩展(仅创建 pgvector 扩展)
|
||||
// 必须在 AutoMigrate 之前调用
|
||||
func InitPgSQLExtension() {
|
||||
if global.GVA_CONFIG.System.DbType != "pgsql" {
|
||||
return
|
||||
}
|
||||
|
||||
db := global.GVA_DB
|
||||
|
||||
// 安装 pgvector 扩展(用于向量存储)
|
||||
if err := db.Exec("CREATE EXTENSION IF NOT EXISTS vector").Error; err != nil {
|
||||
global.GVA_LOG.Error("failed to create pgvector extension", zap.Error(err))
|
||||
global.GVA_LOG.Warn("请确保 PostgreSQL 已安装 pgvector 扩展")
|
||||
} else {
|
||||
global.GVA_LOG.Info("pgvector extension is ready")
|
||||
}
|
||||
}
|
||||
|
||||
// CreateVectorIndexes 创建向量索引
|
||||
// 必须在 AutoMigrate 之后调用(确保表已存在)
|
||||
func CreateVectorIndexes() {
|
||||
if global.GVA_CONFIG.System.DbType != "pgsql" {
|
||||
return
|
||||
}
|
||||
|
||||
db := global.GVA_DB
|
||||
|
||||
// 为 ai_memory_vectors 表创建 HNSW 索引(余弦相似度)
|
||||
sql := `
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_vectors_embedding
|
||||
ON ai_memory_vectors
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
`
|
||||
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
global.GVA_LOG.Error("failed to create vector indexes", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info("vector indexes created successfully")
|
||||
}
|
||||
@@ -35,6 +35,10 @@ func (fs justFilesFilesystem) Open(name string) (http.File, error) {
|
||||
|
||||
func Routers() *gin.Engine {
|
||||
Router := gin.New()
|
||||
|
||||
// 设置文件上传大小限制(10MB)
|
||||
Router.MaxMultipartMemory = 10 << 20 // 10 MB
|
||||
|
||||
// 使用自定义的 Recovery 中间件,记录 panic 并入库
|
||||
Router.Use(middleware.GinRecovery(true))
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
@@ -58,6 +62,24 @@ func Routers() *gin.Engine {
|
||||
systemRouter := router.RouterGroupApp.System
|
||||
exampleRouter := router.RouterGroupApp.Example
|
||||
appRouter := router.RouterGroupApp.App // 前台应用路由
|
||||
|
||||
// 前台用户端静态文件服务(web-app)
|
||||
// 开发环境:直接使用 web-app/public 目录
|
||||
// 生产环境:使用打包后的 web-app/dist 目录
|
||||
webAppPath := "../web-app/public"
|
||||
if _, err := os.Stat(webAppPath); err == nil {
|
||||
Router.Static("/css", webAppPath+"/css")
|
||||
Router.Static("/scripts", webAppPath+"/scripts")
|
||||
Router.Static("/img", webAppPath+"/img")
|
||||
Router.Static("/fonts", webAppPath+"/fonts")
|
||||
Router.Static("/webfonts", webAppPath+"/webfonts")
|
||||
Router.StaticFile("/auth.html", webAppPath+"/auth.html")
|
||||
Router.StaticFile("/dashboard-example.html", webAppPath+"/dashboard-example.html")
|
||||
Router.StaticFile("/favicon.ico", webAppPath+"/favicon.ico")
|
||||
global.GVA_LOG.Info("前台静态文件服务已启动: " + webAppPath)
|
||||
}
|
||||
|
||||
// 管理后台前端静态文件(web)
|
||||
// 如果想要不使用nginx代理前端网页,可以修改 web/.env.production 下的
|
||||
// VUE_APP_BASE_API = /
|
||||
// VUE_APP_BASE_PATH = http://localhost
|
||||
@@ -67,10 +89,10 @@ func Routers() *gin.Engine {
|
||||
// Router.StaticFile("/", "./dist/index.html") // 前端网页入口页面
|
||||
|
||||
Router.StaticFS(global.GVA_CONFIG.Local.StorePath, justFilesFilesystem{http.Dir(global.GVA_CONFIG.Local.StorePath)}) // Router.Use(middleware.LoadTls()) // 如果需要使用https 请打开此中间件 然后前往 core/server.go 将启动模式 更变为 Router.RunTLS("端口","你的cre/pem文件","你的key文件")
|
||||
// 跨域,如需跨域可以打开下面的注释
|
||||
// Router.Use(middleware.Cors()) // 直接放行全部跨域请求
|
||||
// Router.Use(middleware.CorsByRules()) // 按照配置的规则放行跨域请求
|
||||
// global.GVA_LOG.Info("use middleware cors")
|
||||
|
||||
// 跨域配置(前台应用需要)
|
||||
Router.Use(middleware.Cors()) // 直接放行全部跨域请求
|
||||
global.GVA_LOG.Info("use middleware cors")
|
||||
docs.SwaggerInfo.BasePath = global.GVA_CONFIG.System.RouterPrefix
|
||||
Router.GET(global.GVA_CONFIG.System.RouterPrefix+"/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
global.GVA_LOG.Info("register swagger handler")
|
||||
@@ -121,8 +143,9 @@ func Routers() *gin.Engine {
|
||||
|
||||
// 前台应用路由(新增)
|
||||
{
|
||||
appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀
|
||||
appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/*
|
||||
appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀
|
||||
appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/*
|
||||
appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/*
|
||||
}
|
||||
|
||||
//插件路由安装
|
||||
|
||||
147
server/middleware/app_jwt.go
Normal file
147
server/middleware/app_jwt.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"git.echol.cn/loser/st/server/model/common/response"
|
||||
"git.echol.cn/loser/st/server/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// AppJWTAuth 前台用户 JWT 认证中间件
|
||||
func AppJWTAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := GetToken(c)
|
||||
if token == "" {
|
||||
response.FailWithDetailed(gin.H{"reload": true}, "未登录或非法访问", c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 JWT
|
||||
claims, err := utils.ParseAppToken(token)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
response.FailWithDetailed(gin.H{"reload": true}, "Token 已过期", c)
|
||||
} else {
|
||||
response.FailWithDetailed(gin.H{"reload": true}, "Token 无效", c)
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户类型(确保是前台用户)
|
||||
if claims.UserType != utils.UserTypeApp {
|
||||
response.FailWithMessage("无效的用户类型", c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户是否存在
|
||||
var user app.AppUser
|
||||
err = global.GVA_DB.Where("id = ?", claims.UserID).First(&user).Error
|
||||
if err != nil {
|
||||
response.FailWithMessage("用户不存在", c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if !user.Enable {
|
||||
response.FailWithMessage("用户已被禁用", c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
response.FailWithMessage("账户状态异常", c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入上下文
|
||||
c.Set("appUserId", user.ID)
|
||||
c.Set("appUser", &user)
|
||||
c.Set("appUsername", user.Username)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetAppUserID 从上下文获取前台用户 ID(需要鉴权的接口使用)
|
||||
func GetAppUserID(c *gin.Context) uint {
|
||||
if userID, exists := c.Get("appUserId"); exists {
|
||||
return userID.(uint)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetOptionalAppUserID 从上下文获取可选的前台用户 ID(公开接口使用)
|
||||
// 如果用户已登录,返回用户 ID;否则返回 nil
|
||||
func GetOptionalAppUserID(c *gin.Context) *uint {
|
||||
// 先尝试从上下文获取(通过鉴权中间件设置)
|
||||
if userID, exists := c.Get("appUserId"); exists {
|
||||
if id, ok := userID.(uint); ok {
|
||||
return &id
|
||||
}
|
||||
}
|
||||
|
||||
// 如果上下文中没有,尝试手动解析 Token(用于公开接口)
|
||||
token := GetToken(c)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims, err := utils.ParseAppToken(token)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if claims.UserType != utils.UserTypeApp {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &claims.UserID
|
||||
}
|
||||
|
||||
// GetAppUser 从上下文获取前台用户信息
|
||||
func GetAppUser(c *gin.Context) *app.AppUser {
|
||||
if user, exists := c.Get("appUser"); exists {
|
||||
return user.(*app.AppUser)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAppUsername 从上下文获取前台用户名
|
||||
func GetAppUsername(c *gin.Context) string {
|
||||
if username, exists := c.Get("appUsername"); exists {
|
||||
return username.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetToken 从请求中获取 Token
|
||||
// 优先从 Header 获取,其次从 Query 参数获取
|
||||
func GetToken(c *gin.Context) string {
|
||||
token := c.Request.Header.Get("x-token")
|
||||
if token == "" {
|
||||
token = c.Request.Header.Get("Authorization")
|
||||
if token != "" && len(token) > 7 && token[:7] == "Bearer " {
|
||||
token = token[7:]
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// SetAppUserID 设置用户 ID 到上下文(用于某些特殊场景)
|
||||
func SetAppUserID(c *gin.Context, userID uint) {
|
||||
c.Set("appUserId", userID)
|
||||
c.Set("appUserIdStr", strconv.Itoa(int(userID)))
|
||||
}
|
||||
213
server/model/app/README.md
Normal file
213
server/model/app/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# App 前台应用数据模型
|
||||
|
||||
## 📋 模型列表
|
||||
|
||||
本目录包含所有前台用户应用相关的数据模型,与管理后台的 `system` 模块完全独立。
|
||||
|
||||
### 1. 用户相关模型
|
||||
|
||||
| 文件 | 模型 | 表名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `app_user.go` | `AppUser` | `app_users` | 前台用户表 |
|
||||
| `app_user_session.go` | `AppUserSession` | `app_user_sessions` | 用户会话表 |
|
||||
|
||||
### 2. AI 角色相关模型
|
||||
|
||||
| 文件 | 模型 | 表名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ai_character.go` | `AICharacter` | `ai_characters` | AI 角色表 |
|
||||
| `ai_character.go` | `AppUserFavoriteCharacter` | `app_user_favorite_characters` | 用户收藏角色表 |
|
||||
|
||||
### 3. 对话相关模型
|
||||
|
||||
| 文件 | 模型 | 表名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ai_chat.go` | `AIChat` | `ai_chats` | 对话表 |
|
||||
| `ai_chat.go` | `AIChatMember` | `ai_chat_members` | 群聊成员表 |
|
||||
| `ai_message.go` | `AIMessage` | `ai_messages` | 消息表 |
|
||||
| `ai_message.go` | `AIMessageSwipe` | `ai_message_swipes` | 消息变体表 |
|
||||
|
||||
### 4. 向量记忆模型
|
||||
|
||||
| 文件 | 模型 | 表名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ai_memory.go` | `AIMemoryVector` | `ai_memory_vectors` | 向量记忆表(使用 pgvector) |
|
||||
|
||||
### 5. AI 服务配置模型
|
||||
|
||||
| 文件 | 模型 | 表名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ai_provider.go` | `AIProvider` | `ai_providers` | AI 提供商配置表 |
|
||||
| `ai_provider.go` | `AIModel` | `ai_models` | AI 模型配置表 |
|
||||
|
||||
### 6. 文件管理模型
|
||||
|
||||
| 文件 | 模型 | 表名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ai_file.go` | `AIFile` | `ai_files` | 文件表 |
|
||||
|
||||
### 7. 其他模型
|
||||
|
||||
| 文件 | 模型 | 表名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ai_preset.go` | `AIPreset` | `ai_presets` | 对话预设表 |
|
||||
| `ai_world_info.go` | `AIWorldInfo` | `ai_world_info` | 世界书表 |
|
||||
| `ai_usage_stat.go` | `AIUsageStat` | `ai_usage_stats` | 使用统计表 |
|
||||
|
||||
## 🔧 使用说明
|
||||
|
||||
### 1. 数据库自动迁移
|
||||
|
||||
所有模型已在 `initialize/gorm.go` 中注册,启动服务时会自动创建表:
|
||||
|
||||
```go
|
||||
// 在 RegisterTables() 函数中已注册
|
||||
app.AppUser{},
|
||||
app.AppUserSession{},
|
||||
app.AICharacter{},
|
||||
app.AppUserFavoriteCharacter{},
|
||||
app.AIChat{},
|
||||
app.AIChatMember{},
|
||||
app.AIMessage{},
|
||||
app.AIMessageSwipe{},
|
||||
app.AIMemoryVector{},
|
||||
app.AIProvider{},
|
||||
app.AIModel{},
|
||||
app.AIFile{},
|
||||
app.AIPreset{},
|
||||
app.AIWorldInfo{},
|
||||
app.AIUsageStat{},
|
||||
```
|
||||
|
||||
### 2. PostgreSQL 向量扩展
|
||||
|
||||
向量记忆功能依赖 `pgvector` 扩展,已在 `initialize/gorm_pgsql_extension.go` 中自动安装:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE INDEX idx_memory_vectors_embedding ON ai_memory_vectors
|
||||
USING hnsw (embedding vector_cosine_ops);
|
||||
```
|
||||
|
||||
### 3. 外键关系
|
||||
|
||||
模型之间的关系已通过 GORM 标签定义:
|
||||
|
||||
- `AppUser` ← `AppUserSession`(一对多)
|
||||
- `AppUser` ← `AICharacter`(一对多,创建者)
|
||||
- `AppUser` ← `AIChat`(一对多)
|
||||
- `AppUser` ← `AppUserFavoriteCharacter`(多对多,通过中间表)
|
||||
- `AICharacter` ← `AppUserFavoriteCharacter`(多对多,通过中间表)
|
||||
- `AICharacter` ← `AIChat`(一对多)
|
||||
- `AIChat` ← `AIMessage`(一对多)
|
||||
- `AIChat` ← `AIChatMember`(多对多,通过中间表)
|
||||
- `AICharacter` ← `AIChatMember`(多对多,通过中间表)
|
||||
- `AIMessage` ← `AIMessageSwipe`(一对多)
|
||||
- `AIProvider` ← `AIModel`(一对多)
|
||||
|
||||
### 4. JSONB 字段
|
||||
|
||||
以下字段使用 PostgreSQL 的 JSONB 类型:
|
||||
|
||||
- `AppUser.AISettings` - AI 相关配置
|
||||
- `AppUser.Preferences` - 用户偏好设置
|
||||
- `AICharacter.CardData` - 角色卡片数据
|
||||
- `AICharacter.Tags` - 角色标签
|
||||
- `AICharacter.ExampleMessages` - 消息示例
|
||||
- `AIChat.Settings` - 对话设置
|
||||
- `AIMessage.GenerationParams` - AI 生成参数
|
||||
- `AIMessage.Metadata` - 消息元数据
|
||||
- `AIMemoryVector.Metadata` - 记忆元数据
|
||||
- `AIProvider.APIConfig` - API 配置
|
||||
- `AIModel.Config` - 模型配置
|
||||
- `AIFile.RelatedTo` - 文件关联对象
|
||||
- `AIFile.Metadata` - 文件元数据
|
||||
- `AIPreset.Config` - 预设配置
|
||||
- `AIWorldInfo.TriggerConfig` - 触发条件配置
|
||||
|
||||
### 5. 向量字段
|
||||
|
||||
`AIMemoryVector.Embedding` 使用 `pgvector.Vector` 类型,维度为 1536(OpenAI text-embedding-ada-002)。
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **不要修改 system 包**:所有管理后台相关的模型在 `model/system/` 包中,**不要修改**
|
||||
2. **表名前缀**:
|
||||
- 前台用户相关:`app_*`
|
||||
- AI 功能相关:`ai_*`
|
||||
- 系统管理相关:`sys_*`(不修改)
|
||||
3. **UUID 生成**:`AppUser.UUID` 使用数据库自动生成(PostgreSQL 的 `gen_random_uuid()`)
|
||||
4. **软删除**:所有模型继承 `global.GVA_MODEL`,自动支持软删除
|
||||
5. **时间字段**:`CreatedAt`、`UpdatedAt`、`DeletedAt` 由 GORM 自动管理
|
||||
|
||||
## 📊 ER 图关系
|
||||
|
||||
```
|
||||
AppUser (前台用户)
|
||||
├── AppUserSession (会话)
|
||||
├── AICharacter (创建的角色)
|
||||
├── AIChat (对话)
|
||||
├── AppUserFavoriteCharacter (收藏的角色)
|
||||
├── AIMemoryVector (记忆)
|
||||
├── AIProvider (AI 提供商配置)
|
||||
├── AIFile (文件)
|
||||
├── AIPreset (预设)
|
||||
├── AIWorldInfo (世界书)
|
||||
└── AIUsageStat (使用统计)
|
||||
|
||||
AICharacter (AI 角色)
|
||||
├── AIChat (对话)
|
||||
├── AIChatMember (群聊成员)
|
||||
├── AppUserFavoriteCharacter (被收藏)
|
||||
└── AIMemoryVector (记忆)
|
||||
|
||||
AIChat (对话)
|
||||
├── AIMessage (消息)
|
||||
├── AIChatMember (群聊成员)
|
||||
└── AIMemoryVector (记忆)
|
||||
|
||||
AIMessage (消息)
|
||||
└── AIMessageSwipe (消息变体)
|
||||
|
||||
AIProvider (AI 提供商)
|
||||
└── AIModel (AI 模型)
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
1. 确保 PostgreSQL 已安装 pgvector 扩展
|
||||
2. 配置 `config.yaml` 中的数据库连接
|
||||
3. 启动服务,AutoMigrate 会自动创建所有表
|
||||
4. 检查日志确认表创建成功
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
go run main.go
|
||||
|
||||
# 查看日志
|
||||
# [GVA] pgvector extension is ready
|
||||
# [GVA] vector indexes created successfully
|
||||
# [GVA] register table success
|
||||
```
|
||||
|
||||
## 📝 开发建议
|
||||
|
||||
1. 查询时使用预加载避免 N+1 问题:
|
||||
```go
|
||||
db.Preload("User").Preload("Character").Find(&chats)
|
||||
```
|
||||
|
||||
2. 向量搜索示例:
|
||||
```go
|
||||
db.Order("embedding <=> ?", queryVector).Limit(10).Find(&memories)
|
||||
```
|
||||
|
||||
3. JSONB 查询示例:
|
||||
```go
|
||||
db.Where("ai_settings->>'model' = ?", "gpt-4").Find(&users)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**创建日期**: 2026-02-10
|
||||
**维护者**: 开发团队
|
||||
49
server/model/app/ai_character.go
Normal file
49
server/model/app/ai_character.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AICharacter AI 角色表
|
||||
type AICharacter struct {
|
||||
global.GVA_MODEL
|
||||
Name string `json:"name" gorm:"type:varchar(500);not null;comment:角色名称"`
|
||||
Description string `json:"description" gorm:"type:text;comment:角色描述"`
|
||||
Personality string `json:"personality" gorm:"type:text;comment:角色性格"`
|
||||
Scenario string `json:"scenario" gorm:"type:text;comment:角色场景"`
|
||||
Avatar string `json:"avatar" gorm:"type:varchar(1024);comment:角色头像"`
|
||||
CreatorID *uint `json:"creatorId" gorm:"index;comment:创建者ID"`
|
||||
Creator *AppUser `json:"creator" gorm:"foreignKey:CreatorID"`
|
||||
CreatorName string `json:"creatorName" gorm:"type:varchar(200);comment:创建者名称"`
|
||||
CreatorNotes string `json:"creatorNotes" gorm:"type:text;comment:创建者备注"`
|
||||
CardData datatypes.JSON `json:"cardData" gorm:"type:jsonb;not null;comment:角色卡片数据"`
|
||||
Tags pq.StringArray `json:"tags" gorm:"type:text[];comment:角色标签"`
|
||||
IsPublic bool `json:"isPublic" gorm:"default:false;index;comment:是否公开"`
|
||||
Version int `json:"version" gorm:"default:1;comment:角色版本"`
|
||||
FirstMessage string `json:"firstMessage" gorm:"type:text;comment:第一条消息"`
|
||||
ExampleMessages pq.StringArray `json:"exampleMessages" gorm:"type:text[];comment:消息示例"`
|
||||
TotalChats int `json:"totalChats" gorm:"default:0;comment:对话总数"`
|
||||
TotalLikes int `json:"totalLikes" gorm:"default:0;comment:点赞总数"`
|
||||
UsageCount int `json:"usageCount" gorm:"default:0;comment:使用次数"`
|
||||
FavoriteCount int `json:"favoriteCount" gorm:"default:0;comment:收藏次数"`
|
||||
TokenCount int `json:"tokenCount" gorm:"default:0;comment:Token数量"`
|
||||
}
|
||||
|
||||
func (AICharacter) TableName() string {
|
||||
return "ai_characters"
|
||||
}
|
||||
|
||||
// AppUserFavoriteCharacter 用户收藏的角色
|
||||
type AppUserFavoriteCharacter struct {
|
||||
global.GVA_MODEL
|
||||
UserID uint `json:"userId" gorm:"not null;index:idx_user_character,unique;comment:用户ID"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
CharacterID uint `json:"characterId" gorm:"not null;index:idx_user_character,unique;comment:角色ID"`
|
||||
Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"`
|
||||
}
|
||||
|
||||
func (AppUserFavoriteCharacter) TableName() string {
|
||||
return "app_user_favorite_characters"
|
||||
}
|
||||
41
server/model/app/ai_chat.go
Normal file
41
server/model/app/ai_chat.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"gorm.io/datatypes"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AIChat 对话表
|
||||
type AIChat struct {
|
||||
global.GVA_MODEL
|
||||
Title string `json:"title" gorm:"type:varchar(500);default:新对话;comment:对话标题"`
|
||||
UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
CharacterID *uint `json:"characterId" gorm:"index;comment:关联角色ID"`
|
||||
Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"`
|
||||
ChatType string `json:"chatType" gorm:"type:varchar(50);default:single;comment:对话类型"`
|
||||
Settings datatypes.JSON `json:"settings" gorm:"type:jsonb;comment:对话设置"`
|
||||
LastMessageAt *time.Time `json:"lastMessageAt" gorm:"index;comment:最后一条消息时间"`
|
||||
MessageCount int `json:"messageCount" gorm:"default:0;comment:消息数量"`
|
||||
IsPinned bool `json:"isPinned" gorm:"default:false;comment:是否固定"`
|
||||
}
|
||||
|
||||
func (AIChat) TableName() string {
|
||||
return "ai_chats"
|
||||
}
|
||||
|
||||
// AIChatMember 群聊成员表
|
||||
type AIChatMember struct {
|
||||
global.GVA_MODEL
|
||||
ChatID uint `json:"chatId" gorm:"not null;index:idx_chat_character,unique;comment:对话ID"`
|
||||
Chat *AIChat `json:"chat" gorm:"foreignKey:ChatID"`
|
||||
CharacterID uint `json:"characterId" gorm:"not null;index:idx_chat_character,unique;comment:角色ID"`
|
||||
Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"`
|
||||
DisplayOrder int `json:"displayOrder" gorm:"default:0;comment:显示顺序"`
|
||||
Settings datatypes.JSON `json:"settings" gorm:"type:jsonb;comment:成员设置"`
|
||||
}
|
||||
|
||||
func (AIChatMember) TableName() string {
|
||||
return "ai_chat_members"
|
||||
}
|
||||
26
server/model/app/ai_file.go
Normal file
26
server/model/app/ai_file.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AIFile 文件表
|
||||
type AIFile struct {
|
||||
global.GVA_MODEL
|
||||
UserID uint `json:"userId" gorm:"not null;index;comment:上传者ID"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
Filename string `json:"filename" gorm:"type:varchar(500);not null;comment:文件名"`
|
||||
OriginalFilename string `json:"originalFilename" gorm:"type:varchar(500);not null;comment:原始文件名"`
|
||||
FileType string `json:"fileType" gorm:"type:varchar(100);not null;index;comment:文件类型"`
|
||||
MimeType string `json:"mimeType" gorm:"type:varchar(200);comment:MIME类型"`
|
||||
FileSize int64 `json:"fileSize" gorm:"comment:文件大小(字节)"`
|
||||
StoragePath string `json:"storagePath" gorm:"type:varchar(1024);not null;comment:存储路径"`
|
||||
URL string `json:"url" gorm:"type:varchar(1024);comment:对象存储URL"`
|
||||
RelatedTo datatypes.JSON `json:"relatedTo" gorm:"type:jsonb;comment:关联对象"`
|
||||
Metadata datatypes.JSON `json:"metadata" gorm:"type:jsonb;comment:元数据"`
|
||||
}
|
||||
|
||||
func (AIFile) TableName() string {
|
||||
return "ai_files"
|
||||
}
|
||||
26
server/model/app/ai_memory.go
Normal file
26
server/model/app/ai_memory.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AIMemoryVector 向量记忆表(使用 pgvector)
|
||||
type AIMemoryVector struct {
|
||||
global.GVA_MODEL
|
||||
UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
CharacterID *uint `json:"characterId" gorm:"index;comment:所属角色ID"`
|
||||
Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"`
|
||||
ChatID *uint `json:"chatId" gorm:"index;comment:所属对话ID"`
|
||||
Chat *AIChat `json:"chat" gorm:"foreignKey:ChatID"`
|
||||
Content string `json:"content" gorm:"type:text;not null;comment:文本内容"`
|
||||
Embedding pgvector.Vector `json:"-" gorm:"type:vector(1536);comment:向量嵌入"`
|
||||
Metadata datatypes.JSON `json:"metadata" gorm:"type:jsonb;comment:元数据"`
|
||||
Importance float64 `json:"importance" gorm:"default:0.5;comment:重要性评分"`
|
||||
}
|
||||
|
||||
func (AIMemoryVector) TableName() string {
|
||||
return "ai_memory_vectors"
|
||||
}
|
||||
46
server/model/app/ai_message.go
Normal file
46
server/model/app/ai_message.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AIMessage 消息表
|
||||
type AIMessage struct {
|
||||
global.GVA_MODEL
|
||||
ChatID uint `json:"chatId" gorm:"not null;index:idx_chat_sequence;comment:所属对话ID"`
|
||||
Chat *AIChat `json:"chat" gorm:"foreignKey:ChatID"`
|
||||
Content string `json:"content" gorm:"type:text;not null;comment:消息内容"`
|
||||
Role string `json:"role" gorm:"type:varchar(50);not null;comment:发送者类型"`
|
||||
SenderID *uint `json:"senderId" gorm:"comment:发送者ID"`
|
||||
Sender *AppUser `json:"sender" gorm:"foreignKey:SenderID"`
|
||||
CharacterID *uint `json:"characterId" gorm:"comment:AI角色ID"`
|
||||
Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"`
|
||||
SequenceNumber int `json:"sequenceNumber" gorm:"not null;index:idx_chat_sequence;comment:消息序号"`
|
||||
Model string `json:"model" gorm:"type:varchar(200);comment:AI模型"`
|
||||
PromptTokens int `json:"promptTokens" gorm:"default:0;comment:提示词Token数"`
|
||||
CompletionTokens int `json:"completionTokens" gorm:"default:0;comment:补全Token数"`
|
||||
TotalTokens int `json:"totalTokens" gorm:"default:0;comment:总Token数"`
|
||||
GenerationParams datatypes.JSON `json:"generationParams" gorm:"type:jsonb;comment:生成参数"`
|
||||
Metadata datatypes.JSON `json:"metadata" gorm:"type:jsonb;comment:消息元数据"`
|
||||
IsDeleted bool `json:"isDeleted" gorm:"default:false;comment:是否被删除"`
|
||||
}
|
||||
|
||||
func (AIMessage) TableName() string {
|
||||
return "ai_messages"
|
||||
}
|
||||
|
||||
// AIMessageSwipe 消息变体表(swipe 功能)
|
||||
type AIMessageSwipe struct {
|
||||
global.GVA_MODEL
|
||||
MessageID uint `json:"messageId" gorm:"not null;index:idx_message_swipe,unique;comment:消息ID"`
|
||||
Message *AIMessage `json:"message" gorm:"foreignKey:MessageID"`
|
||||
Content string `json:"content" gorm:"type:text;not null;comment:变体内容"`
|
||||
SwipeIndex int `json:"swipeIndex" gorm:"not null;index:idx_message_swipe,unique;comment:变体序号"`
|
||||
IsActive bool `json:"isActive" gorm:"default:false;comment:是否为当前选中"`
|
||||
GenerationParams datatypes.JSON `json:"generationParams" gorm:"type:jsonb;comment:生成参数"`
|
||||
}
|
||||
|
||||
func (AIMessageSwipe) TableName() string {
|
||||
return "ai_message_swipes"
|
||||
}
|
||||
22
server/model/app/ai_preset.go
Normal file
22
server/model/app/ai_preset.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AIPreset 对话预设表
|
||||
type AIPreset struct {
|
||||
global.GVA_MODEL
|
||||
Name string `json:"name" gorm:"type:varchar(200);not null;comment:预设名称"`
|
||||
UserID *uint `json:"userId" gorm:"index;comment:所属用户ID(NULL表示系统预设)"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
PresetType string `json:"presetType" gorm:"type:varchar(100);not null;index;comment:预设类型"`
|
||||
Config datatypes.JSON `json:"config" gorm:"type:jsonb;not null;comment:预设配置"`
|
||||
IsSystem bool `json:"isSystem" gorm:"default:false;comment:是否为系统预设"`
|
||||
IsDefault bool `json:"isDefault" gorm:"default:false;comment:是否为默认预设"`
|
||||
}
|
||||
|
||||
func (AIPreset) TableName() string {
|
||||
return "ai_presets"
|
||||
}
|
||||
36
server/model/app/ai_provider.go
Normal file
36
server/model/app/ai_provider.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AIProvider AI 服务提供商配置
|
||||
type AIProvider struct {
|
||||
global.GVA_MODEL
|
||||
UserID *uint `json:"userId" gorm:"index;comment:用户ID(NULL表示系统配置)"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
ProviderName string `json:"providerName" gorm:"type:varchar(100);not null;index;comment:提供商名称"`
|
||||
APIConfig datatypes.JSON `json:"apiConfig" gorm:"type:jsonb;not null;comment:API配置(加密存储)"`
|
||||
IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"`
|
||||
IsDefault bool `json:"isDefault" gorm:"default:false;comment:是否为默认提供商"`
|
||||
}
|
||||
|
||||
func (AIProvider) TableName() string {
|
||||
return "ai_providers"
|
||||
}
|
||||
|
||||
// AIModel AI 模型配置
|
||||
type AIModel struct {
|
||||
global.GVA_MODEL
|
||||
ProviderID uint `json:"providerId" gorm:"not null;index;comment:提供商ID"`
|
||||
Provider *AIProvider `json:"provider" gorm:"foreignKey:ProviderID"`
|
||||
ModelName string `json:"modelName" gorm:"type:varchar(200);not null;comment:模型名称"`
|
||||
DisplayName string `json:"displayName" gorm:"type:varchar(200);comment:模型显示名称"`
|
||||
Config datatypes.JSON `json:"config" gorm:"type:jsonb;comment:模型参数配置"`
|
||||
IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"`
|
||||
}
|
||||
|
||||
func (AIModel) TableName() string {
|
||||
return "ai_models"
|
||||
}
|
||||
25
server/model/app/ai_usage_stat.go
Normal file
25
server/model/app/ai_usage_stat.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AIUsageStat AI 使用统计表
|
||||
type AIUsageStat struct {
|
||||
global.GVA_MODEL
|
||||
UserID uint `json:"userId" gorm:"not null;index:idx_user_stat,unique;comment:用户ID"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
StatDate time.Time `json:"statDate" gorm:"type:date;not null;index:idx_user_stat,unique;comment:统计日期"`
|
||||
ProviderName string `json:"providerName" gorm:"type:varchar(100);index:idx_user_stat,unique;comment:AI提供商"`
|
||||
ModelName string `json:"modelName" gorm:"type:varchar(200);index:idx_user_stat,unique;comment:模型名称"`
|
||||
RequestCount int `json:"requestCount" gorm:"default:0;comment:请求次数"`
|
||||
TotalTokens int64 `json:"totalTokens" gorm:"default:0;comment:总Token使用量"`
|
||||
PromptTokens int64 `json:"promptTokens" gorm:"default:0;comment:提示词Token使用量"`
|
||||
CompletionTokens int64 `json:"completionTokens" gorm:"default:0;comment:补全Token使用量"`
|
||||
Cost float64 `json:"cost" gorm:"type:decimal(10,4);default:0;comment:费用"`
|
||||
}
|
||||
|
||||
func (AIUsageStat) TableName() string {
|
||||
return "ai_usage_stats"
|
||||
}
|
||||
26
server/model/app/ai_world_info.go
Normal file
26
server/model/app/ai_world_info.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// AIWorldInfo 世界书(World Info)表
|
||||
type AIWorldInfo struct {
|
||||
global.GVA_MODEL
|
||||
UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"`
|
||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||
CharacterID *uint `json:"characterId" gorm:"index;comment:关联角色ID"`
|
||||
Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"`
|
||||
Name string `json:"name" gorm:"type:varchar(500);not null;comment:世界书名称"`
|
||||
Keywords pq.StringArray `json:"keywords" gorm:"type:text[];comment:触发关键词"`
|
||||
Content string `json:"content" gorm:"type:text;not null;comment:内容"`
|
||||
Priority int `json:"priority" gorm:"default:0;comment:优先级"`
|
||||
IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"`
|
||||
TriggerConfig datatypes.JSON `json:"triggerConfig" gorm:"type:jsonb;comment:触发条件配置"`
|
||||
}
|
||||
|
||||
func (AIWorldInfo) TableName() string {
|
||||
return "ai_world_info"
|
||||
}
|
||||
31
server/model/app/app_user.go
Normal file
31
server/model/app/app_user.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"gorm.io/datatypes"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppUser 前台用户模型(与 sys_users 独立)
|
||||
type AppUser struct {
|
||||
global.GVA_MODEL
|
||||
UUID string `json:"uuid" gorm:"type:uuid;uniqueIndex;comment:用户UUID"`
|
||||
Username string `json:"username" gorm:"uniqueIndex;comment:用户登录名"`
|
||||
Password string `json:"-" gorm:"comment:用户登录密码"`
|
||||
NickName string `json:"nickName" gorm:"comment:用户昵称"`
|
||||
Email string `json:"email" gorm:"index;comment:用户邮箱"`
|
||||
Phone string `json:"phone" gorm:"comment:用户手机号"`
|
||||
Avatar string `json:"avatar" gorm:"type:varchar(1024);comment:用户头像"`
|
||||
Status string `json:"status" gorm:"type:varchar(50);default:active;comment:账户状态"`
|
||||
Enable bool `json:"enable" gorm:"default:true;comment:用户是否启用"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt" gorm:"comment:最后登录时间"`
|
||||
LastLoginIP string `json:"lastLoginIp" gorm:"type:varchar(100);comment:最后登录IP"`
|
||||
AISettings datatypes.JSON `json:"aiSettings" gorm:"type:jsonb;comment:AI配置"`
|
||||
Preferences datatypes.JSON `json:"preferences" gorm:"type:jsonb;comment:用户偏好"`
|
||||
ChatCount int `json:"chatCount" gorm:"default:0;comment:对话数量"`
|
||||
MessageCount int `json:"messageCount" gorm:"default:0;comment:消息数量"`
|
||||
}
|
||||
|
||||
func (AppUser) TableName() string {
|
||||
return "app_users"
|
||||
}
|
||||
24
server/model/app/app_user_session.go
Normal file
24
server/model/app/app_user_session.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"gorm.io/datatypes"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppUserSession 前台用户会话
|
||||
type AppUserSession struct {
|
||||
global.GVA_MODEL
|
||||
UserID uint `json:"userId" gorm:"index;comment:用户ID"`
|
||||
SessionToken string `json:"sessionToken" gorm:"type:varchar(500);uniqueIndex;comment:会话Token"`
|
||||
RefreshToken string `json:"refreshToken" gorm:"type:varchar(500);comment:刷新Token"`
|
||||
ExpiresAt time.Time `json:"expiresAt" gorm:"index;comment:过期时间"`
|
||||
RefreshExpiresAt *time.Time `json:"refreshExpiresAt" gorm:"comment:刷新Token过期时间"`
|
||||
IPAddress string `json:"ipAddress" gorm:"type:varchar(100);comment:IP地址"`
|
||||
UserAgent string `json:"userAgent" gorm:"type:text;comment:用户代理"`
|
||||
DeviceInfo datatypes.JSON `json:"deviceInfo" gorm:"type:jsonb;comment:设备信息"`
|
||||
}
|
||||
|
||||
func (AppUserSession) TableName() string {
|
||||
return "app_user_sessions"
|
||||
}
|
||||
37
server/model/app/request/auth.go
Normal file
37
server/model/app/request/auth.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package request
|
||||
|
||||
// RegisterRequest 用户注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||
Password string `json:"password" binding:"required,min=6,max=32"`
|
||||
NickName string `json:"nickName" binding:"max=50"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Phone string `json:"phone" binding:"omitempty"`
|
||||
}
|
||||
|
||||
// LoginRequest 用户登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 刷新 Token 请求
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=6,max=32"`
|
||||
}
|
||||
|
||||
// UpdateProfileRequest 更新用户信息请求
|
||||
type UpdateProfileRequest struct {
|
||||
NickName string `json:"nickName" binding:"max=50"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Phone string `json:"phone"`
|
||||
Avatar string `json:"avatar"`
|
||||
Preferences string `json:"preferences"` // JSON 字符串
|
||||
AISettings string `json:"aiSettings"` // JSON 字符串
|
||||
}
|
||||
54
server/model/app/request/character.go
Normal file
54
server/model/app/request/character.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package request
|
||||
|
||||
import "mime/multipart"
|
||||
|
||||
// CreateCharacterRequest 创建角色卡请求
|
||||
type CreateCharacterRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=500"`
|
||||
Description string `json:"description"`
|
||||
Personality string `json:"personality"`
|
||||
Scenario string `json:"scenario"`
|
||||
Avatar string `json:"avatar"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
CreatorNotes string `json:"creatorNotes"`
|
||||
FirstMessage string `json:"firstMessage"`
|
||||
ExampleMessages []string `json:"exampleMessages"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
// UpdateCharacterRequest 更新角色卡请求
|
||||
type UpdateCharacterRequest struct {
|
||||
ID uint `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=500"`
|
||||
Description string `json:"description"`
|
||||
Personality string `json:"personality"`
|
||||
Scenario string `json:"scenario"`
|
||||
Avatar string `json:"avatar"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
CreatorNotes string `json:"creatorNotes"`
|
||||
FirstMessage string `json:"firstMessage"`
|
||||
ExampleMessages []string `json:"exampleMessages"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
// CharacterListRequest 角色卡列表请求
|
||||
type CharacterListRequest struct {
|
||||
Page int `form:"page" binding:"min=1"`
|
||||
PageSize int `form:"pageSize" binding:"min=1,max=100"`
|
||||
Keyword string `form:"keyword"`
|
||||
Tags []string `form:"tags"`
|
||||
SortBy string `form:"sortBy"` // newest, popular, mostChats, mostLikes
|
||||
}
|
||||
|
||||
// ImportCharacterRequest 导入角色卡请求
|
||||
type ImportCharacterRequest struct {
|
||||
File *multipart.FileHeader `form:"file" binding:"required"`
|
||||
IsPublic bool `form:"isPublic"`
|
||||
}
|
||||
|
||||
// CharacterActionRequest 角色卡操作请求(点赞、收藏等)
|
||||
type CharacterActionRequest struct {
|
||||
CharacterID uint `json:"characterId" binding:"required"`
|
||||
}
|
||||
54
server/model/app/response/auth.go
Normal file
54
server/model/app/response/auth.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
User AppUserResponse `json:"user"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"` // Unix 时间戳
|
||||
}
|
||||
|
||||
// AppUserResponse 用户信息响应(不包含密码)
|
||||
type AppUserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Username string `json:"username"`
|
||||
NickName string `json:"nickName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Avatar string `json:"avatar"`
|
||||
Status string `json:"status"`
|
||||
Enable bool `json:"enable"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt"`
|
||||
ChatCount int `json:"chatCount"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
AISettings interface{} `json:"aiSettings"`
|
||||
Preferences interface{} `json:"preferences"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ToAppUserResponse 将 AppUser 转换为 AppUserResponse
|
||||
func ToAppUserResponse(user *app.AppUser) AppUserResponse {
|
||||
return AppUserResponse{
|
||||
ID: user.ID,
|
||||
UUID: user.UUID,
|
||||
Username: user.Username,
|
||||
NickName: user.NickName,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
Avatar: user.Avatar,
|
||||
Status: user.Status,
|
||||
Enable: user.Enable,
|
||||
LastLoginAt: user.LastLoginAt,
|
||||
ChatCount: user.ChatCount,
|
||||
MessageCount: user.MessageCount,
|
||||
AISettings: user.AISettings,
|
||||
Preferences: user.Preferences,
|
||||
CreatedAt: user.CreatedAt,
|
||||
}
|
||||
}
|
||||
79
server/model/app/response/character.go
Normal file
79
server/model/app/response/character.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CharacterResponse 角色卡响应
|
||||
type CharacterResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Personality string `json:"personality"`
|
||||
Scenario string `json:"scenario"`
|
||||
Avatar string `json:"avatar"`
|
||||
CreatorID *uint `json:"creatorId"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
CreatorNotes string `json:"creatorNotes"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
Version int `json:"version"`
|
||||
FirstMessage string `json:"firstMessage"`
|
||||
ExampleMessages []string `json:"exampleMessages"`
|
||||
TotalChats int `json:"totalChats"`
|
||||
TotalLikes int `json:"totalLikes"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
FavoriteCount int `json:"favoriteCount"`
|
||||
TokenCount int `json:"tokenCount"`
|
||||
IsFavorited bool `json:"isFavorited"` // 当前用户是否收藏
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// CharacterListResponse 角色卡列表响应
|
||||
type CharacterListResponse struct {
|
||||
List []CharacterResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// ToCharacterResponse 转换为角色卡响应
|
||||
func ToCharacterResponse(character *app.AICharacter, isFavorited bool) CharacterResponse {
|
||||
// pq.StringArray 可以直接赋值给 []string
|
||||
tags := []string{}
|
||||
if character.Tags != nil {
|
||||
tags = character.Tags
|
||||
}
|
||||
|
||||
exampleMessages := []string{}
|
||||
if character.ExampleMessages != nil {
|
||||
exampleMessages = character.ExampleMessages
|
||||
}
|
||||
|
||||
return CharacterResponse{
|
||||
ID: character.ID,
|
||||
Name: character.Name,
|
||||
Description: character.Description,
|
||||
Personality: character.Personality,
|
||||
Scenario: character.Scenario,
|
||||
Avatar: character.Avatar,
|
||||
CreatorID: character.CreatorID,
|
||||
CreatorName: character.CreatorName,
|
||||
CreatorNotes: character.CreatorNotes,
|
||||
Tags: tags,
|
||||
IsPublic: character.IsPublic,
|
||||
Version: character.Version,
|
||||
FirstMessage: character.FirstMessage,
|
||||
ExampleMessages: exampleMessages,
|
||||
TotalChats: character.TotalChats,
|
||||
TotalLikes: character.TotalLikes,
|
||||
UsageCount: character.UsageCount,
|
||||
FavoriteCount: character.FavoriteCount,
|
||||
TokenCount: character.TokenCount,
|
||||
IsFavorited: isFavorited,
|
||||
CreatedAt: character.CreatedAt,
|
||||
UpdatedAt: character.UpdatedAt,
|
||||
}
|
||||
}
|
||||
36
server/router/app/auth.go
Normal file
36
server/router/app/auth.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
v1 "git.echol.cn/loser/st/server/api/v1"
|
||||
"git.echol.cn/loser/st/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthRouter struct{}
|
||||
|
||||
// InitAuthRouter 初始化前台用户认证路由
|
||||
func (r *AuthRouter) InitAuthRouter(Router *gin.RouterGroup) {
|
||||
authRouter := Router.Group("auth")
|
||||
authApi := v1.ApiGroupApp.AppApiGroup.AuthApi
|
||||
|
||||
{
|
||||
// 公开路由(无需认证)
|
||||
authRouter.POST("register", authApi.Register) // 注册
|
||||
authRouter.POST("login", authApi.Login) // 登录
|
||||
authRouter.POST("refresh", authApi.RefreshToken) // 刷新Token
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
authRouterAuth := Router.Group("auth").Use(middleware.AppJWTAuth())
|
||||
{
|
||||
authRouterAuth.POST("logout", authApi.Logout) // 登出
|
||||
authRouterAuth.GET("userinfo", authApi.GetUserInfo) // 获取用户信息
|
||||
}
|
||||
|
||||
// 用户相关路由
|
||||
userRouter := Router.Group("user").Use(middleware.AppJWTAuth())
|
||||
{
|
||||
userRouter.PUT("profile", authApi.UpdateProfile) // 更新用户信息
|
||||
userRouter.POST("change-password", authApi.ChangePassword) // 修改密码
|
||||
}
|
||||
}
|
||||
34
server/router/app/character.go
Normal file
34
server/router/app/character.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
v1 "git.echol.cn/loser/st/server/api/v1"
|
||||
"git.echol.cn/loser/st/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CharacterRouter struct{}
|
||||
|
||||
func (cr *CharacterRouter) InitCharacterRouter(Router *gin.RouterGroup) {
|
||||
characterRouter := Router.Group("character")
|
||||
characterApi := v1.ApiGroupApp.AppApiGroup.CharacterApi
|
||||
|
||||
{
|
||||
// 公开接口(无需鉴权)
|
||||
characterRouter.GET("/public", characterApi.GetPublicCharacterList) // 获取公开角色卡列表
|
||||
characterRouter.GET("/:id", characterApi.GetCharacterDetail) // 获取角色卡详情
|
||||
characterRouter.GET("/:id/export", characterApi.ExportCharacter) // 导出角色卡为 JSON
|
||||
characterRouter.GET("/:id/export/png", characterApi.ExportCharacterAsPNG) // 导出角色卡为 PNG
|
||||
characterRouter.POST("/like", characterApi.LikeCharacter) // 点赞角色卡
|
||||
}
|
||||
|
||||
// 需要鉴权的接口
|
||||
characterRouterAuth := Router.Group("character").Use(middleware.AppJWTAuth())
|
||||
{
|
||||
characterRouterAuth.GET("/my", characterApi.GetMyCharacterList) // 获取我的角色卡列表
|
||||
characterRouterAuth.POST("", characterApi.CreateCharacter) // 创建角色卡
|
||||
characterRouterAuth.PUT("", characterApi.UpdateCharacter) // 更新角色卡
|
||||
characterRouterAuth.DELETE("/:id", characterApi.DeleteCharacter) // 删除角色卡
|
||||
characterRouterAuth.POST("/favorite", characterApi.ToggleFavorite) // 切换收藏状态
|
||||
characterRouterAuth.POST("/import", characterApi.ImportCharacter) // 导入角色卡
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ package app
|
||||
|
||||
type RouterGroup struct {
|
||||
AuthRouter
|
||||
CharacterRouter
|
||||
}
|
||||
|
||||
254
server/service/app/auth.go
Normal file
254
server/service/app/auth.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/app/response"
|
||||
"git.echol.cn/loser/st/server/utils"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthService struct{}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(req *request.RegisterRequest) error {
|
||||
// 检查用户名是否已存在
|
||||
var count int64
|
||||
err := global.GVA_DB.Model(&app.AppUser{}).Where("username = ?", req.Username).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("用户名已存在")
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if req.Email != "" {
|
||||
err = global.GVA_DB.Model(&app.AppUser{}).Where("email = ?", req.Email).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("邮箱已被使用")
|
||||
}
|
||||
}
|
||||
|
||||
// 密码加密
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errors.New("密码加密失败")
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := app.AppUser{
|
||||
UUID: uuid.New().String(),
|
||||
Username: req.Username,
|
||||
Password: string(hashedPassword),
|
||||
NickName: req.NickName,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Status: "active",
|
||||
Enable: true,
|
||||
}
|
||||
|
||||
if user.NickName == "" {
|
||||
user.NickName = req.Username
|
||||
}
|
||||
|
||||
return global.GVA_DB.Create(&user).Error
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(req *request.LoginRequest, ip string) (*response.LoginResponse, error) {
|
||||
// 查询用户
|
||||
var user app.AppUser
|
||||
err := global.GVA_DB.Where("username = ?", req.Username).First(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if !user.Enable {
|
||||
return nil, errors.New("账户已被禁用")
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return nil, errors.New("账户状态异常")
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
|
||||
if err != nil {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
|
||||
// 生成 Token
|
||||
token, expiresAt, err := utils.CreateAppToken(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return nil, errors.New("Token 生成失败")
|
||||
}
|
||||
|
||||
// 生成刷新 Token
|
||||
refreshToken, refreshExpiresAt, err := utils.CreateAppRefreshToken(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return nil, errors.New("刷新 Token 生成失败")
|
||||
}
|
||||
|
||||
// 更新最后登录信息
|
||||
now := time.Now()
|
||||
global.GVA_DB.Model(&user).Updates(map[string]interface{}{
|
||||
"last_login_at": now,
|
||||
"last_login_ip": ip,
|
||||
})
|
||||
|
||||
// 保存会话信息(可选)
|
||||
session := app.AppUserSession{
|
||||
UserID: user.ID,
|
||||
SessionToken: token,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Unix(expiresAt, 0),
|
||||
RefreshExpiresAt: func() *time.Time { t := time.Unix(refreshExpiresAt, 0); return &t }(),
|
||||
IPAddress: ip,
|
||||
}
|
||||
global.GVA_DB.Create(&session)
|
||||
|
||||
return &response.LoginResponse{
|
||||
User: response.ToAppUserResponse(&user),
|
||||
Token: token,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
func (s *AuthService) RefreshToken(req *request.RefreshTokenRequest) (*response.LoginResponse, error) {
|
||||
// 解析刷新 Token
|
||||
claims, err := utils.ParseAppToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, errors.New("刷新 Token 无效")
|
||||
}
|
||||
|
||||
// 查询用户
|
||||
var user app.AppUser
|
||||
err = global.GVA_DB.Where("id = ?", claims.UserID).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if !user.Enable {
|
||||
return nil, errors.New("账户已被禁用")
|
||||
}
|
||||
|
||||
// 生成新的 Token
|
||||
token, expiresAt, err := utils.CreateAppToken(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return nil, errors.New("Token 生成失败")
|
||||
}
|
||||
|
||||
// 生成新的刷新 Token
|
||||
refreshToken, _, err := utils.CreateAppRefreshToken(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return nil, errors.New("刷新 Token 生成失败")
|
||||
}
|
||||
|
||||
return &response.LoginResponse{
|
||||
User: response.ToAppUserResponse(&user),
|
||||
Token: token,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout 用户登出
|
||||
func (s *AuthService) Logout(userID uint, token string) error {
|
||||
// 删除会话记录
|
||||
return global.GVA_DB.Where("user_id = ? AND session_token = ?", userID, token).
|
||||
Delete(&app.AppUserSession{}).Error
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (s *AuthService) GetUserInfo(userID uint) (*response.AppUserResponse, error) {
|
||||
var user app.AppUser
|
||||
err := global.GVA_DB.Where("id = ?", userID).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := response.ToAppUserResponse(&user)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 更新用户信息
|
||||
func (s *AuthService) UpdateProfile(userID uint, req *request.UpdateProfileRequest) error {
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if req.NickName != "" {
|
||||
updates["nick_name"] = req.NickName
|
||||
}
|
||||
if req.Email != "" {
|
||||
// 检查邮箱是否已被其他用户使用
|
||||
var count int64
|
||||
err := global.GVA_DB.Model(&app.AppUser{}).
|
||||
Where("email = ? AND id != ?", req.Email, userID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("邮箱已被使用")
|
||||
}
|
||||
updates["email"] = req.Email
|
||||
}
|
||||
if req.Phone != "" {
|
||||
updates["phone"] = req.Phone
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
updates["avatar"] = req.Avatar
|
||||
}
|
||||
if req.Preferences != "" {
|
||||
updates["preferences"] = req.Preferences
|
||||
}
|
||||
if req.AISettings != "" {
|
||||
updates["ai_settings"] = req.AISettings
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return global.GVA_DB.Model(&app.AppUser{}).Where("id = ?", userID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
func (s *AuthService) ChangePassword(userID uint, req *request.ChangePasswordRequest) error {
|
||||
// 查询用户
|
||||
var user app.AppUser
|
||||
err := global.GVA_DB.Where("id = ?", userID).First(&user).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword))
|
||||
if err != nil {
|
||||
return errors.New("原密码错误")
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return errors.New("密码加密失败")
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
return global.GVA_DB.Model(&user).Update("password", string(hashedPassword)).Error
|
||||
}
|
||||
694
server/service/app/character.go
Normal file
694
server/service/app/character.go
Normal file
@@ -0,0 +1,694 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
"strings"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/app/response"
|
||||
"git.echol.cn/loser/st/server/utils"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CharacterService struct{}
|
||||
|
||||
// GetPublicCharacterList 获取公开角色卡列表(无需鉴权)
|
||||
func (cs *CharacterService) GetPublicCharacterList(req request.CharacterListRequest, userID *uint) (response.CharacterListResponse, error) {
|
||||
db := global.GVA_DB.Model(&app.AICharacter{})
|
||||
|
||||
// 只查询公开的角色卡
|
||||
db = db.Where("is_public = ?", true)
|
||||
|
||||
// 关键词搜索
|
||||
if req.Keyword != "" {
|
||||
keyword := "%" + req.Keyword + "%"
|
||||
db = db.Where("name ILIKE ? OR description ILIKE ? OR creator_name ILIKE ?", keyword, keyword, keyword)
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if len(req.Tags) > 0 {
|
||||
for _, tag := range req.Tags {
|
||||
db = db.Where("tags @> ?", fmt.Sprintf(`["%s"]`, tag))
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
switch req.SortBy {
|
||||
case "popular":
|
||||
db = db.Order("usage_count DESC, created_at DESC")
|
||||
case "mostChats":
|
||||
db = db.Order("total_chats DESC, created_at DESC")
|
||||
case "mostLikes":
|
||||
db = db.Order("total_likes DESC, created_at DESC")
|
||||
case "newest":
|
||||
fallthrough
|
||||
default:
|
||||
db = db.Order("created_at DESC")
|
||||
}
|
||||
|
||||
// 分页
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var characters []app.AICharacter
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
err := db.Offset(offset).Limit(req.PageSize).Find(&characters).Error
|
||||
if err != nil {
|
||||
return response.CharacterListResponse{}, err
|
||||
}
|
||||
|
||||
// 查询当前用户的收藏状态
|
||||
favoriteMap := make(map[uint]bool)
|
||||
if userID != nil {
|
||||
var favorites []app.AppUserFavoriteCharacter
|
||||
global.GVA_DB.Where("user_id = ?", *userID).Find(&favorites)
|
||||
for _, fav := range favorites {
|
||||
favoriteMap[fav.CharacterID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应
|
||||
list := make([]response.CharacterResponse, len(characters))
|
||||
for i, char := range characters {
|
||||
list[i] = response.ToCharacterResponse(&char, favoriteMap[char.ID])
|
||||
}
|
||||
|
||||
return response.CharacterListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMyCharacterList 获取我的角色卡列表(需要鉴权)
|
||||
func (cs *CharacterService) GetMyCharacterList(req request.CharacterListRequest, userID uint) (response.CharacterListResponse, error) {
|
||||
db := global.GVA_DB.Model(&app.AICharacter{})
|
||||
|
||||
// 只查询当前用户创建的角色卡
|
||||
db = db.Where("creator_id = ?", userID)
|
||||
|
||||
// 关键词搜索
|
||||
if req.Keyword != "" {
|
||||
keyword := "%" + req.Keyword + "%"
|
||||
db = db.Where("name ILIKE ? OR description ILIKE ?", keyword, keyword)
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if len(req.Tags) > 0 {
|
||||
for _, tag := range req.Tags {
|
||||
db = db.Where("tags @> ?", fmt.Sprintf(`["%s"]`, tag))
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
switch req.SortBy {
|
||||
case "popular":
|
||||
db = db.Order("usage_count DESC, created_at DESC")
|
||||
case "mostChats":
|
||||
db = db.Order("total_chats DESC, created_at DESC")
|
||||
case "mostLikes":
|
||||
db = db.Order("total_likes DESC, created_at DESC")
|
||||
case "newest":
|
||||
fallthrough
|
||||
default:
|
||||
db = db.Order("created_at DESC")
|
||||
}
|
||||
|
||||
// 分页
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var characters []app.AICharacter
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
err := db.Offset(offset).Limit(req.PageSize).Find(&characters).Error
|
||||
if err != nil {
|
||||
return response.CharacterListResponse{}, err
|
||||
}
|
||||
|
||||
// 查询收藏状态
|
||||
favoriteMap := make(map[uint]bool)
|
||||
var favorites []app.AppUserFavoriteCharacter
|
||||
global.GVA_DB.Where("user_id = ?", userID).Find(&favorites)
|
||||
for _, fav := range favorites {
|
||||
favoriteMap[fav.CharacterID] = true
|
||||
}
|
||||
|
||||
// 转换为响应
|
||||
list := make([]response.CharacterResponse, len(characters))
|
||||
for i, char := range characters {
|
||||
list[i] = response.ToCharacterResponse(&char, favoriteMap[char.ID])
|
||||
}
|
||||
|
||||
return response.CharacterListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCharacterDetail 获取角色卡详情
|
||||
func (cs *CharacterService) GetCharacterDetail(characterID uint, userID *uint) (response.CharacterResponse, error) {
|
||||
var character app.AICharacter
|
||||
err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return response.CharacterResponse{}, errors.New("角色卡不存在")
|
||||
}
|
||||
return response.CharacterResponse{}, err
|
||||
}
|
||||
|
||||
// 检查访问权限
|
||||
if !character.IsPublic {
|
||||
if userID == nil {
|
||||
return response.CharacterResponse{}, errors.New("无权访问")
|
||||
}
|
||||
if character.CreatorID == nil || *character.CreatorID != *userID {
|
||||
return response.CharacterResponse{}, errors.New("无权访问")
|
||||
}
|
||||
}
|
||||
|
||||
// 查询是否收藏
|
||||
isFavorited := false
|
||||
if userID != nil {
|
||||
var count int64
|
||||
global.GVA_DB.Model(&app.AppUserFavoriteCharacter{}).
|
||||
Where("user_id = ? AND character_id = ?", *userID, characterID).
|
||||
Count(&count)
|
||||
isFavorited = count > 0
|
||||
}
|
||||
|
||||
return response.ToCharacterResponse(&character, isFavorited), nil
|
||||
}
|
||||
|
||||
// CreateCharacter 创建角色卡
|
||||
func (cs *CharacterService) CreateCharacter(req request.CreateCharacterRequest, userID uint) (response.CharacterResponse, error) {
|
||||
// 构建 CardData
|
||||
cardData := map[string]interface{}{
|
||||
"name": req.Name,
|
||||
"description": req.Description,
|
||||
"personality": req.Personality,
|
||||
"scenario": req.Scenario,
|
||||
"first_message": req.FirstMessage,
|
||||
"example_messages": req.ExampleMessages,
|
||||
"creator_name": req.CreatorName,
|
||||
"creator_notes": req.CreatorNotes,
|
||||
}
|
||||
cardDataJSON, _ := json.Marshal(cardData)
|
||||
|
||||
// 处理标签和示例消息
|
||||
tags := req.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
exampleMessages := req.ExampleMessages
|
||||
if exampleMessages == nil {
|
||||
exampleMessages = []string{}
|
||||
}
|
||||
|
||||
character := app.AICharacter{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Personality: req.Personality,
|
||||
Scenario: req.Scenario,
|
||||
Avatar: req.Avatar,
|
||||
CreatorID: &userID,
|
||||
CreatorName: req.CreatorName,
|
||||
CreatorNotes: req.CreatorNotes,
|
||||
CardData: datatypes.JSON(cardDataJSON),
|
||||
Tags: tags,
|
||||
IsPublic: req.IsPublic,
|
||||
FirstMessage: req.FirstMessage,
|
||||
ExampleMessages: exampleMessages,
|
||||
TokenCount: calculateTokenCount(req),
|
||||
}
|
||||
|
||||
err := global.GVA_DB.Create(&character).Error
|
||||
if err != nil {
|
||||
return response.CharacterResponse{}, err
|
||||
}
|
||||
|
||||
return response.ToCharacterResponse(&character, false), nil
|
||||
}
|
||||
|
||||
// UpdateCharacter 更新角色卡
|
||||
func (cs *CharacterService) UpdateCharacter(req request.UpdateCharacterRequest, userID uint) (response.CharacterResponse, error) {
|
||||
var character app.AICharacter
|
||||
err := global.GVA_DB.Where("id = ?", req.ID).First(&character).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return response.CharacterResponse{}, errors.New("角色卡不存在")
|
||||
}
|
||||
return response.CharacterResponse{}, err
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if character.CreatorID == nil || *character.CreatorID != userID {
|
||||
return response.CharacterResponse{}, errors.New("无权修改")
|
||||
}
|
||||
|
||||
// 构建 CardData
|
||||
cardData := map[string]interface{}{
|
||||
"name": req.Name,
|
||||
"description": req.Description,
|
||||
"personality": req.Personality,
|
||||
"scenario": req.Scenario,
|
||||
"first_message": req.FirstMessage,
|
||||
"example_messages": req.ExampleMessages,
|
||||
"creator_name": req.CreatorName,
|
||||
"creator_notes": req.CreatorNotes,
|
||||
}
|
||||
cardDataJSON, _ := json.Marshal(cardData)
|
||||
|
||||
// 处理标签和示例消息
|
||||
tags := req.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
exampleMessages := req.ExampleMessages
|
||||
if exampleMessages == nil {
|
||||
exampleMessages = []string{}
|
||||
}
|
||||
|
||||
// 更新
|
||||
updates := map[string]interface{}{
|
||||
"name": req.Name,
|
||||
"description": req.Description,
|
||||
"personality": req.Personality,
|
||||
"scenario": req.Scenario,
|
||||
"avatar": req.Avatar,
|
||||
"creator_name": req.CreatorName,
|
||||
"creator_notes": req.CreatorNotes,
|
||||
"card_data": cardDataJSON,
|
||||
"tags": tags,
|
||||
"is_public": req.IsPublic,
|
||||
"first_message": req.FirstMessage,
|
||||
"example_messages": exampleMessages,
|
||||
"token_count": calculateTokenCount(req),
|
||||
"version": character.Version + 1,
|
||||
}
|
||||
|
||||
err = global.GVA_DB.Model(&character).Updates(updates).Error
|
||||
if err != nil {
|
||||
return response.CharacterResponse{}, err
|
||||
}
|
||||
|
||||
// 重新查询
|
||||
err = global.GVA_DB.Where("id = ?", req.ID).First(&character).Error
|
||||
if err != nil {
|
||||
return response.CharacterResponse{}, err
|
||||
}
|
||||
|
||||
// 查询是否收藏
|
||||
var count int64
|
||||
global.GVA_DB.Model(&app.AppUserFavoriteCharacter{}).
|
||||
Where("user_id = ? AND character_id = ?", userID, character.ID).
|
||||
Count(&count)
|
||||
|
||||
return response.ToCharacterResponse(&character, count > 0), nil
|
||||
}
|
||||
|
||||
// DeleteCharacter 删除角色卡
|
||||
func (cs *CharacterService) DeleteCharacter(characterID uint, userID uint) error {
|
||||
var character app.AICharacter
|
||||
err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("角色卡不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if character.CreatorID == nil || *character.CreatorID != userID {
|
||||
return errors.New("无权删除")
|
||||
}
|
||||
|
||||
// 删除相关的收藏记录
|
||||
global.GVA_DB.Where("character_id = ?", characterID).Delete(&app.AppUserFavoriteCharacter{})
|
||||
|
||||
// 删除角色卡
|
||||
return global.GVA_DB.Delete(&character).Error
|
||||
}
|
||||
|
||||
// ToggleFavorite 切换收藏状态
|
||||
func (cs *CharacterService) ToggleFavorite(characterID uint, userID uint) (bool, error) {
|
||||
// 检查角色卡是否存在
|
||||
var character app.AICharacter
|
||||
err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, errors.New("角色卡不存在")
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 检查是否已收藏
|
||||
var favorite app.AppUserFavoriteCharacter
|
||||
err = global.GVA_DB.Where("user_id = ? AND character_id = ?", userID, characterID).First(&favorite).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 未收藏,添加收藏
|
||||
favorite = app.AppUserFavoriteCharacter{
|
||||
UserID: userID,
|
||||
CharacterID: characterID,
|
||||
}
|
||||
err = global.GVA_DB.Create(&favorite).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 增加收藏数
|
||||
global.GVA_DB.Model(&character).UpdateColumn("favorite_count", gorm.Expr("favorite_count + ?", 1))
|
||||
|
||||
return true, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
} else {
|
||||
// 已收藏,取消收藏
|
||||
err = global.GVA_DB.Delete(&favorite).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 减少收藏数
|
||||
global.GVA_DB.Model(&character).UpdateColumn("favorite_count", gorm.Expr("favorite_count - ?", 1))
|
||||
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// LikeCharacter 点赞角色卡
|
||||
func (cs *CharacterService) LikeCharacter(characterID uint) error {
|
||||
var character app.AICharacter
|
||||
err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("角色卡不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 增加点赞数
|
||||
return global.GVA_DB.Model(&character).UpdateColumn("total_likes", gorm.Expr("total_likes + ?", 1)).Error
|
||||
}
|
||||
|
||||
// ExportCharacter 导出角色卡为 JSON
|
||||
func (cs *CharacterService) ExportCharacter(characterID uint, userID *uint) (map[string]interface{}, error) {
|
||||
var character app.AICharacter
|
||||
err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("角色卡不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查访问权限
|
||||
if !character.IsPublic {
|
||||
if userID == nil {
|
||||
return nil, errors.New("无权访问")
|
||||
}
|
||||
if character.CreatorID == nil || *character.CreatorID != *userID {
|
||||
return nil, errors.New("无权访问")
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 tags 和 exampleMessages
|
||||
tags := []string{}
|
||||
if character.Tags != nil {
|
||||
tags = character.Tags
|
||||
}
|
||||
|
||||
exampleMessages := []string{}
|
||||
if character.ExampleMessages != nil {
|
||||
exampleMessages = character.ExampleMessages
|
||||
}
|
||||
|
||||
// 构建导出数据(兼容 SillyTavern 格式)
|
||||
exportData := map[string]interface{}{
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": map[string]interface{}{
|
||||
"name": character.Name,
|
||||
"description": character.Description,
|
||||
"personality": character.Personality,
|
||||
"scenario": character.Scenario,
|
||||
"first_mes": character.FirstMessage,
|
||||
"mes_example": strings.Join(exampleMessages, "\n<START>\n"),
|
||||
"creator_notes": character.CreatorNotes,
|
||||
"system_prompt": "",
|
||||
"post_history_instructions": "",
|
||||
"tags": tags,
|
||||
"creator": character.CreatorName,
|
||||
"character_version": character.Version,
|
||||
"extensions": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
return exportData, nil
|
||||
}
|
||||
|
||||
// ImportCharacter 导入角色卡(支持 PNG 和 JSON)
|
||||
func (cs *CharacterService) ImportCharacter(fileData []byte, filename string, userID uint, isPublic bool) (response.CharacterResponse, error) {
|
||||
// 添加 defer 捕获 panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
global.GVA_LOG.Error("导入角色卡时发生 panic",
|
||||
zap.Any("panic", r),
|
||||
zap.String("filename", filename))
|
||||
}
|
||||
}()
|
||||
|
||||
global.GVA_LOG.Info("开始导入角色卡",
|
||||
zap.String("filename", filename),
|
||||
zap.Int("fileSize", len(fileData)),
|
||||
zap.Uint("userID", userID))
|
||||
|
||||
var card *utils.CharacterCardV2
|
||||
var err error
|
||||
var avatarData []byte
|
||||
|
||||
// 判断文件类型
|
||||
if strings.HasSuffix(strings.ToLower(filename), ".png") {
|
||||
global.GVA_LOG.Info("检测到 PNG 格式,开始提取角色卡数据")
|
||||
// PNG 格式:提取角色卡数据和头像
|
||||
card, err = utils.ExtractCharacterFromPNG(fileData)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("解析 PNG 角色卡失败", zap.Error(err))
|
||||
return response.CharacterResponse{}, errors.New("解析 PNG 角色卡失败: " + err.Error())
|
||||
}
|
||||
global.GVA_LOG.Info("PNG 角色卡解析成功", zap.String("characterName", card.Data.Name))
|
||||
avatarData = fileData
|
||||
} else if strings.HasSuffix(strings.ToLower(filename), ".json") {
|
||||
global.GVA_LOG.Info("检测到 JSON 格式,开始解析")
|
||||
// JSON 格式:只有数据,没有头像
|
||||
card, err = utils.ParseCharacterCardJSON(fileData)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("解析 JSON 角色卡失败", zap.Error(err))
|
||||
return response.CharacterResponse{}, errors.New("解析 JSON 角色卡失败: " + err.Error())
|
||||
}
|
||||
global.GVA_LOG.Info("JSON 角色卡解析成功", zap.String("characterName", card.Data.Name))
|
||||
} else {
|
||||
return response.CharacterResponse{}, errors.New("不支持的文件格式,请上传 PNG 或 JSON 文件")
|
||||
}
|
||||
|
||||
// 转换为创建请求
|
||||
global.GVA_LOG.Info("转换角色卡数据为创建请求")
|
||||
createReq := convertCardToCreateRequest(card, avatarData, isPublic)
|
||||
|
||||
global.GVA_LOG.Info("开始创建角色卡到数据库",
|
||||
zap.String("name", createReq.Name),
|
||||
zap.Bool("isPublic", createReq.IsPublic))
|
||||
|
||||
// 创建角色卡
|
||||
result, err := cs.CreateCharacter(createReq, userID)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("创建角色卡到数据库失败", zap.Error(err))
|
||||
return response.CharacterResponse{}, err
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info("角色卡导入完成", zap.Uint("characterID", result.ID))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExportCharacterAsPNG 导出角色卡为 PNG 格式
|
||||
func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint) ([]byte, error) {
|
||||
var character app.AICharacter
|
||||
err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("角色卡不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查访问权限
|
||||
if !character.IsPublic {
|
||||
if userID == nil {
|
||||
return nil, errors.New("无权访问")
|
||||
}
|
||||
if character.CreatorID == nil || *character.CreatorID != *userID {
|
||||
return nil, errors.New("无权访问")
|
||||
}
|
||||
}
|
||||
|
||||
// 构建角色卡数据
|
||||
card := convertCharacterToCard(&character)
|
||||
|
||||
// 获取角色头像
|
||||
var img image.Image
|
||||
if character.Avatar != "" {
|
||||
// TODO: 从 URL 或文件系统加载头像
|
||||
// 这里暂时创建一个默认图片
|
||||
img = createDefaultAvatar()
|
||||
} else {
|
||||
img = createDefaultAvatar()
|
||||
}
|
||||
|
||||
// 将角色卡数据嵌入到 PNG
|
||||
pngData, err := utils.EmbedCharacterToPNG(img, card)
|
||||
if err != nil {
|
||||
return nil, errors.New("生成 PNG 失败: " + err.Error())
|
||||
}
|
||||
|
||||
return pngData, nil
|
||||
}
|
||||
|
||||
// convertCardToCreateRequest 将角色卡转换为创建请求
|
||||
func convertCardToCreateRequest(card *utils.CharacterCardV2, avatarData []byte, isPublic bool) request.CreateCharacterRequest {
|
||||
// 处理示例消息
|
||||
exampleMessages := []string{}
|
||||
if card.Data.MesExample != "" {
|
||||
// 按 <START> 分割
|
||||
exampleMessages = strings.Split(card.Data.MesExample, "<START>")
|
||||
// 清理空白
|
||||
cleaned := []string{}
|
||||
for _, msg := range exampleMessages {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg != "" {
|
||||
cleaned = append(cleaned, msg)
|
||||
}
|
||||
}
|
||||
exampleMessages = cleaned
|
||||
}
|
||||
|
||||
// 合并备用问候语
|
||||
if len(card.Data.AlternateGreetings) > 0 {
|
||||
exampleMessages = append(exampleMessages, card.Data.AlternateGreetings...)
|
||||
}
|
||||
|
||||
// TODO: 处理头像数据,上传到文件服务器
|
||||
avatar := ""
|
||||
if avatarData != nil {
|
||||
// 这里应该将头像上传到文件服务器并获取 URL
|
||||
// avatar = uploadAvatar(avatarData)
|
||||
}
|
||||
|
||||
return request.CreateCharacterRequest{
|
||||
Name: card.Data.Name,
|
||||
Description: card.Data.Description,
|
||||
Personality: card.Data.Personality,
|
||||
Scenario: card.Data.Scenario,
|
||||
Avatar: avatar,
|
||||
CreatorName: card.Data.Creator,
|
||||
CreatorNotes: card.Data.CreatorNotes,
|
||||
FirstMessage: card.Data.FirstMes,
|
||||
ExampleMessages: exampleMessages,
|
||||
Tags: card.Data.Tags,
|
||||
IsPublic: isPublic,
|
||||
}
|
||||
}
|
||||
|
||||
// convertCharacterToCard 将角色卡转换为 CharacterCardV2
|
||||
func convertCharacterToCard(character *app.AICharacter) *utils.CharacterCardV2 {
|
||||
tags := []string{}
|
||||
if character.Tags != nil {
|
||||
tags = character.Tags
|
||||
}
|
||||
|
||||
exampleMessages := []string{}
|
||||
if character.ExampleMessages != nil {
|
||||
exampleMessages = character.ExampleMessages
|
||||
}
|
||||
|
||||
return &utils.CharacterCardV2{
|
||||
Spec: "chara_card_v2",
|
||||
SpecVersion: "2.0",
|
||||
Data: utils.CharacterCardV2Data{
|
||||
Name: character.Name,
|
||||
Description: character.Description,
|
||||
Personality: character.Personality,
|
||||
Scenario: character.Scenario,
|
||||
FirstMes: character.FirstMessage,
|
||||
MesExample: strings.Join(exampleMessages, "\n<START>\n"),
|
||||
CreatorNotes: character.CreatorNotes,
|
||||
SystemPrompt: "",
|
||||
PostHistoryInstructions: "",
|
||||
Tags: tags,
|
||||
Creator: character.CreatorName,
|
||||
CharacterVersion: fmt.Sprintf("%d", character.Version),
|
||||
AlternateGreetings: []string{},
|
||||
Extensions: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createDefaultAvatar 创建默认头像
|
||||
func createDefaultAvatar() image.Image {
|
||||
// 创建一个 400x533 的默认图片(3:4 比例)
|
||||
width, height := 400, 533
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
// 填充渐变色
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
// 简单的渐变效果
|
||||
r := uint8(102 + y*155/height)
|
||||
g := uint8(126 + y*138/height)
|
||||
b := uint8(234 - y*72/height)
|
||||
img.Set(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).At(0, 0))
|
||||
img.SetRGBA(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).RGBAAt(0, 0))
|
||||
// 设置颜色
|
||||
img.SetRGBA(x, y, image.NewRGBA(image.Rect(0, 0, 1, 1)).RGBAAt(0, 0))
|
||||
pix := img.Pix[y*img.Stride+x*4:]
|
||||
pix[0] = r
|
||||
pix[1] = g
|
||||
pix[2] = b
|
||||
pix[3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
// calculateTokenCount 计算角色卡的 Token 数量(简单估算)
|
||||
func calculateTokenCount(req interface{}) int {
|
||||
var text string
|
||||
switch v := req.(type) {
|
||||
case request.CreateCharacterRequest:
|
||||
text = v.Name + v.Description + v.Personality + v.Scenario + v.FirstMessage
|
||||
for _, msg := range v.ExampleMessages {
|
||||
text += msg
|
||||
}
|
||||
case request.UpdateCharacterRequest:
|
||||
text = v.Name + v.Description + v.Personality + v.Scenario + v.FirstMessage
|
||||
for _, msg := range v.ExampleMessages {
|
||||
text += msg
|
||||
}
|
||||
}
|
||||
|
||||
// 简单估算:中文约 1.5 token/字,英文约 0.25 token/词
|
||||
return len([]rune(text))
|
||||
}
|
||||
@@ -2,4 +2,5 @@ package app
|
||||
|
||||
type AppServiceGroup struct {
|
||||
AuthService
|
||||
CharacterService
|
||||
}
|
||||
|
||||
84
server/utils/app_jwt.go
Normal file
84
server/utils/app_jwt.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
UserTypeApp = "app" // 前台用户类型标识
|
||||
)
|
||||
|
||||
// AppJWTClaims 前台用户 JWT Claims
|
||||
type AppJWTClaims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
UserType string `json:"userType"` // 用户类型标识
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// CreateAppToken 创建前台用户 Token(有效期 7 天)
|
||||
func CreateAppToken(userID uint, username string) (tokenString string, expiresAt int64, err error) {
|
||||
// Token 有效期为 7 天
|
||||
expiresTime := time.Now().Add(7 * 24 * time.Hour)
|
||||
expiresAt = expiresTime.Unix()
|
||||
|
||||
claims := AppJWTClaims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
UserType: UserTypeApp,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: global.GVA_CONFIG.JWT.Issuer,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err = token.SignedString([]byte(global.GVA_CONFIG.JWT.SigningKey))
|
||||
return
|
||||
}
|
||||
|
||||
// CreateAppRefreshToken 创建前台用户刷新 Token(有效期更长)
|
||||
func CreateAppRefreshToken(userID uint, username string) (tokenString string, expiresAt int64, err error) {
|
||||
// 刷新 Token 有效期为 7 天
|
||||
expiresTime := time.Now().Add(7 * 24 * time.Hour)
|
||||
expiresAt = expiresTime.Unix()
|
||||
|
||||
claims := AppJWTClaims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
UserType: UserTypeApp,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: global.GVA_CONFIG.JWT.Issuer,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err = token.SignedString([]byte(global.GVA_CONFIG.JWT.SigningKey))
|
||||
return
|
||||
}
|
||||
|
||||
// ParseAppToken 解析前台用户 Token
|
||||
func ParseAppToken(tokenString string) (*AppJWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AppJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(global.GVA_CONFIG.JWT.SigningKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*AppJWTClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
284
server/utils/character_card.go
Normal file
284
server/utils/character_card.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
)
|
||||
|
||||
// CharacterCardV2 SillyTavern 角色卡 V2 格式
|
||||
type CharacterCardV2 struct {
|
||||
Spec string `json:"spec"`
|
||||
SpecVersion string `json:"spec_version"`
|
||||
Data CharacterCardV2Data `json:"data"`
|
||||
}
|
||||
|
||||
type CharacterCardV2Data struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Personality string `json:"personality"`
|
||||
Scenario string `json:"scenario"`
|
||||
FirstMes string `json:"first_mes"`
|
||||
MesExample string `json:"mes_example"`
|
||||
CreatorNotes string `json:"creator_notes"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
PostHistoryInstructions string `json:"post_history_instructions"`
|
||||
Tags []string `json:"tags"`
|
||||
Creator string `json:"creator"`
|
||||
CharacterVersion string `json:"character_version"`
|
||||
AlternateGreetings []string `json:"alternate_greetings"`
|
||||
Extensions map[string]interface{} `json:"extensions"`
|
||||
}
|
||||
|
||||
// ExtractCharacterFromPNG 从 PNG 图片中提取角色卡数据
|
||||
func ExtractCharacterFromPNG(pngData []byte) (*CharacterCardV2, error) {
|
||||
reader := bytes.NewReader(pngData)
|
||||
|
||||
// 验证 PNG 格式(解码但不保存图片)
|
||||
_, err := png.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, errors.New("无效的 PNG 文件")
|
||||
}
|
||||
|
||||
// 重新读取以获取 tEXt chunks
|
||||
reader.Seek(0, 0)
|
||||
|
||||
// 查找 tEXt chunk 中的 "chara" 字段
|
||||
charaJSON, err := extractTextChunk(reader, "chara")
|
||||
if err != nil {
|
||||
return nil, errors.New("PNG 中没有找到角色卡数据")
|
||||
}
|
||||
|
||||
// 尝试 Base64 解码
|
||||
decodedJSON, err := base64.StdEncoding.DecodeString(charaJSON)
|
||||
if err != nil {
|
||||
// 如果不是 Base64,直接使用原始 JSON
|
||||
decodedJSON = []byte(charaJSON)
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
var card CharacterCardV2
|
||||
err = json.Unmarshal(decodedJSON, &card)
|
||||
if err != nil {
|
||||
return nil, errors.New("解析角色卡数据失败: " + err.Error())
|
||||
}
|
||||
|
||||
return &card, nil
|
||||
}
|
||||
|
||||
// extractTextChunk 从 PNG 中提取指定 key 的 tEXt chunk
|
||||
func extractTextChunk(r io.Reader, key string) (string, error) {
|
||||
// 跳过 PNG signature (8 bytes)
|
||||
signature := make([]byte, 8)
|
||||
if _, err := io.ReadFull(r, signature); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 验证 PNG signature
|
||||
expectedSig := []byte{137, 80, 78, 71, 13, 10, 26, 10}
|
||||
if !bytes.Equal(signature, expectedSig) {
|
||||
return "", errors.New("invalid PNG signature")
|
||||
}
|
||||
|
||||
// 读取所有 chunks
|
||||
for {
|
||||
// 读取 chunk length (4 bytes)
|
||||
lengthBytes := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, lengthBytes); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
length := uint32(lengthBytes[0])<<24 | uint32(lengthBytes[1])<<16 |
|
||||
uint32(lengthBytes[2])<<8 | uint32(lengthBytes[3])
|
||||
|
||||
// 读取 chunk type (4 bytes)
|
||||
chunkType := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, chunkType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 读取 chunk data
|
||||
data := make([]byte, length)
|
||||
if _, err := io.ReadFull(r, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 读取 CRC (4 bytes)
|
||||
crc := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, crc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 检查是否是 tEXt chunk
|
||||
if string(chunkType) == "tEXt" {
|
||||
// tEXt chunk 格式: keyword\0text
|
||||
nullIndex := bytes.IndexByte(data, 0)
|
||||
if nullIndex == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
keyword := string(data[:nullIndex])
|
||||
text := string(data[nullIndex+1:])
|
||||
|
||||
if keyword == key {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
|
||||
// IEND chunk 表示结束
|
||||
if string(chunkType) == "IEND" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("text chunk not found")
|
||||
}
|
||||
|
||||
// EmbedCharacterToPNG 将角色卡数据嵌入到 PNG 图片中
|
||||
func EmbedCharacterToPNG(img image.Image, card *CharacterCardV2) ([]byte, error) {
|
||||
// 序列化角色卡数据
|
||||
cardJSON, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Base64 编码
|
||||
encodedJSON := base64.StdEncoding.EncodeToString(cardJSON)
|
||||
|
||||
// 创建一个 buffer 来写入 PNG
|
||||
var buf bytes.Buffer
|
||||
|
||||
// 写入 PNG signature
|
||||
buf.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10})
|
||||
|
||||
// 编码原始图片到临时 buffer
|
||||
var imgBuf bytes.Buffer
|
||||
if err := png.Encode(&imgBuf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 跳过原始 PNG 的 signature
|
||||
imgData := imgBuf.Bytes()[8:]
|
||||
|
||||
// 将原始图片的 chunks 复制到输出,在 IEND 之前插入 tEXt chunk
|
||||
r := bytes.NewReader(imgData)
|
||||
|
||||
for {
|
||||
// 读取 chunk length
|
||||
lengthBytes := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, lengthBytes); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
length := uint32(lengthBytes[0])<<24 | uint32(lengthBytes[1])<<16 |
|
||||
uint32(lengthBytes[2])<<8 | uint32(lengthBytes[3])
|
||||
|
||||
// 读取 chunk type
|
||||
chunkType := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, chunkType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 读取 chunk data
|
||||
data := make([]byte, length)
|
||||
if _, err := io.ReadFull(r, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 读取 CRC
|
||||
crc := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, crc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果是 IEND chunk,先写入 tEXt chunk
|
||||
if string(chunkType) == "IEND" {
|
||||
// 写入 tEXt chunk
|
||||
writeTextChunk(&buf, "chara", encodedJSON)
|
||||
}
|
||||
|
||||
// 写入原始 chunk
|
||||
buf.Write(lengthBytes)
|
||||
buf.Write(chunkType)
|
||||
buf.Write(data)
|
||||
buf.Write(crc)
|
||||
|
||||
if string(chunkType) == "IEND" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// writeTextChunk 写入 tEXt chunk
|
||||
func writeTextChunk(w io.Writer, keyword, text string) error {
|
||||
data := append([]byte(keyword), 0)
|
||||
data = append(data, []byte(text)...)
|
||||
|
||||
// 写入 length
|
||||
length := uint32(len(data))
|
||||
lengthBytes := []byte{
|
||||
byte(length >> 24),
|
||||
byte(length >> 16),
|
||||
byte(length >> 8),
|
||||
byte(length),
|
||||
}
|
||||
w.Write(lengthBytes)
|
||||
|
||||
// 写入 type
|
||||
w.Write([]byte("tEXt"))
|
||||
|
||||
// 写入 data
|
||||
w.Write(data)
|
||||
|
||||
// 计算并写入 CRC
|
||||
crcData := append([]byte("tEXt"), data...)
|
||||
crc := calculateCRC(crcData)
|
||||
crcBytes := []byte{
|
||||
byte(crc >> 24),
|
||||
byte(crc >> 16),
|
||||
byte(crc >> 8),
|
||||
byte(crc),
|
||||
}
|
||||
w.Write(crcBytes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateCRC 计算 CRC32
|
||||
func calculateCRC(data []byte) uint32 {
|
||||
crc := uint32(0xFFFFFFFF)
|
||||
|
||||
for _, b := range data {
|
||||
crc ^= uint32(b)
|
||||
for i := 0; i < 8; i++ {
|
||||
if crc&1 != 0 {
|
||||
crc = (crc >> 1) ^ 0xEDB88320
|
||||
} else {
|
||||
crc >>= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crc ^ 0xFFFFFFFF
|
||||
}
|
||||
|
||||
// ParseCharacterCardJSON 解析 JSON 格式的角色卡
|
||||
func ParseCharacterCardJSON(jsonData []byte) (*CharacterCardV2, error) {
|
||||
var card CharacterCardV2
|
||||
err := json.Unmarshal(jsonData, &card)
|
||||
if err != nil {
|
||||
return nil, errors.New("解析角色卡 JSON 失败: " + err.Error())
|
||||
}
|
||||
|
||||
return &card, nil
|
||||
}
|
||||
Reference in New Issue
Block a user