Connect React workspace to backend data

This commit is contained in:
2026-07-20 13:02:26 +08:00
parent 215268e836
commit 875b326ddc
28 changed files with 1017 additions and 257 deletions

View File

@@ -47,6 +47,12 @@ func cors() gin.HandlerFunc {
allowedOrigins := map[string]bool{
"http://localhost:5173": true,
"http://127.0.0.1:5173": true,
"http://localhost:4173": true,
"http://127.0.0.1:4173": true,
"http://localhost:4174": true,
"http://127.0.0.1:4174": true,
"http://localhost:4175": true,
"http://127.0.0.1:4175": true,
"http://tauri.localhost": true,
}

View File

@@ -0,0 +1,36 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Register(router gin.IRouter) {
router.POST("/auth/login", h.login)
}
func (h *Handler) login(c *gin.Context) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
token, err := h.service.Login(input.Email, input.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}

View File

@@ -0,0 +1,72 @@
package auth_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"senlinai-agent/backend/internal/config"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/models"
)
func TestLoginHandlerReturnsSessionToken(t *testing.T) {
newTestDB(t)
service := auth.NewService("test-secret")
invite, err := service.CreateInvite(1, "demo@senlin.ai")
require.NoError(t, err)
_, err = service.RegisterWithInvite(invite, "demo@senlin.ai", "Demo User", "password123")
require.NoError(t, err)
router := httpx.NewProtectedRouter(config.Config{Env: "test"}, service.VerifySession, auth.NewHandler(service))
body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "password123"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var response struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
require.NotEmpty(t, response.Token)
userID, err := service.VerifySession(response.Token)
require.NoError(t, err)
require.Equal(t, uint(1), userID)
}
func TestLoginHandlerRejectsInvalidCredentials(t *testing.T) {
newTestDB(t)
service := auth.NewService("test-secret")
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "demo@senlin.ai", DisplayName: "Demo User", PasswordHash: "not-a-bcrypt-hash"}).Error)
router := httpx.NewProtectedRouter(config.Config{Env: "test"}, service.VerifySession, auth.NewHandler(service))
body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "wrong"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
database, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, models.AutoMigrate(database))
models.DBService = database
return database
}

View File

@@ -11,6 +11,11 @@ const CurrentUserIDKey = "currentUserID"
func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == http.MethodPost && c.Request.URL.Path == "/api/auth/login" {
c.Next()
return
}
header := c.GetHeader("Authorization")
token := strings.TrimPrefix(header, "Bearer ")
if token == "" || token == header {

View File

@@ -0,0 +1,249 @@
package seed
import (
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type DemoOptions struct {
Email string
DisplayName string
Password string
}
type DemoResult struct {
User models.SenlinAgentUser
Projects []models.SenlinAgentProject
}
func Demo(database *gorm.DB, options DemoOptions) (DemoResult, error) {
email := strings.ToLower(strings.TrimSpace(options.Email))
if email == "" {
email = "demo@senlin.ai"
}
displayName := strings.TrimSpace(options.DisplayName)
if displayName == "" {
displayName = "演示用户"
}
password := options.Password
if password == "" {
password = "password123"
}
var result DemoResult
err := database.Transaction(func(tx *gorm.DB) error {
user, err := upsertUser(tx, email, displayName, password)
if err != nil {
return err
}
result.User = user
projects, err := seedProjects(tx, user.ID)
if err != nil {
return err
}
result.Projects = projects
return nil
})
return result, err
}
func upsertUser(tx *gorm.DB, email string, displayName string, password string) (models.SenlinAgentUser, error) {
var user models.SenlinAgentUser
err := tx.Where("email = ?", email).First(&user).Error
if err == nil {
return user, nil
}
if err != gorm.ErrRecordNotFound {
return user, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return user, err
}
user = models.SenlinAgentUser{
Email: email,
DisplayName: displayName,
PasswordHash: string(hash),
Role: "user",
}
return user, tx.Create(&user).Error
}
func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error) {
definitions := []struct {
Name string
Description string
}{
{Name: "项目 A1", Description: "企业智能协作平台的核心工作空间"},
{Name: "项目 A2", Description: "客户成功与交付跟踪"},
{Name: "数据中台", Description: "内部数据治理与指标分析"},
{Name: "运营自动化", Description: "定时任务和业务流程自动化"},
}
projects := make([]models.SenlinAgentProject, 0, len(definitions))
for _, definition := range definitions {
project, err := firstOrCreateProject(tx, ownerID, definition.Name, definition.Description)
if err != nil {
return nil, err
}
projects = append(projects, project)
}
if len(projects) == 0 {
return projects, nil
}
if err := seedProjectA1(tx, ownerID, projects[0].ID); err != nil {
return nil, err
}
for _, project := range projects[1:] {
if err := seedCompactProject(tx, ownerID, project.ID); err != nil {
return nil, err
}
}
return projects, nil
}
func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, description string) (models.SenlinAgentProject, error) {
var project models.SenlinAgentProject
err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error
if err == nil {
return project, nil
}
if err != gorm.ErrRecordNotFound {
return project, err
}
project = models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description}
return project, tx.Create(&project).Error
}
func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
now := time.Now().UTC()
nextRun := now.Add(6 * time.Hour)
dueSoon := now.Add(72 * time.Hour)
dueLater := now.Add(7 * 24 * time.Hour)
for _, tag := range []string{"UI", "知识碎片", "客户反馈", "自动化"} {
if err := firstOrCreateTag(tx, projectID, tag); err != nil {
return err
}
}
for _, item := range []models.SenlinAgentInboxItem{
{ProjectID: projectID, CreatedBy: userID, SourceType: "产品研究", Title: "竞争对手定价更新监控", Body: "跟进企业版价格调整和功能组合变化。", Status: "open"},
{ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
{ProjectID: projectID, CreatedBy: userID, SourceType: "架构讨论", Title: "API 调用策略评估建议", Body: "评估批量任务调度和 AI 限流策略。", Status: "open"},
{ProjectID: projectID, CreatedBy: userID, SourceType: "需求文档", Title: "更新需求文档 v1.3.2", Body: "补充项目频道和属性弹层的交互说明。", Status: "open"},
} {
if err := firstOrCreateInbox(tx, item); err != nil {
return err
}
}
for _, task := range []models.SenlinAgentTask{
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
} {
if err := firstOrCreateTask(tx, task); err != nil {
return err
}
}
for _, session := range []models.SenlinAgentAISession{
{ProjectID: projectID, CreatedBy: userID, Title: "生成 Q3 营销策略初稿", Context: "基于市场分析和竞品数据,输出 Q3 营销策略重点。"},
{ProjectID: projectID, CreatedBy: userID, Title: "产品定价模型讨论", Context: "对企业版与标准版定价模型进行对比分析。"},
{ProjectID: projectID, CreatedBy: userID, Title: "客户流失原因分析", Context: "分析近三个月客户流失数据与反馈原因。"},
{ProjectID: projectID, CreatedBy: userID, Title: "行业趋势与机会洞察", Context: "围绕 AI 在企业协同领域的趋势与机会。"},
} {
if err := firstOrCreateAISession(tx, session); err != nil {
return err
}
}
for _, note := range []models.SenlinAgentNote{
{ProjectID: projectID, CreatedBy: userID, Title: "用户权限体系设计文档 v2.1", Markdown: "# 权限体系\n\n围绕项目、任务和资料分享做保守授权。"},
{ProjectID: projectID, CreatedBy: userID, Title: "导出功能技术方案评审", Markdown: "# 导出功能\n\n记录权限校验和任务队列方案。"},
} {
if err := firstOrCreateNote(tx, note); err != nil {
return err
}
}
for _, source := range []models.SenlinAgentSource{
{ProjectID: projectID, CreatedBy: userID, Kind: "file", Title: "2026 Q3 竞品功能对比表", FilePath: "projects/a1/competitors.xlsx", ContentText: "竞品功能矩阵和价格摘要"},
{ProjectID: projectID, CreatedBy: userID, Kind: "link", Title: "客户访谈记录索引", URL: "https://example.com/interviews", ContentText: "客户访谈和问题归类"},
} {
if err := firstOrCreateSource(tx, source); err != nil {
return err
}
}
for _, plan := range []models.SenlinAgentCronPlan{
{ProjectID: projectID, CreatedBy: userID, Title: "每日 Inbox 摘要", Schedule: "0 9 * * *", NextRunAt: &nextRun, Enabled: true, LastResult: "上次执行成功"},
{ProjectID: projectID, CreatedBy: userID, Title: "每周任务风险扫描", Schedule: "0 10 * * 1", NextRunAt: &dueSoon, Enabled: true, LastResult: "等待下次执行"},
{ProjectID: projectID, CreatedBy: userID, Title: "月底知识归档提醒", Schedule: "0 18 28 * *", NextRunAt: &dueLater, Enabled: false, LastResult: "已暂停"},
} {
if err := firstOrCreateCronPlan(tx, plan); err != nil {
return err
}
}
for _, channel := range []models.SenlinAgentProjectChannel{
{ProjectID: projectID, Title: "客户研究频道", Icon: "user", URL: "https://example.com/research", SortOrder: 7},
{ProjectID: projectID, Title: "产品设计频道", Icon: "link", URL: "https://example.com/design", SortOrder: 8},
} {
if err := firstOrCreateChannel(tx, channel); err != nil {
return err
}
}
return nil
}
func seedCompactProject(tx *gorm.DB, userID uint, projectID uint) error {
item := models.SenlinAgentInboxItem{ProjectID: projectID, CreatedBy: userID, SourceType: "手动收集", Title: "待处理事项", Body: "项目启动资料待整理。", Status: "open"}
if err := firstOrCreateInbox(tx, item); err != nil {
return err
}
task := models.SenlinAgentTask{ProjectID: projectID, CreatedBy: userID, Title: "项目例会纪要整理", Description: "归档本周同步内容。", Status: "open"}
return firstOrCreateTask(tx, task)
}
func firstOrCreateTag(tx *gorm.DB, projectID uint, name string) error {
return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name})
}
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
}
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task)
}
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {
return firstOrCreate(tx, &models.SenlinAgentAISession{}, "project_id = ? AND title = ?", []any{session.ProjectID, session.Title}, session)
}
func firstOrCreateNote(tx *gorm.DB, note models.SenlinAgentNote) error {
return firstOrCreate(tx, &models.SenlinAgentNote{}, "project_id = ? AND title = ?", []any{note.ProjectID, note.Title}, note)
}
func firstOrCreateSource(tx *gorm.DB, source models.SenlinAgentSource) error {
return firstOrCreate(tx, &models.SenlinAgentSource{}, "project_id = ? AND title = ?", []any{source.ProjectID, source.Title}, source)
}
func firstOrCreateCronPlan(tx *gorm.DB, plan models.SenlinAgentCronPlan) error {
return firstOrCreate(tx, &models.SenlinAgentCronPlan{}, "project_id = ? AND title = ?", []any{plan.ProjectID, plan.Title}, plan)
}
func firstOrCreateChannel(tx *gorm.DB, channel models.SenlinAgentProjectChannel) error {
return firstOrCreate(tx, &models.SenlinAgentProjectChannel{}, "project_id = ? AND title = ?", []any{channel.ProjectID, channel.Title}, channel)
}
func firstOrCreate[T any](tx *gorm.DB, model *T, where string, args []any, create T) error {
err := tx.Where(where, args...).First(model).Error
if err == nil {
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
return tx.Create(&create).Error
}

View File

@@ -0,0 +1,65 @@
package seed
import (
"fmt"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/projects"
"senlinai-agent/backend/internal/models"
)
func TestDemoSeedCreatesFrontendWorkspaceData(t *testing.T) {
database := newTestDB(t)
result, err := Demo(database, DemoOptions{
Email: "demo@senlin.ai",
DisplayName: "演示用户",
Password: "password123",
})
require.NoError(t, err)
require.Equal(t, "demo@senlin.ai", result.User.Email)
require.Len(t, result.Projects, 4)
token, err := auth.NewService("test-secret").Login("demo@senlin.ai", "password123")
require.NoError(t, err)
require.NotEmpty(t, token)
workspace, err := projects.NewService().Workspace(result.User.ID, result.Projects[0].ID)
require.NoError(t, err)
require.Equal(t, "项目 A1", workspace.Project.Name)
require.GreaterOrEqual(t, len(workspace.Inbox), 4)
require.GreaterOrEqual(t, len(workspace.Tasks), 3)
require.GreaterOrEqual(t, len(workspace.AISessions), 4)
require.GreaterOrEqual(t, len(workspace.NotesSources), 3)
require.GreaterOrEqual(t, len(workspace.CronPlans), 3)
require.Len(t, workspace.Channels, 8)
}
func TestDemoSeedIsIdempotent(t *testing.T) {
database := newTestDB(t)
first, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
require.NoError(t, err)
second, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
require.NoError(t, err)
require.Equal(t, first.User.ID, second.User.ID)
require.Len(t, second.Projects, 4)
var userCount int64
require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", "demo@senlin.ai").Count(&userCount).Error)
require.Equal(t, int64(1), userCount)
}
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, models.AutoMigrate(database))
models.DBService = database
return database
}