feat(bookmark): 初始化书签服务并配置路由与权限控制

新增书签服务主程序,使用 Gin 框架搭建 HTTP 服务器,并注册书签相关接口处理器。
配置 CORS 中间件以支持跨域请求。服务监听端口为 8081。

feat(user_np): 初始化用户权限服务并注册认证接口

新增用户权限服务主程序,使用 Gin 框架搭建 HTTP 服务器,并注册登录、注册及修改密码等接口处理器。
服务监听端口为 8082。

refactor(config): 重构 OpenAPI 配置文件结构并拆分模块

将原有合并的 OpenAPI 配置文件按功能模块拆分为 bookmark 和 user_np 两个独立目录,
分别管理各自的 server、client 及 API 定义文件,便于后续维护和扩展。

refactor(vfs): 调整虚拟文件系统 API 接口路径与参数定义

更新 VFS API 配置文件,修改部分接口路径及参数结构,
如将文件路径参数由 path 转为 query 参数,并优化响应结构体定义。
This commit is contained in:
zzy
2025-09-23 21:52:51 +08:00
parent 60d6628b0d
commit 1e81e603de
26 changed files with 1832 additions and 1685 deletions

View File

@ -6,50 +6,14 @@ import (
"gorm.io/gorm"
)
// Bookmark 书签结构体
type Bookmark struct {
ID int64 `json:"id" gorm:"primaryKey"`
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
ExternalID int64 `json:"external_id" gorm:"uniqueIndex;not null"`
Name string `json:"name" gorm:"not null;index;size:255"`
Link *string `json:"link" gorm:"type:url"`
Detail *string `json:"detail" gorm:"type:text"`
Description *string `json:"description" gorm:"type:text"`
ParentID int64 `json:"parent_id" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// Folder 文件夹结构体
type Folder struct {
ID int64 `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null;index;size:255"`
ParentID *int64 `json:"parent_id" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// IsValidParent 检查父文件夹ID是否有效
func (f *Folder) IsValidParent(db *gorm.DB, parentID int64) bool {
// 检查父文件夹是否存在且未被删除
if err := db.Where("id = ?", parentID).First(&Folder{}).Error; err != nil {
return false
}
// 防止循环引用(不能将文件夹设置为自己的子文件夹)
if parentID == f.ID {
return false
}
return true
}
// IsValidParent 检查书签的父文件夹ID是否有效
func (b *Bookmark) IsValidParent(db *gorm.DB, parentID int64) bool {
// 检查父文件夹是否存在且未被删除
if err := db.Where("id = ?", parentID).First(&Folder{}).Error; err != nil {
return false
}
return true
}

View File

@ -1,15 +0,0 @@
package models
type PasswordUser struct {
ID int64
Username string
Password string
BookMarkUserID int64
}
type BookMarkUser struct {
ID int64
RootFolderID int64
Token string
Data *string
}

100
internal/models/user_np.go Normal file
View File

@ -0,0 +1,100 @@
// internal/models/user_np.go
package models
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// UserNP 用户名密码认证模型
type UserNP struct {
ID int64 `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"not null;index;size:255;unique"`
Password string `json:"password" gorm:"not null;size:255"`
Email *string `json:"email" gorm:"type:text;unique"`
Token *string `json:"token" gorm:"type:text"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// JWTSecret JWT签名密钥在实际应用中应该从环境变量或配置文件中读取
var JWTSecret = []byte("your-secret-key-change-in-production")
// SimpleClaims 简单的JWT声明结构体
type SimpleClaims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// HashPassword 对密码进行哈希处理
func (u *UserNP) HashPassword(password string) error {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
if err != nil {
return err
}
u.Password = string(bytes)
return nil
}
// CheckPassword 验证密码
func (u *UserNP) CheckPassword(providedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(providedPassword))
return err == nil
}
// GenerateSimpleJWT 生成简单的JWT Token
func (u *UserNP) GenerateSimpleJWT() (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &SimpleClaims{
Username: u.Username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "zzyxyz_user_np_api",
Subject: u.Username,
ID: string(rune(u.ID)), // 将用户ID作为JWT ID
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(JWTSecret)
if err != nil {
return "", err
}
return tokenString, nil
}
// CheckSimpleJWT 验证JWT Token
func CheckSimpleJWT(tokenString string) (string, error) {
claims := &SimpleClaims{}
// 解析token
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return JWTSecret, nil
})
if err != nil {
// 检查是否是token过期错误
if errors.Is(err, jwt.ErrTokenExpired) {
return "", errors.New("token已过期")
}
return "", errors.New("无效的token")
}
// 验证token有效性
if !token.Valid {
return "", errors.New("无效的token")
}
// 返回用户名
return claims.Username, nil
}

291
internal/models/vfs.go Normal file
View File

@ -0,0 +1,291 @@
// vfs.go
package models
import (
"database/sql"
"errors"
"path"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Vfs struct {
DB *sql.DB
}
type VfsNodeType int
const (
VfsNodeTypeFile VfsNodeType = iota
VfsNodeTypeService
VfsNodeTypeDirectory
VfsNodeTypeSymlink
)
type VfsNode struct {
ID uint64
Name string
ParentID uint64
Type VfsNodeType
CreatedAt time.Time
UpdatedAt time.Time
}
type VfsDirEntry struct {
ID uint64
Name string
Type VfsNodeType
}
// NewVfs 创建新的 VFS 实例
func NewVfs(dbPath string) (*Vfs, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
// 创建表
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS vfs_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
type INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(parent_id, name)
)`)
if err != nil {
return nil, err
}
return &Vfs{DB: db}, nil
}
// GetChildrenID 获取目录下所有子项的 ID
func (v *Vfs) GetChildren(parentID uint64) ([]VfsDirEntry, error) {
rows, err := v.DB.Query("SELECT id, name, type FROM vfs_nodes WHERE parent_id = ?", parentID)
if err != nil {
return nil, err
}
defer rows.Close()
var dirEntrys []VfsDirEntry
for rows.Next() {
var entry VfsDirEntry
if err := rows.Scan(&entry.ID, &entry.Name, &entry.Type); err != nil {
return nil, err
}
dirEntrys = append(dirEntrys, entry)
}
return dirEntrys, nil
}
// GetParentID 根据父路径查找父节点 ID
// parentPath 应该是 ParsePathComponents 的第一个返回值
func (v *Vfs) GetParentID(parentPath string) (uint64, error) {
// 根目录特殊处理
if parentPath == "/" || parentPath == "" {
return 0, nil
}
// 递归查找父路径ID
// 先解析父路径的父路径和节点名
grandParentPath, parentName, _, err := ParsePathComponents(parentPath)
if err != nil {
return 0, err
}
// 递归获取祖父节点ID
grandParentID, err := v.GetParentID(grandParentPath)
if err != nil {
return 0, err
}
// 在祖父节点下查找父节点
var parentID uint64
err = v.DB.QueryRow("SELECT id FROM vfs_nodes WHERE parent_id = ? AND name = ?",
grandParentID, parentName).Scan(&parentID)
if err != nil {
if err == sql.ErrNoRows {
return 0, errors.New("parent path not found")
}
return 0, err
}
return parentID, nil
}
// GetNodeByPath 根据完整路径查找节点
func (v *Vfs) GetNodeByPath(fullPath string) (*VfsNode, error) {
// 根目录特殊处理
if path.Clean(fullPath) == "/" {
return &VfsNode{
ID: 0,
Name: "/",
ParentID: 0,
Type: VfsNodeTypeDirectory,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// 使用 ParsePathComponents 解析路径
parentPath, nodeName, nodeType, err := ParsePathComponents(fullPath)
if err != nil {
return nil, err
}
// 获取父节点ID
parentID, err := v.GetParentID(parentPath)
if err != nil {
return nil, err
}
// 根据 parentID, nodeName, nodeType 查找或创建节点
node, err := v.GetNodeByParentIDAndName(parentID, nodeName)
if err != nil {
// 如果节点不存在,可以选择创建它或者返回错误
// 这里根据你的需求决定是返回错误还是创建节点
return nil, err
}
if node.Type != nodeType {
return nil, errors.New("node type mismatch")
}
return node, nil
}
// GetNodeByParentIDAndName 根据父ID和名称查找节点
func (v *Vfs) GetNodeByParentIDAndName(parentID uint64, name string) (*VfsNode, error) {
node := &VfsNode{}
err := v.DB.QueryRow(`
SELECT id, name, parent_id, type, created_at, updated_at
FROM vfs_nodes
WHERE parent_id = ? AND name = ?`, parentID, name).Scan(
&node.ID, &node.Name, &node.ParentID, &node.Type, &node.CreatedAt, &node.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, errors.New("node not found")
}
return nil, err
}
return node, nil
}
// CreateNodeByComponents 根据路径组件创建节点
func (v *Vfs) CreateNodeByComponents(parentPath, nodeName string, nodeType VfsNodeType) (*VfsNode, error) {
// 获取父节点ID
parentID, err := v.GetParentID(parentPath)
if err != nil {
return nil, err
}
// 创建新节点
node := &VfsNode{
Name: nodeName,
ParentID: parentID,
Type: nodeType,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 保存到数据库
result, err := v.DB.Exec(`
INSERT INTO vfs_nodes (name, parent_id, type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
node.Name, node.ParentID, node.Type, node.CreatedAt, node.UpdatedAt)
if err != nil {
return nil, err
}
// 获取插入的ID
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
node.ID = uint64(id)
return node, nil
}
func ParsePathComponents(pathStr string) (parentPath, nodeName string, nodeType VfsNodeType, err error) {
abspath := path.Clean(pathStr)
if !path.IsAbs(abspath) {
return "", "", 0, errors.New("path must be absolute")
}
// 特殊处理根路径
if abspath == "/" {
return "", "", VfsNodeTypeDirectory, nil
}
// 判断是文件还是目录
nodeType = VfsNodeTypeFile
// 如果原始路径以 / 结尾,则为目录
if len(pathStr) > 1 && pathStr[len(pathStr)-1] == '/' {
nodeType = VfsNodeTypeDirectory
}
if nodeType == VfsNodeTypeFile && path.Ext(pathStr) == ".api" {
nodeType = VfsNodeTypeService
}
// 分割路径
parentPath, nodeName = path.Split(abspath)
// 清理父路径
if parentPath != "/" && parentPath != "" {
parentPath = path.Clean(parentPath)
}
// 处理特殊情况
if parentPath == "." {
parentPath = "/"
}
return parentPath, nodeName, nodeType, nil
}
func (v *Vfs) CreateVFSNode(p *VfsNode) error {
_, err := v.DB.Exec(`
INSERT INTO vfs_nodes (name, parent_id, type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
p.Name, p.ParentID, p.Type, time.Now(), time.Now())
return err
}
func (v *Vfs) DeleteVFSNode(p *VfsNode) error {
_, err := v.DB.Exec("DELETE FROM vfs_nodes WHERE id = ?", p.ID)
return err
}
func (v *Vfs) GetVFSNode(p *VfsNode) *VfsNode {
node := &VfsNode{}
err := v.DB.QueryRow(`
SELECT id, name, parent_id, type, created_at, updated_at
FROM vfs_nodes
WHERE id = ?`, p.ID).Scan(
&node.ID, &node.Name, &node.ParentID, &node.Type, &node.CreatedAt, &node.UpdatedAt)
if err != nil {
return nil
}
return node
}
func (v *Vfs) UpdateVFSNode(p *VfsNode) error {
_, err := v.DB.Exec(`
UPDATE vfs_nodes
SET name = ?, parent_id = ?, type = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.ParentID, p.Type, time.Now(), p.ID)
return err
}

129
internal/models/vfs_test.go Normal file
View File

@ -0,0 +1,129 @@
package models_test
import (
"testing"
"git.zzyxyz.com/zzy/zzyxyz_go_api/internal/models"
)
func TestParsePathComponents(t *testing.T) {
tests := []struct {
name string
pathStr string
wantParent string
wantName string
wantNodeType models.VfsNodeType
wantErr bool
}{
{
name: "directory path with trailing slash",
pathStr: "/home/",
wantParent: "/",
wantName: "home",
wantNodeType: models.VfsNodeTypeDirectory,
wantErr: false,
},
{
name: "file path without extension",
pathStr: "/home",
wantParent: "/",
wantName: "home",
wantNodeType: models.VfsNodeTypeFile,
wantErr: false,
},
{
name: "file path with extension",
pathStr: "/home.txt",
wantParent: "/",
wantName: "home.txt",
wantNodeType: models.VfsNodeTypeFile,
wantErr: false,
},
{
name: "nested directory path with trailing slash",
pathStr: "/home/user/",
wantParent: "/home",
wantName: "user",
wantNodeType: models.VfsNodeTypeDirectory,
wantErr: false,
},
{
name: "nested file path",
pathStr: "/home/user/file.txt",
wantParent: "/home/user",
wantName: "file.txt",
wantNodeType: models.VfsNodeTypeFile,
wantErr: false,
},
{
name: "deep nested directory path with trailing slash",
pathStr: "/home/user/documents/",
wantParent: "/home/user",
wantName: "documents",
wantNodeType: models.VfsNodeTypeDirectory,
wantErr: false,
},
{
name: "deep nested file path",
pathStr: "/home/user/documents/file.txt",
wantParent: "/home/user/documents",
wantName: "file.txt",
wantNodeType: models.VfsNodeTypeFile,
wantErr: false,
},
{
name: "relative path should error",
pathStr: ".",
wantErr: true,
},
{
name: "relative path should error",
pathStr: "home.txt",
wantErr: true,
},
{
name: "relative path should error",
pathStr: "home/user/",
wantErr: true,
},
{
name: "path with multiple slashes",
pathStr: "//home//user//",
wantParent: "/home",
wantName: "user",
wantNodeType: models.VfsNodeTypeDirectory,
wantErr: false,
},
{
name: "path with multiple slashes and file",
pathStr: "//home//user//file.txt",
wantParent: "/home/user",
wantName: "file.txt",
wantNodeType: models.VfsNodeTypeFile,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent, name, nodeType, err := models.ParsePathComponents(tt.pathStr)
if (err != nil) != tt.wantErr {
t.Errorf("ParsePathComponents() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if parent != tt.wantParent {
t.Errorf("ParsePathComponents() parent = %v, want %v", parent, tt.wantParent)
}
if name != tt.wantName {
t.Errorf("ParsePathComponents() name = %v, want %v", name, tt.wantName)
}
if nodeType != tt.wantNodeType {
t.Errorf("ParsePathComponents() nodeType = %v, want %v", nodeType, tt.wantNodeType)
}
}
})
}
}