🎨 重构用户端前端为vue开发,完善基础类和角色相关接口

This commit is contained in:
2026-02-10 21:55:45 +08:00
parent db934ebed7
commit 56e821b222
92 changed files with 18377 additions and 21 deletions

84
server/utils/app_jwt.go Normal file
View 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")
}

View 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
}