commit 3ad559f05e319dc6eb743e494f6ea7c3a63d57d2 Author: Fendy Date: Wed Jul 15 22:42:31 2026 +0800 migrate from github diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8624cb6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.gitignore +config.example.toml +pkg/gen/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..857a6d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +http_test/ + +# binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# debug binaries +__debug_bin* + +# config +config.yaml +config.toml + +# generated gRPC code +pkg/gen/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4634924 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.24-alpine AS dev +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct + +RUN apk add --no-cache wget && \ + go install github.com/go-delve/delve/cmd/dlv@v1.24.2 && \ + wget -O /usr/local/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/v0.4.22/grpc_health_probe-linux-amd64 && \ + chmod +x /usr/local/bin/grpc_health_probe + +COPY go.mod ./ +RUN go mod download + +EXPOSE 8080 +EXPOSE 9090 +EXPOSE 2345 + +CMD ["sh", "-c", "go mod download && go run ./cmd/migrate && go run ./cmd/db_init && go run ./cmd/es_init && dlv debug --headless --listen=:2345 --api-version=2 --accept-multiclient --continue --log ./cmd/main.go"] diff --git a/Dockerfile.notification.prod b/Dockerfile.notification.prod new file mode 100644 index 0000000..e278571 --- /dev/null +++ b/Dockerfile.notification.prod @@ -0,0 +1,32 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct + +RUN apk add --no-cache protobuf +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.5 && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 + +COPY backend/go.mod backend/go.sum ./backend/ +COPY backend ./backend +COPY proto ./proto + +WORKDIR /app/backend + +RUN go mod download +RUN mkdir -p /app/backend/pkg/gen && \ + protoc -I /app/proto \ + --go_out=/app/backend/pkg/gen --go_opt=paths=source_relative \ + --go-grpc_out=/app/backend/pkg/gen --go-grpc_opt=paths=source_relative \ + /app/proto/yoresee_doc/v1/yoresee_doc.proto +RUN go mod tidy +RUN CGO_ENABLED=0 go build -p 1 -o notification_worker.out ./cmd/notification-worker + +FROM alpine:latest AS runner + +WORKDIR /app + +COPY --from=builder /app/backend/notification_worker.out . + +CMD ["./notification_worker.out"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..44b9e8c --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,41 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct + +RUN apk add --no-cache protobuf +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.5 && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 + +COPY backend/go.mod backend/go.sum ./backend/ +COPY backend ./backend +COPY proto ./proto + +WORKDIR /app/backend + +RUN go mod download +RUN mkdir -p /app/backend/pkg/gen && \ + protoc -I /app/proto \ + --go_out=/app/backend/pkg/gen --go_opt=paths=source_relative \ + --go-grpc_out=/app/backend/pkg/gen --go-grpc_opt=paths=source_relative \ + /app/proto/yoresee_doc/v1/yoresee_doc.proto +RUN go mod tidy +RUN CGO_ENABLED=0 go build -p 1 -o main.out cmd/main.go && \ + CGO_ENABLED=0 go build -p 1 -o migrate.out ./cmd/migrate && \ + CGO_ENABLED=0 go build -p 1 -o db_init.out ./cmd/db_init && \ + CGO_ENABLED=0 go build -p 1 -o es_init.out ./cmd/es_init + +FROM alpine:latest AS runner + +WORKDIR /app + +COPY --from=builder /app/backend/main.out . +COPY --from=builder /app/backend/migrate.out . +COPY --from=builder /app/backend/db_init.out . +COPY --from=builder /app/backend/es_init.out . + +EXPOSE 8080 +EXPOSE 9090 + +CMD ["sh", "-c", "./migrate.out && ./db_init.out && ./es_init.out && ./main.out"] diff --git a/Dockerfile.search-sync.prod b/Dockerfile.search-sync.prod new file mode 100644 index 0000000..8438e93 --- /dev/null +++ b/Dockerfile.search-sync.prod @@ -0,0 +1,32 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct + +RUN apk add --no-cache protobuf +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.5 && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 + +COPY backend/go.mod backend/go.sum ./backend/ +COPY backend ./backend +COPY proto ./proto + +WORKDIR /app/backend + +RUN go mod download +RUN mkdir -p /app/backend/pkg/gen && \ + protoc -I /app/proto \ + --go_out=/app/backend/pkg/gen --go_opt=paths=source_relative \ + --go-grpc_out=/app/backend/pkg/gen --go-grpc_opt=paths=source_relative \ + /app/proto/yoresee_doc/v1/yoresee_doc.proto +RUN go mod tidy +RUN CGO_ENABLED=0 go build -p 1 -o search_sync_worker.out ./cmd/search-sync-worker + +FROM alpine:latest AS runner + +WORKDIR /app + +COPY --from=builder /app/backend/search_sync_worker.out . + +CMD ["./search_sync_worker.out"] diff --git a/Dockerfile.snapshot.prod b/Dockerfile.snapshot.prod new file mode 100644 index 0000000..a05913b --- /dev/null +++ b/Dockerfile.snapshot.prod @@ -0,0 +1,32 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct + +RUN apk add --no-cache protobuf +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.5 && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 + +COPY backend/go.mod backend/go.sum ./backend/ +COPY backend ./backend +COPY proto ./proto + +WORKDIR /app/backend + +RUN go mod download +RUN mkdir -p /app/backend/pkg/gen && \ + protoc -I /app/proto \ + --go_out=/app/backend/pkg/gen --go_opt=paths=source_relative \ + --go-grpc_out=/app/backend/pkg/gen --go-grpc_opt=paths=source_relative \ + /app/proto/yoresee_doc/v1/yoresee_doc.proto +RUN go mod tidy +RUN CGO_ENABLED=0 go build -p 1 -o snapshot_worker.out ./cmd/snapshot-worker + +FROM alpine:latest AS runner + +WORKDIR /app + +COPY --from=builder /app/backend/snapshot_worker.out . + +CMD ["./snapshot_worker.out"] diff --git a/cmd/db_init/admin.go b/cmd/db_init/admin.go new file mode 100644 index 0000000..a981238 --- /dev/null +++ b/cmd/db_init/admin.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func createUserInTx(tx *gorm.DB, username, email, password string) error { + logrus.Printf("Creating user %s in transaction...", username) + + hashedPwd, err := utils.HashPassword(password) + if err != nil { + return err + } + externalID := utils.GenerateExternalID(utils.ExternalIDContextDocument) + + user := model.User{ + ExternalID: externalID, + Username: username, + PasswordHash: hashedPwd, + Email: email, + Nickname: username, + Status: 1, + } + + var count int64 + tx.Model(&model.User{}).Where("email = ?", user.Email).Count(&count) + if count == 0 { + if err := tx.Create(&user).Error; err != nil { + return err + } + logrus.Printf("User %s created successfully in transaction.", username) + } else { + logrus.Printf("User %s already exists in transaction.", username) + } + + return nil +} + +func createUserWithUsername(tx *gorm.DB, username string) error { + email := fmt.Sprintf("%s@yoresee.cc", username) + password := username + return createUserInTx(tx, username, email, password) +} + +func createAdminUserInTx(tx *gorm.DB) error { + logrus.Println("Creating admin user in transaction...") + + if err := createUserWithUsername(tx, "admin"); err != nil { + return err + } + + logrus.Println("Admin permission granted successfully in transaction.") + return nil +} diff --git a/cmd/db_init/config.go b/cmd/db_init/config.go new file mode 100644 index 0000000..859cf75 --- /dev/null +++ b/cmd/db_init/config.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "github.com/XingfenD/yoresee_doc/internal/constant" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/storage" +) + +func initializeConfigInConsul(ctx context.Context) error { + registerModeKey := utils.GenConfigKey( + constant.ConfigKey_First_System, + constant.ConfigKey_Second_Security, + constant.ConfigKey_Third_RegisterMode, + ) + + if _, ok, err := storage.Consul.Get(ctx, registerModeKey); err != nil { + return err + } else if !ok { + if err := storage.Consul.Set(ctx, registerModeKey, constant.RegisterMode_Invite); err != nil { + return err + } + } + return nil +} + +func markDatabaseInitializedInConsul(ctx context.Context) error { + initializedKey := utils.GenConfigKey( + constant.ConfigKey_First_System, + constant.ConfigKey_Second_Database, + constant.ConfigKey_Third_Initialized, + ) + return storage.Consul.Set(ctx, initializedKey, constant.Database_Initialized_True) +} diff --git a/cmd/db_init/document.go b/cmd/db_init/document.go new file mode 100644 index 0000000..9652455 --- /dev/null +++ b/cmd/db_init/document.go @@ -0,0 +1,182 @@ +package main + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type DocumentInput struct { + Title string + Content string + Summary string + ParentID int64 + Tags []string + KnowledgeID *int64 +} + +func createDocumentWithAllTables(tx *gorm.DB, adminUserID int64, input DocumentInput) (*model.Document, error) { + document := model.Document{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextDocument), + Title: input.Title, + Type: "markdown", + Summary: input.Summary, + ParentID: input.ParentID, + UserID: adminUserID, + Content: input.Content, + Tags: input.Tags, + ViewCount: 0, + EditCount: 0, + KnowledgeID: input.KnowledgeID, + Path: "0", + Depth: 0, + } + + if err := tx.Create(&document).Error; err != nil { + return nil, err + } + if err := updateDocumentPathDepth(tx, document.ID, document.ParentID); err != nil { + return nil, err + } + + documentVersion := model.DocumentVersion{ + DocumentID: document.ID, + Version: 1, + Title: document.Title, + Content: document.Content, + UserID: adminUserID, + ChangeSummary: "Initial version", + } + + if err := tx.Create(&documentVersion).Error; err != nil { + return nil, err + } + + return &document, nil +} + +func initializeDocumentsInTx(tx *gorm.DB) error { + logrus.Println("Initializing default documents in transaction...") + + var adminUser model.User + if err := tx.Where("username = ?", "admin").First(&adminUser).Error; err != nil { + return err + } + + var count int64 + tx.Model(&model.Document{}).Where("title = ?", "欢迎使用 Yoresee Doc").Count(&count) + if count > 0 { + logrus.Println("Default document already exists in transaction.") + return nil + } + contentString := "# 欢迎使用 Yoresee Doc\n\n这是您的第一个文档。Yoresee Doc 是一个功能强大的文档管理系统,支持以下特性:\n\n- 📝 富文本编辑\n- 📁 文档分类管理\n- 🔍 全文搜索\n- 👥 协作编辑\n- 📊 版本控制\n- 🔒 权限管理\n\n## 快速开始\n\n1. 点击左侧菜单创建新文档\n2. 使用编辑器撰写内容\n3. 保存文档并分享给团队成员\n\n祝您使用愉快!" + + document := model.Document{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextDocument), + Title: "欢迎使用 Yoresee Doc", + Type: "markdown", + Summary: "Yoresee Doc 系统欢迎文档", + ParentID: 0, + UserID: adminUser.ID, + Content: contentString, + Tags: []string{"guide", "welcome"}, + ViewCount: 0, + EditCount: 0, + Path: "0", + Depth: 0, + } + + if err := tx.Create(&document).Error; err != nil { + return err + } + if err := updateDocumentPathDepth(tx, document.ID, document.ParentID); err != nil { + return err + } + + documentVersion := model.DocumentVersion{ + DocumentID: document.ID, + Version: 1, + Title: document.Title, + Content: contentString, + UserID: adminUser.ID, + ChangeSummary: "Initial version", + } + + if err := tx.Create(&documentVersion).Error; err != nil { + return err + } + + logrus.Println("Adding child documents to welcome document...") + + childDocumentTitles := []string{ + "功能介绍", + "使用教程", + "常见问题", + "更新日志", + "联系我们", + } + + for _, title := range childDocumentTitles { + childDoc, err := createDocumentWithAllTables(tx, adminUser.ID, DocumentInput{ + Title: title, + Content: "# " + title + "\n\n这是" + title + "的详细文档内容。", + Summary: title + "相关文档", + ParentID: document.ID, + Tags: []string{"welcome", title}, + KnowledgeID: nil, + }) + if err != nil { + logrus.Warnf("Failed to create child document %s: %v", title, err) + continue + } + + for k := 1; k <= 2; k++ { + grandchildTitle := title + " - 详情" + string(rune('0'+k)) + _, err := createDocumentWithAllTables(tx, adminUser.ID, DocumentInput{ + Title: grandchildTitle, + Content: "# " + grandchildTitle + "\n\n这是" + grandchildTitle + "的详细文档内容。", + Summary: grandchildTitle + "相关文档", + ParentID: childDoc.ID, + Tags: []string{"welcome", title, "详情"}, + KnowledgeID: nil, + }) + if err != nil { + logrus.Warnf("Failed to create grandchild document %s: %v", grandchildTitle, err) + } + } + } + + logrus.Println("Child documents added to welcome document successfully") + + logrus.Println("Default document created successfully in transaction") + return nil +} + +func updateDocumentPathDepth(tx *gorm.DB, docID int64, parentID int64) error { + if parentID == 0 { + return tx.Exec(` + UPDATE document_metas + SET path = (id::text)::ltree, depth = 0 + WHERE id = ? + `, docID).Error + } + + type pathDepth struct { + Path string + Depth int + } + var parent pathDepth + if err := tx.Model(&model.Document{}). + Select("path, depth"). + Where("id = ?", parentID). + Take(&parent).Error; err != nil { + return err + } + + return tx.Exec(` + UPDATE document_metas + SET path = (?::ltree) || (id::text)::ltree, depth = ? + WHERE id = ? + `, parent.Path, parent.Depth+1, docID).Error +} diff --git a/cmd/db_init/knowledge_base.go b/cmd/db_init/knowledge_base.go new file mode 100644 index 0000000..5982df6 --- /dev/null +++ b/cmd/db_init/knowledge_base.go @@ -0,0 +1,117 @@ +package main + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func initializeKnowledgeBasesInTx(tx *gorm.DB) error { + logrus.Println("Initializing default knowledge bases in transaction...") + + var adminUser model.User + if err := tx.Where("username = ?", "admin").First(&adminUser).Error; err != nil { + return err + } + + var count int64 + tx.Model(&model.KnowledgeBase{}).Where("name = ?", "默认知识库").Count(&count) + if count > 0 { + logrus.Println("Default knowledge base already exists in transaction.") + return nil + } + + defaultKnowledgeBase := model.KnowledgeBase{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDKnowledgeBase), + Name: "默认知识库", + Description: "系统默认创建的知识库,用于存储常用文档和资料", + Cover: "", + CreatorUserID: adminUser.ID, + IsPublic: false, + } + + if err := tx.Create(&defaultKnowledgeBase).Error; err != nil { + return err + } + + exampleKnowledgeBase := model.KnowledgeBase{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDKnowledgeBase), + Name: "示例知识库", + Description: "示例知识库,展示知识库功能", + Cover: "", + CreatorUserID: adminUser.ID, + IsPublic: true, + } + + if err := tx.Create(&exampleKnowledgeBase).Error; err != nil { + return err + } + + logrus.Println("Adding documents to default knowledge base...") + + rootDocumentTitles := []string{ + "项目概述", + "系统架构", + "开发规范", + "API文档", + "数据库设计", + "前端开发", + "后端开发", + "测试计划", + "部署指南", + "安全规范", + "用户手册", + } + + for _, title := range rootDocumentTitles { + rootDoc, err := createDocumentWithAllTables(tx, adminUser.ID, DocumentInput{ + Title: title, + Content: "# " + title + "\n\n这是" + title + "的详细文档内容。", + Summary: title + "相关文档", + ParentID: 0, + Tags: []string{title}, + KnowledgeID: &defaultKnowledgeBase.ID, + }) + if err != nil { + logrus.Warnf("Failed to create root document %s: %v", title, err) + continue + } + + for j := 1; j <= 3; j++ { + childTitle := title + " - 子文档" + string(rune('0'+j)) + childDoc, err := createDocumentWithAllTables(tx, adminUser.ID, DocumentInput{ + Title: childTitle, + Content: "# " + childTitle + "\n\n这是" + childTitle + "的详细文档内容。", + Summary: childTitle + "相关文档", + ParentID: rootDoc.ID, + Tags: []string{title, "子文档"}, + KnowledgeID: &defaultKnowledgeBase.ID, + }) + if err != nil { + logrus.Warnf("Failed to create child document %s: %v", childTitle, err) + continue + } + + for k := 1; k <= 2; k++ { + grandchildTitle := childTitle + " - 孙文档" + string(rune('0'+k)) + _, err := createDocumentWithAllTables(tx, adminUser.ID, DocumentInput{ + Title: grandchildTitle, + Content: "# " + grandchildTitle + "\n\n这是" + grandchildTitle + "的详细文档内容。", + Summary: grandchildTitle + "相关文档", + ParentID: childDoc.ID, + Tags: []string{title, "子文档", "孙文档"}, + KnowledgeID: &defaultKnowledgeBase.ID, + }) + if err != nil { + logrus.Warnf("Failed to create grandchild document %s: %v", grandchildTitle, err) + } + } + } + } + + logrus.Println("Documents added to default knowledge base successfully") + + logrus.Println("Default knowledge bases created successfully in transaction") + return nil +} diff --git a/cmd/db_init/main.go b/cmd/db_init/main.go new file mode 100644 index 0000000..e312921 --- /dev/null +++ b/cmd/db_init/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/bootstrap" + "github.com/XingfenD/yoresee_doc/internal/constant" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func main() { + initializer := bootstrap.NewInitializer(). + InitConfig(). + InitPostgres(). + InitConsul(). + RequireConsulEnabled() + if err := initializer.Err(); err != nil { + logrus.Fatalf("Init db_init failed: %v", err) + } + defer initializer.Shutdown() + + if isDatabaseInitialized() { + logrus.Println("Database already initialized, skipping initialization steps") + } else { + if err := initializeDatabaseInTransaction(); err != nil { + logrus.Fatalf("Database initialization failed: %v", err) + } + if err := initializeConfigInConsul(context.Background()); err != nil { + logrus.Fatalf("Consul config initialization failed: %v", err) + } + if err := markDatabaseInitializedInConsul(context.Background()); err != nil { + logrus.Fatalf("Mark database initialized failed: %v", err) + } + logrus.Println("Database initialized successfully") + } + + logrus.Println("All initialization tasks completed successfully!") +} + +func isDatabaseInitialized() bool { + value, ok, err := storage.Consul.Get(context.Background(), utils.GenConfigKey( + constant.ConfigKey_First_System, + constant.ConfigKey_Second_Database, + constant.ConfigKey_Third_Initialized, + )) + if err != nil || !ok { + return false + } + return value == constant.Database_Initialized_True +} + +func initializeDatabaseInTransaction() error { + return utils.WithTransaction(storage.DB, func(tx *gorm.DB) error { + if err := initializePermissionsInTx(tx); err != nil { + return err + } + + if err := createAdminUserInTx(tx); err != nil { + return err + } + + if err := createTestUserIntx(tx); err != nil { + return err + } + + if err := initializeDocumentsInTx(tx); err != nil { + return err + } + + if err := initializeKnowledgeBasesInTx(tx); err != nil { + return err + } + + if err := initializeTemplatesInTx(tx); err != nil { + return err + } + + if err := createNormalUserInTx(tx); err != nil { + return err + } + + if err := initializeUserGroupsInTx(tx); err != nil { + return err + } + + return nil + }) +} diff --git a/cmd/db_init/other_user.go b/cmd/db_init/other_user.go new file mode 100644 index 0000000..3d8f2dc --- /dev/null +++ b/cmd/db_init/other_user.go @@ -0,0 +1,57 @@ +package main + +import ( + "strconv" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func createTestUserIntx(tx *gorm.DB) error { + logrus.Println("Creating test user in transaction...") + return createUserWithUsername(tx, "test") +} + +func createNormalUserInTx(tx *gorm.DB) error { + logrus.Println("Creating 100 users in transaction...") + + for i := 1; i <= 20; i++ { + username := "user" + strconv.Itoa(i) + if err := createUserWithUsername(tx, username); err != nil { + return err + } + + var user model.User + if err := tx.Where("username = ?", username).First(&user).Error; err != nil { + return err + } + userNum := strconv.Itoa(i) + logrus.Printf("User%s created successfully with ID: %s in transaction.\n", userNum, user.ExternalID) + + logrus.Printf("Creating knowledge base for user%s...\n", userNum) + + kbExternalID := utils.GenerateExternalID(utils.ExternalIDKnowledgeBase) + knowledgeBase := model.KnowledgeBase{ + ExternalID: kbExternalID, + Name: "User" + userNum + "'s Knowledge Base", + Description: "Knowledge base created by user" + userNum, + CreatorUserID: user.ID, + IsPublic: false, + } + + var kbCount int64 + tx.Model(&model.KnowledgeBase{}).Where("name = ?", knowledgeBase.Name).Count(&kbCount) + if kbCount == 0 { + if err := tx.Create(&knowledgeBase).Error; err != nil { + return err + } + logrus.Printf("Knowledge base created successfully for user%s: %s\n", userNum, knowledgeBase.Name) + } else { + logrus.Printf("Knowledge base for user%s already exists.\n", userNum) + } + } + + return nil +} diff --git a/cmd/db_init/permissions.go b/cmd/db_init/permissions.go new file mode 100644 index 0000000..8f389c4 --- /dev/null +++ b/cmd/db_init/permissions.go @@ -0,0 +1,22 @@ +package main + +import ( + "gorm.io/gorm" +) + +func initializePermissionsInTx(tx *gorm.DB) error { + // logrus.Println("Starting permission initialization in transaction...") + + // adminSubject := model.Subject{ + // ID: "1", + // Type: model.SubjectTypeUser, + // } + + // if err := tx.Where("id = ?", adminSubject.ID).FirstOrCreate(&adminSubject).Error; err != nil { + // return err + // } + // logrus.Println("Admin subject created successfully in transaction.") + + // logrus.Println("Permission initialization completed successfully in transaction.") + return nil +} diff --git a/cmd/db_init/template.go b/cmd/db_init/template.go new file mode 100644 index 0000000..6ac6caf --- /dev/null +++ b/cmd/db_init/template.go @@ -0,0 +1,141 @@ +package main + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type templateSeed struct { + Name string + Description string + Content string + Scope string + KnowledgeBaseID *int64 + Tags []string +} + +func initializeTemplatesInTx(tx *gorm.DB) error { + logrus.Println("Initializing default templates in transaction...") + + var adminUser model.User + if err := tx.Where("username = ?", "admin").First(&adminUser).Error; err != nil { + return err + } + + var defaultKB model.KnowledgeBase + if err := tx.Where("name = ?", "默认知识库").First(&defaultKB).Error; err != nil { + return err + } + + seeds := []templateSeed{ + { + Name: "个人周报模板", + Description: "用于个人每周工作复盘与计划", + Content: `# 个人周报 + +## 本周完成 +- + +## 问题与风险 +- + +## 下周计划 +- `, + Scope: "private", + Tags: []string{"个人", "周报"}, + }, + { + Name: "会议纪要模板", + Description: "通用会议记录模板", + Content: `# 会议纪要 + +## 会议信息 +- 时间: +- 参与人: + +## 讨论内容 +- + +## 行动项 +- [ ] `, + Scope: "private", + Tags: []string{"个人", "会议"}, + }, + { + Name: "需求评审模板", + Description: "知识库内需求评审记录模板", + KnowledgeBaseID: &defaultKB.ID, + Scope: "knowledge_base", + Content: `# 需求评审 + +## 背景 + +## 目标 + +## 方案评审 +- 优点: +- 风险: + +## 结论`, + Tags: []string{"知识库", "需求"}, + }, + { + Name: "技术方案模板", + Description: "知识库内技术方案沉淀模板", + KnowledgeBaseID: &defaultKB.ID, + Scope: "knowledge_base", + Content: `# 技术方案 + +## 问题定义 + +## 方案设计 + +## 数据模型 + +## 发布计划 +`, + Tags: []string{"知识库", "技术"}, + }, + } + + for _, seed := range seeds { + if err := createTemplateIfNotExists(tx, adminUser.ID, seed); err != nil { + return err + } + } + + logrus.Println("Default templates initialized successfully in transaction") + return nil +} + +func createTemplateIfNotExists(tx *gorm.DB, userID int64, seed templateSeed) error { + query := tx.Model(&model.Template{}). + Where("user_id = ? AND name = ? AND scope = ?", userID, seed.Name, seed.Scope) + if seed.KnowledgeBaseID == nil { + query = query.Where("knowledge_base_id IS NULL") + } else { + query = query.Where("knowledge_base_id = ?", *seed.KnowledgeBaseID) + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + + template := model.Template{ + Name: seed.Name, + Description: seed.Description, + DocumentType: model.DocumentType_Markdown, + Content: seed.Content, + UserID: userID, + Scope: seed.Scope, + KnowledgeBaseID: seed.KnowledgeBaseID, + IsPublic: false, + Tags: seed.Tags, + } + return tx.Create(&template).Error +} diff --git a/cmd/db_init/user_group.go b/cmd/db_init/user_group.go new file mode 100644 index 0000000..df83e85 --- /dev/null +++ b/cmd/db_init/user_group.go @@ -0,0 +1,127 @@ +package main + +import ( + "errors" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type seedUserGroup struct { + Name string + Description string + MemberUsers []string +} + +func initializeUserGroupsInTx(tx *gorm.DB) error { + logrus.Println("Creating user groups in transaction...") + + adminUser, err := getUserByUsername(tx, "admin") + if err != nil { + return err + } + user2, err := getUserByUsername(tx, "user2") + if err != nil { + return err + } + + seedGroups := []seedUserGroup{ + { + Name: "Admin Group", + Description: "Group for admin only", + MemberUsers: []string{"admin"}, + }, + { + Name: "User2 Group", + Description: "Group for user2 only", + MemberUsers: []string{"user2"}, + }, + { + Name: "Admin & User2 Group", + Description: "Group for admin and user2", + MemberUsers: []string{"admin", "user2"}, + }, + { + Name: "Empty Group", + Description: "Group with no members", + MemberUsers: []string{}, + }, + } + + for _, seed := range seedGroups { + group, err := findOrCreateGroup(tx, seed.Name, seed.Description, adminUser.ID) + if err != nil { + return err + } + for _, username := range seed.MemberUsers { + var userID int64 + switch username { + case "admin": + userID = adminUser.ID + case "user2": + userID = user2.ID + default: + continue + } + if err := ensureMembership(tx, group.ID, userID); err != nil { + return err + } + } + } + + logrus.Println("User groups initialized successfully in transaction.") + return nil +} + +func getUserByUsername(tx *gorm.DB, username string) (*model.User, error) { + var user model.User + if err := tx.Where("username = ?", username).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func findOrCreateGroup(tx *gorm.DB, name, description string, creatorID int64) (*model.UserGroupMeta, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, gorm.ErrInvalidValue + } + + var group model.UserGroupMeta + if err := tx.Where("name = ?", name).First(&group).Error; err == nil { + return &group, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + group = model.UserGroupMeta{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextUserGroup), + Name: name, + Description: strings.TrimSpace(description), + CreatorID: creatorID, + } + if err := tx.Create(&group).Error; err != nil { + return nil, err + } + return &group, nil +} + +func ensureMembership(tx *gorm.DB, groupID, userID int64) error { + var count int64 + if err := tx.Model(&model.MembershipRelation{}). + Where("type = ? AND membership_id = ? AND user_id = ?", model.MembershipType_UserGroup, groupID, userID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + return tx.Create(&model.MembershipRelation{ + Type: model.MembershipType_UserGroup, + MembershipID: groupID, + UserID: userID, + }).Error +} diff --git a/cmd/es_init/main.go b/cmd/es_init/main.go new file mode 100644 index 0000000..114009a --- /dev/null +++ b/cmd/es_init/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "errors" + + "github.com/XingfenD/yoresee_doc/internal/bootstrap" + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/constant" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/search" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +const reindexBatchSize = 200 + +var errESInitDisabled = errors.New("es_init disabled") + +func main() { + initializer := bootstrap.NewInitializer(). + InitConfig(). + Check("check elasticsearch enabled", func() error { + if config.GlobalConfig == nil || !config.GlobalConfig.Elasticsearch.Enabled { + return errESInitDisabled + } + return nil + }). + InitPostgres(). + InitConsul(). + RequireConsulEnabled(). + InitElasticsearch() + if err := initializer.Err(); err != nil { + if errors.Is(err, errESInitDisabled) { + logrus.Println("Elasticsearch disabled, skip es_init") + return + } + logrus.Fatalf("Init es_init failed: %v", err) + } + + defer initializer.Shutdown() + + ctx := context.Background() + indexName := search.DocumentIndexName() + initialized := isESInitialized(ctx) + + exists, err := storage.ES.IndexExists(ctx, indexName) + if err != nil { + logrus.Fatalf("Check Elasticsearch index failed, index=%s, err=%v", indexName, err) + } + + if initialized && exists { + logrus.Printf("Elasticsearch already initialized, index=%s", indexName) + return + } + + if !exists { + if err := storage.ES.CreateIndex(ctx, indexName, search.BuildDocumentIndexMapping()); err != nil { + logrus.Fatalf("Create Elasticsearch index failed, index=%s, err=%v", indexName, err) + } + logrus.Printf("Elasticsearch index created, index=%s", indexName) + } + + if err := reindexDocuments(ctx); err != nil { + logrus.Fatalf("Reindex documents failed: %v", err) + } + + if err := markESInitializedInConsul(ctx); err != nil { + logrus.Fatalf("Mark Elasticsearch initialized failed: %v", err) + } + + logrus.Println("Elasticsearch initialization completed successfully") +} + +func isESInitialized(ctx context.Context) bool { + value, ok, err := storage.Consul.Get(ctx, utils.GenConfigKey( + constant.ConfigKey_First_System, + constant.ConfigKey_Second_ES, + constant.ConfigKey_Third_Initialized, + )) + if err != nil || !ok { + return false + } + return value == constant.ES_Initialized_True +} + +func markESInitializedInConsul(ctx context.Context) error { + return storage.Consul.Set( + ctx, + utils.GenConfigKey( + constant.ConfigKey_First_System, + constant.ConfigKey_Second_ES, + constant.ConfigKey_Third_Initialized, + ), + constant.ES_Initialized_True, + ) +} + +func reindexDocuments(ctx context.Context) error { + logrus.Println("Start reindexing documents to Elasticsearch...") + + var docs []model.Document + return storage.DB. + Model(&model.Document{}). + Where("deleted_at IS NULL"). + FindInBatches(&docs, reindexBatchSize, func(tx *gorm.DB, batch int) error { + for i := range docs { + doc := docs[i] + if err := search.UpsertDocument(ctx, &doc); err != nil { + return err + } + } + logrus.Printf("Reindex batch done, batch=%d, size=%d", batch, len(docs)) + return nil + }).Error +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..2be3872 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "net/http" + "time" + + "github.com/XingfenD/yoresee_doc/internal/bootstrap" + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/transport/connectserver" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" +) + +func main() { + initializer := bootstrap.NewInitializer(). + InitConfig(). + InitPostgres(). + InitRedis(). + InitConsul(). + RequireConsulEnabled(). + InitMinio(). + InitElasticsearchAllowFail(). + InitMQ(). + InitRepository(). + InitService() + if err := initializer.Err(); err != nil { + logrus.Fatalf("Init backend failed: %v", err) + } + + grpcServer, grpcWebServer, err := connectserver.Start( + config.GlobalConfig.Server.GrpcPort, + config.GlobalConfig.Server.GrpcWebPort, + initializer.Repositories.DB, + initializer.Repositories.Redis, + initializer.Repositories, + ) + if err != nil { + logrus.Fatalf("Start connect servers failed: %v", err) + } + + waitForShutdown(initializer, grpcServer, grpcWebServer) +} + +func waitForShutdown(initializer *bootstrap.Initializer, grpcServer, grpcWebServer *http.Server) { + signalCtx, stop := utils.ShutdownContext() + defer stop() + + <-signalCtx.Done() + logrus.Info("Shutdown signal received, start draining") + connectserver.SetDraining(true) + + shutdownServer("grpc-web", grpcWebServer, 10*time.Second) + shutdownServer("grpc", grpcServer, 10*time.Second) + + initializer.Shutdown() +} + +func shutdownServer(name string, server *http.Server, timeout time.Duration) { + if server == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + logrus.Errorf("Shutdown %s server failed: %v", name, err) + } +} diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..00f0d32 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/sirupsen/logrus" + + "github.com/XingfenD/yoresee_doc/internal/bootstrap" +) + +func main() { + initializer := bootstrap.NewInitializer(). + InitConfig(). + InitPostgres() + if err := initializer.Err(); err != nil { + logrus.Fatalf("Init migrate failed: %v", err) + } + defer initializer.Shutdown() + + logrus.Println("Starting migration...") + + if err := runMigration(); err != nil { + logrus.Fatalf("Migration failed: %v", err) + } + logrus.Println("Database migration completed successfully") + +} diff --git a/cmd/migrate/migration.go b/cmd/migrate/migration.go new file mode 100644 index 0000000..7ad78f6 --- /dev/null +++ b/cmd/migrate/migration.go @@ -0,0 +1,69 @@ +package main + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func runMigration() error { + if err := storage.DB.Exec("CREATE EXTENSION IF NOT EXISTS ltree").Error; err != nil { + return err + } + logrus.Println("ltree extension created successfully") + + err := storage.DB.AutoMigrate( + &model.Attachment{}, + &model.Document{}, + &model.DocumentComment{}, + &model.DocumentYjsSnapshot{}, + &model.DocumentVersion{}, + &model.Invitation{}, + &model.InvitationRecord{}, + &model.KnowledgeBase{}, + &model.RecentDocument{}, + &model.RecentKnowledgeBase{}, + &model.RecentTemplate{}, + &model.MembershipRelation{}, + &model.Notification{}, + &model.OrgNodeMeta{}, + &model.Template{}, + &model.UserGroupMeta{}, + &model.User{}, + ) + if err != nil { + return err + } + + if err := migrateRecentKnowledgeBaseIndex(storage.DB); err != nil { + return err + } + if err := migrateRecentDocumentIndex(storage.DB); err != nil { + return err + } + if err := migrateRecentTemplateIndex(storage.DB); err != nil { + return err + } + if err := dropDocumentCommentQuote(storage.DB); err != nil { + return err + } + + return nil +} + +func migrateRecentKnowledgeBaseIndex(db *gorm.DB) error { + return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_recent_kb_user_kb ON recent_knowledge_bases (user_id, knowledge_base_id)").Error +} + +func migrateRecentTemplateIndex(db *gorm.DB) error { + return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_recent_tpl_user_tpl ON recent_templates (user_id, template_id)").Error +} + +func migrateRecentDocumentIndex(db *gorm.DB) error { + return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_recent_doc_user_doc ON recent_documents (user_id, document_id)").Error +} + +func dropDocumentCommentQuote(db *gorm.DB) error { + return db.Exec("ALTER TABLE document_comments DROP COLUMN IF EXISTS quote").Error +} diff --git a/cmd/notification-worker/main.go b/cmd/notification-worker/main.go new file mode 100644 index 0000000..eaed43c --- /dev/null +++ b/cmd/notification-worker/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/internal/bootstrap" + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/service/mq_service" + "github.com/XingfenD/yoresee_doc/internal/service/notification_service" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/mq" + "github.com/sirupsen/logrus" +) + +func main() { + initializer := bootstrap.NewInitializer(). + InitConfig(). + InitPostgres(). + InitRedis(). + InitMQ(). + InitRepository(). + InitService() + if err := initializer.Err(); err != nil { + logrus.Fatalf("Init notification-worker failed: %v", err) + } + + topic := domain_event.NotificationCreateTopic() + group := utils.GetEnvVar("NOTIFICATION_MQ_GROUP", "notification-worker") + + go func() { + if err := mq_service.MQSvc.Consume( + context.Background(), + mq.BackendRabbitMQ, + mq.ConsumeOptions{ + Topic: topic, + Mode: mq.ConsumeModeGroup, + Group: group, + AutoAck: false, + OnError: mq.ErrorActionRequeue, + }, + func(ctx context.Context, message mq.Message) error { + return handleNotificationEvent(ctx, message.Body) + }, + ); err != nil { + logrus.Fatalf("Subscribe failed: %v", err) + } + }() + + initializer.ShutdownOnSignal(500 * time.Millisecond) +} + +func handleNotificationEvent(ctx context.Context, data []byte) error { + payload := strings.TrimSpace(string(data)) + if payload == "" { + return nil + } + + evt, err := domain_event.DecodeNotificationCreateEvent(data) + if err != nil { + logrus.Errorf("parse notification event failed: %v", err) + return nil + } + + req := &dto.CreateNotificationRequest{ + ReceiverExternalIDs: evt.ReceiverExternalIDs, + Type: evt.Type, + Title: evt.Title, + Content: evt.Content, + PayloadJSON: evt.PayloadJSON, + } + if err := notification_service.NotificationSvc.CreateNotifications(req); err != nil { + logrus.Errorf("create notifications failed: %v", err) + return err + } + return nil +} diff --git a/cmd/search-sync-worker/main.go b/cmd/search-sync-worker/main.go new file mode 100644 index 0000000..96f98f8 --- /dev/null +++ b/cmd/search-sync-worker/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "errors" + "time" + + "github.com/XingfenD/yoresee_doc/internal/bootstrap" + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/repository/document_repo" + "github.com/XingfenD/yoresee_doc/internal/search" + "github.com/XingfenD/yoresee_doc/internal/service/mq_service" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/mq" + "github.com/sirupsen/logrus" +) + +var errSearchSyncWorkerDisabled = errors.New("search-sync-worker disabled") + +func main() { + initializer, err := initSearchSyncWorker() + if err != nil { + if errors.Is(err, errSearchSyncWorkerDisabled) { + logrus.Println("Elasticsearch disabled, skip search-sync-worker") + return + } + logrus.Fatalf("Init search-sync-worker failed: %v", err) + } + + backend := mq.BackendRabbitMQ + topic := domain_event.DocumentSyncTopic() + group := utils.GetEnvVar("SEARCH_SYNC_MQ_GROUP", "search-sync-worker") + logrus.Infof("Search sync worker started: backend=%s topic=%s group=%s", backend, topic, group) + + go func() { + if err := mq_service.MQSvc.Consume( + context.Background(), + backend, + mq.ConsumeOptions{ + Topic: topic, + Mode: mq.ConsumeModeGroup, + Group: group, + AutoAck: false, + OnError: mq.ErrorActionRequeue, + }, + func(ctx context.Context, message mq.Message) error { + return handleDocumentEvent(ctx, message.Body, initializer.Repositories.Document) + }, + ); err != nil { + logrus.Fatalf("Subscribe search sync topic failed: %v", err) + } + }() + + initializer.ShutdownOnSignal(500 * time.Millisecond) +} + +func initSearchSyncWorker() (*bootstrap.Initializer, error) { + initializer := bootstrap.NewInitializer(). + InitConfig(). + Check("check elasticsearch enabled", func() error { + if config.GlobalConfig == nil || !config.GlobalConfig.Elasticsearch.Enabled { + return errSearchSyncWorkerDisabled + } + return nil + }). + InitPostgres(). + InitRedis(). + InitElasticsearch(). + InitMQ(). + InitRepository() + return initializer, initializer.Err() +} + +func handleDocumentEvent(ctx context.Context, data []byte, docRepo *document_repo.DocumentRepository) error { + evt, err := domain_event.DecodeDocumentSyncEvent(data) + if err != nil { + logrus.Warnf("Parse search sync event failed: %v", err) + return nil + } + + switch evt.Action { + case domain_event.DocumentActionUpsert: + doc, err := docRepo.GetByExternalID(evt.ExternalID).Exec(ctx) + if err != nil { + logrus.Warnf("Search sync load document failed, external_id=%s, err=%v", evt.ExternalID, err) + return err + } + if err := search.UpsertDocument(ctx, doc); err != nil { + logrus.Warnf("Search sync upsert failed, external_id=%s, err=%v", evt.ExternalID, err) + return err + } + logrus.Infof("Search sync upsert success, external_id=%s", evt.ExternalID) + case domain_event.DocumentActionDelete: + // reserved for future hard-delete sync. + default: + logrus.Warnf("Search sync skip unknown action, action=%s, external_id=%s", evt.Action, evt.ExternalID) + } + return nil +} diff --git a/cmd/snapshot-worker/main.go b/cmd/snapshot-worker/main.go new file mode 100644 index 0000000..30cd756 --- /dev/null +++ b/cmd/snapshot-worker/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "net/http" + "sync" + "time" + + "github.com/XingfenD/yoresee_doc/internal/bootstrap" + "github.com/XingfenD/yoresee_doc/internal/service/mq_service" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/constant" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/XingfenD/yoresee_doc/pkg/mq" + "github.com/sirupsen/logrus" +) + +func main() { + initializer := bootstrap.NewInitializer(). + InitConfig(). + InitPostgres(). + InitRedis(). + InitElasticsearchAllowFail(). + InitMQ(). + InitRepository(). + InitService() + if err := initializer.Err(); err != nil { + logrus.Fatalf("Init snapshot-worker failed: %v", err) + } + + w := &Worker{ + collabCoreHTTP: utils.GetEnvVar("COLLAB_CORE_HTTP", "http://collab-core:1234"), + mqGroup: utils.GetEnvVar("SNAPSHOT_MQ_GROUP", "snapshot-worker"), + topic: constant.DirtyDocTopicDefault, + mqBackend: mq.BackendRabbitMQ, + client: &http.Client{Timeout: 10 * time.Second}, + dirtySetKey: key.KeyCollabDirtyDocSet(), + inFlight: &sync.Map{}, + } + + logrus.Infof("Snapshot worker started: topic=%s group=%s collabCore=%s", w.topic, w.mqGroup, w.collabCoreHTTP) + + go w.runMQConsumer(mq_service.MQSvc) + go w.runScanLoop() + + initializer.ShutdownOnSignal(0) +} diff --git a/cmd/snapshot-worker/message.go b/cmd/snapshot-worker/message.go new file mode 100644 index 0000000..034bb2b --- /dev/null +++ b/cmd/snapshot-worker/message.go @@ -0,0 +1,29 @@ +package main + +import ( + "strings" + + "github.com/bytedance/sonic" +) + +type dirtyDocMessage struct { + DocID string `json:"doc_id"` + DocId string `json:"docId"` +} + +func parseDocID(data []byte) string { + payload := strings.TrimSpace(string(data)) + if payload == "" { + return "" + } + var msg dirtyDocMessage + if err := sonic.Unmarshal(data, &msg); err == nil { + if msg.DocID != "" { + return msg.DocID + } + if msg.DocId != "" { + return msg.DocId + } + } + return payload +} diff --git a/cmd/snapshot-worker/snapshot.go b/cmd/snapshot-worker/snapshot.go new file mode 100644 index 0000000..98e4944 --- /dev/null +++ b/cmd/snapshot-worker/snapshot.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/service/document_service" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/bytedance/sonic" + "github.com/sirupsen/logrus" +) + +func (w *Worker) snapshotDoc(ctx context.Context, docID string, force bool) error { + if _, loaded := w.inFlight.LoadOrStore(docID, struct{}{}); loaded { + return nil + } + defer w.inFlight.Delete(docID) + + logrus.Infof("Snapshot start docId=%s force=%v", docID, force) + + docType, err := document_service.DocumentSvc.GetDocumentTypeByExternalID(ctx, docID) + if err != nil { + if errors.Is(err, status.StatusDocumentNotFound) { + logrus.Infof("Snapshot doc not found docId=%s, cleaning up Redis", docID) + cleanupCollabKeys(ctx, docID) + return nil + } + logrus.Errorf("Snapshot get doc type failed docId=%s err=%v", docID, err) + return err + } + + state, content, err := w.fetchSnapshot(ctx, docID, string(docType)) + if err != nil { + logrus.Errorf("Snapshot fetch failed docId=%s err=%v", docID, err) + return err + } + if len(state) == 0 { + logrus.Infof("Snapshot empty docId=%s", docID) + return nil + } + logrus.Infof("Snapshot fetched docId=%s bytes=%d", docID, len(state)) + + contentChanged, err := document_service.DocumentSvc.SaveDocumentSnapshotAndContent(ctx, docID, state, content) + if err != nil { + logrus.Errorf("Snapshot save failed docId=%s err=%v", docID, err) + return err + } + if contentChanged { + if pubErr := domain_event.PublishDocumentUpsertEvent(ctx, docID); pubErr != nil { + logrus.Warnf("Snapshot publish search sync event failed docId=%s err=%v", docID, pubErr) + } + } + + if err := flushCollabKeys(ctx, docID, state); err != nil { + logrus.Errorf("Snapshot redis flush failed docId=%s err=%v", docID, err) + return err + } + + if force { + logrus.Infof("Snapshot saved (mq) for %s", docID) + } else { + logrus.Infof("Snapshot saved (scan) for %s", docID) + } + return nil +} + +func (w *Worker) fetchSnapshot(ctx context.Context, docID, docType string) (state []byte, content string, err error) { + url := fmt.Sprintf("%s/internal/yjs/doc-snapshot/%s?type=%s", strings.TrimRight(w.collabCoreHTTP, "/"), docID, docType) + resp, err := w.client.Get(url) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, "", nil + } + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + + var payload struct { + State string `json:"state"` + Content string `json:"content"` + } + if err := sonic.Unmarshal(body, &payload); err != nil { + return nil, "", err + } + if payload.State == "" { + return nil, payload.Content, nil + } + + state, err = utils.DecodeBase64(payload.State) + if err != nil { + return nil, "", err + } + return state, payload.Content, nil +} + +func flushCollabKeys(ctx context.Context, docID string, state []byte) error { + rdb := storage.GetRedis() + if rdb == nil { + return nil + } + updatesKey := key.KeyCollabDocUpdates(docID) + pipe := rdb.TxPipeline() + pipe.Del(ctx, updatesKey) + pipe.RPush(ctx, updatesKey, state) + pipe.SRem(ctx, key.KeyCollabDirtyDocSet(), docID) + _, err := pipe.Exec(ctx) + return err +} + +func cleanupCollabKeys(ctx context.Context, docID string) { + rdb := storage.GetRedis() + if rdb == nil { + return + } + pipe := rdb.TxPipeline() + pipe.SRem(ctx, key.KeyCollabDirtyDocSet(), docID) + pipe.Del(ctx, key.KeyCollabDocUpdates(docID)) + pipe.Del(ctx, key.KeyCollabRoom(docID)) + if _, err := pipe.Exec(ctx); err != nil { + logrus.Warnf("Snapshot cleanup Redis failed docId=%s err=%v", docID, err) + } +} diff --git a/cmd/snapshot-worker/worker.go b/cmd/snapshot-worker/worker.go new file mode 100644 index 0000000..2161f05 --- /dev/null +++ b/cmd/snapshot-worker/worker.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/XingfenD/yoresee_doc/internal/service/mq_service" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/XingfenD/yoresee_doc/pkg/mq" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" +) + +type Worker struct { + collabCoreHTTP string + mqGroup string + topic string + mqBackend mq.Backend + client *http.Client + dirtySetKey string + inFlight *sync.Map +} + +func (w *Worker) runMQConsumer(mqSvc *mq_service.MQService) { + err := mqSvc.Consume( + context.Background(), + w.mqBackend, + mq.ConsumeOptions{ + Topic: w.topic, + Mode: mq.ConsumeModeGroup, + Group: w.mqGroup, + AutoAck: false, + OnError: mq.ErrorActionRequeue, + }, + func(ctx context.Context, message mq.Message) error { + docID := parseDocID(message.Body) + if docID == "" { + return nil + } + return w.snapshotDoc(ctx, docID, true) + }, + ) + if err != nil { + logrus.Fatalf("MQ consumer failed: %v", err) + } +} + +func (w *Worker) runScanLoop() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for range ticker.C { + w.scanAndSnapshot() + } +} + +func (w *Worker) scanAndSnapshot() { + ctx := context.Background() + rdb := storage.GetRedis() + if rdb == nil { + return + } + + docIDs, err := rdb.SMembers(ctx, w.dirtySetKey).Result() + if err != nil { + logrus.Errorf("Scan dirty docs failed: %v", err) + return + } + + candidates := filterCandidates(ctx, docIDs) + logrus.Infof("Scan dirty docs: candidates=%d", len(candidates)) + if len(candidates) > 0 { + logrus.Infof("Dirty doc candidates: %v", candidates) + } + for _, docID := range candidates { + _ = w.snapshotDoc(ctx, docID, false) + } +} + +func filterCandidates(ctx context.Context, docIDs []string) []string { + now := time.Now().UnixMilli() + candidates := make([]string, 0, len(docIDs)) + for _, docID := range docIDs { + if docID == "" { + continue + } + lastStr, err := storage.GetRedis().Get(ctx, key.KeyCollabRoom(docID)).Result() + if err != nil { + logrus.Infof("Dirty doc %s skipped: room key missing", docID) + continue + } + last, err := utils.ParseInt64(lastStr) + if err != nil { + logrus.Infof("Dirty doc %s skipped: invalid room timestamp %s", docID, lastStr) + continue + } + if now-last < 10_000 { + logrus.Infof("Dirty doc %s skipped: lastEditAgo=%dms", docID, now-last) + continue + } + candidates = append(candidates, docID) + } + return candidates +} diff --git a/config.toml.tmpl b/config.toml.tmpl new file mode 100644 index 0000000..04a52bf --- /dev/null +++ b/config.toml.tmpl @@ -0,0 +1,87 @@ +[server] +grpc_port = ${BACKEND_GRPC_PORT} +grpc_web_port = ${BACKEND_GRPC_WEB_PORT} +read_timeout = 10 +write_timeout = 10 + +[database] +host = "${POSTGRES_HOST}" +port = ${POSTGRES_PORT} +user = "${POSTGRES_USER}" +password = "${POSTGRES_PASSWORD}" +name = "${POSTGRES_DB}" +# max_idle_conns = 10 +# max_open_conns = 100 +# conn_max_lifetime = 3600 +# charset = "utf8mb4" + +[redis] +host = "${REDIS_HOST}" +port = ${REDIS_PORT} +password = "${REDIS_PASSWORD}" +db = ${REDIS_DB} +timeout = 5 + +[consul] +enabled = true +address = "${CONSUL_HOST}:${CONSUL_PORT}" +scheme = "http" +token = "${CONSUL_ROOT_TOKEN}" +datacenter = "" +prefix = "${CONSUL_PREFIX}" + +[minio] +endpoint = "${MINIO_HOST}:${MINIO_API_PORT}" +access_key = "${MINIO_ROOT_USER}" +secret_key = "${MINIO_ROOT_PASSWORD}" +bucket = "${MINIO_BUCKET}" +use_ssl = false +timeout = 30 + +[elasticsearch] +enabled = ${ELASTICSEARCH_ENABLED} +addresses = ["http://${ELASTICSEARCH_HOST}:${ELASTICSEARCH_PORT}"] +username = "${ELASTICSEARCH_USERNAME}" +password = "${ELASTICSEARCH_PASSWORD}" +index_prefix = "${ELASTICSEARCH_INDEX_PREFIX}" +timeout = 5 + +[mq_config] + +# [backend.mq_config.redis] + +[mq_config.rabbitmq] +url = "amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@${RABBITMQ_HOST}:${RABBITMQ_AMQP_PORT}/" + +[backend] +system_name = "${SYSTEM_NAME}" +internal_rpc_key = "${BACKEND_INTERNAL_RPC_KEY}" + +[backend.jwt] +secret = "${JWT_SECRET}" +expire = 86400 # 24 hours +refresh_expire = 604800 # 7 days + +[backend.log] +level = "debug" +# Optional: silent/error/warn/info. When empty, follows backend.log.level. +# gorm_log_level = "warn" +file = "logs/app.log" +console = true +file_log = true + +[backend.document] +max_size = 10 +allowed_extensions = [".md", ".txt", ".pdf", ".doc", ".docx"] +storage_path = "uploads" + +[backend.security] +password_hash_cost = 12 +max_login_attempts = 5 +login_lock_duration = 30 +[backend.security.cors] +allowed_origins = ["*"] +allowed_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] +allowed_headers = ["Origin", "Content-Type", "Authorization"] +allow_credentials = true +max_age = 86400 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ae6107 --- /dev/null +++ b/go.mod @@ -0,0 +1,74 @@ +module github.com/XingfenD/yoresee_doc + +go 1.24.1 + +require ( + connectrpc.com/connect v1.19.1 + github.com/bwmarrin/snowflake v0.3.0 + github.com/bytedance/gg v1.1.0 + github.com/bytedance/sonic v1.14.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/improbable-eng/grpc-web v0.15.0 + github.com/minio/minio-go/v7 v7.0.98 + github.com/rabbitmq/amqp091-go v1.10.0 + github.com/redis/go-redis/v9 v9.18.0 + github.com/sirupsen/logrus v1.9.4 + github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.46.0 + golang.org/x/net v0.48.0 + golang.org/x/sync v0.19.0 + google.golang.org/grpc v1.79.2 + google.golang.org/protobuf v1.36.10 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cenkalti/backoff/v4 v4.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/rs/cors v1.7.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + nhooyr.io/websocket v1.8.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6b9b233 --- /dev/null +++ b/go.sum @@ -0,0 +1,652 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/bytedance/gg v1.1.0 h1:FSKRxOZeN30w7h6snEbHxzgVMUV7+Xu4gc/Lz1cmBFw= +github.com/bytedance/gg v1.1.0/go.mod h1:MeGhXyy5K20hNAU9GkMM51sXdm/lsqdU0CxwIiGvZpo= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ= +github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f h1:U5y3Y5UE0w7amNe7Z5G/twsBW0KEalRQXZzf8ufSh9I= +github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= +github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/improbable-eng/grpc-web v0.15.0 h1:BN+7z6uNXZ1tQGcNAuaU1YjsLTApzkjt2tzCixLaUPQ= +github.com/improbable-eng/grpc-web v0.15.0/go.mod h1:1sy9HKV4Jt9aEs9JSnkWlRJPuPtwNr0l57L4f878wP8= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= +github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/grpc-proxy v0.0.0-20181017164139-0f1106ef9c76/go.mod h1:x5OoJHDHqxHS801UIuhqGl6QdSAEJvtausosHSdazIo= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= +github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.3.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k= +nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..2d6bbd5 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,67 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/golang-jwt/jwt/v5" +) + +type JWTValidator struct{} + +func (v *JWTValidator) Validate(tokenString string) (*Claims, error) { + return ParseToken(tokenString) +} + +func (v *JWTValidator) IsExpired(claims *Claims) bool { + return time.Now().After(claims.ExpiresAt.Time) +} + +type Claims struct { + ExternalID string `json:"external_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +func GenerateToken(externalID string, username string) (string, error) { + cfg := config.GlobalConfig.Backend.Jwt + now := time.Now() + expireTime := now.Add(time.Duration(cfg.Expire) * time.Second) + + claims := Claims{ + ExternalID: externalID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expireTime), + Issuer: "doc_manager", + }, + } + + tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + token, err := tokenClaims.SignedString([]byte(cfg.Secret)) + if err != nil { + return "", err + } + + if err := StoreJWTToken(externalID, token, time.Until(expireTime)); err != nil { + return "", fmt.Errorf("store jwt token failed: %w", err) + } + + return token, nil +} + +func ParseToken(token string) (*Claims, error) { + cfg := config.GlobalConfig.Backend.Jwt + tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(cfg.Secret), nil + }) + + if tokenClaims != nil { + if claims, ok := tokenClaims.Claims.(*Claims); ok && tokenClaims.Valid { + return claims, nil + } + } + + return nil, err +} diff --git a/internal/auth/jwt_redis.go b/internal/auth/jwt_redis.go new file mode 100644 index 0000000..9cd5a9e --- /dev/null +++ b/internal/auth/jwt_redis.go @@ -0,0 +1,102 @@ +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/redis/go-redis/v9" +) + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func StoreJWTToken(userExternalID, token string, ttl time.Duration) error { + if storage.KVS == nil { + return status.StatusRedisNotInitialized + } + if ttl <= 0 { + return status.GenErrWithCustomMsg(status.StatusInternalParamsError, "invalid jwt ttl") + } + + ctx := context.Background() + tokenHash := hashToken(token) + pipe := storage.KVS.TxPipeline() + pipe.Set(ctx, key.KeyJWTActiveToken(tokenHash), userExternalID, ttl) + pipe.SAdd(ctx, key.KeyJWTUserTokenSet(userExternalID), tokenHash) + pipe.Expire(ctx, key.KeyJWTUserTokenSet(userExternalID), ttl) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("persist jwt token failed: %w", err) + } + return nil +} + +func ValidateJWTTokenInRedis(userExternalID, token string) error { + if storage.KVS == nil { + return status.StatusRedisNotInitialized + } + + ctx := context.Background() + tokenHash := hashToken(token) + blacklisted, err := storage.KVS.Exists(ctx, key.KeyJWTBlacklistToken(tokenHash)).Result() + if err != nil { + return fmt.Errorf("query jwt blacklist failed: %w", err) + } + if blacklisted > 0 { + return status.GenErrWithCustomMsg(status.StatusTokenInvalid, "token is blacklisted") + } + + storedUserExternalID, err := storage.KVS.Get(ctx, key.KeyJWTActiveToken(tokenHash)).Result() + if err != nil { + if err == redis.Nil { + return status.GenErrWithCustomMsg(status.StatusTokenInvalid, "token is invalid") + } + return fmt.Errorf("query active jwt token failed: %w", err) + } + if storedUserExternalID != userExternalID { + return status.GenErrWithCustomMsg(status.StatusTokenInvalid, "token user mismatch") + } + return nil +} + +func BlacklistUserJWTs(userExternalID string) error { + if storage.KVS == nil { + return status.StatusRedisNotInitialized + } + + ctx := context.Background() + tokenHashes, err := storage.KVS.SMembers(ctx, key.KeyJWTUserTokenSet(userExternalID)).Result() + if err != nil { + return fmt.Errorf("list user jwt tokens failed: %w", err) + } + if len(tokenHashes) == 0 { + return nil + } + + pipe := storage.KVS.TxPipeline() + for _, tokenHash := range tokenHashes { + activeKey := key.KeyJWTActiveToken(tokenHash) + ttlCmd := storage.KVS.TTL(ctx, activeKey) + ttl, ttlErr := ttlCmd.Result() + if ttlErr != nil && ttlErr != redis.Nil { + return fmt.Errorf("query token ttl failed: %w", ttlErr) + } + if ttl <= 0 { + ttl = 24 * time.Hour + } + pipe.Set(ctx, key.KeyJWTBlacklistToken(tokenHash), "1", ttl) + pipe.Del(ctx, activeKey) + } + pipe.Del(ctx, key.KeyJWTUserTokenSet(userExternalID)) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("blacklist user jwt tokens failed: %w", err) + } + return nil +} diff --git a/internal/bootstrap/initializer.go b/internal/bootstrap/initializer.go new file mode 100644 index 0000000..74ec838 --- /dev/null +++ b/internal/bootstrap/initializer.go @@ -0,0 +1,274 @@ +package bootstrap + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/service" + "github.com/XingfenD/yoresee_doc/internal/service/mq_service" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" +) + +type Initializer struct { + err error + shutdownHooks []utils.ShutdownHook + hookNames map[string]struct{} + shutdownOnce sync.Once + Repositories *repository.Repositories +} + +func NewInitializer() *Initializer { + return &Initializer{ + hookNames: make(map[string]struct{}), + } +} + +func (i *Initializer) Check(step string, fn func() error) *Initializer { + if i.err != nil { + return i + } + if err := fn(); err != nil { + i.err = fmt.Errorf("%s failed: %w", step, err) + } + return i +} + +func (i *Initializer) CheckAllowFail(step string, fn func() error) *Initializer { + if i.err != nil { + return i + } + if err := fn(); err != nil { + logrus.Warnf("%s failed, continue with degraded mode: %v", step, err) + } + return i +} + +func (i *Initializer) InitConfig() *Initializer { + return i.Check("config.InitConfig", config.InitConfig) +} + +func (i *Initializer) InitPostgres() *Initializer { + i.Check("storage.OpenPostgres", func() error { + dbCfg := config.GlobalConfig.Database + logCfg := config.GlobalConfig.Backend.Log + gormLogLevel := logCfg.GormLogLevel + if gormLogLevel == "" { + gormLogLevel = logCfg.Level + } + + db, err := storage.OpenPostgres(&storage.PostgresOptions{ + Host: dbCfg.Host, + Port: dbCfg.Port, + User: dbCfg.User, + Password: dbCfg.Password, + Name: dbCfg.Name, + MaxIdleConns: dbCfg.MaxIdleConns, + MaxOpenConns: dbCfg.MaxOpenConns, + GormLogLevel: gormLogLevel, + }) + if err != nil { + return err + } + storage.DB = db + return nil + }) + + return i.addShutdownHook("Postgres", func() error { + if storage.DB == nil { + return nil + } + sqlDB, err := storage.DB.DB() + if err != nil { + return err + } + storage.DB = nil + return sqlDB.Close() + }) +} + +func (i *Initializer) InitRedis() *Initializer { + i.Check("storage.NewRedisClient", func() error { + cfg := config.GlobalConfig.Redis + client, err := storage.NewRedisClient(&storage.RedisOptions{ + Host: cfg.Host, + Port: cfg.Port, + Password: cfg.Password, + DB: cfg.DB, + }) + if err != nil { + return err + } + storage.KVS = client + return nil + }) + + return i.addShutdownHook("Redis", func() error { + if storage.KVS == nil { + return nil + } + client := storage.KVS + storage.KVS = nil + return client.Close() + }) +} + +func (i *Initializer) InitConsul() *Initializer { + return i.Check("storage.NewConsulClient", func() error { + cfg := config.GlobalConfig.Consul + storage.Consul = storage.NewConsulClient(&storage.ConsulOptions{ + Enabled: cfg.Enabled, + Address: cfg.Address, + Scheme: cfg.Scheme, + Token: cfg.Token, + Datacenter: cfg.Datacenter, + Prefix: cfg.Prefix, + }) + return nil + }) +} + +func (i *Initializer) RequireConsulEnabled() *Initializer { + return i.Check("storage.ConsulEnabled", func() error { + if !storage.ConsulEnabled() { + return fmt.Errorf("consul is required but not enabled") + } + return nil + }) +} + +func (i *Initializer) InitMinio() *Initializer { + return i.Check("storage.NewMinioClient", func() error { + cfg := config.GlobalConfig.Minio + client, err := storage.NewMinioClient(&storage.MinioOptions{ + Endpoint: cfg.Endpoint, + AccessKey: cfg.AccessKey, + SecretKey: cfg.SecretKey, + UseSSL: cfg.UseSSL, + }) + if err != nil { + return err + } + if err = storage.EnsureBucket(context.Background(), client, cfg.Bucket); err != nil { + return err + } + if err = storage.EnsureBucketPublicRead(context.Background(), client, cfg.Bucket); err != nil { + return err + } + storage.MinioClient = client + return nil + }) +} + +func (i *Initializer) InitElasticsearch() *Initializer { + i.Check("storage.NewElasticsearchClient", func() error { + cfg := config.GlobalConfig.Elasticsearch + client, err := storage.NewElasticsearchClient(&storage.ElasticsearchOptions{ + Enabled: cfg.Enabled, + Addresses: cfg.Addresses, + Username: cfg.Username, + Password: cfg.Password, + Timeout: cfg.Timeout, + }) + if err != nil { + return err + } + storage.ES = client + return nil + }) + + return i.addShutdownHook("Elasticsearch", func() error { + storage.ES = nil + return nil + }) +} + +func (i *Initializer) InitElasticsearchAllowFail() *Initializer { + i.CheckAllowFail("storage.NewElasticsearchClient", func() error { + cfg := config.GlobalConfig.Elasticsearch + client, err := storage.NewElasticsearchClient(&storage.ElasticsearchOptions{ + Enabled: cfg.Enabled, + Addresses: cfg.Addresses, + Username: cfg.Username, + Password: cfg.Password, + Timeout: cfg.Timeout, + }) + if err != nil { + return err + } + storage.ES = client + return nil + }) + if storage.ES != nil { + i.addShutdownHook("Elasticsearch", func() error { + storage.ES = nil + return nil + }) + } + return i +} + +func (i *Initializer) InitMQ() *Initializer { + i.Check("mq_service.Init", func() error { + return mq_service.MQSvc.Init(&config.GlobalConfig.MQConfig) + }) + return i.addShutdownHook("MQ", mq_service.MQSvc.Close) +} + +func (i *Initializer) InitRepository() *Initializer { + return i.Check("repository.NewRepositories", func() error { + i.Repositories = repository.NewRepositories(storage.DB, storage.KVS) + return nil + }) +} + +func (i *Initializer) InitService() *Initializer { + return i.Check("service.Init", func() error { + return service.Init(config.GlobalConfig, i.Repositories) + }) +} + +func (i *Initializer) Err() error { + return i.err +} + +func (i *Initializer) Shutdown() { + i.shutdownOnce.Do(func() { + if len(i.shutdownHooks) == 0 { + return + } + reversed := make([]utils.ShutdownHook, 0, len(i.shutdownHooks)) + for idx := len(i.shutdownHooks) - 1; idx >= 0; idx-- { + reversed = append(reversed, i.shutdownHooks[idx]) + } + utils.RunShutdownHooks(reversed...) + }) +} + +func (i *Initializer) ShutdownOnSignal(delay time.Duration) { + utils.WaitForShutdownSignal() + if delay > 0 { + time.Sleep(delay) + } + i.Shutdown() +} + +func (i *Initializer) addShutdownHook(name string, fn func() error) *Initializer { + if i.err != nil || fn == nil || name == "" { + return i + } + if _, exists := i.hookNames[name]; exists { + return i + } + i.hookNames[name] = struct{}{} + i.shutdownHooks = append(i.shutdownHooks, utils.ShutdownHook{ + Name: name, + Fn: fn, + }) + return i +} diff --git a/internal/cache/cache_loader.go b/internal/cache/cache_loader.go new file mode 100644 index 0000000..7bbdc03 --- /dev/null +++ b/internal/cache/cache_loader.go @@ -0,0 +1,144 @@ +package cache + +import ( + "context" + "errors" + "math/rand" + "time" + + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/bytedance/sonic" + "github.com/redis/go-redis/v9" + "golang.org/x/sync/singleflight" + "gorm.io/gorm" +) + +type Loader struct { + redis *redis.Client + sf singleflight.Group +} + +func (l *Loader) Redis() *redis.Client { + return l.redis +} + +func NewLoader(redis *redis.Client) *Loader { + return &Loader{ + redis: redis, + } +} + +type CacheLoadOperation[T any] struct { + loader *Loader + keyParserMap map[string]Parser[T] + defaultKey *string + baseTTL time.Duration + dbLoader func() (*T, error) + valueType bool +} + +func NewCacheLoadOperation[T any](loader *Loader) *CacheLoadOperation[T] { + return &CacheLoadOperation[T]{ + loader: loader, + baseTTL: time.Hour, + valueType: false, + keyParserMap: make(map[string]Parser[T]), + } +} + +func (op *CacheLoadOperation[T]) WithDefaultKeyAndParser(key string, parser Parser[T]) *CacheLoadOperation[T] { + var defaultParser Parser[T] + op.defaultKey = &key + if parser == nil { + defaultParser = DefaultParser[T] + } else { + defaultParser = parser + } + + op.keyParserMap[key] = defaultParser + return op +} + +func (op *CacheLoadOperation[T]) WithKeyAndParser(key string, parser Parser[T]) *CacheLoadOperation[T] { + op.keyParserMap[key] = parser + return op +} + +func (op *CacheLoadOperation[T]) WithTTL(ttl time.Duration) *CacheLoadOperation[T] { + op.baseTTL = ttl + return op +} + +func (op *CacheLoadOperation[T]) WithDBLoader(dbLoader func() (*T, error)) *CacheLoadOperation[T] { + op.dbLoader = dbLoader + return op +} + +func (op *CacheLoadOperation[T]) Exec(ctx context.Context) (*T, error) { + if op.defaultKey == nil { + return nil, status.GenErrWithCustomMsg(status.StatusInternalParamsError, "default key is needed") + } + if op.dbLoader == nil { + return nil, status.GenErrWithCustomMsg(status.StatusInternalParamsError, "database loader function is required") + } + + // try to load from any of the cache keys + for key, parser := range op.keyParserMap { + val, err := op.loader.redis.Get(ctx, key).Result() + if err == nil { + if val == "null" { + return nil, nil + } + + if data, err := parser([]byte(val)); err == nil { + return data, nil + } + } + } + + // all caches missed, use singleflight to avoid concurrent db queries + sfKey := *op.defaultKey + v, err, _ := op.loader.sf.Do(sfKey, func() (interface{}, error) { + // double check all caches + for key, parser := range op.keyParserMap { + + val, err := op.loader.redis.Get(ctx, key).Result() + if err == nil { + if val == "null" { + return nil, nil + } + + if data, err := parser([]byte(val)); err == nil { + return data, nil + } + } + } + + // query database + data, err := op.dbLoader() + if err != nil { + // cache empty value for all keys if record not found + if errors.Is(err, gorm.ErrRecordNotFound) { + op.loader.redis.Set(ctx, *op.defaultKey, "null", 5*time.Minute) + } + return nil, err + } + + bytes, _ := sonic.Marshal(data) + ttl := op.baseTTL + time.Duration(rand.Intn(600))*time.Second + + op.loader.redis.Set(ctx, *op.defaultKey, bytes, ttl) + + return data, nil + }) + + if err != nil { + return nil, err + } + + if v == nil { + return nil, nil + } + + return v.(*T), nil +} diff --git a/internal/cache/parsers.go b/internal/cache/parsers.go new file mode 100644 index 0000000..f3805ca --- /dev/null +++ b/internal/cache/parsers.go @@ -0,0 +1,27 @@ +package cache + +import ( + "encoding/json" + + "github.com/XingfenD/yoresee_doc/internal/model" +) + +type Parser[T any] func(data []byte) (*T, error) + +func DefaultParser[T any](data []byte) (*T, error) { + var value T + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + return &value, nil +} + +var ParseInt64 = DefaultParser[int64] + +func ParseIDFromDocument(data []byte) (*int64, error) { + var doc model.Document + if err := json.Unmarshal(data, &doc); err != nil { + return nil, err + } + return &doc.ID, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b5ba7f8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,151 @@ +package config + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + Minio MinioConfig `mapstructure:"minio"` + Elasticsearch ElasticsearchConfig `mapstructure:"elasticsearch"` + MQConfig MQConfig `mapstructure:"mq_config"` + Consul ConsulConfig `mapstructure:"consul"` + Backend BackendConfig `mapstructure:"backend"` +} + +type ServerConfig struct { + GrpcPort int `mapstructure:"grpc_port"` + GrpcWebPort int `mapstructure:"grpc_web_port"` +} + +type DatabaseConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Name string `mapstructure:"name"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + MaxOpenConns int `mapstructure:"max_open_conns"` +} + +type RedisConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` +} + +type ConsulConfig struct { + Enabled bool `mapstructure:"enabled"` + Address string `mapstructure:"address"` + Scheme string `mapstructure:"scheme"` + Token string `mapstructure:"token"` + Datacenter string `mapstructure:"datacenter"` + Prefix string `mapstructure:"prefix"` +} + +type MinioConfig struct { + Endpoint string `mapstructure:"endpoint"` + AccessKey string `mapstructure:"access_key"` + SecretKey string `mapstructure:"secret_key"` + Bucket string `mapstructure:"bucket"` + UseSSL bool `mapstructure:"use_ssl"` +} + +type ElasticsearchConfig struct { + Enabled bool `mapstructure:"enabled"` + Addresses []string `mapstructure:"addresses"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + IndexPrefix string `mapstructure:"index_prefix"` + Timeout int `mapstructure:"timeout"` +} + +type ClusterRole string + +const ( + ClusterRoleProducer ClusterRole = "producer" + ClusterRoleConsumer ClusterRole = "consumer" + ClusterRoleHybrid ClusterRole = "hybrid" // producer and consumer +) + +type BackendConfig struct { + Jwt JWTConfig `mapstructure:"jwt"` + Log LogConfig `mapstructure:"log"` + Security SecurityConfig `mapstructure:"security"` + SystemName string `mapstructure:"system_name"` + InternalRPCKey string `mapstructure:"internal_rpc_key"` +} + +type LogConfig struct { + Level string `mapstructure:"level"` + GormLogLevel string `mapstructure:"gorm_log_level"` +} + +type MQConfig struct { + Redis RedisQueueConfig `mapstructure:"redis"` + RabbitMQ RabbitMQQueueConfig `mapstructure:"rabbitmq"` +} + +type RedisQueueConfig struct { +} + +type RabbitMQQueueConfig struct { + URL string `mapstructure:"url"` +} + +type JWTConfig struct { + Secret string `mapstructure:"secret"` + Expire int64 `mapstructure:"expire"` +} + +type SecurityConfig struct { + PasswordHashCost int `mapstructure:"password_hash_cost"` + CORS CORSConfig `mapstructure:"cors"` +} + +type CORSConfig struct { + AllowedOrigins []string `mapstructure:"allowed_origins"` + AllowedMethods []string `mapstructure:"allowed_methods"` + AllowedHeaders []string `mapstructure:"allowed_headers"` + AllowCredentials bool `mapstructure:"allow_credentials"` + MaxAge int `mapstructure:"max_age"` +} + +var GlobalConfig *Config + +func InitConfig() error { + viper.SetConfigName("config") + viper.SetConfigType("toml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + + if err := viper.ReadInConfig(); err != nil { + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "read config failed") + } + + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.AutomaticEnv() + + if err := viper.Unmarshal(&GlobalConfig); err != nil { + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "unmarshal config failed") + } + + levelText := strings.TrimSpace(GlobalConfig.Backend.Log.Level) + if levelText == "" { + levelText = "info" + } + level, err := logrus.ParseLevel(strings.ToLower(levelText)) + if err != nil { + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "invalid backend.log.level") + } + logrus.SetLevel(level) + + return nil +} diff --git a/internal/constant/config_key.go b/internal/constant/config_key.go new file mode 100644 index 0000000..23abbed --- /dev/null +++ b/internal/constant/config_key.go @@ -0,0 +1,17 @@ +package constant + +const ( + ConfigKey_First_System string = "system" +) + +const ( + ConfigKey_Second_Security string = "security" + ConfigKey_Second_Database string = "database" + ConfigKey_Second_ES string = "elasticsearch" +) + +const ( + ConfigKey_Third_RegisterMode string = "register_mode" + ConfigKey_Third_Initialized string = "initialized" + ConfigKey_Third_RegisterLimit string = "register_limit" // token_bucket_limit +) diff --git a/internal/constant/config_value.go b/internal/constant/config_value.go new file mode 100644 index 0000000..679efd9 --- /dev/null +++ b/internal/constant/config_value.go @@ -0,0 +1,23 @@ +package constant + +// system.security.register_mode +const ( + RegisterMode_Open string = "open" + RegisterMode_Invite string = "invite" +) + +// system.database.initialized +const ( + Database_Initialized_True string = "true" + // Database_Initialized_False string = "false" +) + +// system.elasticsearch.initialized +const ( + ES_Initialized_True string = "true" +) + +const ( + RegisterLimit_On string = "on" + RegisterLimit_Off string = "off" +) diff --git a/internal/constant/notification.go b/internal/constant/notification.go new file mode 100644 index 0000000..6b839ea --- /dev/null +++ b/internal/constant/notification.go @@ -0,0 +1,27 @@ +package constant + +// Notification type constants. +const ( + NotificationType_Comment = "comment" + NotificationType_Reply = "reply" + NotificationType_Mention = "mention" + NotificationType_System = "system" +) + +// notificationTitles holds default (zh-CN) titles keyed by notification type. +// Replace with a proper i18n lookup once multi-language support is added. +var notificationTitles = map[string]string{ + NotificationType_Comment: "收到新评论", + NotificationType_Reply: "回复了你的评论", + NotificationType_Mention: "在评论中提及了你", + NotificationType_System: "系统通知", +} + +// GetNotificationTitle returns the default title for the given notification type. +// Falls back to the type string itself if the type is unrecognised. +func GetNotificationTitle(notifType string) string { + if title, ok := notificationTitles[notifType]; ok { + return title + } + return notifType +} diff --git a/internal/domain_event/document_event.go b/internal/domain_event/document_event.go new file mode 100644 index 0000000..89f86ff --- /dev/null +++ b/internal/domain_event/document_event.go @@ -0,0 +1,51 @@ +package domain_event + +import ( + "context" + "fmt" + "strings" + + "github.com/XingfenD/yoresee_doc/pkg/constant" + "github.com/bytedance/sonic" +) + +const ( + DocumentActionUpsert = "upsert" + DocumentActionDelete = "delete" +) + +type DocumentSyncEvent struct { + Action string `json:"action"` + ExternalID string `json:"external_id"` +} + +func DocumentSyncTopic() string { + return constant.SearchSyncTopicDefault +} + +func PublishDocumentUpsertEvent(ctx context.Context, externalID string) error { + externalID = strings.TrimSpace(externalID) + if externalID == "" { + return fmt.Errorf("external_id is empty") + } + return publishJSONToRabbitMQ(ctx, DocumentSyncTopic(), DocumentSyncEvent{ + Action: DocumentActionUpsert, + ExternalID: externalID, + }) +} + +func DecodeDocumentSyncEvent(data []byte) (*DocumentSyncEvent, error) { + var evt DocumentSyncEvent + if err := sonic.Unmarshal(data, &evt); err != nil { + return nil, err + } + evt.Action = strings.TrimSpace(evt.Action) + evt.ExternalID = strings.TrimSpace(evt.ExternalID) + if evt.Action == "" { + evt.Action = DocumentActionUpsert + } + if evt.ExternalID == "" { + return nil, fmt.Errorf("external_id is empty") + } + return &evt, nil +} diff --git a/internal/domain_event/notification_event.go b/internal/domain_event/notification_event.go new file mode 100644 index 0000000..0a7eb65 --- /dev/null +++ b/internal/domain_event/notification_event.go @@ -0,0 +1,42 @@ +package domain_event + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/pkg/constant" + "github.com/bytedance/sonic" +) + +type NotificationCreateEvent struct { + ReceiverExternalIDs []string `json:"receiver_external_ids"` + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + PayloadJSON string `json:"payload_json"` +} + +func NotificationCreateTopic() string { + return constant.NotificationTopicDefault +} + +func PublishNotificationCreateEvent(ctx context.Context, evt NotificationCreateEvent) error { + return publishJSONToRabbitMQ(ctx, NotificationCreateTopic(), evt) +} + +func DecodeNotificationCreateEvent(data []byte) (*NotificationCreateEvent, error) { + var evt NotificationCreateEvent + if err := sonic.Unmarshal(data, &evt); err != nil { + return nil, err + } + cleanReceiverIDs := make([]string, 0, len(evt.ReceiverExternalIDs)) + for _, receiverID := range evt.ReceiverExternalIDs { + receiverID = strings.TrimSpace(receiverID) + if receiverID == "" { + continue + } + cleanReceiverIDs = append(cleanReceiverIDs, receiverID) + } + evt.ReceiverExternalIDs = cleanReceiverIDs + return &evt, nil +} diff --git a/internal/domain_event/publisher.go b/internal/domain_event/publisher.go new file mode 100644 index 0000000..8d63cc6 --- /dev/null +++ b/internal/domain_event/publisher.go @@ -0,0 +1,20 @@ +package domain_event + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/service/mq_service" + "github.com/XingfenD/yoresee_doc/pkg/mq" + "github.com/bytedance/sonic" +) + +func publishJSONToRabbitMQ(ctx context.Context, topic string, payload any) error { + data, err := sonic.Marshal(payload) + if err != nil { + return err + } + return mq_service.MQSvc.Publish(ctx, mq.BackendRabbitMQ, mq.PublishMessage{ + Topic: topic, + Body: data, + }) +} diff --git a/internal/dto/attachment.go b/internal/dto/attachment.go new file mode 100644 index 0000000..a4e5213 --- /dev/null +++ b/internal/dto/attachment.go @@ -0,0 +1,15 @@ +package dto + +import "time" + +type AttachmentResponse struct { + ExternalID string `json:"external_id"` + DocumentExternalID string `json:"document_external_id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + Path string `json:"path"` + URL string `json:"url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/dto/comment.go b/internal/dto/comment.go new file mode 100644 index 0000000..8b3e88c --- /dev/null +++ b/internal/dto/comment.go @@ -0,0 +1,28 @@ +package dto + +type CreateCommentRequest struct { + DocumentExternalID string `json:"document_external_id"` + Content string `json:"content"` + ParentExternalID *string `json:"parent_external_id,omitempty"` + AnchorID *string `json:"anchor_id,omitempty"` + Quote *string `json:"quote,omitempty"` + CreatorExternalID string `json:"creator_external_id"` + MentionedUserExternalIDs []string `json:"mentioned_user_external_ids,omitempty"` +} + +type ListCommentsRequest struct { + DocumentExternalID string `json:"document_external_id"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type DeleteCommentRequest struct { + ExternalID string `json:"external_id"` + OperatorExternalID string `json:"operator_external_id"` +} + +type UpdateCommentRequest struct { + ExternalID string `json:"external_id"` + Content string `json:"content"` + OperatorExternalID string `json:"operator_external_id"` +} diff --git a/internal/dto/document.go b/internal/dto/document.go new file mode 100644 index 0000000..dfc8178 --- /dev/null +++ b/internal/dto/document.go @@ -0,0 +1,169 @@ +package dto + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" +) + +type DocumentType string + +const DocumentType_Markdown DocumentType = "markdown" +const DocumentType_Table DocumentType = "table" +const DocumentType_MarkdownSlide DocumentType = "markdown_slide" +const DocumentType_YoreseeRichText DocumentType = "yoresee_rich_text" + +type ContainerType string + +const ContainerType_Own ContainerType = "own" +const ContainerType_KnowledgeBase ContainerType = "knowledge_base" + +type DocumentBase struct { + ExternalID string `json:"external_id"` + Title string `json:"title"` + Type DocumentType `json:"type"` + Summary string `json:"summary"` + IsPublic bool `json:"is_public"` + Tags []string `json:"tags"` + ViewCount int `json:"view_count"` + EditCount int `json:"edit_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DocumentMetaResponse struct { + DocumentBase + Children []*DocumentMetaResponse `json:"children,omitempty"` + HasChildren bool `json:"hasChildren,omitempty"` +} + +type DocumentResponse struct { + DocumentMetaResponse + Content string `json:"content"` +} + +func NewDocumentMetaResponseFromModel(doc *model.Document) *DocumentMetaResponse { + response := &DocumentMetaResponse{ + DocumentBase: DocumentBase{ + ExternalID: doc.ExternalID, + Title: doc.Title, + Type: DocumentType(doc.Type), + Summary: doc.Summary, + IsPublic: doc.IsPublic, + Tags: doc.Tags, + ViewCount: doc.ViewCount, + EditCount: doc.EditCount, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }, + } + + return response +} + +func NewDocumentResponseFromModel(doc *model.Document) *DocumentResponse { + if doc == nil { + return &DocumentResponse{} + } + response := &DocumentResponse{ + DocumentMetaResponse: DocumentMetaResponse{ + DocumentBase: DocumentBase{ + ExternalID: doc.ExternalID, + Title: doc.Title, + Type: DocumentType(doc.Type), + Summary: doc.Summary, + IsPublic: doc.IsPublic, + Tags: doc.Tags, + ViewCount: doc.ViewCount, + EditCount: doc.EditCount, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }, + }, + Content: doc.Content, + } + + return response +} + +// type DirectoryResponse struct { +// ExternalID string `json:"external_id"` +// Title string `json:"title"` +// HasChildren bool `json:"has_children"` +// ParentID string `json:"parent_id"` +// } + +type DocumentsListExternalArgs struct { + UserExternalID *string `json:"user_external_id"` + RootDocumentExternalID *string `json:"root_document_external_id"` + KnowledgeExternalID *string `json:"knowledge_external_id"` +} + +type DocumentsListFilterArgs struct { + TitleKeyword *string `json:"title_keyword"` + DocType *string `json:"doc_type"` + Tags []string `json:"tags"` + CreateTimeRangeStart *string `json:"create_time_range_start"` + CreateTimeRangeEnd *string `json:"create_time_range_end"` + UpdateTimeRangeStart *string `json:"update_time_range_start"` + UpdateTimeRangeEnd *string `json:"update_time_range_end"` +} + +type ListDocumentsBaseArgs struct { + ListOwnDoc bool `json:"list_own_doc"` + DirectoryOnly bool `json:"directory_only"` +} + +type ListRecentDocumentsRequest struct { + UserExternalID string `json:"user_external_id"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + Pagination Pagination `json:"pagination"` +} + +type RecordRecentDocumentRequest struct { + UserExternalID string `json:"user_external_id"` + DocumentExternalID string `json:"document_external_id"` +} + +type ListDocumentsByExternalRequest struct { + ExternalArgs *DocumentsListExternalArgs `json:"external_args"` + ListDocumentsBaseArgs + FilterArgs *DocumentsListFilterArgs `json:"filter_args"` + SortArgs SortArgs `json:"sort_args"` + Pagination Pagination `json:"pagination"` + Options *RecursiveOptions `json:"options"` +} + +type CreateDocumentRequest struct { + Title string `json:"title"` + Type DocumentType `json:"type"` + ContainerType ContainerType `json:"container_type"` + IsPublic bool `json:"is_public"` + CreatorExternalID *string `json:"creator_external_id"` + TemplateID *int64 `json:"template_id"` + + // optional + ParentExternalID *string `json:"parent_external_id"` + KnowledgeExternalID *string `json:"knowledge_external_id"` +} + +type CreateDocumentResponse struct { + ExternalID string `json:"external_id"` +} + +type UpdateDocumentRequest struct { + ExternalID string `json:"external_id"` + Title *string `json:"title,omitempty"` + KnowledgeBaseExternalID *string `json:"knowledge_base_external_id,omitempty"` + ParentExternalID *string `json:"parent_external_id,omitempty"` + MoveToContainer *ContainerType `json:"move_to_container,omitempty"` + Content *string `json:"content,omitempty"` +} + +type UpdateDocumentMetaRequest struct { + ExternalID string `json:"external_id"` + Title *string `json:"title,omitempty"` + Summary *string `json:"summary,omitempty"` + Tags *[]string `json:"tags,omitempty"` +} diff --git a/internal/dto/document_setting.go b/internal/dto/document_setting.go new file mode 100644 index 0000000..f4e0cc1 --- /dev/null +++ b/internal/dto/document_setting.go @@ -0,0 +1,14 @@ +package dto + +type GetDocumentSettingsRequest struct { + ExternalID string `json:"external_id"` +} + +type DocumentSettingsResponse struct { + IsPublic bool `json:"is_public"` +} + +type UpdateDocumentSettingsRequest struct { + ExternalID string `json:"external_id"` + IsPublic bool `json:"is_public"` +} diff --git a/internal/dto/document_version.go b/internal/dto/document_version.go new file mode 100644 index 0000000..a1c62ed --- /dev/null +++ b/internal/dto/document_version.go @@ -0,0 +1,11 @@ +package dto + +import "time" + +type DocumentVersionResponse struct { + Version int `json:"version"` + Title string `json:"title"` + Content string `json:"content"` + ChangeSummary string `json:"change_summary"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/dto/dto.go b/internal/dto/dto.go new file mode 100644 index 0000000..f147ffd --- /dev/null +++ b/internal/dto/dto.go @@ -0,0 +1,27 @@ +package dto + +type Pagination struct { + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +func (p *Pagination) Validate() bool { + if p == nil { + return false + } + if p.Page <= 0 || p.PageSize <= 0 { + return false + } + return true +} + +type SortArgs struct { + Field string `json:"field"` + Desc bool `json:"desc"` +} + +type RecursiveOptions struct { + IncludeChildren bool `json:"include_children"` + Recursive bool `json:"recursive"` + Depth *int `json:"depth"` +} diff --git a/internal/dto/invitation.go b/internal/dto/invitation.go new file mode 100644 index 0000000..9cbaa7a --- /dev/null +++ b/internal/dto/invitation.go @@ -0,0 +1,67 @@ +package dto + +import "time" + +type ListInvitationsReq struct { + CreatorID *int64 `json:"creator_id"` + MaxUsedCnt *int64 `json:"max_used_cnt"` + ExpiresAtStart *string `json:"expires_at_start"` + ExpiresAtEnd *string `json:"expires_at_end"` + CreatedAtStart *string `json:"created_at_start"` + CreatedAtEnd *string `json:"created_at_end"` + Disabled *bool `json:"disabled"` + OnlyMine *bool `json:"only_mine"` + Keyword *string `json:"keyword"` + SortArgs SortArgs `json:"sort_args"` + Pagination Pagination `json:"pagination"` +} + +type CreateInvitationRequest struct { + CreatorExternalID string `json:"creator_external_id"` + MaxUsedCnt *int64 `json:"max_used_cnt"` + ExpiresAt *time.Time `json:"expires_at"` + Note *string `json:"note"` +} + +type UpdateInvitationRequest struct { + Code string `json:"code"` + MaxUsedCnt *int64 `json:"max_used_cnt"` + ExpiresAt *time.Time `json:"expires_at"` + Disabled *bool `json:"disabled"` + Note *string `json:"note"` +} + +type DeleteInvitationRequest struct { + Code string `json:"code"` +} + +type InvitationResponse struct { + Code string `json:"code"` + CreatedByExternalID string `json:"created_by_external_id"` + CreatedByName string `json:"created_by_name"` + UsedCnt int64 `json:"used_cnt"` + MaxUsedCnt *int64 `json:"max_used_cnt"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + Disabled bool `json:"disabled"` + Note *string `json:"note"` +} + +type ListInvitationRecordsRequest struct { + Code *string `json:"code"` + Status *string `json:"status"` + UsedAtStart *string `json:"used_at_start"` + UsedAtEnd *string `json:"used_at_end"` + CreatorID *int64 `json:"creator_id"` + OnlyMine *bool `json:"only_mine"` + Keyword *string `json:"keyword"` + Pagination Pagination `json:"pagination"` +} + +type InvitationRecordResponse struct { + Code string `json:"code"` + UsedBy string `json:"used_by"` + UsedByExternalID string `json:"used_by_external_id"` + UsedAt time.Time `json:"used_at"` + Status string `json:"status"` +} diff --git a/internal/dto/knowledge_base.go b/internal/dto/knowledge_base.go new file mode 100644 index 0000000..78fcce9 --- /dev/null +++ b/internal/dto/knowledge_base.go @@ -0,0 +1,98 @@ +package dto + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" +) + +type KnowledgeBaseBase struct { + ExternalID string `json:"external_id"` + Name string `json:"name"` + Description string `json:"description"` + Cover string `json:"cover"` + IsPublic bool `json:"is_public"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt time.Time `json:"deleted_at"` +} + +type KnowledgeBaseExtend struct { + CreatorUserExternalID string `json:"creator_user_external_id"` + CreatorName string `json:"creator_name"` + DocumentsCount int64 `json:"documents_count"` +} + +type KnowledgeBaseResponse struct { + KnowledgeBaseBase + KnowledgeBaseExtend +} + +func NewKnowledgeBaseResponseFromModel(kb *model.KnowledgeBase, kbExtend *KnowledgeBaseExtend) *KnowledgeBaseResponse { + response := &KnowledgeBaseResponse{ + KnowledgeBaseBase: KnowledgeBaseBase{ + ExternalID: kb.ExternalID, + Name: kb.Name, + Description: kb.Description, + Cover: kb.Cover, + IsPublic: kb.IsPublic, + CreatedAt: kb.CreatedAt, + UpdatedAt: kb.UpdatedAt, + DeletedAt: kb.DeletedAt, + }, + } + if kbExtend != nil { + response.KnowledgeBaseExtend = KnowledgeBaseExtend{ + CreatorUserExternalID: kbExtend.CreatorUserExternalID, + CreatorName: kbExtend.CreatorName, + DocumentsCount: kbExtend.DocumentsCount, + } + } + + return response +} + +type CreateRecentKnowledgeBaseRequest struct { + UserExternalID string `json:"user_external_id"` + KnowledgeBaseExternalID string `json:"knowledge_base_external_id"` + AssessTime time.Time `json:"assess_time"` +} + +type CreateKnowledgeBaseRequest struct { + CreatorExternalID string `json:"creator_external_id"` + Name string `json:"name"` + Description string `json:"description"` + Cover string `json:"cover"` + IsPublic bool `json:"is_public"` +} + +type CreateKnowledgeBaseResponse struct { + ExternalID string `json:"external_id"` +} + +type ListRecentKnowledgeBasesRequest struct { + UserExternalID string `json:"user_external_id"` + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + Pagination Pagination `json:"pagination"` +} + +type KnowledgeBaseListByExternalRequest struct { + CreatorExternalID string `json:"creator_external_id"` + FilterArgs *KnowledgeBaseListFilterArgs `json:"filter_args"` + SortArgs SortArgs `json:"sort_args"` + Pagination Pagination `json:"pagination"` +} + +type KnowledgeBaseGetByExternalIDRequest struct { + KnowledgeBaseExternalID string `json:"knowledge_base_external_id"` +} + +type KnowledgeBaseListFilterArgs struct { + IsPublic *bool `json:"is_public"` + NameKeyword *string `json:"name_keyword"` + CreateTimeRangeStart *string `json:"create_time_range_start"` + CreateTimeRangeEnd *string `json:"create_time_range_end"` + UpdateTimeRangeStart *string `json:"update_time_range_start"` + UpdateTimeRangeEnd *string `json:"update_time_range_end"` +} diff --git a/internal/dto/membership.go b/internal/dto/membership.go new file mode 100644 index 0000000..5b26ffc --- /dev/null +++ b/internal/dto/membership.go @@ -0,0 +1,47 @@ +package dto + +// MembershipType mirrors model.MembershipType to keep DTO layer independent. +type MembershipType int64 + +const ( + MembershipType_UserGroup MembershipType = 1 + MembershipType_OrgNode MembershipType = 2 +) + +type MembershipRelationBase struct { + Type MembershipType `json:"type"` + UserID int64 `json:"user_id"` + MembershipID int64 `json:"membership_id"` +} + +type MembershipBase struct { + Type MembershipType `json:"type"` + MembershipExternalID string `json:"membership_external_id"` + MembershipName string `json:"membership_name"` +} + +type MembershipBaseRequest struct { + Type MembershipType `json:"type"` + MembershipExternalID string `json:"membership_external_id"` +} + +type MembershipMetaResponse struct { + MembershipBase + Description string `json:"description"` +} + +type CreateMembershipRelationRequest struct { + MembershipBaseRequest + UserExternalID string `json:"user_external_id"` +} + +type MembershipRelationResponse struct { + MembershipBase + UserList []UserBase `json:"user_list"` +} + +// type MembershipAuthorityResponse struct { +// MembershipBase +// CreatorExternalID string +// CurrentUserAuthority []model.Permission +// } diff --git a/internal/dto/notification.go b/internal/dto/notification.go new file mode 100644 index 0000000..0d8c49a --- /dev/null +++ b/internal/dto/notification.go @@ -0,0 +1,20 @@ +package dto + +type CreateNotificationRequest struct { + ReceiverExternalIDs []string `json:"receiver_external_ids"` + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + PayloadJSON string `json:"payload_json"` +} + +type ListNotificationsRequest struct { + UserExternalID string `json:"user_external_id"` + Status *string `json:"status,omitempty"` + Pagination Pagination `json:"pagination"` +} + +type MarkNotificationsReadRequest struct { + UserExternalID string `json:"user_external_id"` + ExternalIDs []string `json:"external_ids"` +} diff --git a/internal/dto/org_node.go b/internal/dto/org_node.go new file mode 100644 index 0000000..518c20a --- /dev/null +++ b/internal/dto/org_node.go @@ -0,0 +1,55 @@ +package dto + +type OrgNodeResponse struct { + ExternalID string `json:"external_id"` + ParentExternalID string `json:"parent_external_id"` + Name string `json:"name"` + Path string `json:"path"` + Description string `json:"description"` + CreatorUserExternalID string `json:"creator_user_external_id"` + MemberCount int `json:"member_count"` + Children []*OrgNodeResponse `json:"children,omitempty"` +} + +type ListOrgNodesRequest struct { + ParentExternalID string `json:"parent_external_id"` + Keyword *string `json:"keyword"` + Pagination Pagination `json:"pagination"` + IncludeChildren bool `json:"include_children"` +} + +type GetOrgNodeRequest struct { + ExternalID string `json:"external_id"` + IncludeChildren bool `json:"include_children"` +} + +type CreateOrgNodeRequest struct { + CreatorUserExternalID string `json:"creator_user_external_id"` + ParentExternalID string `json:"parent_external_id"` + Name string `json:"name"` + Description string `json:"description"` + MemberUserExternalIDs []string `json:"member_user_external_ids"` +} + +type UpdateOrgNodeRequest struct { + ExternalID string `json:"external_id"` + Name *string `json:"name"` + Description *string `json:"description"` + SyncMembers bool `json:"sync_members"` + MemberUserExternalIDs []string `json:"member_user_external_ids"` +} + +type DeleteOrgNodeRequest struct { + ExternalID string `json:"external_id"` +} + +type MoveOrgNodeRequest struct { + ExternalID string `json:"external_id"` + NewParentExternalID string `json:"new_parent_external_id"` +} + +type ListOrgNodeMembersRequest struct { + ExternalID string `json:"external_id"` + Keyword *string `json:"keyword"` + Pagination Pagination `json:"pagination"` +} diff --git a/internal/dto/permission.go b/internal/dto/permission.go new file mode 100644 index 0000000..1690146 --- /dev/null +++ b/internal/dto/permission.go @@ -0,0 +1,46 @@ +package dto + +import ( + "time" +) + +// PermissionGrant 权限授予请求 +type PermissionGrant struct { + Resource Resource `json:"resource" binding:"required"` + Subject Subject `json:"subject" binding:"required"` + Permissions []string `json:"permissions" binding:"required"` + Scope Scope `json:"scope" binding:"required"` + Priority int `json:"priority"` + IsDeny bool `json:"is_deny"` + ValidFrom *time.Time `json:"valid_from"` + ValidUntil *time.Time `json:"valid_until"` +} + +// Resource 资源DTO +type Resource struct { + Type string `json:"type" binding:"required"` + ID string `json:"id" binding:"required"` + Path string `json:"path"` +} +type Subject struct { + Type string `json:"type" binding:"required"` + ID string `json:"id" binding:"required"` +} + +type Scope struct { + Type string `json:"type" binding:"required"` +} + +type PermissionCheck struct { + Resource Resource `json:"resource" binding:"required"` + Permission string `json:"permission" binding:"required"` +} + +type PermissionBatchCheck struct { + Resource Resource `json:"resource" binding:"required"` + Permissions []string `json:"permissions" binding:"required"` +} + +type PermissionBatchCheckResponse map[string]bool + +type PermissionEffectiveResponse []string diff --git a/internal/dto/template.go b/internal/dto/template.go new file mode 100644 index 0000000..943e396 --- /dev/null +++ b/internal/dto/template.go @@ -0,0 +1,67 @@ +package dto + +import "time" + +type TemplateContainer string + +const ( + TemplateContainerOwn TemplateContainer = "own" + TemplateContainerKnowledgeBase TemplateContainer = "knowledge_base" + TemplateContainerPublic TemplateContainer = "public" +) + +type CreateTemplateRequest struct { + UserExternalID string `json:"user_external_id"` + TargetContainer TemplateContainer `json:"target_container"` + KnowledgeBaseExternalID *string `json:"knowledge_base_external_id,omitempty"` + TemplateContent string `json:"template_content"` + Type DocumentType `json:"type"` +} + +type UpdateTemplateSettingsRequest struct { + UserExternalID string `json:"user_external_id"` + TemplateID int64 `json:"template_id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + IsPublic *bool `json:"is_public,omitempty"` +} + +type TemplateResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` + Type DocumentType `json:"type"` + Scope string `json:"scope"` + KnowledgeBaseExternalID string `json:"knowledge_base_external_id"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TemplateListFilterArgs struct { + NameKeyword *string `json:"name_keyword"` + TargetContainer *TemplateContainer `json:"target_container"` + KnowledgeBaseID *string `json:"knowledge_base_id"` + Type *DocumentType `json:"type"` +} + +type TemplateListByExternalRequest struct { + CreatorExternalID string `json:"creator_external_id"` + FilterArgs *TemplateListFilterArgs `json:"filter_args"` + SortArgs SortArgs `json:"sort_args"` + Pagination Pagination `json:"pagination"` +} + +type CreateRecentTemplateRequest struct { + UserExternalID string `json:"user_external_id"` + TemplateID int64 `json:"template_id"` + AccessTime time.Time `json:"access_time"` +} + +type ListRecentTemplatesRequest struct { + UserExternalID string `json:"user_external_id"` + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + Pagination Pagination `json:"pagination"` +} diff --git a/internal/dto/user.go b/internal/dto/user.go new file mode 100644 index 0000000..5121f1d --- /dev/null +++ b/internal/dto/user.go @@ -0,0 +1,70 @@ +package dto + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" +) + +type UserBase struct { + ExternalID string `json:"external_id"` + Username string `json:"username"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + AvatarObjectKey string `json:"-"` + AvatarVersion int64 `json:"-"` + Status int `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserCreate struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + InvitationCode *string `json:"invitation_code"` +} + +type UserUpdate struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Status int `json:"status"` + InvitationCode *string `json:"invitation_code"` +} + +type UpdateProfileRequest struct { + Username *string `json:"username"` + Email *string `json:"email"` + Nickname *string `json:"nickname"` + Password *string `json:"password"` + AvatarFile []byte `json:"avatar_file"` + AvatarFilename *string `json:"avatar_filename"` + AvatarContentType *string `json:"avatar_content_type"` +} + +type UserResponse struct { + UserBase + InvitationCode *string `json:"invitation_code"` +} + +func NewUserResponseFromModel(user *model.User) *UserResponse { + return &UserResponse{ + UserBase: UserBase{ + ExternalID: user.ExternalID, + Username: user.Username, + Email: user.Email, + Nickname: user.Nickname, + Avatar: "", + AvatarObjectKey: user.AvatarObjectKey, + AvatarVersion: user.AvatarVersion, + Status: user.Status, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + }, + InvitationCode: user.InvitationCode, + } +} diff --git a/internal/dto/user_group.go b/internal/dto/user_group.go new file mode 100644 index 0000000..0aba548 --- /dev/null +++ b/internal/dto/user_group.go @@ -0,0 +1,56 @@ +package dto + +type UserGroupResponse struct { + ExternalID string `json:"external_id"` + Name string `json:"name"` + Description string `json:"description"` + CreatorUserExternalID string `json:"creator_user_external_id"` + MemberCount int `json:"member_count"` +} + +type ListUserGroupsRequest struct { + Keyword *string `json:"keyword"` + Pagination Pagination `json:"pagination"` +} + +type GetUserGroupRequest struct { + ExternalID string `json:"external_id"` +} + +type CreateUserGroupRequest struct { + CreatorUserExternalID string `json:"creator_user_external_id"` + Name string `json:"name"` + Description string `json:"description"` + MemberUserExternalIDs []string `json:"member_user_external_ids"` +} + +type UpdateUserGroupRequest struct { + ExternalID string `json:"external_id"` + Name *string `json:"name"` + Description *string `json:"description"` + SyncMembers bool `json:"sync_members"` + MemberUserExternalIDs []string `json:"member_user_external_ids"` +} + +type DeleteUserGroupRequest struct { + ExternalID string `json:"external_id"` +} + +type ListUsersRequest struct { + Keyword *string `json:"keyword"` + Pagination Pagination `json:"pagination"` +} + +type UpdateUserRequest struct { + ExternalID string `json:"external_id"` + Username *string `json:"username"` + Email *string `json:"email"` + Nickname *string `json:"nickname"` + Status *int32 `json:"status"` +} + +type ListUserGroupMembersRequest struct { + ExternalID string `json:"external_id"` + Keyword *string `json:"keyword"` + Pagination Pagination `json:"pagination"` +} diff --git a/internal/mapper/doc_container_mapper/knowledge_base.go b/internal/mapper/doc_container_mapper/knowledge_base.go new file mode 100644 index 0000000..6dc1c2f --- /dev/null +++ b/internal/mapper/doc_container_mapper/knowledge_base.go @@ -0,0 +1,31 @@ +package doc_container_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type knowledgeBaseAdapter struct{} + +func (knowledgeBaseAdapter) Name() string { + return string(dto.ContainerType_KnowledgeBase) +} + +func (knowledgeBaseAdapter) ProtoType() pb.CreateDocumentContainerType { + return pb.CreateDocumentContainerType_CREATE_DOCUMENT_CONTAINER_TYPE_KNOWLEDGE_BASE +} + +func (knowledgeBaseAdapter) DTOType() dto.ContainerType { + return dto.ContainerType_KnowledgeBase +} + +func (knowledgeBaseAdapter) ModelType() model.ContainerType { + return model.ContainerType_KnowledgeBase +} + +func (knowledgeBaseAdapter) RequiresKnowledgeBaseID() bool { + return true +} + +func init() { RegisterMapper(knowledgeBaseAdapter{}) } diff --git a/internal/mapper/doc_container_mapper/own.go b/internal/mapper/doc_container_mapper/own.go new file mode 100644 index 0000000..17f9fa5 --- /dev/null +++ b/internal/mapper/doc_container_mapper/own.go @@ -0,0 +1,31 @@ +package doc_container_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type ownAdapter struct{} + +func (ownAdapter) Name() string { + return string(dto.ContainerType_Own) +} + +func (ownAdapter) ProtoType() pb.CreateDocumentContainerType { + return pb.CreateDocumentContainerType_CREATE_DOCUMENT_CONTAINER_TYPE_OWN +} + +func (ownAdapter) DTOType() dto.ContainerType { + return dto.ContainerType_Own +} + +func (ownAdapter) ModelType() model.ContainerType { + return model.ContainerType_Own +} + +func (ownAdapter) RequiresKnowledgeBaseID() bool { + return false +} + +func init() { RegisterMapper(ownAdapter{}) } diff --git a/internal/mapper/doc_container_mapper/registry.go b/internal/mapper/doc_container_mapper/registry.go new file mode 100644 index 0000000..cf8f393 --- /dev/null +++ b/internal/mapper/doc_container_mapper/registry.go @@ -0,0 +1,168 @@ +package doc_container_mapper + +import ( + "fmt" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +const defaultContainerName = "own" + +// Mapper defines a document container type mapping across transport(dto/proto) and model layers. +type Mapper interface { + Name() string + ProtoType() pb.CreateDocumentContainerType + DTOType() dto.ContainerType + ModelType() model.ContainerType + RequiresKnowledgeBaseID() bool +} + +type registry struct { + byName map[string]Mapper + byProto map[pb.CreateDocumentContainerType]Mapper + byDTO map[dto.ContainerType]Mapper + byModel map[model.ContainerType]Mapper +} + +var globalRegistry = ®istry{ + byName: make(map[string]Mapper), + byProto: make(map[pb.CreateDocumentContainerType]Mapper), + byDTO: make(map[dto.ContainerType]Mapper), + byModel: make(map[model.ContainerType]Mapper), +} + +func RegisterMapper(mapper Mapper) { + if err := globalRegistry.register(mapper); err != nil { + panic(err) + } +} + +func (r *registry) register(mapper Mapper) error { + if mapper == nil { + return fmt.Errorf("document container mapper is nil") + } + name := normalizeName(mapper.Name()) + if name == "" { + return fmt.Errorf("document container mapper name is empty") + } + + protoType := mapper.ProtoType() + dtoType := mapper.DTOType() + modelType := mapper.ModelType() + if protoType == pb.CreateDocumentContainerType_CREATE_DOCUMENT_CONTAINER_TYPE_UNSPECIFIED || dtoType == "" || modelType == "" { + return fmt.Errorf("document container mapper %q has invalid mapping", name) + } + + if _, exists := r.byName[name]; exists { + return fmt.Errorf("document container mapper %q already registered", name) + } + if _, exists := r.byProto[protoType]; exists { + return fmt.Errorf("document container proto mapping %v already registered", protoType) + } + if _, exists := r.byDTO[dtoType]; exists { + return fmt.Errorf("document container dto mapping %q already registered", dtoType) + } + if _, exists := r.byModel[modelType]; exists { + return fmt.Errorf("document container model mapping %q already registered", modelType) + } + + r.byName[name] = mapper + r.byProto[protoType] = mapper + r.byDTO[dtoType] = mapper + r.byModel[modelType] = mapper + return nil +} + +func normalizeName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func (r *registry) findByName(name string) Mapper { + return r.byName[normalizeName(name)] +} + +func (r *registry) findByProto(t pb.CreateDocumentContainerType) Mapper { + return r.byProto[t] +} + +func (r *registry) findByDTO(t dto.ContainerType) Mapper { + if mapper, ok := r.byDTO[t]; ok { + return mapper + } + return r.byName[normalizeName(string(t))] +} + +func (r *registry) findByModel(t model.ContainerType) Mapper { + if mapper, ok := r.byModel[t]; ok { + return mapper + } + return r.byName[normalizeName(string(t))] +} + +func (r *registry) defaultMapper() Mapper { + if mapper := r.findByName(defaultContainerName); mapper != nil { + return mapper + } + for _, mapper := range r.byName { + return mapper + } + return nil +} + +func DefaultDTOType() dto.ContainerType { + mapper := globalRegistry.defaultMapper() + if mapper == nil { + return dto.ContainerType(defaultContainerName) + } + return mapper.DTOType() +} + +func IsSupportedDTOType(t dto.ContainerType) bool { + return globalRegistry.findByDTO(t) != nil +} + +func IsSupportedProtoType(t pb.CreateDocumentContainerType) bool { + return globalRegistry.findByProto(t) != nil +} + +func FromProtoType(t pb.CreateDocumentContainerType) (dto.ContainerType, bool) { + if mapper := globalRegistry.findByProto(t); mapper != nil { + return mapper.DTOType(), true + } + return "", false +} + +func ToProtoType(t dto.ContainerType) pb.CreateDocumentContainerType { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.ProtoType() + } + return pb.CreateDocumentContainerType_CREATE_DOCUMENT_CONTAINER_TYPE_UNSPECIFIED +} + +func ToModelType(t dto.ContainerType) model.ContainerType { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.ModelType() + } + return model.ContainerType(DefaultDTOType()) +} + +func FromModelType(t model.ContainerType) dto.ContainerType { + if mapper := globalRegistry.findByModel(t); mapper != nil { + return mapper.DTOType() + } + normalized := normalizeName(string(t)) + if normalized != "" { + return dto.ContainerType(normalized) + } + return DefaultDTOType() +} + +func RequiresKnowledgeBaseID(t dto.ContainerType) bool { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.RequiresKnowledgeBaseID() + } + return false +} diff --git a/internal/mapper/doc_type_mapper/markdown.go b/internal/mapper/doc_type_mapper/markdown.go new file mode 100644 index 0000000..7bd3ad5 --- /dev/null +++ b/internal/mapper/doc_type_mapper/markdown.go @@ -0,0 +1,27 @@ +package doc_type_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type markdownAdapter struct{} + +func (markdownAdapter) Name() string { + return string(dto.DocumentType_Markdown) +} + +func (markdownAdapter) ProtoType() pb.DocumentType { + return pb.DocumentType_DOCUMENT_TYPE_MARKDOWN +} + +func (markdownAdapter) DTOType() dto.DocumentType { + return dto.DocumentType_Markdown +} + +func (markdownAdapter) ModelType() model.DocumentType { + return model.DocumentType_Markdown +} + +func init() { RegisterMapper(markdownAdapter{}) } diff --git a/internal/mapper/doc_type_mapper/markdown_slide.go b/internal/mapper/doc_type_mapper/markdown_slide.go new file mode 100644 index 0000000..04daf7f --- /dev/null +++ b/internal/mapper/doc_type_mapper/markdown_slide.go @@ -0,0 +1,27 @@ +package doc_type_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type markdownSlideAdapter struct{} + +func (markdownSlideAdapter) Name() string { + return string(dto.DocumentType_MarkdownSlide) +} + +func (markdownSlideAdapter) ProtoType() pb.DocumentType { + return pb.DocumentType_DOCUMENT_TYPE_MARKDOWN_SLIDE +} + +func (markdownSlideAdapter) DTOType() dto.DocumentType { + return dto.DocumentType_MarkdownSlide +} + +func (markdownSlideAdapter) ModelType() model.DocumentType { + return model.DocumentType_MarkdownSlide +} + +func init() { RegisterMapper(markdownSlideAdapter{}) } diff --git a/internal/mapper/doc_type_mapper/registry.go b/internal/mapper/doc_type_mapper/registry.go new file mode 100644 index 0000000..119132c --- /dev/null +++ b/internal/mapper/doc_type_mapper/registry.go @@ -0,0 +1,167 @@ +package doc_type_mapper + +import ( + "fmt" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +const defaultTypeName = "markdown" + +// Mapper defines a document type mapping across transport(dto/proto) and model layers. +type Mapper interface { + Name() string + ProtoType() pb.DocumentType + DTOType() dto.DocumentType + ModelType() model.DocumentType +} + +type registry struct { + byName map[string]Mapper + byProto map[pb.DocumentType]Mapper + byDTO map[dto.DocumentType]Mapper + byModel map[model.DocumentType]Mapper +} + +var globalRegistry = ®istry{ + byName: make(map[string]Mapper), + byProto: make(map[pb.DocumentType]Mapper), + byDTO: make(map[dto.DocumentType]Mapper), + byModel: make(map[model.DocumentType]Mapper), +} + +// RegisterMapper registers a document type mapper. +// Implementing a new type only requires adding a new mapper and calling this in init(). +func RegisterMapper(mapper Mapper) { + if err := globalRegistry.register(mapper); err != nil { + panic(err) + } +} + +func (r *registry) register(mapper Mapper) error { + if mapper == nil { + return fmt.Errorf("document type mapper is nil") + } + + name := normalizeName(mapper.Name()) + if name == "" { + return fmt.Errorf("document type mapper name is empty") + } + + dtoType := mapper.DTOType() + modelType := mapper.ModelType() + protoType := mapper.ProtoType() + if dtoType == "" || modelType == "" || protoType == pb.DocumentType_DOCUMENT_TYPE_UNSPECIFIED { + return fmt.Errorf("document type mapper %q has invalid mapping", name) + } + + if _, exists := r.byName[name]; exists { + return fmt.Errorf("document type mapper %q already registered", name) + } + if _, exists := r.byProto[protoType]; exists { + return fmt.Errorf("document type proto mapping %v already registered", protoType) + } + if _, exists := r.byDTO[dtoType]; exists { + return fmt.Errorf("document type dto mapping %q already registered", dtoType) + } + if _, exists := r.byModel[modelType]; exists { + return fmt.Errorf("document type model mapping %q already registered", modelType) + } + + r.byName[name] = mapper + r.byProto[protoType] = mapper + r.byDTO[dtoType] = mapper + r.byModel[modelType] = mapper + return nil +} + +func (r *registry) findByName(name string) Mapper { + return r.byName[normalizeName(name)] +} + +func (r *registry) findByProto(t pb.DocumentType) Mapper { + return r.byProto[t] +} + +func (r *registry) findByDTO(t dto.DocumentType) Mapper { + if mapper, ok := r.byDTO[t]; ok { + return mapper + } + return r.byName[normalizeName(string(t))] +} + +func (r *registry) findByModel(t model.DocumentType) Mapper { + if mapper, ok := r.byModel[t]; ok { + return mapper + } + return r.byName[normalizeName(string(t))] +} + +func (r *registry) defaultMapper() Mapper { + if mapper := r.findByName(defaultTypeName); mapper != nil { + return mapper + } + for _, mapper := range r.byName { + return mapper + } + return nil +} + +func normalizeName(name string) string { + normalized := strings.ToLower(strings.TrimSpace(name)) + switch normalized { + case "slide": + return "markdown_slide" + case "yoreseerichtext", "yoresee-rich-text": + return "yoresee_rich_text" + default: + return normalized + } +} + +func DefaultDTOType() dto.DocumentType { + mapper := globalRegistry.defaultMapper() + if mapper == nil { + return dto.DocumentType(defaultTypeName) + } + return mapper.DTOType() +} + +func IsSupportedDTOType(t dto.DocumentType) bool { + return globalRegistry.findByDTO(t) != nil +} + +func FromProtoType(t pb.DocumentType) dto.DocumentType { + if mapper := globalRegistry.findByProto(t); mapper != nil { + return mapper.DTOType() + } + return DefaultDTOType() +} + +func ToProtoType(t dto.DocumentType) pb.DocumentType { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.ProtoType() + } + return pb.DocumentType_DOCUMENT_TYPE_UNSPECIFIED +} + +func ToModelType(t dto.DocumentType) model.DocumentType { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.ModelType() + } + return model.DocumentType(DefaultDTOType()) +} + +func FromModelType(t model.DocumentType) dto.DocumentType { + if mapper := globalRegistry.findByModel(t); mapper != nil { + return mapper.DTOType() + } + normalized := normalizeName(string(t)) + if normalized != "" { + return dto.DocumentType(normalized) + } + return DefaultDTOType() +} diff --git a/internal/mapper/doc_type_mapper/table.go b/internal/mapper/doc_type_mapper/table.go new file mode 100644 index 0000000..af39692 --- /dev/null +++ b/internal/mapper/doc_type_mapper/table.go @@ -0,0 +1,27 @@ +package doc_type_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type tableAdapter struct{} + +func (tableAdapter) Name() string { + return string(dto.DocumentType_Table) +} + +func (tableAdapter) ProtoType() pb.DocumentType { + return pb.DocumentType_DOCUMENT_TYPE_TABLE +} + +func (tableAdapter) DTOType() dto.DocumentType { + return dto.DocumentType_Table +} + +func (tableAdapter) ModelType() model.DocumentType { + return model.DocumentType_Table +} + +func init() { RegisterMapper(tableAdapter{}) } diff --git a/internal/mapper/doc_type_mapper/yoresee_rich_text.go b/internal/mapper/doc_type_mapper/yoresee_rich_text.go new file mode 100644 index 0000000..2614ef5 --- /dev/null +++ b/internal/mapper/doc_type_mapper/yoresee_rich_text.go @@ -0,0 +1,27 @@ +package doc_type_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type yoreseeRichTextAdapter struct{} + +func (yoreseeRichTextAdapter) Name() string { + return string(dto.DocumentType_YoreseeRichText) +} + +func (yoreseeRichTextAdapter) ProtoType() pb.DocumentType { + return pb.DocumentType_DOCUMENT_TYPE_YORESEE_RICH_TEXT +} + +func (yoreseeRichTextAdapter) DTOType() dto.DocumentType { + return dto.DocumentType_YoreseeRichText +} + +func (yoreseeRichTextAdapter) ModelType() model.DocumentType { + return model.DocumentType_YoreseeRichText +} + +func init() { RegisterMapper(yoreseeRichTextAdapter{}) } diff --git a/internal/mapper/template_container_mapper/knowledge_base.go b/internal/mapper/template_container_mapper/knowledge_base.go new file mode 100644 index 0000000..b3f7468 --- /dev/null +++ b/internal/mapper/template_container_mapper/knowledge_base.go @@ -0,0 +1,34 @@ +package template_container_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type knowledgeBaseAdapter struct{} + +func (knowledgeBaseAdapter) Name() string { + return "knowledge_base" +} + +func (knowledgeBaseAdapter) ProtoType() pb.CreateTemplateContainer { + return pb.CreateTemplateContainer_KNOWLEDGEBASE_TEMPLATE +} + +func (knowledgeBaseAdapter) DTOType() dto.TemplateContainer { + return dto.TemplateContainerKnowledgeBase +} + +func (knowledgeBaseAdapter) Scope() string { + return "knowledge_base" +} + +func (knowledgeBaseAdapter) IsPublic() bool { + return false +} + +func (knowledgeBaseAdapter) RequiresKnowledgeBaseID() bool { + return true +} + +func init() { RegisterMapper(knowledgeBaseAdapter{}) } diff --git a/internal/mapper/template_container_mapper/own.go b/internal/mapper/template_container_mapper/own.go new file mode 100644 index 0000000..39b12b9 --- /dev/null +++ b/internal/mapper/template_container_mapper/own.go @@ -0,0 +1,34 @@ +package template_container_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type ownAdapter struct{} + +func (ownAdapter) Name() string { + return "own" +} + +func (ownAdapter) ProtoType() pb.CreateTemplateContainer { + return pb.CreateTemplateContainer_OWN_TEMPLATE +} + +func (ownAdapter) DTOType() dto.TemplateContainer { + return dto.TemplateContainerOwn +} + +func (ownAdapter) Scope() string { + return "private" +} + +func (ownAdapter) IsPublic() bool { + return false +} + +func (ownAdapter) RequiresKnowledgeBaseID() bool { + return false +} + +func init() { RegisterMapper(ownAdapter{}) } diff --git a/internal/mapper/template_container_mapper/public.go b/internal/mapper/template_container_mapper/public.go new file mode 100644 index 0000000..bcee8c2 --- /dev/null +++ b/internal/mapper/template_container_mapper/public.go @@ -0,0 +1,34 @@ +package template_container_mapper + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type publicAdapter struct{} + +func (publicAdapter) Name() string { + return "public" +} + +func (publicAdapter) ProtoType() pb.CreateTemplateContainer { + return pb.CreateTemplateContainer_PUBLIC_TEMPLATE +} + +func (publicAdapter) DTOType() dto.TemplateContainer { + return dto.TemplateContainerPublic +} + +func (publicAdapter) Scope() string { + return "system" +} + +func (publicAdapter) IsPublic() bool { + return true +} + +func (publicAdapter) RequiresKnowledgeBaseID() bool { + return false +} + +func init() { RegisterMapper(publicAdapter{}) } diff --git a/internal/mapper/template_container_mapper/registry.go b/internal/mapper/template_container_mapper/registry.go new file mode 100644 index 0000000..c59fd78 --- /dev/null +++ b/internal/mapper/template_container_mapper/registry.go @@ -0,0 +1,151 @@ +package template_container_mapper + +import ( + "fmt" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +const defaultContainerName = "own" + +type Mapper interface { + Name() string + ProtoType() pb.CreateTemplateContainer + DTOType() dto.TemplateContainer + Scope() string + IsPublic() bool + RequiresKnowledgeBaseID() bool +} + +type registry struct { + byName map[string]Mapper + byProto map[pb.CreateTemplateContainer]Mapper + byDTO map[dto.TemplateContainer]Mapper +} + +var globalRegistry = ®istry{ + byName: make(map[string]Mapper), + byProto: make(map[pb.CreateTemplateContainer]Mapper), + byDTO: make(map[dto.TemplateContainer]Mapper), +} + +func RegisterMapper(mapper Mapper) { + if err := globalRegistry.register(mapper); err != nil { + panic(err) + } +} + +func (r *registry) register(mapper Mapper) error { + if mapper == nil { + return fmt.Errorf("template container mapper is nil") + } + name := normalizeName(mapper.Name()) + if name == "" { + return fmt.Errorf("template container mapper name is empty") + } + + protoType := mapper.ProtoType() + dtoType := mapper.DTOType() + if dtoType == "" { + return fmt.Errorf("template container mapper %q has invalid dto mapping", name) + } + if strings.TrimSpace(mapper.Scope()) == "" { + return fmt.Errorf("template container mapper %q has empty scope mapping", name) + } + + if _, exists := r.byName[name]; exists { + return fmt.Errorf("template container mapper %q already registered", name) + } + if _, exists := r.byProto[protoType]; exists { + return fmt.Errorf("template container proto mapping %v already registered", protoType) + } + if _, exists := r.byDTO[dtoType]; exists { + return fmt.Errorf("template container dto mapping %q already registered", dtoType) + } + + r.byName[name] = mapper + r.byProto[protoType] = mapper + r.byDTO[dtoType] = mapper + return nil +} + +func normalizeName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func (r *registry) findByName(name string) Mapper { + return r.byName[normalizeName(name)] +} + +func (r *registry) findByProto(t pb.CreateTemplateContainer) Mapper { + return r.byProto[t] +} + +func (r *registry) findByDTO(t dto.TemplateContainer) Mapper { + return r.byDTO[t] +} + +func (r *registry) defaultMapper() Mapper { + if mapper := r.findByName(defaultContainerName); mapper != nil { + return mapper + } + for _, mapper := range r.byName { + return mapper + } + return nil +} + +func DefaultDTOType() dto.TemplateContainer { + mapper := globalRegistry.defaultMapper() + if mapper == nil { + return dto.TemplateContainerOwn + } + return mapper.DTOType() +} + +func IsSupportedDTOType(t dto.TemplateContainer) bool { + return globalRegistry.findByDTO(t) != nil +} + +func FromProtoType(t pb.CreateTemplateContainer) dto.TemplateContainer { + if mapper := globalRegistry.findByProto(t); mapper != nil { + return mapper.DTOType() + } + return DefaultDTOType() +} + +func ToProtoType(t dto.TemplateContainer) pb.CreateTemplateContainer { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.ProtoType() + } + return pb.CreateTemplateContainer_OWN_TEMPLATE +} + +func ToScope(t dto.TemplateContainer) string { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.Scope() + } + if mapper := globalRegistry.defaultMapper(); mapper != nil { + return mapper.Scope() + } + return "private" +} + +func ToIsPublic(t dto.TemplateContainer) bool { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.IsPublic() + } + if mapper := globalRegistry.defaultMapper(); mapper != nil { + return mapper.IsPublic() + } + return false +} + +func RequiresKnowledgeBaseID(t dto.TemplateContainer) bool { + if mapper := globalRegistry.findByDTO(t); mapper != nil { + return mapper.RequiresKnowledgeBaseID() + } + return false +} diff --git a/internal/media/avatar.go b/internal/media/avatar.go new file mode 100644 index 0000000..37aaf59 --- /dev/null +++ b/internal/media/avatar.go @@ -0,0 +1,39 @@ +package media + +import ( + "strings" +) + +const ( + MaxAvatarSize = 5 * 1024 * 1024 + defaultAvatarExtension = ".jpg" +) + +var avatarContentTypes = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + +func NormalizeAvatarContentType(file []byte, provided string) string { + return NormalizeContentTypeByAllowed(file, provided, avatarContentTypes) +} + +func IsSupportedAvatarContentType(contentType string) bool { + return IsContentTypeAllowed(contentType, avatarContentTypes) +} + +func ResolveAvatarExt(filename, contentType string) string { + return ResolveExtension(filename, contentType, avatarContentTypes, defaultAvatarExtension) +} + +func BuildAvatarObjectKey(userExternalID string, avatarVersion int64, ext string) string { + return BuildVersionedObjectKey("avatars", userExternalID, avatarVersion, ext) +} + +func BuildAvatarURL(userExternalID, objectKey string, avatarVersion int64) string { + if strings.TrimSpace(userExternalID) == "" || strings.TrimSpace(objectKey) == "" { + return "" + } + return BuildStorageURL(objectKey) +} diff --git a/internal/media/core.go b/internal/media/core.go new file mode 100644 index 0000000..0af40d9 --- /dev/null +++ b/internal/media/core.go @@ -0,0 +1,84 @@ +package media + +import ( + "fmt" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/XingfenD/yoresee_doc/pkg/storage" +) + +func normalizeMIMEType(contentType string) string { + normalized := strings.ToLower(strings.TrimSpace(contentType)) + if idx := strings.Index(normalized, ";"); idx > 0 { + normalized = strings.TrimSpace(normalized[:idx]) + } + if normalized == "image/jpg" { + return "image/jpeg" + } + return normalized +} + +func NormalizeContentTypeByAllowed(file []byte, provided string, allowed map[string]string) string { + normalized := normalizeMIMEType(provided) + if _, ok := allowed[normalized]; ok { + return normalized + } + + detected := normalizeMIMEType(http.DetectContentType(file)) + if _, ok := allowed[detected]; ok { + return detected + } + return "" +} + +func IsContentTypeAllowed(contentType string, allowed map[string]string) bool { + _, ok := allowed[normalizeMIMEType(contentType)] + return ok +} + +func ResolveExtension(filename, contentType string, allowed map[string]string, defaultExt string) string { + ext := strings.TrimSpace(strings.ToLower(filepath.Ext(filename))) + if ext != "" { + for _, candidate := range allowed { + if ext == candidate { + return ext + } + } + } + + contentType = normalizeMIMEType(contentType) + if mapped, ok := allowed[contentType]; ok { + return mapped + } + + exts, err := mime.ExtensionsByType(contentType) + if err == nil && len(exts) > 0 { + for _, candidate := range exts { + candidate = strings.TrimSpace(strings.ToLower(candidate)) + for _, allowedExt := range allowed { + if candidate == allowedExt { + return candidate + } + } + } + } + return strings.TrimSpace(strings.ToLower(defaultExt)) +} + +func BuildVersionedObjectKey(prefix, ownerExternalID string, version int64, ext string) string { + prefix = strings.Trim(strings.TrimSpace(prefix), "/") + if prefix == "" { + return fmt.Sprintf("%s/v%d%s", ownerExternalID, version, ext) + } + return fmt.Sprintf("%s/%s/v%d%s", prefix, ownerExternalID, version, ext) +} + +func BuildStorageURL(objectKey string) string { + if strings.TrimSpace(objectKey) == "" { + return "" + } + return storage.BuildPublicObjectPath(objectKey) +} diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go new file mode 100644 index 0000000..bb24dab --- /dev/null +++ b/internal/middleware/jwt.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/auth" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +var JWTAuth = &JWTAuthMiddleware{} + +type JWTAuthMiddleware struct { +} + +func (m *JWTAuthMiddleware) ValidateAuthorizationHeader(authHeader string) (*auth.Claims, error) { + return m.handle(authHeader) +} + +func (m *JWTAuthMiddleware) handle(authHeader string) (*auth.Claims, error) { + if authHeader == "" { + return nil, status.GenErrWithCustomMsg(status.StatusTokenInvalid, "unlogin or illegal access") + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + return nil, status.GenErrWithCustomMsg(status.StatusTokenInvalid, "invalid token format") + } + token := parts[1] + claims, err := auth.ParseToken(token) + if err != nil { + return nil, status.StatusTokenInvalid + } + + if (&auth.JWTValidator{}).IsExpired(claims) { + return nil, status.StatusTokenExpired + } + if err := auth.ValidateJWTTokenInRedis(claims.ExternalID, token); err != nil { + return nil, err + } + return claims, nil +} diff --git a/internal/model/attachment.go b/internal/model/attachment.go new file mode 100644 index 0000000..e00d107 --- /dev/null +++ b/internal/model/attachment.go @@ -0,0 +1,26 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Attachment struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + ExternalID string `gorm:"size:100;unique;not null" json:"external_id"` + DocumentID int64 `gorm:"not null;index" json:"document_id"` + Name string `gorm:"size:255;not null" json:"name"` + Path string `gorm:"size:512;not null" json:"path"` + Size int64 `json:"size"` + MimeType string `gorm:"size:100" json:"mime_type"` + UserID int64 `gorm:"not null" json:"user_id"` + PresignedURL string `gorm:"-" json:"presigned_url"` // 预签名URL,不存储到数据库 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Attachment) TableName() string { + return "attachments" +} diff --git a/internal/model/document_comment.go b/internal/model/document_comment.go new file mode 100644 index 0000000..974340a --- /dev/null +++ b/internal/model/document_comment.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type DocumentComment struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + ExternalID string `gorm:"size:100;unique;index;not null" json:"external_id"` + DocumentID int64 `gorm:"index;not null" json:"document_id"` + ParentID int64 `gorm:"default:0;index" json:"parent_id"` + CreatorID int64 `gorm:"index;not null" json:"creator_id"` + Content string `gorm:"type:text;not null" json:"content"` + AnchorID string `gorm:"size:128;index" json:"anchor_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (DocumentComment) TableName() string { + return "document_comments" +} diff --git a/internal/model/document_meta.go b/internal/model/document_meta.go new file mode 100644 index 0000000..90057e9 --- /dev/null +++ b/internal/model/document_meta.go @@ -0,0 +1,45 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type DocumentType string + +const DocumentType_Markdown DocumentType = "markdown" +const DocumentType_Table DocumentType = "table" +const DocumentType_MarkdownSlide DocumentType = "markdown_slide" +const DocumentType_YoreseeRichText DocumentType = "yoresee_rich_text" + +type ContainerType string + +const ContainerType_Own ContainerType = "own" +const ContainerType_KnowledgeBase ContainerType = "knowledge_base" + +type Document struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + ExternalID string `gorm:"size:100;unique;index;not null" json:"external_id"` + Title string `gorm:"size:200;not null" json:"title"` + Type DocumentType `gorm:"size:20;default:'markdown'" json:"type"` + Summary string `gorm:"type:text" json:"summary"` + ParentID int64 `gorm:"default:0;index" json:"parent_id"` // 0 means root + UserID int64 `gorm:"not null;index" json:"user_id"` + KnowledgeID *int64 `gorm:"index" json:"knowledge_id"` + ContainerType ContainerType `gorm:"not null;default:'own';index" json:"containter_type"` + IsPublic bool `gorm:"default:false;index" json:"is_public"` + Tags []string `gorm:"serializer:json" json:"tags"` + Path string `gorm:"type:ltree;not null;default:'';index:idx_path_gist,using:gist"` + Depth int `gorm:"not null;default:0;index"` + ViewCount int `gorm:"default:0" json:"view_count"` + EditCount int `gorm:"default:0" json:"edit_count"` + Content string `gorm:"type:text" json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Document) TableName() string { + return "document_metas" +} diff --git a/internal/model/document_version.go b/internal/model/document_version.go new file mode 100644 index 0000000..ea154ca --- /dev/null +++ b/internal/model/document_version.go @@ -0,0 +1,18 @@ +package model + +import "time" + +type DocumentVersion struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + DocumentID int64 `gorm:"not null;index" json:"document_id"` + Version int `gorm:"not null" json:"version"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text" json:"content"` + UserID int64 `gorm:"not null;index" json:"user_id"` + ChangeSummary string `gorm:"type:text" json:"change_summary"` + CreatedAt time.Time `json:"created_at"` +} + +func (DocumentVersion) TableName() string { + return "document_versions" +} diff --git a/internal/model/document_yjs_snapshot.go b/internal/model/document_yjs_snapshot.go new file mode 100644 index 0000000..78d5bd2 --- /dev/null +++ b/internal/model/document_yjs_snapshot.go @@ -0,0 +1,14 @@ +package model + +import "time" + +type DocumentYjsSnapshot struct { + DocID int64 `gorm:"primaryKey;column:doc_id" json:"doc_id"` + YjsState []byte `gorm:"type:bytea;not null" json:"yjs_state"` + Version int64 `gorm:"not null" json:"version"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (DocumentYjsSnapshot) TableName() string { + return "documents_yjs_snapshot" +} diff --git a/internal/model/invitation.go b/internal/model/invitation.go new file mode 100644 index 0000000..7ddb14e --- /dev/null +++ b/internal/model/invitation.go @@ -0,0 +1,22 @@ +package model + +import ( + "time" +) + +type Invitation struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Code string `gorm:"size:32;unique;not null" json:"code"` + CreatedBy int64 `gorm:"not null" json:"created_by"` + UsedCnt int64 `gorm:"default:0" json:"used_cnt"` + MaxUsedCnt *int64 `gorm:"default:1" json:"max_used_cnt"` + ExpiresAt *time.Time `json:"expires_at"` + Note *string `gorm:"type:text" json:"note"` + CreatedAt time.Time `json:"created_at"` + DeletedAt *time.Time `gorm:"index" json:"deleted_at"` + Disabled bool `gorm:"index,default:false" json:"disabled_at"` +} + +func (Invitation) TableName() string { + return "invitations" +} diff --git a/internal/model/invitation_record.go b/internal/model/invitation_record.go new file mode 100644 index 0000000..a7f308e --- /dev/null +++ b/internal/model/invitation_record.go @@ -0,0 +1,17 @@ +package model + +import "time" + +type InvitationRecord struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Code string `gorm:"size:32;index;not null" json:"code"` + UsedByUserID *int64 `gorm:"index" json:"used_by_user_id"` + UsedBy string `gorm:"size:100" json:"used_by"` + Status string `gorm:"size:20;index" json:"status"` + UsedAt time.Time `json:"used_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (InvitationRecord) TableName() string { + return "invitation_records" +} diff --git a/internal/model/knowledge_base.go b/internal/model/knowledge_base.go new file mode 100644 index 0000000..151e99c --- /dev/null +++ b/internal/model/knowledge_base.go @@ -0,0 +1,33 @@ +package model + +import ( + "time" +) + +type KnowledgeBase struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + ExternalID string `gorm:"size:100;unique;not null;index" json:"external_id"` + Name string `gorm:"size:100;not null" json:"name"` + Description string `gorm:"size:255" json:"description"` + Cover string `gorm:"size:255" json:"cover"` + CreatorUserID int64 `gorm:"not null" json:"creator_user_id"` // creator user id + IsPublic bool `gorm:"default:false" json:"is_public"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt time.Time `gorm:"index" json:"deleted_at"` +} + +type RecentKnowledgeBase struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + UserID int64 `gorm:"not null;index" json:"user_id"` + KnowledgeBaseID int64 `gorm:"not null;index" json:"knowledge_base_id"` + AccessedAt time.Time `gorm:"not null" json:"accessed_at"` +} + +func (RecentKnowledgeBase) TableName() string { + return "recent_knowledge_bases" +} + +func (KnowledgeBase) TableName() string { + return "knowledge_bases" +} diff --git a/internal/model/membership.go b/internal/model/membership.go new file mode 100644 index 0000000..55c8fa9 --- /dev/null +++ b/internal/model/membership.go @@ -0,0 +1,24 @@ +package model + +type MembershipType int64 + +const ( + MembershipType_UserGroup MembershipType = 1 // equals to SubjectTypeUserGroup + MembershipType_OrgNode MembershipType = 2 // equals to SubjectTypeOrgNode +) + +type MembershipRelation struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + Type MembershipType `gorm:"not null;index" json:"type"` // 1: UserGroup, 2: OrgNode + UserID int64 `gorm:"not null;index" json:"user_id"` + MembershipID int64 `gorm:"not null;index" json:"membership_id"` // user_group_id or org_node_id + + // virtual field + User User `gorm:"foreignKey:UserID;references:ID"` +} + +// query all user belong to membership with type and member_id + +func (MembershipRelation) TableName() string { + return "membership" +} diff --git a/internal/model/notification.go b/internal/model/notification.go new file mode 100644 index 0000000..9f7b5e7 --- /dev/null +++ b/internal/model/notification.go @@ -0,0 +1,22 @@ +package model + +import ( + "time" +) + +type Notification struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + ExternalID string `gorm:"size:100;unique;index" json:"external_id"` + ReceiverID int64 `gorm:"index;not null" json:"receiver_id"` + Type string `gorm:"size:64;not null" json:"type"` + Status string `gorm:"size:32;not null;default:unread" json:"status"` + Title string `gorm:"size:255" json:"title"` + Content string `gorm:"size:1024" json:"content"` + Payload string `gorm:"type:jsonb" json:"payload"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Notification) TableName() string { + return "notifications" +} diff --git a/internal/model/org_node_meta.go b/internal/model/org_node_meta.go new file mode 100644 index 0000000..23c019a --- /dev/null +++ b/internal/model/org_node_meta.go @@ -0,0 +1,15 @@ +package model + +type OrgNodeMeta struct { + ID int64 `gorm:"primaryKey;autoIncrement"` // org_node_id + ExternalID string `gorm:"not null;index;unique" json:"external_id"` // external org_node_id + ParentID int64 `gorm:"not null;index" json:"parent_id"` // org_node_id, root node is 0 + Name string `gorm:"not null;index" json:"name"` + Path string `gorm:"type:ltree;not null;index" json:"path"` // format: n.n... + Description string `gorm:"type:text;index" json:"description"` + CreatorID int64 `gorm:"not null;index" json:"creator_id"` // user_id +} + +func (OrgNodeMeta) TableName() string { + return "org_node_meta" +} diff --git a/internal/model/recent_document.go b/internal/model/recent_document.go new file mode 100644 index 0000000..e9bc31e --- /dev/null +++ b/internal/model/recent_document.go @@ -0,0 +1,14 @@ +package model + +import "time" + +type RecentDocument struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + UserID int64 `gorm:"not null;index" json:"user_id"` + DocumentID int64 `gorm:"not null;index" json:"document_id"` + AccessedAt time.Time `gorm:"not null" json:"accessed_at"` +} + +func (RecentDocument) TableName() string { + return "recent_documents" +} diff --git a/internal/model/recent_template.go b/internal/model/recent_template.go new file mode 100644 index 0000000..d6efa86 --- /dev/null +++ b/internal/model/recent_template.go @@ -0,0 +1,14 @@ +package model + +import "time" + +type RecentTemplate struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + UserID int64 `gorm:"not null;index" json:"user_id"` + TemplateID int64 `gorm:"not null;index" json:"template_id"` + AccessedAt time.Time `gorm:"not null" json:"accessed_at"` +} + +func (RecentTemplate) TableName() string { + return "recent_templates" +} diff --git a/internal/model/template.go b/internal/model/template.go new file mode 100644 index 0000000..cb6da75 --- /dev/null +++ b/internal/model/template.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" +) + +type Template struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + DocumentType DocumentType `gorm:"column:document_type;size:20;default:'markdown'" json:"document_type"` + Content string `gorm:"type:text" json:"content"` + UserID int64 `gorm:"not null" json:"user_id"` + Scope string `gorm:"size:20;default:'private'" json:"scope"` // private, system, knowledge_base + KnowledgeBaseID *int64 `json:"knowledge_base_id"` + IsPublic bool `gorm:"default:false" json:"is_public"` // Deprecated, keep for migration + Tags []string `gorm:"serializer:json" json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Template) TableName() string { + return "templates" +} diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..e749e16 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + ExternalID string `gorm:"size:100;unique" json:"external_id"` + Username string `gorm:"size:50;not null" json:"username"` + Email string `gorm:"size:100;unique;not null" json:"email"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + Nickname string `gorm:"size:50" json:"nickname"` + Avatar string `gorm:"size:255" json:"avatar"` // deprecated + AvatarObjectKey string `gorm:"size:255" json:"avatar_object_key"` + AvatarVersion int64 `gorm:"not null;default:0" json:"avatar_version"` + AvatarUpdatedAt *time.Time `json:"avatar_updated_at"` + Status int `gorm:"default:1" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + InvitationCode *string `gorm:"size:32" json:"invitation_code"` +} + +func (User) TableName() string { + return "users" +} diff --git a/internal/model/user_group_meta.go b/internal/model/user_group_meta.go new file mode 100644 index 0000000..792c4e8 --- /dev/null +++ b/internal/model/user_group_meta.go @@ -0,0 +1,13 @@ +package model + +type UserGroupMeta struct { + ID int64 `gorm:"primaryKey;autoIncrement"` // user_group_id + ExternalID string `gorm:"not null;index;unique" json:"external_id"` // external user_group_id + Name string `gorm:"not null;index" json:"name"` + CreatorID int64 `gorm:"not null;index" json:"creator_id"` // user_id + Description string `gorm:"type:text;index" json:"description"` +} + +func (UserGroupMeta) TableName() string { + return "user_group_meta" +} diff --git a/internal/repository/attachment_repo/attachment_repo.go b/internal/repository/attachment_repo/attachment_repo.go new file mode 100644 index 0000000..5d81b6c --- /dev/null +++ b/internal/repository/attachment_repo/attachment_repo.go @@ -0,0 +1,47 @@ +package attachment_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type AttachmentRepository struct { + db *gorm.DB +} + +func NewAttachmentRepository(db *gorm.DB) *AttachmentRepository { + return &AttachmentRepository{db: db} +} + +func (r *AttachmentRepository) Create(attachment *model.Attachment) error { + return r.db.Create(attachment).Error +} + +func (r *AttachmentRepository) ListByDocumentID(documentID int64) ([]*model.Attachment, error) { + attachments := make([]*model.Attachment, 0) + if err := r.db. + Where("document_id = ?", documentID). + Order("created_at desc"). + Find(&attachments).Error; err != nil { + return nil, err + } + return attachments, nil +} + +func (r *AttachmentRepository) GetByExternalIDAndDocumentID(externalID string, documentID int64) (*model.Attachment, error) { + attachment := &model.Attachment{} + if err := r.db. + Where("external_id = ? AND document_id = ?", externalID, documentID). + First(attachment).Error; err != nil { + return nil, err + } + return attachment, nil +} + +func (r *AttachmentRepository) DeleteByID(id int64) error { + return r.db.Delete(&model.Attachment{}, id).Error +} + +func IsNotFound(err error) bool { + return err == gorm.ErrRecordNotFound +} diff --git a/internal/repository/comment_repo/comment_repo.go b/internal/repository/comment_repo/comment_repo.go new file mode 100644 index 0000000..71afe01 --- /dev/null +++ b/internal/repository/comment_repo/comment_repo.go @@ -0,0 +1,11 @@ +package comment_repo + +import "gorm.io/gorm" + +type CommentRepository struct { + db *gorm.DB +} + +func NewCommentRepository(db *gorm.DB) *CommentRepository { + return &CommentRepository{db: db} +} diff --git a/internal/repository/comment_repo/create.go b/internal/repository/comment_repo/create.go new file mode 100644 index 0000000..27d66e8 --- /dev/null +++ b/internal/repository/comment_repo/create.go @@ -0,0 +1,35 @@ +package comment_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CommentCreateOperation struct { + repo *CommentRepository + item *model.DocumentComment + tx *gorm.DB +} + +func (r *CommentRepository) Create(item *model.DocumentComment) *CommentCreateOperation { + return &CommentCreateOperation{ + repo: r, + item: item, + } +} + +func (op *CommentCreateOperation) WithTx(tx *gorm.DB) *CommentCreateOperation { + op.tx = tx + return op +} + +func (op *CommentCreateOperation) Exec() error { + if op.item == nil { + return nil + } + db := op.repo.db + if op.tx != nil { + db = op.tx + } + return db.Create(op.item).Error +} diff --git a/internal/repository/comment_repo/delete.go b/internal/repository/comment_repo/delete.go new file mode 100644 index 0000000..ae3555e --- /dev/null +++ b/internal/repository/comment_repo/delete.go @@ -0,0 +1,32 @@ +package comment_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CommentDeleteOperation struct { + repo *CommentRepository + id int64 + tx *gorm.DB +} + +func (r *CommentRepository) Delete(id int64) *CommentDeleteOperation { + return &CommentDeleteOperation{ + repo: r, + id: id, + } +} + +func (op *CommentDeleteOperation) WithTx(tx *gorm.DB) *CommentDeleteOperation { + op.tx = tx + return op +} + +func (op *CommentDeleteOperation) Exec() error { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + return db.Delete(&model.DocumentComment{}, op.id).Error +} diff --git a/internal/repository/comment_repo/get_by_external_id.go b/internal/repository/comment_repo/get_by_external_id.go new file mode 100644 index 0000000..f4132ee --- /dev/null +++ b/internal/repository/comment_repo/get_by_external_id.go @@ -0,0 +1,36 @@ +package comment_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CommentGetByExternalIDOperation struct { + repo *CommentRepository + externalID string + tx *gorm.DB +} + +func (r *CommentRepository) GetByExternalID(externalID string) *CommentGetByExternalIDOperation { + return &CommentGetByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *CommentGetByExternalIDOperation) WithTx(tx *gorm.DB) *CommentGetByExternalIDOperation { + op.tx = tx + return op +} + +func (op *CommentGetByExternalIDOperation) Exec() (*model.DocumentComment, error) { + var item model.DocumentComment + db := op.repo.db + if op.tx != nil { + db = op.tx + } + if err := db.First(&item, "external_id = ?", op.externalID).Error; err != nil { + return nil, err + } + return &item, nil +} diff --git a/internal/repository/comment_repo/list_by_document.go b/internal/repository/comment_repo/list_by_document.go new file mode 100644 index 0000000..3809144 --- /dev/null +++ b/internal/repository/comment_repo/list_by_document.go @@ -0,0 +1,66 @@ +package comment_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CommentListByDocumentOperation struct { + repo *CommentRepository + documentID int64 + page int + pageSize int + tx *gorm.DB +} + +func (r *CommentRepository) ListByDocument(documentID int64) *CommentListByDocumentOperation { + return &CommentListByDocumentOperation{ + repo: r, + documentID: documentID, + } +} + +func (op *CommentListByDocumentOperation) WithTx(tx *gorm.DB) *CommentListByDocumentOperation { + op.tx = tx + return op +} + +func (op *CommentListByDocumentOperation) WithPagination(page, pageSize int) *CommentListByDocumentOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *CommentListByDocumentOperation) ExecWithTotal() ([]model.DocumentComment, int64, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + query := db.Model(&model.DocumentComment{}). + Where("document_id = ?", op.documentID). + Where("anchor_id IS NOT NULL AND anchor_id <> ''") + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + var items []model.DocumentComment + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} diff --git a/internal/repository/comment_repo/update_content.go b/internal/repository/comment_repo/update_content.go new file mode 100644 index 0000000..628374e --- /dev/null +++ b/internal/repository/comment_repo/update_content.go @@ -0,0 +1,34 @@ +package comment_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CommentUpdateContentOperation struct { + repo *CommentRepository + id int64 + content string + tx *gorm.DB +} + +func (r *CommentRepository) UpdateContentByID(id int64, content string) *CommentUpdateContentOperation { + return &CommentUpdateContentOperation{ + repo: r, + id: id, + content: content, + } +} + +func (op *CommentUpdateContentOperation) WithTx(tx *gorm.DB) *CommentUpdateContentOperation { + op.tx = tx + return op +} + +func (op *CommentUpdateContentOperation) Exec() error { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + return db.Model(&model.DocumentComment{}).Where("id = ?", op.id).Update("content", op.content).Error +} diff --git a/internal/repository/document_repo/create.go b/internal/repository/document_repo/create.go new file mode 100644 index 0000000..21b874d --- /dev/null +++ b/internal/repository/document_repo/create.go @@ -0,0 +1,32 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentCreateOperation struct { + repo *DocumentRepository + doc *model.Document + tx *gorm.DB +} + +func (r *DocumentRepository) Create(doc *model.Document) *DocumentCreateOperation { + return &DocumentCreateOperation{ + repo: r, + doc: doc, + } +} + +func (op *DocumentCreateOperation) WithTx(tx *gorm.DB) *DocumentCreateOperation { + op.tx = tx + return op +} + +func (op *DocumentCreateOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + return op.tx.Create(op.doc).Error +} diff --git a/internal/repository/document_repo/create_recent_document.go b/internal/repository/document_repo/create_recent_document.go new file mode 100644 index 0000000..69e52d5 --- /dev/null +++ b/internal/repository/document_repo/create_recent_document.go @@ -0,0 +1,36 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type UpsertRecentDocumentOperation struct { + repo *DocumentRepository + m *model.RecentDocument + tx *gorm.DB +} + +func (r *DocumentRepository) UpsertRecentDocument(m *model.RecentDocument) *UpsertRecentDocumentOperation { + return &UpsertRecentDocumentOperation{ + repo: r, + m: m, + } +} + +func (op *UpsertRecentDocumentOperation) WithTx(tx *gorm.DB) *UpsertRecentDocumentOperation { + op.tx = tx + return op +} + +func (op *UpsertRecentDocumentOperation) Exec() error { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "document_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{"accessed_at": op.m.AccessedAt}), + }).Create(op.m).Error +} diff --git a/internal/repository/document_repo/document_repo.go b/internal/repository/document_repo/document_repo.go new file mode 100644 index 0000000..647fbbf --- /dev/null +++ b/internal/repository/document_repo/document_repo.go @@ -0,0 +1,92 @@ +package document_repo + +import ( + "context" + + cache_loader "github.com/XingfenD/yoresee_doc/internal/cache" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type DocumentRepository struct { + db *gorm.DB + redis *redis.Client + Loader cache_loader.Loader +} + +func NewDocumentRepository(db *gorm.DB, redis *redis.Client) *DocumentRepository { + return &DocumentRepository{ + db: db, + redis: redis, + Loader: *cache_loader.NewLoader(redis), + } +} + +type DocumentDeleteOperation struct { + repo *DocumentRepository + id int64 + tx *gorm.DB + ctx context.Context +} + +func (r *DocumentRepository) Delete(id int64) *DocumentDeleteOperation { + return &DocumentDeleteOperation{ + repo: r, + id: id, + ctx: context.Background(), + } +} + +func (op *DocumentDeleteOperation) WithTx(tx *gorm.DB) *DocumentDeleteOperation { + op.tx = tx + return op +} + +func (op *DocumentDeleteOperation) WithContext(ctx context.Context) *DocumentDeleteOperation { + op.ctx = ctx + return op +} + +func (op *DocumentDeleteOperation) Exec() error { + var doc model.Document + var db *gorm.DB + var err error + + if op.tx != nil { + db = op.tx + } else { + db = op.repo.db + } + + if err := db.First(&doc, op.id).Error; err != nil { + return err + } + + if op.tx == nil { + db = db.Begin() + defer func() { + if err != nil { + db.Rollback() + } else { + db.Commit() + } + }() + } + + if err := db.Delete(&model.Document{}, op.id).Error; err != nil { + return err + } + + if op.tx == nil { + docCacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, doc.ExternalID) + if err := op.repo.redis.Del(op.ctx, docCacheKey).Err(); err != nil { + } + + if err := op.repo.BumpSubtreeVersionsByPath(op.ctx, doc.Path); err != nil { + } + } + + return nil +} diff --git a/internal/repository/document_repo/get_by_external_id.go b/internal/repository/document_repo/get_by_external_id.go new file mode 100644 index 0000000..6f50a4a --- /dev/null +++ b/internal/repository/document_repo/get_by_external_id.go @@ -0,0 +1,60 @@ +package document_repo + +import ( + "context" + + cache_loader "github.com/XingfenD/yoresee_doc/internal/cache" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type DocumentGetByExternalIDOperation struct { + repo *DocumentRepository + externalID string + tx *gorm.DB +} + +func (r *DocumentRepository) GetByExternalID(externalID string) *DocumentGetByExternalIDOperation { + return &DocumentGetByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *DocumentGetByExternalIDOperation) WithTx(tx *gorm.DB) *DocumentGetByExternalIDOperation { + op.tx = tx + return op +} + +func (op *DocumentGetByExternalIDOperation) query(db *gorm.DB) (*model.Document, error) { + var document model.Document + var err error + + err = db.First(&document, "external_id = ?", op.externalID).Error + + return &document, err +} + +func (op *DocumentGetByExternalIDOperation) Exec(ctx context.Context) (*model.Document, error) { + var err error + + if op.tx != nil { + return op.query(op.tx) + } + + documentCacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, op.externalID) + document, err := cache_loader.NewCacheLoadOperation[model.Document](&op.repo.Loader). + WithDBLoader(func() (*model.Document, error) { + return op.query(op.repo.db) + }).WithDefaultKeyAndParser(documentCacheKey, nil). + Exec(ctx) + + if err != nil { + logrus.Errorf("load data failed for DocumentGetByExternalIDOperation: %+v", err) + return nil, err + } + + return document, nil +} diff --git a/internal/repository/document_repo/get_content.go b/internal/repository/document_repo/get_content.go new file mode 100644 index 0000000..2fc669d --- /dev/null +++ b/internal/repository/document_repo/get_content.go @@ -0,0 +1,41 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentGetContentOperation struct { + repo *DocumentRepository + documentID int64 + tx *gorm.DB +} + +func (r *DocumentRepository) GetContent(documentID int64) *DocumentGetContentOperation { + return &DocumentGetContentOperation{ + repo: r, + documentID: documentID, + } +} + +func (op *DocumentGetContentOperation) WithTx(tx *gorm.DB) *DocumentGetContentOperation { + op.tx = tx + return op +} + +func (op *DocumentGetContentOperation) Exec() (string, error) { + var docMeta model.Document + var err error + + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + err = db.Where("id = ?", op.documentID).First(&docMeta).Error + if err != nil { + return "", err + } + + return docMeta.Content, nil +} diff --git a/internal/repository/document_repo/get_id_by_external_id.go b/internal/repository/document_repo/get_id_by_external_id.go new file mode 100644 index 0000000..1d57156 --- /dev/null +++ b/internal/repository/document_repo/get_id_by_external_id.go @@ -0,0 +1,66 @@ +package document_repo + +import ( + "context" + + cache_loader "github.com/XingfenD/yoresee_doc/internal/cache" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "gorm.io/gorm" +) + +type DocumentGetIDByExternalIDOperation struct { + repo *DocumentRepository + externalID string + tx *gorm.DB +} + +func (r *DocumentRepository) GetIDByExternalID(externalID string) *DocumentGetIDByExternalIDOperation { + return &DocumentGetIDByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *DocumentGetIDByExternalIDOperation) WithTx(tx *gorm.DB) *DocumentGetIDByExternalIDOperation { + op.tx = tx + return op +} + +func (op DocumentGetIDByExternalIDOperation) query(tx *gorm.DB) (int64, error) { + var id int64 + err := tx.Model(&model.Document{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + if err != nil { + return 0, err + } + return id, nil +} + +func (op *DocumentGetIDByExternalIDOperation) Exec(ctx context.Context) (int64, error) { + if op.tx != nil { + return op.query(op.tx) + } + + extidKey := key.KeyIDByExternalID(key.KeyObjectTypeEnum_Doc, op.externalID) + modelKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, op.externalID) + + id, err := cache_loader.NewCacheLoadOperation[int64](&op.repo.Loader). + WithDefaultKeyAndParser(extidKey, cache_loader.ParseInt64). + WithKeyAndParser(modelKey, cache_loader.ParseIDFromDocument). + WithDBLoader(func() (*int64, error) { + id, err := op.query(op.repo.db) + if err != nil { + return nil, err + } + return &id, nil + }).Exec(ctx) + if err != nil { + return 0, err + } + + if id == nil { + return 0, nil + } + + return *id, nil +} diff --git a/internal/repository/document_repo/get_subtree.go b/internal/repository/document_repo/get_subtree.go new file mode 100644 index 0000000..f5cbdb5 --- /dev/null +++ b/internal/repository/document_repo/get_subtree.go @@ -0,0 +1,149 @@ +package document_repo + +import ( + "context" + "errors" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "gorm.io/gorm" +) + +type DocumentGetSubtreeOperation struct { + repo *DocumentRepository + rootParentID int64 + knowledgeID *int64 + depth *int + + directoryOnly bool + + tx *gorm.DB +} + +func (r *DocumentRepository) GetSubtree(rootParentID int64) *DocumentGetSubtreeOperation { + return &DocumentGetSubtreeOperation{ + repo: r, + rootParentID: rootParentID, + } +} + +func (op *DocumentGetSubtreeOperation) WithTx(tx *gorm.DB) *DocumentGetSubtreeOperation { + op.tx = tx + return op +} + +func (op *DocumentGetSubtreeOperation) WithKnowledgeID(knowledgeID *int64) *DocumentGetSubtreeOperation { + op.knowledgeID = knowledgeID + return op +} + +func (op *DocumentGetSubtreeOperation) WithDepth(depth *int) *DocumentGetSubtreeOperation { + op.depth = depth + return op +} + +func (op *DocumentGetSubtreeOperation) WithDirectoryOnly(with bool) *DocumentGetSubtreeOperation { + op.directoryOnly = with + return op +} + +func (op *DocumentGetSubtreeOperation) Exec(ctx context.Context) ([]*model.Document, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + var documents []*model.Document + + if op.depth != nil && *op.depth == 0 { + return documents, nil + } + + type pathDepth struct { + Path string + Depth int + } + var root pathDepth + err := db.Model(&model.Document{}). + Select("path, depth"). + Where("id = ?", op.rootParentID). + Take(&root).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return documents, nil + } + return nil, err + } + + // in-transaction query should bypass cache to avoid stale reads + if op.tx != nil || op.repo.redis == nil { + return op.queryWithRoot(db, root.Path, root.Depth) + } + + version, err := op.repo.getSubtreeVersion(ctx, root.Path) + if err == nil { + cacheKey := key.KeyDocSubtree(root.Path, version, op.depth) + if cachedIDs, ok, err := getCachedSubtreeIDs(ctx, cacheKey); err == nil && ok { + return op.repo.fetchDocumentsByIDs(cachedIDs) + } + + val, err, _ := subtreeCacheSF.Do(cacheKey, func() (interface{}, error) { + dbDocs, err := op.queryWithRoot(db, root.Path, root.Depth) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(dbDocs)) + for _, doc := range dbDocs { + ids = append(ids, doc.ID) + } + setCachedSubtreeIDs(ctx, cacheKey, ids) + return dbDocs, nil + }) + if err != nil { + return nil, err + } + if typed, ok := val.([]*model.Document); ok { + return typed, nil + } + } + + return op.queryWithRoot(db, root.Path, root.Depth) +} + +func (op *DocumentGetSubtreeOperation) queryWithRoot(db *gorm.DB, rootPath string, rootDepth int) ([]*model.Document, error) { + var documents []*model.Document + + query := ` + SELECT * + FROM document_metas + WHERE deleted_at IS NULL + AND id <> ? + AND path <@ ? + ` + if op.directoryOnly { + query = ` + SELECT id, external_id, title, parent_id, type + FROM document_metas + WHERE deleted_at IS NULL + AND id <> ? + AND path <@ ? + ` + } + args := []interface{}{op.rootParentID, rootPath} + if op.knowledgeID != nil { + query += " AND knowledge_id = ?" + args = append(args, *op.knowledgeID) + } + if op.depth != nil { + maxDepth := rootDepth + *op.depth + query += " AND depth <= ?" + args = append(args, maxDepth) + } + query += " ORDER BY depth, created_at" + + if err := db.Raw(query, args...).Find(&documents).Error; err != nil { + return nil, err + } + + return documents, nil +} diff --git a/internal/repository/document_repo/get_subtree_by_knowledge_id.go b/internal/repository/document_repo/get_subtree_by_knowledge_id.go new file mode 100644 index 0000000..a4c894a --- /dev/null +++ b/internal/repository/document_repo/get_subtree_by_knowledge_id.go @@ -0,0 +1,64 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentGetSubtreeByKnowledgeIDOperation struct { + repo *DocumentRepository + knowledgeID int64 + depth *int + tx *gorm.DB +} + +func (r *DocumentRepository) GetSubtreeByKnowledgeID(knowledgeID int64) *DocumentGetSubtreeByKnowledgeIDOperation { + return &DocumentGetSubtreeByKnowledgeIDOperation{ + repo: r, + knowledgeID: knowledgeID, + } +} + +func (op *DocumentGetSubtreeByKnowledgeIDOperation) WithTx(tx *gorm.DB) *DocumentGetSubtreeByKnowledgeIDOperation { + op.tx = tx + return op +} + +func (op *DocumentGetSubtreeByKnowledgeIDOperation) WithDepth(depth int) *DocumentGetSubtreeByKnowledgeIDOperation { + op.depth = &depth + return op +} + +func (op *DocumentGetSubtreeByKnowledgeIDOperation) Exec() ([]model.Document, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + var documents []model.Document + + if op.depth != nil && *op.depth == 0 { + return documents, nil + } + + query := ` + SELECT * + FROM document_metas + WHERE deleted_at IS NULL + AND knowledge_id = ? + ` + + args := []interface{}{op.knowledgeID} + if op.depth != nil { + query += " AND depth <= ?" + args = append(args, *op.depth) + } + query += " ORDER BY depth, created_at" + + err := db.Raw(query, args...).Find(&documents).Error + if err != nil { + return nil, err + } + + return documents, nil +} diff --git a/internal/repository/document_repo/list.go b/internal/repository/document_repo/list.go new file mode 100644 index 0000000..6bdaecc --- /dev/null +++ b/internal/repository/document_repo/list.go @@ -0,0 +1,246 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentsListOperation struct { + repo *DocumentRepository + model *model.Document + userID *int64 + parentID *int64 + knowledgeID *int64 + listOwnDoc bool + ids []int64 + titleKeyword *string + docType *string + tags []string + createTimeRangeStart *string + createTimeRangeEnd *string + updateTimeRangeStart *string + updateTimeRangeEnd *string + sortField string + sortDesc bool + page int + pageSize int + + directoryOnly bool + + tx *gorm.DB +} + +func (r *DocumentRepository) ListDocuments(documentModel *model.Document) *DocumentsListOperation { + return &DocumentsListOperation{ + repo: r, + model: documentModel, + } +} + +func (op *DocumentsListOperation) WithUserID(userID *int64) *DocumentsListOperation { + op.userID = userID + return op +} + +func (op *DocumentsListOperation) WithParentID(parentID *int64) *DocumentsListOperation { + op.parentID = parentID + return op +} + +func (op *DocumentsListOperation) WithKnowledgeID(knowledgeID *int64) *DocumentsListOperation { + op.knowledgeID = knowledgeID + return op +} + +func (op *DocumentsListOperation) WithListOwnDoc(listOwnDoc bool) *DocumentsListOperation { + op.listOwnDoc = listOwnDoc + return op +} + +func (op *DocumentsListOperation) WithIDs(ids []int64) *DocumentsListOperation { + op.ids = ids + return op +} + +func (op *DocumentsListOperation) WithTitleKeyword(titleKeyword *string) *DocumentsListOperation { + op.titleKeyword = titleKeyword + return op +} + +func (op *DocumentsListOperation) WithType(docType *string) *DocumentsListOperation { + op.docType = docType + return op +} + +func (op *DocumentsListOperation) WithTags(tags []string) *DocumentsListOperation { + op.tags = tags + return op +} + +func (op *DocumentsListOperation) WithCreateTimeRange(start, end *string) *DocumentsListOperation { + op.createTimeRangeStart = start + op.createTimeRangeEnd = end + return op +} + +func (op *DocumentsListOperation) WithUpdateTimeRange(start, end *string) *DocumentsListOperation { + op.updateTimeRangeStart = start + op.updateTimeRangeEnd = end + return op +} + +func (op *DocumentsListOperation) WithPagination(page, pageSize int) *DocumentsListOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *DocumentsListOperation) WithSort(field string, desc bool) *DocumentsListOperation { + op.sortField = field + op.sortDesc = desc + return op +} + +func (op *DocumentsListOperation) WithDirectoryOnly(with bool) *DocumentsListOperation { + op.directoryOnly = with + return op +} + +func (op *DocumentsListOperation) WithTx(tx *gorm.DB) *DocumentsListOperation { + op.tx = tx + return op +} + +func (op *DocumentsListOperation) buildBaseQuery() *gorm.DB { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + dbQuery := db.Model(&model.Document{}) + if op.directoryOnly { + dbQuery = dbQuery.Select("id, external_id, title, parent_id, type") + } + + if op.model != nil { + dbQuery = dbQuery.Where(op.model) + } + + if op.userID != nil { + dbQuery = dbQuery.Where("user_id = ?", *op.userID) + } + + if op.parentID != nil { + dbQuery = dbQuery.Where("parent_id = ?", *op.parentID) + } + + if op.listOwnDoc { + dbQuery = dbQuery.Where("knowledge_id IS NULL") + } + + if op.ids != nil { + if len(op.ids) == 0 { + dbQuery = dbQuery.Where("1 = 0") + } else { + dbQuery = dbQuery.Where("id IN ?", op.ids) + } + } + + if op.knowledgeID != nil { + dbQuery = dbQuery.Where("knowledge_id = ?", *op.knowledgeID) + } + + if op.titleKeyword != nil && *op.titleKeyword != "" { + like := "%" + *op.titleKeyword + "%" + dbQuery = dbQuery.Where("(title LIKE ? OR content LIKE ?)", like, like) + } + + if op.docType != nil && *op.docType != "" { + dbQuery = dbQuery.Where("type = ?", *op.docType) + } + + if len(op.tags) > 0 { + for _, tag := range op.tags { + dbQuery = dbQuery.Where("JSON_CONTAINS(tags, ?)", "\""+tag+"\"") + } + } + + if op.createTimeRangeStart != nil && *op.createTimeRangeStart != "" { + dbQuery = dbQuery.Where("created_at >= ?", *op.createTimeRangeStart) + } + if op.createTimeRangeEnd != nil && *op.createTimeRangeEnd != "" { + dbQuery = dbQuery.Where("created_at <= ?", *op.createTimeRangeEnd) + } + if op.updateTimeRangeStart != nil && *op.updateTimeRangeStart != "" { + dbQuery = dbQuery.Where("updated_at >= ?", *op.updateTimeRangeStart) + } + if op.updateTimeRangeEnd != nil && *op.updateTimeRangeEnd != "" { + dbQuery = dbQuery.Where("updated_at <= ?", *op.updateTimeRangeEnd) + } + + return dbQuery +} + +func (op *DocumentsListOperation) appendOtherArgs(db *gorm.DB) *gorm.DB { + dbQuery := db + + sortField := op.sortField + if sortField == "" { + sortField = "created_at" + } + orderDirection := "ASC" + if op.sortDesc { + orderDirection = "DESC" + } + dbQuery = dbQuery.Order(sortField + " " + orderDirection) + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + offset := (page - 1) * pageSize + dbQuery = dbQuery.Offset(offset).Limit(pageSize) + + return dbQuery +} + +func (op *DocumentsListOperation) Exec() ([]model.Document, error) { + dbQuery := op.buildBaseQuery() + dbQuery = op.appendOtherArgs(dbQuery) + + var documents []model.Document + err := dbQuery.Find(&documents).Error + if err != nil { + return nil, err + } + + return documents, nil +} + +func (op *DocumentsListOperation) ExecWithTotal() ([]model.Document, int64, error) { + dbQuery := op.buildBaseQuery() + + var total int64 + err := dbQuery.Count(&total).Error + if err != nil { + return nil, 0, err + } + + dbQuery = op.appendOtherArgs(dbQuery) + + var documents []model.Document + err = dbQuery.Find(&documents).Error + if err != nil { + return nil, 0, err + } + + return documents, total, nil +} diff --git a/internal/repository/document_repo/list_recent_documents.go b/internal/repository/document_repo/list_recent_documents.go new file mode 100644 index 0000000..c4e8e58 --- /dev/null +++ b/internal/repository/document_repo/list_recent_documents.go @@ -0,0 +1,82 @@ +package document_repo + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type ListRecentDocumentsOperation struct { + repo *DocumentRepository + userID int64 + startTime *time.Time + endTime *time.Time + page int + pageSize int + tx *gorm.DB +} + +func (r *DocumentRepository) ListRecentDocuments(userID int64) *ListRecentDocumentsOperation { + return &ListRecentDocumentsOperation{ + repo: r, + userID: userID, + } +} + +func (op *ListRecentDocumentsOperation) WithTimeRange(start, end *time.Time) *ListRecentDocumentsOperation { + op.startTime = start + op.endTime = end + return op +} + +func (op *ListRecentDocumentsOperation) WithPagination(page, pageSize int) *ListRecentDocumentsOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *ListRecentDocumentsOperation) WithTx(tx *gorm.DB) *ListRecentDocumentsOperation { + op.tx = tx + return op +} + +func (op *ListRecentDocumentsOperation) Exec() ([]*model.RecentDocument, int64, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + query := db.Model(&model.RecentDocument{}). + Where("user_id = ?", op.userID) + if op.startTime != nil { + query = query.Where("accessed_at >= ?", *op.startTime) + } + if op.endTime != nil { + query = query.Where("accessed_at <= ?", *op.endTime) + } + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var records []*model.RecentDocument + if err := query.Order("accessed_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { + return nil, 0, err + } + return records, total, nil +} diff --git a/internal/repository/document_repo/mget_by_ids.go b/internal/repository/document_repo/mget_by_ids.go new file mode 100644 index 0000000..1095b7d --- /dev/null +++ b/internal/repository/document_repo/mget_by_ids.go @@ -0,0 +1,29 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" +) + +func (r *DocumentRepository) MGetByIDs(ids []int64) ([]*model.Document, error) { + if len(ids) == 0 { + return []*model.Document{}, nil + } + + var docs []*model.Document + if err := r.db.Model(&model.Document{}).Where("id IN ?", ids).Find(&docs).Error; err != nil { + return nil, err + } + + docMap := make(map[int64]*model.Document, len(docs)) + for _, doc := range docs { + docMap[doc.ID] = doc + } + + ordered := make([]*model.Document, 0, len(ids)) + for _, id := range ids { + if doc, ok := docMap[id]; ok { + ordered = append(ordered, doc) + } + } + return ordered, nil +} diff --git a/internal/repository/document_repo/move_subtree.go b/internal/repository/document_repo/move_subtree.go new file mode 100644 index 0000000..5c76a4e --- /dev/null +++ b/internal/repository/document_repo/move_subtree.go @@ -0,0 +1,78 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentMoveSubtreeOperation struct { + repo *DocumentRepository + docID int64 + newParentID int64 + tx *gorm.DB +} + +func (r *DocumentRepository) MoveSubtree(docID, newParentID int64) *DocumentMoveSubtreeOperation { + return &DocumentMoveSubtreeOperation{ + repo: r, + docID: docID, + newParentID: newParentID, + } +} + +func (op *DocumentMoveSubtreeOperation) WithTx(tx *gorm.DB) *DocumentMoveSubtreeOperation { + op.tx = tx + return op +} + +func (op *DocumentMoveSubtreeOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + type pathDepth struct { + Path string + Depth int + } + var old pathDepth + if err := op.tx.Model(&model.Document{}). + Select("path, depth"). + Where("id = ? AND deleted_at IS NULL", op.docID). + Take(&old).Error; err != nil { + return err + } + + var newPath string + var newDepth int + if op.newParentID == 0 { + newDepth = 0 + } else { + var parent pathDepth + if err := op.tx.Model(&model.Document{}). + Select("path, depth"). + Where("id = ? AND deleted_at IS NULL", op.newParentID). + Take(&parent).Error; err != nil { + return err + } + newPath = parent.Path + newDepth = parent.Depth + 1 + } + + depthDelta := newDepth - old.Depth + + if op.newParentID == 0 { + return op.tx.Exec(` + UPDATE document_metas + SET path = (?::text)::ltree || COALESCE(subpath(path, nlevel(?::ltree)), ''::ltree), + depth = depth + ? + WHERE path <@ ?::ltree AND deleted_at IS NULL + `, op.docID, old.Path, depthDelta, old.Path).Error + } + + return op.tx.Exec(` + UPDATE document_metas + SET path = (?::ltree) || (?::text)::ltree || COALESCE(subpath(path, nlevel(?::ltree)), ''::ltree), + depth = depth + ? + WHERE path <@ ?::ltree AND deleted_at IS NULL + `, newPath, op.docID, old.Path, depthDelta, old.Path).Error +} diff --git a/internal/repository/document_repo/tree_cache.go b/internal/repository/document_repo/tree_cache.go new file mode 100644 index 0000000..fc93a92 --- /dev/null +++ b/internal/repository/document_repo/tree_cache.go @@ -0,0 +1,137 @@ +package document_repo + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/cache" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + "golang.org/x/sync/singleflight" + "gorm.io/gorm" +) + +const ( + subtreeCacheTTL = 5 * time.Minute + subtreeEmptyTTL = 1 * time.Minute + subtreeCacheMaxDepth = 3 +) + +var subtreeCacheSF singleflight.Group + +func splitPathPrefixes(path string) []string { + parts := strings.Split(path, ".") + out := make([]string, 0, len(parts)) + for i := range parts { + out = append(out, strings.Join(parts[:i+1], ".")) + } + return out +} + +func getDocPathByID(tx *gorm.DB, docID int64) (string, error) { + type docPath struct { + Path string + } + var result docPath + err := tx.Model(&model.Document{}). + Select("path"). + Where("id = ?", docID). + Take(&result).Error + if err != nil { + return "", err + } + return result.Path, nil +} + +func (r *DocumentRepository) GetPathByID(id int64) (string, error) { + return getDocPathByID(r.db, id) +} + +func (r *DocumentRepository) GetPathByIDWithTx(tx *gorm.DB, id int64) (string, error) { + return getDocPathByID(tx, id) +} + +func (r *DocumentRepository) getSubtreeVersion(ctx context.Context, path string) (int64, error) { + if r.redis == nil { + return 0, nil + } + val, err := r.redis.Get(ctx, key.KeyDocSubtreeVersion(path)).Result() + if err == redis.Nil { + return 0, nil + } + if err != nil { + return 0, err + } + version, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return 0, nil + } + return version, nil +} + +func (r *DocumentRepository) BumpSubtreeVersionsByPath(ctx context.Context, path string) error { + if r.redis == nil { + return nil + } + prefixes := splitPathPrefixes(path) + pipe := r.redis.Pipeline() + for _, prefix := range prefixes { + pipe.Incr(ctx, key.KeyDocSubtreeVersion(prefix)) + } + _, err := pipe.Exec(ctx) + if err == redis.Nil { + return nil + } + return err +} + +func getCachedSubtreeIDs(ctx context.Context, key string) ([]int64, bool, error) { + var ids []int64 + ok, err := cache.GetJSON(ctx, key, &ids) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, nil + } + return ids, true, nil +} + +func setCachedSubtreeIDs(ctx context.Context, key string, ids []int64) { + ttl := subtreeCacheTTL + if len(ids) == 0 { + ttl = subtreeEmptyTTL + } + if err := cache.SetJSON(ctx, key, ids, ttl); err != nil { + logrus.Warnf("set subtree cache failed, key=%s, err=%v", key, err) + } +} + +func (r *DocumentRepository) fetchDocumentsByIDs(ids []int64) ([]*model.Document, error) { + if len(ids) == 0 { + return []*model.Document{}, nil + } + + var docs []*model.Document + if err := r.db.Model(&model.Document{}).Where("id IN ?", ids).Find(&docs).Error; err != nil { + return nil, err + } + + docMap := make(map[int64]*model.Document, len(docs)) + for _, doc := range docs { + docMap[doc.ID] = doc + } + + ordered := make([]*model.Document, 0, len(ids)) + for _, id := range ids { + if doc, ok := docMap[id]; ok { + ordered = append(ordered, doc) + } + } + + return ordered, nil +} diff --git a/internal/repository/document_repo/update.go b/internal/repository/document_repo/update.go new file mode 100644 index 0000000..6957a90 --- /dev/null +++ b/internal/repository/document_repo/update.go @@ -0,0 +1,84 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentUpdateOperation struct { + repo *DocumentRepository + doc *model.Document + updateFields map[string]bool + tx *gorm.DB +} + +func (r *DocumentRepository) Update(doc *model.Document) *DocumentUpdateOperation { + return &DocumentUpdateOperation{ + repo: r, + doc: doc, + updateFields: make(map[string]bool), + } +} + +func (op *DocumentUpdateOperation) UpdateTitle() *DocumentUpdateOperation { + op.updateFields["title"] = true + return op +} + +func (op *DocumentUpdateOperation) UpdateSummary() *DocumentUpdateOperation { + op.updateFields["summary"] = true + return op +} + +func (op *DocumentUpdateOperation) UpdateContent() *DocumentUpdateOperation { + op.updateFields["content"] = true + return op +} + +func (op *DocumentUpdateOperation) UpdateParentID() *DocumentUpdateOperation { + op.updateFields["parent_id"] = true + return op +} + +func (op *DocumentUpdateOperation) UpdateKnowledgeID() *DocumentUpdateOperation { + op.updateFields["knowledge_id"] = true + return op +} + +func (op *DocumentUpdateOperation) UpdateContainerType() *DocumentUpdateOperation { + op.updateFields["container_type"] = true + return op +} + +func (op *DocumentUpdateOperation) UpdateIsPublic() *DocumentUpdateOperation { + op.updateFields["is_public"] = true + return op +} + +func (op *DocumentUpdateOperation) UpdateTags() *DocumentUpdateOperation { + op.updateFields["tags"] = true + return op +} + +func (op *DocumentUpdateOperation) WithTx(tx *gorm.DB) *DocumentUpdateOperation { + op.tx = tx + return op +} + +func (op *DocumentUpdateOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + query := op.tx.Model(op.doc) + + if len(op.updateFields) > 0 { + fields := make([]string, 0, len(op.updateFields)) + for field := range op.updateFields { + fields = append(fields, field) + } + query = query.Select(fields) + } + + return query.Updates(*op.doc).Error +} diff --git a/internal/repository/document_repo/update_content_by_external_id.go b/internal/repository/document_repo/update_content_by_external_id.go new file mode 100644 index 0000000..923c352 --- /dev/null +++ b/internal/repository/document_repo/update_content_by_external_id.go @@ -0,0 +1,48 @@ +package document_repo + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/cache" + "github.com/XingfenD/yoresee_doc/pkg/key" + "gorm.io/gorm" +) + +type DocumentUpdateContentByExternalIDOperation struct { + repo *DocumentRepository + externalID string + content string + tx *gorm.DB +} + +func (r *DocumentRepository) UpdateContentByExternalID(externalID, content string) *DocumentUpdateContentByExternalIDOperation { + return &DocumentUpdateContentByExternalIDOperation{ + repo: r, + externalID: externalID, + content: content, + } +} + +func (op *DocumentUpdateContentByExternalIDOperation) WithTx(tx *gorm.DB) *DocumentUpdateContentByExternalIDOperation { + op.tx = tx + return op +} + +func (op *DocumentUpdateContentByExternalIDOperation) Exec(ctx context.Context) error { + if op.tx == nil { + op.tx = op.repo.db + } + docModelCacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, op.externalID) + return cache.DoubleDelete( + context.Background(), + func() error { + return op.tx.WithContext(ctx).Model(&model.Document{}). + Where("external_id = ?", op.externalID). + Select("content"). + Updates(map[string]interface{}{"content": op.content}).Error + }, + docModelCacheKey, + ) + +} diff --git a/internal/repository/document_repo/update_path_depth.go b/internal/repository/document_repo/update_path_depth.go new file mode 100644 index 0000000..a6ac666 --- /dev/null +++ b/internal/repository/document_repo/update_path_depth.go @@ -0,0 +1,58 @@ +package document_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentUpdatePathDepthOperation struct { + repo *DocumentRepository + docID int64 + parentID int64 + tx *gorm.DB +} + +func (r *DocumentRepository) UpdatePathDepth(docID, parentID int64) *DocumentUpdatePathDepthOperation { + return &DocumentUpdatePathDepthOperation{ + repo: r, + docID: docID, + parentID: parentID, + } +} + +func (op *DocumentUpdatePathDepthOperation) WithTx(tx *gorm.DB) *DocumentUpdatePathDepthOperation { + op.tx = tx + return op +} + +func (op *DocumentUpdatePathDepthOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + if op.parentID == 0 { + return op.tx.Exec(` + UPDATE document_metas + SET path = (id::text)::ltree, depth = 0 + WHERE id = ? AND deleted_at IS NULL + `, op.docID).Error + } + + type pathDepth struct { + Path string + Depth int + } + var parent pathDepth + if err := op.tx.Model(&model.Document{}). + Select("path, depth"). + Where("id = ? AND deleted_at IS NULL", op.parentID). + Take(&parent).Error; err != nil { + return err + } + + return op.tx.Exec(` + UPDATE document_metas + SET path = (?::ltree) || (id::text)::ltree, depth = ? + WHERE id = ? AND deleted_at IS NULL + `, parent.Path, parent.Depth+1, op.docID).Error +} diff --git a/internal/repository/document_version_repo/create.go b/internal/repository/document_version_repo/create.go new file mode 100644 index 0000000..5def2ec --- /dev/null +++ b/internal/repository/document_version_repo/create.go @@ -0,0 +1,43 @@ +package document_version_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentVersionCreateOperation struct { + repo *DocumentVersionRepository + docVersion *model.DocumentVersion + tx *gorm.DB +} + +func (repo *DocumentVersionRepository) Create(docVersion *model.DocumentVersion) *DocumentVersionCreateOperation { + return &DocumentVersionCreateOperation{ + repo: repo, + docVersion: docVersion, + } +} + +func (op *DocumentVersionCreateOperation) WithTx(tx *gorm.DB) *DocumentVersionCreateOperation { + op.tx = tx + return op +} + +func (op *DocumentVersionCreateOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + var maxVersion int + err := op.tx.Model(&model.DocumentVersion{}). + Where("document_id = ?", op.docVersion.DocumentID). + Select("COALESCE(MAX(version), 0)"). + Scan(&maxVersion).Error + if err != nil { + return err + } + + op.docVersion.Version = maxVersion + 1 + + return op.tx.Create(op.docVersion).Error +} diff --git a/internal/repository/document_version_repo/document_version_repo.go b/internal/repository/document_version_repo/document_version_repo.go new file mode 100644 index 0000000..3a507ef --- /dev/null +++ b/internal/repository/document_version_repo/document_version_repo.go @@ -0,0 +1,11 @@ +package document_version_repo + +import "gorm.io/gorm" + +type DocumentVersionRepository struct { + db *gorm.DB +} + +func NewDocumentVersionRepository(db *gorm.DB) *DocumentVersionRepository { + return &DocumentVersionRepository{db: db} +} diff --git a/internal/repository/document_version_repo/get_by_document_id_and_version.go b/internal/repository/document_version_repo/get_by_document_id_and_version.go new file mode 100644 index 0000000..16c7859 --- /dev/null +++ b/internal/repository/document_version_repo/get_by_document_id_and_version.go @@ -0,0 +1,13 @@ +package document_version_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" +) + +func (r *DocumentVersionRepository) GetByDocumentIDAndVersion(documentID int64, version int) (*model.DocumentVersion, error) { + item := &model.DocumentVersion{} + if err := r.db.Where("document_id = ? AND version = ?", documentID, version).First(item).Error; err != nil { + return nil, err + } + return item, nil +} diff --git a/internal/repository/document_version_repo/get_latest.go b/internal/repository/document_version_repo/get_latest.go new file mode 100644 index 0000000..08a0a23 --- /dev/null +++ b/internal/repository/document_version_repo/get_latest.go @@ -0,0 +1,41 @@ +package document_version_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DocumentVersionGetLastedOperation struct { + repo *DocumentVersionRepository + docID int64 + tx *gorm.DB +} + +func (r *DocumentVersionRepository) GetLasted(docID int64) *DocumentVersionGetLastedOperation { + return &DocumentVersionGetLastedOperation{ + repo: r, + docID: docID, + } +} + +func (op *DocumentVersionGetLastedOperation) WithTx(tx *gorm.DB) *DocumentVersionGetLastedOperation { + op.tx = tx + return op +} + +func (op *DocumentVersionGetLastedOperation) Exec() (int64, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + var maxVersion int64 + err := op.tx.Model(&model.DocumentVersion{}). + Where("document_id = ?", op.docID). + Select("COALESCE(MAX(version), 0)"). + Scan(&maxVersion).Error + if err != nil { + return 0, err + } + + return maxVersion, nil +} diff --git a/internal/repository/document_version_repo/list_by_document_id.go b/internal/repository/document_version_repo/list_by_document_id.go new file mode 100644 index 0000000..976a939 --- /dev/null +++ b/internal/repository/document_version_repo/list_by_document_id.go @@ -0,0 +1,19 @@ +package document_version_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" +) + +func (r *DocumentVersionRepository) ListByDocumentID(documentID int64, offset, limit int) ([]*model.DocumentVersion, int64, error) { + var total int64 + query := r.db.Model(&model.DocumentVersion{}).Where("document_id = ?", documentID) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + items := make([]*model.DocumentVersion, 0) + if err := query.Order("version DESC").Offset(offset).Limit(limit).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} diff --git a/internal/repository/document_yjs_snapshot_repo/document_yjs_snapshot_repo.go b/internal/repository/document_yjs_snapshot_repo/document_yjs_snapshot_repo.go new file mode 100644 index 0000000..d195ece --- /dev/null +++ b/internal/repository/document_yjs_snapshot_repo/document_yjs_snapshot_repo.go @@ -0,0 +1,86 @@ +package doc_yjs_snapshot_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type DocumentYjsSnapshotRepository struct { + db *gorm.DB +} + +func NewDocumentYjsSnapshotRepository(db *gorm.DB) *DocumentYjsSnapshotRepository { + return &DocumentYjsSnapshotRepository{db: db} +} + +type DocumentYjsSnapshotGetOperation struct { + repo *DocumentYjsSnapshotRepository + docID int64 + tx *gorm.DB +} + +func (r *DocumentYjsSnapshotRepository) GetByDocID(docID int64) *DocumentYjsSnapshotGetOperation { + return &DocumentYjsSnapshotGetOperation{ + repo: r, + docID: docID, + } +} + +func (op *DocumentYjsSnapshotGetOperation) WithTx(tx *gorm.DB) *DocumentYjsSnapshotGetOperation { + op.tx = tx + return op +} + +func (op *DocumentYjsSnapshotGetOperation) Exec() (*model.DocumentYjsSnapshot, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + var snapshot model.DocumentYjsSnapshot + if err := op.tx.First(&snapshot, "doc_id = ?", op.docID).Error; err != nil { + return nil, err + } + return &snapshot, nil +} + +type DocumentYjsSnapshotSaveOperation struct { + repo *DocumentYjsSnapshotRepository + docID int64 + state []byte + tx *gorm.DB +} + +func (r *DocumentYjsSnapshotRepository) Save(docID int64, state []byte) *DocumentYjsSnapshotSaveOperation { + return &DocumentYjsSnapshotSaveOperation{ + repo: r, + docID: docID, + state: state, + } +} + +func (op *DocumentYjsSnapshotSaveOperation) WithTx(tx *gorm.DB) *DocumentYjsSnapshotSaveOperation { + op.tx = tx + return op +} + +func (op *DocumentYjsSnapshotSaveOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + snapshot := &model.DocumentYjsSnapshot{ + DocID: op.docID, + YjsState: op.state, + Version: 1, + } + + return op.tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "doc_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "yjs_state": op.state, + "version": gorm.Expr("documents_yjs_snapshot.version + 1"), + "updated_at": gorm.Expr("NOW()"), + }), + }).Create(snapshot).Error +} diff --git a/internal/repository/init.go b/internal/repository/init.go new file mode 100644 index 0000000..8b75809 --- /dev/null +++ b/internal/repository/init.go @@ -0,0 +1,51 @@ +package repository + +import ( + "github.com/XingfenD/yoresee_doc/internal/repository/attachment_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/comment_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/document_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/document_version_repo" + doc_yjs_snapshot_repo "github.com/XingfenD/yoresee_doc/internal/repository/document_yjs_snapshot_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/invitation_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/knowledge_base_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/membership_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/notification_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/template_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type Repositories struct { + DB *gorm.DB + Redis *redis.Client + Document *document_repo.DocumentRepository + KnowledgeBase *knowledge_base_repo.KnowledgeBaseRepository + User *user_repo.UserRepository + Comment *comment_repo.CommentRepository + Invitation *invitation_repo.InvitationRepository + Membership *membership_repo.MembershipRepository + Template *template_repo.TemplateRepository + Attachment *attachment_repo.AttachmentRepository + DocumentVersion *document_version_repo.DocumentVersionRepository + Notification *notification_repo.NotificationRepository + DocumentYjsSnapshot *doc_yjs_snapshot_repo.DocumentYjsSnapshotRepository +} + +func NewRepositories(db *gorm.DB, redis *redis.Client) *Repositories { + return &Repositories{ + DB: db, + Redis: redis, + Document: document_repo.NewDocumentRepository(db, redis), + KnowledgeBase: knowledge_base_repo.NewKnowledgeBaseRepository(db, redis), + User: user_repo.NewUserRepository(db, redis), + Comment: comment_repo.NewCommentRepository(db), + Invitation: invitation_repo.NewInvitationRepository(db), + Membership: membership_repo.NewMembershipRepository(db), + Template: template_repo.NewTemplateRepository(db), + Attachment: attachment_repo.NewAttachmentRepository(db), + DocumentVersion: document_version_repo.NewDocumentVersionRepository(db), + Notification: notification_repo.NewNotificationRepository(db), + DocumentYjsSnapshot: doc_yjs_snapshot_repo.NewDocumentYjsSnapshotRepository(db), + } +} diff --git a/internal/repository/invitation_repo/base.go b/internal/repository/invitation_repo/base.go new file mode 100644 index 0000000..1a4bb3e --- /dev/null +++ b/internal/repository/invitation_repo/base.go @@ -0,0 +1,112 @@ +package invitation_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type InvitationGetByIDOperation struct { + repo *InvitationRepository + id int64 + tx *gorm.DB +} + +func (r *InvitationRepository) GetByID(id int64) *InvitationGetByIDOperation { + return &InvitationGetByIDOperation{ + repo: r, + id: id, + } +} + +func (op *InvitationGetByIDOperation) WithTx(tx *gorm.DB) *InvitationGetByIDOperation { + op.tx = tx + return op +} + +func (op *InvitationGetByIDOperation) Exec() (*model.Invitation, error) { + var invitation model.Invitation + var err error + + if op.tx != nil { + err = op.tx.First(&invitation, op.id).Error + } else { + err = op.repo.db.First(&invitation, op.id).Error + } + + return &invitation, err +} + +type InvitationCreateOperation struct { + repo *InvitationRepository + invitation *model.Invitation + tx *gorm.DB +} + +func (r *InvitationRepository) Create(invitation *model.Invitation) *InvitationCreateOperation { + return &InvitationCreateOperation{ + repo: r, + invitation: invitation, + } +} + +func (op *InvitationCreateOperation) WithTx(tx *gorm.DB) *InvitationCreateOperation { + op.tx = tx + return op +} + +func (op *InvitationCreateOperation) Exec() error { + if op.tx != nil { + return op.tx.Create(op.invitation).Error + } + return op.repo.db.Create(op.invitation).Error +} + +type InvitationUpdateOperation struct { + repo *InvitationRepository + invitation *model.Invitation + tx *gorm.DB +} + +func (r *InvitationRepository) Update(invitation *model.Invitation) *InvitationUpdateOperation { + return &InvitationUpdateOperation{ + repo: r, + invitation: invitation, + } +} + +func (op *InvitationUpdateOperation) WithTx(tx *gorm.DB) *InvitationUpdateOperation { + op.tx = tx + return op +} + +func (op *InvitationUpdateOperation) Exec() error { + if op.tx != nil { + return op.tx.Save(op.invitation).Error + } + return op.repo.db.Save(op.invitation).Error +} + +type InvitationDeleteOperation struct { + repo *InvitationRepository + id int64 + tx *gorm.DB +} + +func (r *InvitationRepository) Delete(id int64) *InvitationDeleteOperation { + return &InvitationDeleteOperation{ + repo: r, + id: id, + } +} + +func (op *InvitationDeleteOperation) WithTx(tx *gorm.DB) *InvitationDeleteOperation { + op.tx = tx + return op +} + +func (op *InvitationDeleteOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + return op.tx.Delete(&model.Invitation{}, op.id).Error +} diff --git a/internal/repository/invitation_repo/get_by_code.go b/internal/repository/invitation_repo/get_by_code.go new file mode 100644 index 0000000..42f390c --- /dev/null +++ b/internal/repository/invitation_repo/get_by_code.go @@ -0,0 +1,37 @@ +package invitation_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type InvitationGetByCodeOperation struct { + repo *InvitationRepository + code string + tx *gorm.DB +} + +func (r *InvitationRepository) GetByCode(code string) *InvitationGetByCodeOperation { + return &InvitationGetByCodeOperation{ + repo: r, + code: code, + } +} + +func (op *InvitationGetByCodeOperation) WithTx(tx *gorm.DB) *InvitationGetByCodeOperation { + op.tx = tx + return op +} + +func (op *InvitationGetByCodeOperation) Exec() (*model.Invitation, error) { + var invitation model.Invitation + var err error + + if op.tx != nil { + err = op.tx.Where("code = ?", op.code).First(&invitation).Error + } else { + err = op.repo.db.Where("code = ?", op.code).First(&invitation).Error + } + + return &invitation, err +} diff --git a/internal/repository/invitation_repo/invitation_repo.go b/internal/repository/invitation_repo/invitation_repo.go new file mode 100644 index 0000000..f5cbb19 --- /dev/null +++ b/internal/repository/invitation_repo/invitation_repo.go @@ -0,0 +1,11 @@ +package invitation_repo + +import "gorm.io/gorm" + +type InvitationRepository struct { + db *gorm.DB +} + +func NewInvitationRepository(db *gorm.DB) *InvitationRepository { + return &InvitationRepository{db: db} +} diff --git a/internal/repository/invitation_repo/list.go b/internal/repository/invitation_repo/list.go new file mode 100644 index 0000000..e26ef82 --- /dev/null +++ b/internal/repository/invitation_repo/list.go @@ -0,0 +1,234 @@ +package invitation_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type InvitationListOperation struct { + repo *InvitationRepository + m *model.Invitation + creatorID *int64 + keyword *string + maxUsedCnt *int64 + expiresAtStart *string + expiresAtEnd *string + createdAtStart *string + createdAtEnd *string + disabled *bool + sortField string + sortDesc bool + page int + pageSize int + tx *gorm.DB +} + +func (r *InvitationRepository) List(m *model.Invitation) *InvitationListOperation { + return &InvitationListOperation{ + repo: r, + m: m, + } +} + +func (op *InvitationListOperation) WithTx(tx *gorm.DB) *InvitationListOperation { + op.tx = tx + return op +} + +func (op *InvitationListOperation) WithCreatorID(creatorID *int64) *InvitationListOperation { + op.creatorID = creatorID + return op +} + +func (op *InvitationListOperation) WithKeyword(keyword *string) *InvitationListOperation { + op.keyword = keyword + return op +} + +func (op *InvitationListOperation) WithMaxUsedCnt(maxUsedCnt *int64) *InvitationListOperation { + op.maxUsedCnt = maxUsedCnt + return op +} + +func (op *InvitationListOperation) WithExpiresAtRange(start, end *string) *InvitationListOperation { + op.expiresAtStart = start + op.expiresAtEnd = end + return op +} + +func (op *InvitationListOperation) WithCreatedAtRange(start, end *string) *InvitationListOperation { + op.createdAtStart = start + op.createdAtEnd = end + return op +} + +func (op *InvitationListOperation) WithDisabled(disabled *bool) *InvitationListOperation { + op.disabled = disabled + return op +} + +func (op *InvitationListOperation) WithSort(field string, desc bool) *InvitationListOperation { + op.sortField = field + op.sortDesc = desc + return op +} + +func (op *InvitationListOperation) WithPagination(page, pageSize int) *InvitationListOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *InvitationListOperation) Exec() ([]model.Invitation, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + dbQuery := db.Model(&model.Invitation{}) + + if op.m != nil { + dbQuery = dbQuery.Where(op.m) + } + + if op.creatorID != nil { + dbQuery = dbQuery.Where("created_by = ?", *op.creatorID) + } + + if op.keyword != nil && *op.keyword != "" { + dbQuery = dbQuery.Where("code ILIKE ?", "%"+*op.keyword+"%") + } + + if op.maxUsedCnt != nil { + dbQuery = dbQuery.Where("max_used_cnt = ?", *op.maxUsedCnt) + } + + if op.expiresAtStart != nil && *op.expiresAtStart != "" { + dbQuery = dbQuery.Where("expires_at >= ?", *op.expiresAtStart) + } + if op.expiresAtEnd != nil && *op.expiresAtEnd != "" { + dbQuery = dbQuery.Where("expires_at <= ?", *op.expiresAtEnd) + } + if op.createdAtStart != nil && *op.createdAtStart != "" { + dbQuery = dbQuery.Where("created_at >= ?", *op.createdAtStart) + } + if op.createdAtEnd != nil && *op.createdAtEnd != "" { + dbQuery = dbQuery.Where("created_at <= ?", *op.createdAtEnd) + } + if op.disabled != nil { + dbQuery = dbQuery.Where("disabled = ?", *op.disabled) + } + + sortField := op.sortField + if sortField == "" { + sortField = "created_at" + } + orderDirection := "ASC" + if op.sortDesc { + orderDirection = "DESC" + } + dbQuery = dbQuery.Order(sortField + " " + orderDirection) + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + offset := (page - 1) * pageSize + dbQuery = dbQuery.Offset(offset).Limit(pageSize) + + var invitations []model.Invitation + err := dbQuery.Find(&invitations).Error + if err != nil { + return nil, err + } + + return invitations, nil +} + +func (op *InvitationListOperation) ExecWithTotal() ([]model.Invitation, int64, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + dbQuery := db.Model(&model.Invitation{}) + + if op.m != nil { + dbQuery = dbQuery.Where(op.m) + } + + if op.creatorID != nil { + dbQuery = dbQuery.Where("created_by = ?", *op.creatorID) + } + + if op.keyword != nil && *op.keyword != "" { + dbQuery = dbQuery.Where("code ILIKE ?", "%"+*op.keyword+"%") + } + + if op.maxUsedCnt != nil { + dbQuery = dbQuery.Where("max_used_cnt = ?", *op.maxUsedCnt) + } + + if op.expiresAtStart != nil && *op.expiresAtStart != "" { + dbQuery = dbQuery.Where("expires_at >= ?", *op.expiresAtStart) + } + if op.expiresAtEnd != nil && *op.expiresAtEnd != "" { + dbQuery = dbQuery.Where("expires_at <= ?", *op.expiresAtEnd) + } + if op.createdAtStart != nil && *op.createdAtStart != "" { + dbQuery = dbQuery.Where("created_at >= ?", *op.createdAtStart) + } + if op.createdAtEnd != nil && *op.createdAtEnd != "" { + dbQuery = dbQuery.Where("created_at <= ?", *op.createdAtEnd) + } + if op.disabled != nil { + dbQuery = dbQuery.Where("disabled = ?", *op.disabled) + } + + var total int64 + err := dbQuery.Count(&total).Error + if err != nil { + return nil, 0, err + } + + sortField := op.sortField + if sortField == "" { + sortField = "created_at" + } + orderDirection := "ASC" + if op.sortDesc { + orderDirection = "DESC" + } + dbQuery = dbQuery.Order(sortField + " " + orderDirection) + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + + offset := (page - 1) * pageSize + dbQuery = dbQuery.Offset(offset).Limit(pageSize) + + var invitations []model.Invitation + err = dbQuery.Find(&invitations).Error + if err != nil { + return nil, 0, err + } + + return invitations, total, nil +} diff --git a/internal/repository/invitation_repo/record.go b/internal/repository/invitation_repo/record.go new file mode 100644 index 0000000..ee18643 --- /dev/null +++ b/internal/repository/invitation_repo/record.go @@ -0,0 +1,142 @@ +package invitation_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type InvitationRecordCreateOperation struct { + repo *InvitationRepository + record *model.InvitationRecord + tx *gorm.DB +} + +func (r *InvitationRepository) CreateRecord(record *model.InvitationRecord) *InvitationRecordCreateOperation { + return &InvitationRecordCreateOperation{ + repo: r, + record: record, + } +} + +func (op *InvitationRecordCreateOperation) WithTx(tx *gorm.DB) *InvitationRecordCreateOperation { + op.tx = tx + return op +} + +func (op *InvitationRecordCreateOperation) Exec() error { + if op.tx != nil { + return op.tx.Create(op.record).Error + } + return op.repo.db.Create(op.record).Error +} + +type InvitationRecordListOperation struct { + repo *InvitationRepository + code *string + status *string + usedAtStart *string + usedAtEnd *string + creatorID *int64 + keyword *string + page int + pageSize int + tx *gorm.DB +} + +func (r *InvitationRepository) ListRecords() *InvitationRecordListOperation { + return &InvitationRecordListOperation{ + repo: r, + } +} + +func (op *InvitationRecordListOperation) WithTx(tx *gorm.DB) *InvitationRecordListOperation { + op.tx = tx + return op +} + +func (op *InvitationRecordListOperation) WithCode(code *string) *InvitationRecordListOperation { + op.code = code + return op +} + +func (op *InvitationRecordListOperation) WithStatus(status *string) *InvitationRecordListOperation { + op.status = status + return op +} + +func (op *InvitationRecordListOperation) WithUsedAtRange(start, end *string) *InvitationRecordListOperation { + op.usedAtStart = start + op.usedAtEnd = end + return op +} + +func (op *InvitationRecordListOperation) WithCreatorID(creatorID *int64) *InvitationRecordListOperation { + op.creatorID = creatorID + return op +} + +func (op *InvitationRecordListOperation) WithKeyword(keyword *string) *InvitationRecordListOperation { + op.keyword = keyword + return op +} + +func (op *InvitationRecordListOperation) WithPagination(page, pageSize int) *InvitationRecordListOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *InvitationRecordListOperation) ExecWithTotal() ([]model.InvitationRecord, int64, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + query := db.Model(&model.InvitationRecord{}) + if op.creatorID != nil { + query = query.Joins("JOIN invitations ON invitations.code = invitation_records.code"). + Where("invitations.created_by = ?", *op.creatorID) + } + if op.code != nil && *op.code != "" { + query = query.Where("code = ?", *op.code) + } + if op.keyword != nil && *op.keyword != "" { + keyword := "%" + *op.keyword + "%" + query = query.Where("invitation_records.code ILIKE ? OR invitation_records.used_by ILIKE ?", keyword, keyword) + } + if op.status != nil && *op.status != "" { + query = query.Where("status = ?", *op.status) + } + if op.usedAtStart != nil && *op.usedAtStart != "" { + query = query.Where("used_at >= ?", *op.usedAtStart) + } + if op.usedAtEnd != nil && *op.usedAtEnd != "" { + query = query.Where("used_at <= ?", *op.usedAtEnd) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 200 { + pageSize = 200 + } + + offset := (page - 1) * pageSize + query = query.Order("used_at DESC").Offset(offset).Limit(pageSize) + + var records []model.InvitationRecord + if err := query.Find(&records).Error; err != nil { + return nil, 0, err + } + return records, total, nil +} diff --git a/internal/repository/invitation_repo/validate_and_use.go b/internal/repository/invitation_repo/validate_and_use.go new file mode 100644 index 0000000..5b509a4 --- /dev/null +++ b/internal/repository/invitation_repo/validate_and_use.go @@ -0,0 +1,48 @@ +package invitation_repo + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type InvitationValidateAndUseOperation struct { + repo *InvitationRepository + code string + tx *gorm.DB +} + +func (r *InvitationRepository) ValidateAndUse(code string) *InvitationValidateAndUseOperation { + return &InvitationValidateAndUseOperation{ + repo: r, + code: code, + } +} + +func (op *InvitationValidateAndUseOperation) WithTx(tx *gorm.DB) *InvitationValidateAndUseOperation { + op.tx = tx + return op +} + +func (op *InvitationValidateAndUseOperation) Exec() (bool, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + now := time.Now() + result := db.Model(&model.Invitation{}). + Where("code = ? AND deleted_at IS NULL AND disabled = ?", op.code, false). + Where("(expires_at IS NULL OR expires_at > ?)", now). + Where("(max_used_cnt IS NULL OR used_cnt < max_used_cnt)"). + UpdateColumn("used_cnt", gorm.Expr("used_cnt + ?", 1)) + + if result.Error != nil { + return false, result.Error + } + if result.RowsAffected == 0 { + return false, nil + } + return true, nil +} diff --git a/internal/repository/knowledge_base_repo/create.go b/internal/repository/knowledge_base_repo/create.go new file mode 100644 index 0000000..d841f89 --- /dev/null +++ b/internal/repository/knowledge_base_repo/create.go @@ -0,0 +1,32 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CreateKnowledgeBaseOperation struct { + repo *KnowledgeBaseRepository + knowledgeBase *model.KnowledgeBase + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) Create(knowledgeBase *model.KnowledgeBase) (op *CreateKnowledgeBaseOperation) { + return &CreateKnowledgeBaseOperation{ + repo: r, + knowledgeBase: knowledgeBase, + } +} + +func (op *CreateKnowledgeBaseOperation) WithTx(tx *gorm.DB) *CreateKnowledgeBaseOperation { + op.tx = tx + return op +} + +func (op *CreateKnowledgeBaseOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + return op.tx.Create(op.knowledgeBase).Error +} diff --git a/internal/repository/knowledge_base_repo/create_recent_record.go b/internal/repository/knowledge_base_repo/create_recent_record.go new file mode 100644 index 0000000..94ad084 --- /dev/null +++ b/internal/repository/knowledge_base_repo/create_recent_record.go @@ -0,0 +1,38 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type CreateRecentKnowledgeBaseOperation struct { + repo *KnowledgeBaseRepository + m *model.RecentKnowledgeBase + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) CreateRecentKnowledgeBase(m *model.RecentKnowledgeBase) *CreateRecentKnowledgeBaseOperation { + return &CreateRecentKnowledgeBaseOperation{ + repo: r, + m: m, + } +} + +func (op *CreateRecentKnowledgeBaseOperation) WithTx(tx *gorm.DB) { + op.tx = tx +} + +func (op *CreateRecentKnowledgeBaseOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + return op.tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_id"}, + {Name: "knowledge_base_id"}, + }, + DoUpdates: clause.AssignmentColumns([]string{"accessed_at"}), + }).Create(op.m).Error +} diff --git a/internal/repository/knowledge_base_repo/delete.go b/internal/repository/knowledge_base_repo/delete.go new file mode 100644 index 0000000..2cfb6df --- /dev/null +++ b/internal/repository/knowledge_base_repo/delete.go @@ -0,0 +1,32 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DeleteKnowledgeBaseOperation struct { + repo *KnowledgeBaseRepository + knowledgeBase *model.KnowledgeBase + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) Delete(knowledgeBase *model.KnowledgeBase) (op *DeleteKnowledgeBaseOperation) { + return &DeleteKnowledgeBaseOperation{ + repo: r, + knowledgeBase: knowledgeBase, + } +} + +func (op *DeleteKnowledgeBaseOperation) WithTx(tx *gorm.DB) *DeleteKnowledgeBaseOperation { + op.tx = tx + return op +} + +func (op *DeleteKnowledgeBaseOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + err := op.tx.Delete(op.knowledgeBase).Error + return err +} diff --git a/internal/repository/knowledge_base_repo/get_by_external_id.go b/internal/repository/knowledge_base_repo/get_by_external_id.go new file mode 100644 index 0000000..bdd491f --- /dev/null +++ b/internal/repository/knowledge_base_repo/get_by_external_id.go @@ -0,0 +1,56 @@ +package knowledge_base_repo + +import ( + "context" + + cache_loader "github.com/XingfenD/yoresee_doc/internal/cache" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type GetKnowledgeBaseByExternalIDOperation struct { + repo *KnowledgeBaseRepository + externalID string + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) GetByExternalID(externalID string) (op *GetKnowledgeBaseByExternalIDOperation) { + return &GetKnowledgeBaseByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *GetKnowledgeBaseByExternalIDOperation) WithTx(tx *gorm.DB) *GetKnowledgeBaseByExternalIDOperation { + op.tx = tx + return op +} + +func (op *GetKnowledgeBaseByExternalIDOperation) query(db *gorm.DB) (*model.KnowledgeBase, error) { + var knowledgeBase model.KnowledgeBase + err := db.First(&knowledgeBase, "external_id = ?", op.externalID).Error + return &knowledgeBase, err +} + +func (op *GetKnowledgeBaseByExternalIDOperation) Exec() (*model.KnowledgeBase, error) { + if op.tx != nil { + return op.query(op.tx) + } + + knowledgeBaseCacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_KnowledgeBase, op.externalID) + knowledgeBase, err := cache_loader.NewCacheLoadOperation[model.KnowledgeBase](&op.repo.Loader). + WithDBLoader(func() (*model.KnowledgeBase, error) { + return op.query(op.repo.db) + }).WithDefaultKeyAndParser(knowledgeBaseCacheKey, nil). + Exec(context.Background()) + + if err != nil { + logrus.Errorf("load data failed for GetKnowledgeBaseByExternalIDOperation: %+v", err) + return nil, err + } + + return knowledgeBase, nil +} diff --git a/internal/repository/knowledge_base_repo/get_by_id.go b/internal/repository/knowledge_base_repo/get_by_id.go new file mode 100644 index 0000000..dd60824 --- /dev/null +++ b/internal/repository/knowledge_base_repo/get_by_id.go @@ -0,0 +1,32 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type KnowledgeBaseGetByIDOperation struct { + repo *KnowledgeBaseRepository + id int64 + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) GetByID(id int64) (op *KnowledgeBaseGetByIDOperation) { + return &KnowledgeBaseGetByIDOperation{ + repo: r, + id: id, + } +} + +func (op *KnowledgeBaseGetByIDOperation) WithTx(tx *gorm.DB) *KnowledgeBaseGetByIDOperation { + op.tx = tx + return op +} + +func (op *KnowledgeBaseGetByIDOperation) Exec() (knowledgeBase *model.KnowledgeBase, err error) { + if op.tx == nil { + op.tx = op.repo.db + } + err = op.tx.First(knowledgeBase, "id = ?", op.id).Error + return +} diff --git a/internal/repository/knowledge_base_repo/get_id_by_external_id.go b/internal/repository/knowledge_base_repo/get_id_by_external_id.go new file mode 100644 index 0000000..baa25fd --- /dev/null +++ b/internal/repository/knowledge_base_repo/get_id_by_external_id.go @@ -0,0 +1,36 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type KnowledgeBaseGetIDByExternalIDOperation struct { + repo *KnowledgeBaseRepository + externalID string + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) GetIDByExternalID(externalID string) (op *KnowledgeBaseGetIDByExternalIDOperation) { + return &KnowledgeBaseGetIDByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *KnowledgeBaseGetIDByExternalIDOperation) WithTx(tx *gorm.DB) *KnowledgeBaseGetIDByExternalIDOperation { + op.tx = tx + return op +} + +func (op *KnowledgeBaseGetIDByExternalIDOperation) Exec() (int64, error) { + var id int64 + if op.tx == nil { + op.tx = op.repo.db + } + err := op.tx.Model(&model.KnowledgeBase{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + if err != nil { + return 0, err + } + return id, nil +} diff --git a/internal/repository/knowledge_base_repo/knowledge_base_repo.go b/internal/repository/knowledge_base_repo/knowledge_base_repo.go new file mode 100644 index 0000000..a7bf357 --- /dev/null +++ b/internal/repository/knowledge_base_repo/knowledge_base_repo.go @@ -0,0 +1,19 @@ +package knowledge_base_repo + +import ( + cache_loader "github.com/XingfenD/yoresee_doc/internal/cache" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type KnowledgeBaseRepository struct { + db *gorm.DB + Loader cache_loader.Loader +} + +func NewKnowledgeBaseRepository(db *gorm.DB, redis *redis.Client) *KnowledgeBaseRepository { + return &KnowledgeBaseRepository{ + db: db, + Loader: *cache_loader.NewLoader(redis), + } +} diff --git a/internal/repository/knowledge_base_repo/list.go b/internal/repository/knowledge_base_repo/list.go new file mode 100644 index 0000000..5ef8b5a --- /dev/null +++ b/internal/repository/knowledge_base_repo/list.go @@ -0,0 +1,154 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type ListKnowledgeBaseOperation struct { + repo *KnowledgeBaseRepository + model *model.KnowledgeBase + creatorID *int64 + isPublic *bool + nameKeyword *string + createAtStart *string + createAtEnd *string + updateAtStart *string + updateAtEnd *string + sortField string + sortDesc bool + page int + pageSize int + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) List(m *model.KnowledgeBase) (op *ListKnowledgeBaseOperation) { + return &ListKnowledgeBaseOperation{ + repo: r, + model: m, + } +} + +func (op *ListKnowledgeBaseOperation) WithTx(tx *gorm.DB) *ListKnowledgeBaseOperation { + op.tx = tx + return op +} + +func (op *ListKnowledgeBaseOperation) WithCreatorID(creatorID *int64) *ListKnowledgeBaseOperation { + op.creatorID = creatorID + return op +} + +func (op *ListKnowledgeBaseOperation) WithIsPublic(isPublic *bool) *ListKnowledgeBaseOperation { + op.isPublic = isPublic + return op +} + +func (op *ListKnowledgeBaseOperation) WithNameKeyword(nameKeyword *string) *ListKnowledgeBaseOperation { + op.nameKeyword = nameKeyword + return op +} + +func (op *ListKnowledgeBaseOperation) WithCreateTimeRange(start, end *string) *ListKnowledgeBaseOperation { + op.createAtStart = start + op.createAtEnd = end + return op +} + +func (op *ListKnowledgeBaseOperation) WithUpdateTimeRange(start, end *string) *ListKnowledgeBaseOperation { + op.updateAtStart = start + op.updateAtEnd = end + return op +} + +func (op *ListKnowledgeBaseOperation) WithSort(field string, desc bool) *ListKnowledgeBaseOperation { + op.sortField = field + op.sortDesc = desc + return op +} + +func (op *ListKnowledgeBaseOperation) WithPagination(page, pageSize int) *ListKnowledgeBaseOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *ListKnowledgeBaseOperation) Exec() (kbs []*model.KnowledgeBase, err error) { + if op.tx == nil { + op.tx = op.repo.db + } + + dbQuery := op.tx.Model(op.model) + + if op.model != nil { + dbQuery = dbQuery.Where(op.model) + } + + if op.creatorID != nil { + dbQuery = dbQuery.Where("creator_user_id = ?", *op.creatorID) + } + + if op.isPublic != nil { + dbQuery = dbQuery.Where("is_public = ?", *op.isPublic) + } + + orderStr := "created_at DESC" + if op.sortField != "" { + if op.sortDesc { + orderStr = op.sortField + " DESC" + } else { + orderStr = op.sortField + " ASC" + } + } + dbQuery = dbQuery.Order(orderStr) + + if op.page > 0 && op.pageSize > 0 { + offset := (op.page - 1) * op.pageSize + dbQuery = dbQuery.Offset(offset).Limit(op.pageSize) + } + + err = dbQuery.Find(&kbs).Error + return +} + +func (op *ListKnowledgeBaseOperation) ExecWithTotal() (kbs []*model.KnowledgeBase, total int64, err error) { + if op.tx == nil { + op.tx = op.repo.db + } + + dbQuery := op.tx.Model(op.model) + + if op.model != nil { + dbQuery = dbQuery.Where(op.model) + } + + if op.creatorID != nil { + dbQuery = dbQuery.Where("creator_user_id = ?", *op.creatorID) + } + + if op.isPublic != nil { + dbQuery = dbQuery.Where("is_public = ?", *op.isPublic) + } + err = dbQuery.Count(&total).Error + if err != nil { + return nil, 0, err + } + + orderStr := "created_at DESC" + if op.sortField != "" { + if op.sortDesc { + orderStr = op.sortField + " DESC" + } else { + orderStr = op.sortField + " ASC" + } + } + dbQuery = dbQuery.Order(orderStr) + + if op.page > 0 && op.pageSize > 0 { + offset := (op.page - 1) * op.pageSize + dbQuery = dbQuery.Offset(offset).Limit(op.pageSize) + } + + err = dbQuery.Find(&kbs).Error + return +} diff --git a/internal/repository/knowledge_base_repo/list_recent.go b/internal/repository/knowledge_base_repo/list_recent.go new file mode 100644 index 0000000..8489aec --- /dev/null +++ b/internal/repository/knowledge_base_repo/list_recent.go @@ -0,0 +1,83 @@ +package knowledge_base_repo + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" + + "gorm.io/gorm" +) + +type ListRecentKnowledgeBasesOperation struct { + repo *KnowledgeBaseRepository + userID int64 + startTime *time.Time + endTime *time.Time + page int + pageSize int + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) ListRecentKnowledgeBases(userID int64) *ListRecentKnowledgeBasesOperation { + return &ListRecentKnowledgeBasesOperation{ + repo: r, + userID: userID, + } +} + +func (op *ListRecentKnowledgeBasesOperation) WithTimeRange(start, end *time.Time) *ListRecentKnowledgeBasesOperation { + op.startTime = start + op.endTime = end + return op +} + +func (op *ListRecentKnowledgeBasesOperation) WithPagination(page, pageSize int) *ListRecentKnowledgeBasesOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *ListRecentKnowledgeBasesOperation) WithTx(tx *gorm.DB) *ListRecentKnowledgeBasesOperation { + op.tx = tx + return op +} + +func (op *ListRecentKnowledgeBasesOperation) Exec() ([]*model.RecentKnowledgeBase, int64, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + query := db.Model(&model.RecentKnowledgeBase{}). + Where("user_id = ?", op.userID) + if op.startTime != nil { + query = query.Where("accessed_at >= ?", *op.startTime) + } + if op.endTime != nil { + query = query.Where("accessed_at <= ?", *op.endTime) + } + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var records []*model.RecentKnowledgeBase + if err := query.Order("accessed_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { + return nil, 0, err + } + return records, total, nil +} diff --git a/internal/repository/knowledge_base_repo/mget_by_ids.go b/internal/repository/knowledge_base_repo/mget_by_ids.go new file mode 100644 index 0000000..b971610 --- /dev/null +++ b/internal/repository/knowledge_base_repo/mget_by_ids.go @@ -0,0 +1,39 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type MGetKnowledgeBaseByIDsOperation struct { + repo *KnowledgeBaseRepository + ids []int64 + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) MGetKnowledgeBaseByIDs(ids []int64) *MGetKnowledgeBaseByIDsOperation { + return &MGetKnowledgeBaseByIDsOperation{ + repo: r, + ids: ids, + } +} + +func (op *MGetKnowledgeBaseByIDsOperation) WithTx(tx *gorm.DB) *MGetKnowledgeBaseByIDsOperation { + op.tx = tx + return op +} + +func (op *MGetKnowledgeBaseByIDsOperation) Exec() ([]*model.KnowledgeBase, error) { + if len(op.ids) == 0 { + return []*model.KnowledgeBase{}, nil + } + db := op.repo.db + if op.tx != nil { + db = op.tx + } + var kbs []*model.KnowledgeBase + if err := db.Model(&model.KnowledgeBase{}).Where("id IN ?", op.ids).Find(&kbs).Error; err != nil { + return nil, err + } + return kbs, nil +} diff --git a/internal/repository/knowledge_base_repo/mget_documents_count.go b/internal/repository/knowledge_base_repo/mget_documents_count.go new file mode 100644 index 0000000..f730335 --- /dev/null +++ b/internal/repository/knowledge_base_repo/mget_documents_count.go @@ -0,0 +1,63 @@ +package knowledge_base_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type MGetKnowledgeBaseDocumentsCountOperation struct { + repo *KnowledgeBaseRepository + knowledgeBaseIDs []int64 + tx *gorm.DB +} + +func (r *KnowledgeBaseRepository) MGetKnowledgeBaseDocumentsCount(knowledgeBaseIDs []int64) *MGetKnowledgeBaseDocumentsCountOperation { + return &MGetKnowledgeBaseDocumentsCountOperation{ + repo: r, + knowledgeBaseIDs: knowledgeBaseIDs, + } +} + +func (op *MGetKnowledgeBaseDocumentsCountOperation) WithTx(tx *gorm.DB) *MGetKnowledgeBaseDocumentsCountOperation { + op.tx = tx + return op +} + +func (op *MGetKnowledgeBaseDocumentsCountOperation) Exec() (map[int64]int64, error) { + result := make(map[int64]int64) + + if len(op.knowledgeBaseIDs) == 0 { + return result, nil + } + + if op.tx == nil { + op.tx = op.repo.db + } + + var counts []struct { + KnowledgeID int64 + Count int64 + } + + err := op.tx.Model(&model.Document{}). + Select("knowledge_id, count(*) as count"). + Where("knowledge_id IN ?", op.knowledgeBaseIDs). + Group("knowledge_id"). + Find(&counts).Error + + if err != nil { + return nil, err + } + + for _, c := range counts { + result[c.KnowledgeID] = c.Count + } + + for _, id := range op.knowledgeBaseIDs { + if _, exists := result[id]; !exists { + result[id] = 0 + } + } + + return result, nil +} diff --git a/internal/repository/membership_repo/create.go b/internal/repository/membership_repo/create.go new file mode 100644 index 0000000..0a728e4 --- /dev/null +++ b/internal/repository/membership_repo/create.go @@ -0,0 +1,56 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CreateUserGroupOperation struct { + repo *MembershipRepository + group *model.UserGroupMeta + tx *gorm.DB +} + +func (r *MembershipRepository) CreateUserGroup(group *model.UserGroupMeta) *CreateUserGroupOperation { + return &CreateUserGroupOperation{ + repo: r, + group: group, + } +} + +func (op *CreateUserGroupOperation) WithTx(tx *gorm.DB) *CreateUserGroupOperation { + op.tx = tx + return op +} + +func (op *CreateUserGroupOperation) Exec() error { + if op.tx != nil { + return op.tx.Create(op.group).Error + } + return op.repo.db.Create(op.group).Error +} + +type CreateOrgNodeOperation struct { + repo *MembershipRepository + orgNode *model.OrgNodeMeta + tx *gorm.DB +} + +func (r *MembershipRepository) CreateOrgNode(orgNode *model.OrgNodeMeta) *CreateOrgNodeOperation { + return &CreateOrgNodeOperation{ + repo: r, + orgNode: orgNode, + } +} + +func (op *CreateOrgNodeOperation) WithTx(tx *gorm.DB) *CreateOrgNodeOperation { + op.tx = tx + return op +} + +func (op *CreateOrgNodeOperation) Exec() error { + if op.tx != nil { + return op.tx.Create(op.orgNode).Error + } + return op.repo.db.Create(op.orgNode).Error +} diff --git a/internal/repository/membership_repo/delete.go b/internal/repository/membership_repo/delete.go new file mode 100644 index 0000000..cc28ab1 --- /dev/null +++ b/internal/repository/membership_repo/delete.go @@ -0,0 +1,56 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type DeleteUserGroupOperation struct { + repo *MembershipRepository + group *model.UserGroupMeta + tx *gorm.DB +} + +func (r *MembershipRepository) DeleteUserGroup(group *model.UserGroupMeta) *DeleteUserGroupOperation { + return &DeleteUserGroupOperation{ + repo: r, + group: group, + } +} + +func (op *DeleteUserGroupOperation) WithTx(tx *gorm.DB) *DeleteUserGroupOperation { + op.tx = tx + return op +} + +func (op *DeleteUserGroupOperation) Exec() error { + if op.tx != nil { + return op.tx.Delete(op.group).Error + } + return op.repo.db.Delete(op.group).Error +} + +type DeleteOrgNodeOperation struct { + repo *MembershipRepository + orgNode *model.OrgNodeMeta + tx *gorm.DB +} + +func (r *MembershipRepository) DeleteOrgNode(orgNode *model.OrgNodeMeta) *DeleteOrgNodeOperation { + return &DeleteOrgNodeOperation{ + repo: r, + orgNode: orgNode, + } +} + +func (op *DeleteOrgNodeOperation) WithTx(tx *gorm.DB) *DeleteOrgNodeOperation { + op.tx = tx + return op +} + +func (op *DeleteOrgNodeOperation) Exec() error { + if op.tx != nil { + return op.tx.Delete(op.orgNode).Error + } + return op.repo.db.Delete(op.orgNode).Error +} diff --git a/internal/repository/membership_repo/get_by_external_id.go b/internal/repository/membership_repo/get_by_external_id.go new file mode 100644 index 0000000..ef596ba --- /dev/null +++ b/internal/repository/membership_repo/get_by_external_id.go @@ -0,0 +1,68 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type GetUserGroupByExternalIDOperation struct { + repo *MembershipRepository + externalID string + tx *gorm.DB +} + +func (r *MembershipRepository) GetUserGroupByExternalID(externalID string) *GetUserGroupByExternalIDOperation { + return &GetUserGroupByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *GetUserGroupByExternalIDOperation) WithTx(tx *gorm.DB) *GetUserGroupByExternalIDOperation { + op.tx = tx + return op +} + +func (op *GetUserGroupByExternalIDOperation) Exec() (*model.UserGroupMeta, error) { + var group model.UserGroupMeta + var err error + + if op.tx != nil { + err = op.tx.Where("external_id = ?", op.externalID).First(&group).Error + } else { + err = op.repo.db.Where("external_id = ?", op.externalID).First(&group).Error + } + + return &group, err +} + +type GetOrgNodeByExternalID struct { + repo *MembershipRepository + externalID string + tx *gorm.DB +} + +func (r *MembershipRepository) GetOrgNodeByExternalID(externalID string) *GetOrgNodeByExternalID { + return &GetOrgNodeByExternalID{ + repo: r, + externalID: externalID, + } +} + +func (op *GetOrgNodeByExternalID) WithTx(tx *gorm.DB) *GetOrgNodeByExternalID { + op.tx = tx + return op +} + +func (op *GetOrgNodeByExternalID) Exec() (*model.OrgNodeMeta, error) { + var orgNode model.OrgNodeMeta + var err error + + if op.tx != nil { + err = op.tx.Where("external_id = ?", op.externalID).First(&orgNode).Error + } else { + err = op.repo.db.Where("external_id = ?", op.externalID).First(&orgNode).Error + } + + return &orgNode, err +} diff --git a/internal/repository/membership_repo/get_id_by_external_id.go b/internal/repository/membership_repo/get_id_by_external_id.go new file mode 100644 index 0000000..f85cb43 --- /dev/null +++ b/internal/repository/membership_repo/get_id_by_external_id.go @@ -0,0 +1,62 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type GetUserGroupIDByExternalIDOperation struct { + repo *MembershipRepository + externalID string + tx *gorm.DB +} + +func (r *MembershipRepository) GetUserGroupIDByExternalID(externalID string) *GetUserGroupIDByExternalIDOperation { + return &GetUserGroupIDByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *GetUserGroupIDByExternalIDOperation) WithTx(tx *gorm.DB) *GetUserGroupIDByExternalIDOperation { + op.tx = tx + return op +} + +func (op *GetUserGroupIDByExternalIDOperation) Exec() (int64, error) { + var id int64 + var err error + if op.tx != nil { + err = op.tx.Model(&model.UserGroupMeta{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + } + err = op.repo.db.Model(&model.UserGroupMeta{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + return id, err +} + +type GetOrgNodeIDByExternalIDOperation struct { + repo *MembershipRepository + externalID string + tx *gorm.DB +} + +func (r *MembershipRepository) GetOrgNodeIDByExternalID(externalID string) *GetOrgNodeIDByExternalIDOperation { + return &GetOrgNodeIDByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *GetOrgNodeIDByExternalIDOperation) WithTx(tx *gorm.DB) *GetOrgNodeIDByExternalIDOperation { + op.tx = tx + return op +} + +func (op *GetOrgNodeIDByExternalIDOperation) Exec() (int64, error) { + var id int64 + var err error + if op.tx != nil { + err = op.tx.Model(&model.OrgNodeMeta{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + } + err = op.repo.db.Model(&model.OrgNodeMeta{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + return id, err +} diff --git a/internal/repository/membership_repo/list_membership_by_type_ids.go b/internal/repository/membership_repo/list_membership_by_type_ids.go new file mode 100644 index 0000000..699a59c --- /dev/null +++ b/internal/repository/membership_repo/list_membership_by_type_ids.go @@ -0,0 +1,41 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type ListMembershipByTypeAndIDsOperation struct { + repo *MembershipRepository + membershipType model.MembershipType + membershipIDs []int64 + tx *gorm.DB +} + +func (r *MembershipRepository) ListMembershipByTypeAndIDs(membershipType model.MembershipType, membershipIDs []int64) *ListMembershipByTypeAndIDsOperation { + return &ListMembershipByTypeAndIDsOperation{ + repo: r, + membershipType: membershipType, + membershipIDs: membershipIDs, + } +} + +func (op *ListMembershipByTypeAndIDsOperation) WithTx(tx *gorm.DB) *ListMembershipByTypeAndIDsOperation { + op.tx = tx + return op +} + +func (op *ListMembershipByTypeAndIDsOperation) Exec() ([]model.MembershipRelation, error) { + if len(op.membershipIDs) == 0 { + return []model.MembershipRelation{}, nil + } + if op.tx == nil { + op.tx = op.repo.db + } + + var memberships []model.MembershipRelation + err := op.tx. + Where("type = ? AND membership_id IN ?", op.membershipType, op.membershipIDs). + Find(&memberships).Error + return memberships, err +} diff --git a/internal/repository/membership_repo/list_user_group.go b/internal/repository/membership_repo/list_user_group.go new file mode 100644 index 0000000..2c27461 --- /dev/null +++ b/internal/repository/membership_repo/list_user_group.go @@ -0,0 +1,35 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type ListUserGroupOperation struct { + repo *MembershipRepository + tx *gorm.DB +} + +func (r *MembershipRepository) ListUserGroup() *ListUserGroupOperation { + return &ListUserGroupOperation{ + repo: r, + } +} + +func (op *ListUserGroupOperation) WithTx(tx *gorm.DB) *ListUserGroupOperation { + op.tx = tx + return op +} + +func (op *ListUserGroupOperation) Exec() ([]model.UserGroupMeta, error) { + var userGroups []model.UserGroupMeta + var err error + + if op.tx != nil { + err = op.tx.Find(&userGroups).Error + } else { + err = op.repo.db.Find(&userGroups).Error + } + + return userGroups, err +} diff --git a/internal/repository/membership_repo/membership_relation.go b/internal/repository/membership_repo/membership_relation.go new file mode 100644 index 0000000..7af4abd --- /dev/null +++ b/internal/repository/membership_repo/membership_relation.go @@ -0,0 +1,145 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type CreateMembershipOperation struct { + repo *MembershipRepository + membership *model.MembershipRelation + tx *gorm.DB +} + +func (r *MembershipRepository) CreateMembership(membership *model.MembershipRelation) *CreateMembershipOperation { + return &CreateMembershipOperation{ + repo: r, + membership: membership, + } +} + +func (op *CreateMembershipOperation) WithTx(tx *gorm.DB) *CreateMembershipOperation { + op.tx = tx + return op +} + +func (op *CreateMembershipOperation) Exec() error { + if op.tx != nil { + return op.tx.Create(op.membership).Error + } + return op.repo.db.Create(op.membership).Error +} + +type BatchCreateMembershipOperation struct { + repo *MembershipRepository + memberships []*model.MembershipRelation + tx *gorm.DB +} + +func (r *MembershipRepository) BatchCreateMembership(memberships []*model.MembershipRelation) *BatchCreateMembershipOperation { + return &BatchCreateMembershipOperation{ + repo: r, + memberships: memberships, + } +} + +func (op *BatchCreateMembershipOperation) WithTx(tx *gorm.DB) *BatchCreateMembershipOperation { + op.tx = tx + return op +} + +func (op *BatchCreateMembershipOperation) Exec() error { + if len(op.memberships) == 0 { + return nil + } + + if op.tx != nil { + return op.tx.Create(&op.memberships).Error + } + return op.repo.db.Create(&op.memberships).Error +} + +type ListMembershipOperation struct { + repo *MembershipRepository + query *model.MembershipRelation + tx *gorm.DB +} + +func (r *MembershipRepository) ListMembership(query *model.MembershipRelation) *ListMembershipOperation { + return &ListMembershipOperation{ + repo: r, + query: query, + } +} + +func (op *ListMembershipOperation) WithTx(tx *gorm.DB) *ListMembershipOperation { + op.tx = tx + return op +} + +func (op *ListMembershipOperation) Exec() ([]model.MembershipRelation, error) { + var memberships []model.MembershipRelation + var err error + + if op.tx != nil { + err = op.tx.Where(op.query).Find(&memberships).Error + } else { + err = op.repo.db.Where(op.query).Find(&memberships).Error + } + + return memberships, err +} + +type DeleteMembershipOperation struct { + repo *MembershipRepository + membership *model.MembershipRelation + tx *gorm.DB +} + +func (r *MembershipRepository) DeleteMembership(membership *model.MembershipRelation) *DeleteMembershipOperation { + return &DeleteMembershipOperation{ + repo: r, + membership: membership, + } +} + +func (op *DeleteMembershipOperation) WithTx(tx *gorm.DB) *DeleteMembershipOperation { + op.tx = tx + return op +} + +func (op *DeleteMembershipOperation) Exec() error { + if op.tx != nil { + return op.tx.Delete(op.membership).Error + } + return op.repo.db.Delete(op.membership).Error +} + +type BatchDeleteMembershipOperation struct { + repo *MembershipRepository + memberships []*model.MembershipRelation + tx *gorm.DB +} + +func (r *MembershipRepository) BatchDeleteMembership(memberships []*model.MembershipRelation) *BatchDeleteMembershipOperation { + return &BatchDeleteMembershipOperation{ + repo: r, + memberships: memberships, + } +} + +func (op *BatchDeleteMembershipOperation) WithTx(tx *gorm.DB) *BatchDeleteMembershipOperation { + op.tx = tx + return op +} + +func (op *BatchDeleteMembershipOperation) Exec() error { + if len(op.memberships) == 0 { + return nil + } + + if op.tx != nil { + return op.tx.Delete(&op.memberships).Error + } + return op.repo.db.Delete(&op.memberships).Error +} diff --git a/internal/repository/membership_repo/membership_repo.go b/internal/repository/membership_repo/membership_repo.go new file mode 100644 index 0000000..e483c01 --- /dev/null +++ b/internal/repository/membership_repo/membership_repo.go @@ -0,0 +1,11 @@ +package membership_repo + +import "gorm.io/gorm" + +type MembershipRepository struct { + db *gorm.DB +} + +func NewMembershipRepository(db *gorm.DB) *MembershipRepository { + return &MembershipRepository{db: db} +} diff --git a/internal/repository/membership_repo/query_org_node.go b/internal/repository/membership_repo/query_org_node.go new file mode 100644 index 0000000..1a1c5c8 --- /dev/null +++ b/internal/repository/membership_repo/query_org_node.go @@ -0,0 +1,172 @@ +package membership_repo + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type QueryOrgNodeOperation struct { + repo *MembershipRepository + tx *gorm.DB + parentID int64 + keyword *string + page int + pageSize int +} + +func (r *MembershipRepository) QueryOrgNode() *QueryOrgNodeOperation { + return &QueryOrgNodeOperation{ + repo: r, + } +} + +func (op *QueryOrgNodeOperation) WithTx(tx *gorm.DB) *QueryOrgNodeOperation { + op.tx = tx + return op +} + +func (op *QueryOrgNodeOperation) WithParentID(parentID int64) *QueryOrgNodeOperation { + op.parentID = parentID + return op +} + +func (op *QueryOrgNodeOperation) WithKeyword(keyword *string) *QueryOrgNodeOperation { + op.keyword = keyword + return op +} + +func (op *QueryOrgNodeOperation) WithPagination(page, pageSize int) *QueryOrgNodeOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *QueryOrgNodeOperation) ExecWithTotal() ([]model.OrgNodeMeta, int64, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + query := op.tx.Model(&model.OrgNodeMeta{}) + if op.parentID >= 0 { + query = query.Where("parent_id = ?", op.parentID) + } + if op.keyword != nil { + trimmed := strings.TrimSpace(*op.keyword) + if trimmed != "" { + like := "%" + trimmed + "%" + query = query.Where("name ILIKE ? OR description ILIKE ?", like, like) + } + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if op.page > 0 && op.pageSize > 0 { + offset := (op.page - 1) * op.pageSize + query = query.Offset(offset).Limit(op.pageSize) + } + + var orgNodes []model.OrgNodeMeta + if err := query.Order("id DESC").Find(&orgNodes).Error; err != nil { + return nil, 0, err + } + return orgNodes, total, nil +} + +func (op *QueryOrgNodeOperation) Exec() ([]model.OrgNodeMeta, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + query := op.tx.Model(&model.OrgNodeMeta{}) + if op.parentID >= 0 { + query = query.Where("parent_id = ?", op.parentID) + } + if op.keyword != nil { + trimmed := strings.TrimSpace(*op.keyword) + if trimmed != "" { + like := "%" + trimmed + "%" + query = query.Where("name ILIKE ? OR description ILIKE ?", like, like) + } + } + + var orgNodes []model.OrgNodeMeta + if err := query.Order("id DESC").Find(&orgNodes).Error; err != nil { + return nil, err + } + return orgNodes, nil +} + +type MGetOrgNodeByIDOperation struct { + repo *MembershipRepository + nodeIDs []int64 + tx *gorm.DB +} + +func (r *MembershipRepository) MGetOrgNodeByID(nodeIDs []int64) *MGetOrgNodeByIDOperation { + return &MGetOrgNodeByIDOperation{ + repo: r, + nodeIDs: nodeIDs, + } +} + +func (op *MGetOrgNodeByIDOperation) WithTx(tx *gorm.DB) *MGetOrgNodeByIDOperation { + op.tx = tx + return op +} + +func (op *MGetOrgNodeByIDOperation) Exec() (map[int64]*model.OrgNodeMeta, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + var orgNodes []model.OrgNodeMeta + if err := op.tx.Where("id IN ?", op.nodeIDs).Find(&orgNodes).Error; err != nil { + return nil, err + } + + result := make(map[int64]*model.OrgNodeMeta, len(orgNodes)) + for i := range orgNodes { + result[orgNodes[i].ID] = &orgNodes[i] + } + return result, nil +} + +type QueryOrgNodeByPathPrefixOperation struct { + repo *MembershipRepository + tx *gorm.DB + prefix string +} + +func (r *MembershipRepository) QueryOrgNodeByPathPrefix(prefix string) *QueryOrgNodeByPathPrefixOperation { + return &QueryOrgNodeByPathPrefixOperation{ + repo: r, + prefix: prefix, + } +} + +func (op *QueryOrgNodeByPathPrefixOperation) WithTx(tx *gorm.DB) *QueryOrgNodeByPathPrefixOperation { + op.tx = tx + return op +} + +func (op *QueryOrgNodeByPathPrefixOperation) Exec() ([]model.OrgNodeMeta, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + var orgNodes []model.OrgNodeMeta + prefix := strings.TrimSpace(op.prefix) + if prefix == "" { + return []model.OrgNodeMeta{}, nil + } + like := prefix + ".%" + if err := op.tx.Where("path = ? OR path LIKE ?", prefix, like).Find(&orgNodes).Error; err != nil { + return nil, err + } + return orgNodes, nil +} diff --git a/internal/repository/membership_repo/query_user_group.go b/internal/repository/membership_repo/query_user_group.go new file mode 100644 index 0000000..2804ff5 --- /dev/null +++ b/internal/repository/membership_repo/query_user_group.go @@ -0,0 +1,69 @@ +package membership_repo + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type QueryUserGroupOperation struct { + repo *MembershipRepository + tx *gorm.DB + keyword *string + page int + pageSize int +} + +func (r *MembershipRepository) QueryUserGroup() *QueryUserGroupOperation { + return &QueryUserGroupOperation{ + repo: r, + } +} + +func (op *QueryUserGroupOperation) WithTx(tx *gorm.DB) *QueryUserGroupOperation { + op.tx = tx + return op +} + +func (op *QueryUserGroupOperation) WithKeyword(keyword *string) *QueryUserGroupOperation { + op.keyword = keyword + return op +} + +func (op *QueryUserGroupOperation) WithPagination(page, pageSize int) *QueryUserGroupOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *QueryUserGroupOperation) ExecWithTotal() ([]model.UserGroupMeta, int64, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + query := op.tx.Model(&model.UserGroupMeta{}) + if op.keyword != nil { + trimmed := strings.TrimSpace(*op.keyword) + if trimmed != "" { + like := "%" + trimmed + "%" + query = query.Where("name ILIKE ? OR description ILIKE ?", like, like) + } + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if op.page > 0 && op.pageSize > 0 { + offset := (op.page - 1) * op.pageSize + query = query.Offset(offset).Limit(op.pageSize) + } + + var userGroups []model.UserGroupMeta + if err := query.Order("id DESC").Find(&userGroups).Error; err != nil { + return nil, 0, err + } + return userGroups, total, nil +} diff --git a/internal/repository/membership_repo/update.go b/internal/repository/membership_repo/update.go new file mode 100644 index 0000000..a27ab3c --- /dev/null +++ b/internal/repository/membership_repo/update.go @@ -0,0 +1,56 @@ +package membership_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type UpdateUserGroupOperation struct { + repo *MembershipRepository + group *model.UserGroupMeta + tx *gorm.DB +} + +func (r *MembershipRepository) UpdateUserGroup(group *model.UserGroupMeta) *UpdateUserGroupOperation { + return &UpdateUserGroupOperation{ + repo: r, + group: group, + } +} + +func (op *UpdateUserGroupOperation) WithTx(tx *gorm.DB) *UpdateUserGroupOperation { + op.tx = tx + return op +} + +func (op *UpdateUserGroupOperation) Exec() error { + if op.tx != nil { + return op.tx.Save(op.group).Error + } + return op.repo.db.Save(op.group).Error +} + +type UpdateOrgNodeOperation struct { + repo *MembershipRepository + orgNode *model.OrgNodeMeta + tx *gorm.DB +} + +func (r *MembershipRepository) UpdateOrgNode(orgNode *model.OrgNodeMeta) *UpdateOrgNodeOperation { + return &UpdateOrgNodeOperation{ + repo: r, + orgNode: orgNode, + } +} + +func (op *UpdateOrgNodeOperation) WithTx(tx *gorm.DB) *UpdateOrgNodeOperation { + op.tx = tx + return op +} + +func (op *UpdateOrgNodeOperation) Exec() error { + if op.tx != nil { + return op.tx.Save(op.orgNode).Error + } + return op.repo.db.Save(op.orgNode).Error +} diff --git a/internal/repository/notification_repo/create_batch.go b/internal/repository/notification_repo/create_batch.go new file mode 100644 index 0000000..2e7f62b --- /dev/null +++ b/internal/repository/notification_repo/create_batch.go @@ -0,0 +1,35 @@ +package notification_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type NotificationCreateBatchOperation struct { + repo *NotificationRepository + items []model.Notification + tx *gorm.DB +} + +func (r *NotificationRepository) CreateBatch(items []model.Notification) *NotificationCreateBatchOperation { + return &NotificationCreateBatchOperation{ + repo: r, + items: items, + } +} + +func (op *NotificationCreateBatchOperation) WithTx(tx *gorm.DB) *NotificationCreateBatchOperation { + op.tx = tx + return op +} + +func (op *NotificationCreateBatchOperation) Exec() error { + if len(op.items) == 0 { + return nil + } + db := op.repo.db + if op.tx != nil { + db = op.tx + } + return db.Create(&op.items).Error +} diff --git a/internal/repository/notification_repo/list.go b/internal/repository/notification_repo/list.go new file mode 100644 index 0000000..26e488c --- /dev/null +++ b/internal/repository/notification_repo/list.go @@ -0,0 +1,74 @@ +package notification_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type NotificationListOperation struct { + repo *NotificationRepository + receiverID int64 + status *string + page int + pageSize int + tx *gorm.DB +} + +func (r *NotificationRepository) List(receiverID int64) *NotificationListOperation { + return &NotificationListOperation{ + repo: r, + receiverID: receiverID, + } +} + +func (op *NotificationListOperation) WithTx(tx *gorm.DB) *NotificationListOperation { + op.tx = tx + return op +} + +func (op *NotificationListOperation) WithStatus(status *string) *NotificationListOperation { + op.status = status + return op +} + +func (op *NotificationListOperation) WithPagination(page, pageSize int) *NotificationListOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *NotificationListOperation) ExecWithTotal() ([]model.Notification, int64, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + query := db.Model(&model.Notification{}).Where("receiver_id = ?", op.receiverID) + if op.status != nil && *op.status != "" { + query = query.Where("status = ?", *op.status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + var list []model.Notification + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&list).Error; err != nil { + return nil, 0, err + } + return list, total, nil +} diff --git a/internal/repository/notification_repo/mark_read.go b/internal/repository/notification_repo/mark_read.go new file mode 100644 index 0000000..13b7aa2 --- /dev/null +++ b/internal/repository/notification_repo/mark_read.go @@ -0,0 +1,69 @@ +package notification_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type NotificationMarkReadOperation struct { + repo *NotificationRepository + receiverID int64 + externalIDs []string + tx *gorm.DB +} + +func (r *NotificationRepository) MarkRead(receiverID int64, externalIDs []string) *NotificationMarkReadOperation { + return &NotificationMarkReadOperation{ + repo: r, + receiverID: receiverID, + externalIDs: externalIDs, + } +} + +func (op *NotificationMarkReadOperation) WithTx(tx *gorm.DB) *NotificationMarkReadOperation { + op.tx = tx + return op +} + +func (op *NotificationMarkReadOperation) Exec() error { + if len(op.externalIDs) == 0 { + return nil + } + db := op.repo.db + if op.tx != nil { + db = op.tx + } + return db.Model(&model.Notification{}). + Where("receiver_id = ? AND external_id IN ?", op.receiverID, op.externalIDs). + Updates(map[string]any{"status": "read"}). + Error +} + +type NotificationMarkAllReadOperation struct { + repo *NotificationRepository + receiverID int64 + tx *gorm.DB +} + +func (r *NotificationRepository) MarkAllRead(receiverID int64) *NotificationMarkAllReadOperation { + return &NotificationMarkAllReadOperation{ + repo: r, + receiverID: receiverID, + } +} + +func (op *NotificationMarkAllReadOperation) WithTx(tx *gorm.DB) *NotificationMarkAllReadOperation { + op.tx = tx + return op +} + +func (op *NotificationMarkAllReadOperation) Exec() error { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + return db.Model(&model.Notification{}). + Where("receiver_id = ? AND status != ?", op.receiverID, "read"). + Updates(map[string]any{"status": "read"}). + Error +} diff --git a/internal/repository/notification_repo/notification_repo.go b/internal/repository/notification_repo/notification_repo.go new file mode 100644 index 0000000..9ef901f --- /dev/null +++ b/internal/repository/notification_repo/notification_repo.go @@ -0,0 +1,38 @@ +package notification_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type NotificationRepository struct { + db *gorm.DB +} + +func NewNotificationRepository(db *gorm.DB) *NotificationRepository { + return &NotificationRepository{db: db} +} + +type NotificationUpdateExternalIDOperation struct { + repo *NotificationRepository + id int64 + externalID string +} + +func (r *NotificationRepository) UpdateExternalID(id int64, externalID string) *NotificationUpdateExternalIDOperation { + return &NotificationUpdateExternalIDOperation{ + repo: r, + id: id, + externalID: externalID, + } +} + +func (op *NotificationUpdateExternalIDOperation) Exec() error { + if op.id == 0 || op.externalID == "" { + return nil + } + return op.repo.db.Model(&model.Notification{}). + Where("id = ? AND (external_id IS NULL OR external_id = '')", op.id). + Updates(map[string]any{"external_id": op.externalID}). + Error +} diff --git a/internal/repository/template_repo/create.go b/internal/repository/template_repo/create.go new file mode 100644 index 0000000..aa528fb --- /dev/null +++ b/internal/repository/template_repo/create.go @@ -0,0 +1,31 @@ +package template_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type TemplateCreateOperation struct { + repo *TemplateRepository + template *model.Template + tx *gorm.DB +} + +func (r *TemplateRepository) Create(template *model.Template) *TemplateCreateOperation { + return &TemplateCreateOperation{ + repo: r, + template: template, + } +} + +func (op *TemplateCreateOperation) WithTx(tx *gorm.DB) *TemplateCreateOperation { + op.tx = tx + return op +} + +func (op *TemplateCreateOperation) Exec() error { + if op.tx != nil { + return op.tx.Create(op.template).Error + } + return op.repo.db.Create(op.template).Error +} diff --git a/internal/repository/template_repo/create_recent_record.go b/internal/repository/template_repo/create_recent_record.go new file mode 100644 index 0000000..3f17b93 --- /dev/null +++ b/internal/repository/template_repo/create_recent_record.go @@ -0,0 +1,38 @@ +package template_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type CreateRecentTemplateOperation struct { + repo *TemplateRepository + m *model.RecentTemplate + tx *gorm.DB +} + +func (r *TemplateRepository) CreateRecentTemplate(m *model.RecentTemplate) *CreateRecentTemplateOperation { + return &CreateRecentTemplateOperation{ + repo: r, + m: m, + } +} + +func (op *CreateRecentTemplateOperation) WithTx(tx *gorm.DB) { + op.tx = tx +} + +func (op *CreateRecentTemplateOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + return op.tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_id"}, + {Name: "template_id"}, + }, + DoUpdates: clause.AssignmentColumns([]string{"accessed_at"}), + }).Create(op.m).Error +} diff --git a/internal/repository/template_repo/get_by_id.go b/internal/repository/template_repo/get_by_id.go new file mode 100644 index 0000000..2a7c009 --- /dev/null +++ b/internal/repository/template_repo/get_by_id.go @@ -0,0 +1,33 @@ +package template_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type TemplateGetByIDOperation struct { + repo *TemplateRepository + id int64 + tx *gorm.DB +} + +func (r *TemplateRepository) GetByID(id int64) (op *TemplateGetByIDOperation) { + return &TemplateGetByIDOperation{ + repo: r, + id: id, + } +} + +func (op *TemplateGetByIDOperation) WithTx(tx *gorm.DB) *TemplateGetByIDOperation { + op.tx = tx + return op +} + +func (op *TemplateGetByIDOperation) Exec() (template *model.Template, err error) { + if op.tx == nil { + op.tx = op.repo.db + } + template = &model.Template{} + err = op.tx.First(template, "id = ?", op.id).Error + return +} diff --git a/internal/repository/template_repo/list.go b/internal/repository/template_repo/list.go new file mode 100644 index 0000000..40e92c6 --- /dev/null +++ b/internal/repository/template_repo/list.go @@ -0,0 +1,119 @@ +package template_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type ListTemplateOperation struct { + repo *TemplateRepository + model *model.Template + userID *int64 + scope *string + knowledgeBaseID *int64 + nameKeyword *string + documentType *model.DocumentType + sortField string + sortDesc bool + page int + pageSize int + tx *gorm.DB +} + +func (r *TemplateRepository) List(m *model.Template) (op *ListTemplateOperation) { + return &ListTemplateOperation{ + repo: r, + model: m, + } +} + +func (op *ListTemplateOperation) WithTx(tx *gorm.DB) *ListTemplateOperation { + op.tx = tx + return op +} + +func (op *ListTemplateOperation) WithUserID(userID *int64) *ListTemplateOperation { + op.userID = userID + return op +} + +func (op *ListTemplateOperation) WithScope(scope *string) *ListTemplateOperation { + op.scope = scope + return op +} + +func (op *ListTemplateOperation) WithKnowledgeBaseID(knowledgeBaseID *int64) *ListTemplateOperation { + op.knowledgeBaseID = knowledgeBaseID + return op +} + +func (op *ListTemplateOperation) WithNameKeyword(nameKeyword *string) *ListTemplateOperation { + op.nameKeyword = nameKeyword + return op +} + +func (op *ListTemplateOperation) WithDocumentType(documentType *model.DocumentType) *ListTemplateOperation { + op.documentType = documentType + return op +} + +func (op *ListTemplateOperation) WithSort(field string, desc bool) *ListTemplateOperation { + op.sortField = field + op.sortDesc = desc + return op +} + +func (op *ListTemplateOperation) WithPagination(page, pageSize int) *ListTemplateOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *ListTemplateOperation) ExecWithTotal() (templates []*model.Template, total int64, err error) { + if op.tx == nil { + op.tx = op.repo.db + } + + dbQuery := op.tx.Model(op.model) + if op.model != nil { + dbQuery = dbQuery.Where(op.model) + } + + if op.userID != nil { + dbQuery = dbQuery.Where("user_id = ?", *op.userID) + } + if op.scope != nil { + dbQuery = dbQuery.Where("scope = ?", *op.scope) + } + if op.knowledgeBaseID != nil { + dbQuery = dbQuery.Where("knowledge_base_id = ?", *op.knowledgeBaseID) + } + if op.nameKeyword != nil && *op.nameKeyword != "" { + dbQuery = dbQuery.Where("name ILIKE ?", "%"+*op.nameKeyword+"%") + } + if op.documentType != nil && *op.documentType != "" { + dbQuery = dbQuery.Where("document_type = ?", *op.documentType) + } + + if err = dbQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + orderStr := "created_at DESC" + if op.sortField != "" { + if op.sortDesc { + orderStr = op.sortField + " DESC" + } else { + orderStr = op.sortField + " ASC" + } + } + dbQuery = dbQuery.Order(orderStr) + + if op.page > 0 && op.pageSize > 0 { + offset := (op.page - 1) * op.pageSize + dbQuery = dbQuery.Offset(offset).Limit(op.pageSize) + } + + err = dbQuery.Find(&templates).Error + return +} diff --git a/internal/repository/template_repo/list_recent.go b/internal/repository/template_repo/list_recent.go new file mode 100644 index 0000000..8e31556 --- /dev/null +++ b/internal/repository/template_repo/list_recent.go @@ -0,0 +1,82 @@ +package template_repo + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type ListRecentTemplatesOperation struct { + repo *TemplateRepository + userID int64 + startTime *time.Time + endTime *time.Time + page int + pageSize int + tx *gorm.DB +} + +func (r *TemplateRepository) ListRecentTemplates(userID int64) *ListRecentTemplatesOperation { + return &ListRecentTemplatesOperation{ + repo: r, + userID: userID, + } +} + +func (op *ListRecentTemplatesOperation) WithTimeRange(start, end *time.Time) *ListRecentTemplatesOperation { + op.startTime = start + op.endTime = end + return op +} + +func (op *ListRecentTemplatesOperation) WithPagination(page, pageSize int) *ListRecentTemplatesOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *ListRecentTemplatesOperation) WithTx(tx *gorm.DB) *ListRecentTemplatesOperation { + op.tx = tx + return op +} + +func (op *ListRecentTemplatesOperation) Exec() ([]*model.RecentTemplate, int64, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + + query := db.Model(&model.RecentTemplate{}). + Where("user_id = ?", op.userID) + if op.startTime != nil { + query = query.Where("accessed_at >= ?", *op.startTime) + } + if op.endTime != nil { + query = query.Where("accessed_at <= ?", *op.endTime) + } + + page := op.page + pageSize := op.pageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var records []*model.RecentTemplate + if err := query.Order("accessed_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { + return nil, 0, err + } + return records, total, nil +} diff --git a/internal/repository/template_repo/mget_by_ids.go b/internal/repository/template_repo/mget_by_ids.go new file mode 100644 index 0000000..3fcd59d --- /dev/null +++ b/internal/repository/template_repo/mget_by_ids.go @@ -0,0 +1,39 @@ +package template_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type TemplateMGetByIDsOperation struct { + repo *TemplateRepository + ids []int64 + tx *gorm.DB +} + +func (r *TemplateRepository) MGetByIDs(ids []int64) *TemplateMGetByIDsOperation { + return &TemplateMGetByIDsOperation{ + repo: r, + ids: ids, + } +} + +func (op *TemplateMGetByIDsOperation) WithTx(tx *gorm.DB) *TemplateMGetByIDsOperation { + op.tx = tx + return op +} + +func (op *TemplateMGetByIDsOperation) Exec() ([]*model.Template, error) { + db := op.repo.db + if op.tx != nil { + db = op.tx + } + var templates []*model.Template + if len(op.ids) == 0 { + return templates, nil + } + if err := db.Where("id IN ?", op.ids).Find(&templates).Error; err != nil { + return nil, err + } + return templates, nil +} diff --git a/internal/repository/template_repo/template_repo.go b/internal/repository/template_repo/template_repo.go new file mode 100644 index 0000000..b0daba6 --- /dev/null +++ b/internal/repository/template_repo/template_repo.go @@ -0,0 +1,11 @@ +package template_repo + +import "gorm.io/gorm" + +type TemplateRepository struct { + db *gorm.DB +} + +func NewTemplateRepository(db *gorm.DB) *TemplateRepository { + return &TemplateRepository{db: db} +} diff --git a/internal/repository/template_repo/update.go b/internal/repository/template_repo/update.go new file mode 100644 index 0000000..f29cc7d --- /dev/null +++ b/internal/repository/template_repo/update.go @@ -0,0 +1,63 @@ +package template_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type TemplateUpdateOperation struct { + repo *TemplateRepository + template *model.Template + updateFields map[string]bool + tx *gorm.DB +} + +func (r *TemplateRepository) Update(template *model.Template) *TemplateUpdateOperation { + return &TemplateUpdateOperation{ + repo: r, + template: template, + updateFields: make(map[string]bool), + } +} + +func (op *TemplateUpdateOperation) UpdateName() *TemplateUpdateOperation { + op.updateFields["name"] = true + return op +} + +func (op *TemplateUpdateOperation) UpdateDescription() *TemplateUpdateOperation { + op.updateFields["description"] = true + return op +} + +func (op *TemplateUpdateOperation) UpdateScope() *TemplateUpdateOperation { + op.updateFields["scope"] = true + return op +} + +func (op *TemplateUpdateOperation) UpdateIsPublic() *TemplateUpdateOperation { + op.updateFields["is_public"] = true + return op +} + +func (op *TemplateUpdateOperation) WithTx(tx *gorm.DB) *TemplateUpdateOperation { + op.tx = tx + return op +} + +func (op *TemplateUpdateOperation) Exec() error { + if op.tx == nil { + op.tx = op.repo.db + } + + query := op.tx.Model(op.template) + if len(op.updateFields) > 0 { + fields := make([]string, 0, len(op.updateFields)) + for field := range op.updateFields { + fields = append(fields, field) + } + query = query.Select(fields) + } + + return query.Updates(*op.template).Error +} diff --git a/internal/repository/user_repo/create.go b/internal/repository/user_repo/create.go new file mode 100644 index 0000000..6027387 --- /dev/null +++ b/internal/repository/user_repo/create.go @@ -0,0 +1,45 @@ +package user_repo + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "gorm.io/gorm" +) + +type UserCreateOperation struct { + repo *UserRepository + user *model.User + tx *gorm.DB +} + +func (r *UserRepository) Create(user *model.User) *UserCreateOperation { + return &UserCreateOperation{ + repo: r, + user: user, + } +} + +func (op *UserCreateOperation) WithTx(tx *gorm.DB) *UserCreateOperation { + op.tx = tx + return op +} + +func (op *UserCreateOperation) Exec() error { + if op.tx != nil { + if err := op.tx.Create(op.user).Error; err != nil { + return err + } + return op.clearQueryCache() + } + if err := op.repo.db.Create(op.user).Error; err != nil { + return err + } + return op.clearQueryCache() +} + +func (op *UserCreateOperation) clearQueryCache() error { + _, _ = op.repo.redis.Incr(context.Background(), key.KeyUserQueryVersion()).Result() + return nil +} diff --git a/internal/repository/user_repo/delete.go b/internal/repository/user_repo/delete.go new file mode 100644 index 0000000..75c115e --- /dev/null +++ b/internal/repository/user_repo/delete.go @@ -0,0 +1,45 @@ +package user_repo + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "gorm.io/gorm" +) + +type UserDeleteOperation struct { + repo *UserRepository + id int64 + tx *gorm.DB +} + +func (r *UserRepository) Delete(id int64) *UserDeleteOperation { + return &UserDeleteOperation{ + repo: r, + id: id, + } +} + +func (op *UserDeleteOperation) WithTx(tx *gorm.DB) *UserDeleteOperation { + op.tx = tx + return op +} + +func (op *UserDeleteOperation) Exec() error { + if op.tx != nil { + if err := op.tx.Delete(&model.User{}, op.id).Error; err != nil { + return err + } + return op.clearQueryCache() + } + if err := op.repo.db.Delete(&model.User{}, op.id).Error; err != nil { + return err + } + return op.clearQueryCache() +} + +func (op *UserDeleteOperation) clearQueryCache() error { + _, _ = op.repo.redis.Incr(context.Background(), key.KeyUserQueryVersion()).Result() + return nil +} diff --git a/internal/repository/user_repo/get_by_email.go b/internal/repository/user_repo/get_by_email.go new file mode 100644 index 0000000..0e26795 --- /dev/null +++ b/internal/repository/user_repo/get_by_email.go @@ -0,0 +1,37 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type UserGetByEmailOperation struct { + repo *UserRepository + email string + tx *gorm.DB +} + +func (r *UserRepository) GetByEmail(email string) *UserGetByEmailOperation { + return &UserGetByEmailOperation{ + repo: r, + email: email, + } +} + +func (op *UserGetByEmailOperation) WithTx(tx *gorm.DB) *UserGetByEmailOperation { + op.tx = tx + return op +} + +func (op *UserGetByEmailOperation) Exec() (*model.User, error) { + var user model.User + var err error + + if op.tx != nil { + err = op.tx.Where("email = ?", op.email).First(&user).Error + } else { + err = op.repo.db.Where("email = ?", op.email).First(&user).Error + } + + return &user, err +} diff --git a/internal/repository/user_repo/get_by_external_id.go b/internal/repository/user_repo/get_by_external_id.go new file mode 100644 index 0000000..f4dc8f3 --- /dev/null +++ b/internal/repository/user_repo/get_by_external_id.go @@ -0,0 +1,37 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type UserGetByExternalIDOperation struct { + repo *UserRepository + externalID string + tx *gorm.DB +} + +func (r *UserRepository) GetByExternalID(externalID string) *UserGetByExternalIDOperation { + return &UserGetByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *UserGetByExternalIDOperation) WithTx(tx *gorm.DB) *UserGetByExternalIDOperation { + op.tx = tx + return op +} + +func (op *UserGetByExternalIDOperation) Exec() (*model.User, error) { + var user model.User + var err error + + if op.tx != nil { + err = op.tx.Where("external_id = ?", op.externalID).First(&user).Error + } else { + err = op.repo.db.Where("external_id = ?", op.externalID).First(&user).Error + } + + return &user, err +} diff --git a/internal/repository/user_repo/get_by_id.go b/internal/repository/user_repo/get_by_id.go new file mode 100644 index 0000000..a708042 --- /dev/null +++ b/internal/repository/user_repo/get_by_id.go @@ -0,0 +1,37 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type UserGetByIDOperation struct { + repo *UserRepository + id int64 + tx *gorm.DB +} + +func (r *UserRepository) GetByID(id int64) *UserGetByIDOperation { + return &UserGetByIDOperation{ + repo: r, + id: id, + } +} + +func (op *UserGetByIDOperation) WithTx(tx *gorm.DB) *UserGetByIDOperation { + op.tx = tx + return op +} + +func (op *UserGetByIDOperation) Exec() (*model.User, error) { + var user model.User + var err error + + if op.tx != nil { + err = op.tx.First(&user, op.id).Error + } else { + err = op.repo.db.First(&user, op.id).Error + } + + return &user, err +} diff --git a/internal/repository/user_repo/get_id_by_external_id.go b/internal/repository/user_repo/get_id_by_external_id.go new file mode 100644 index 0000000..9eeae15 --- /dev/null +++ b/internal/repository/user_repo/get_id_by_external_id.go @@ -0,0 +1,37 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type UserGetIDByExternalIDOperation struct { + repo *UserRepository + externalID string + tx *gorm.DB +} + +func (r *UserRepository) GetIDByExternalID(externalID string) *UserGetIDByExternalIDOperation { + return &UserGetIDByExternalIDOperation{ + repo: r, + externalID: externalID, + } +} + +func (op *UserGetIDByExternalIDOperation) WithTx(tx *gorm.DB) *UserGetIDByExternalIDOperation { + op.tx = tx + return op +} + +func (op *UserGetIDByExternalIDOperation) Exec() (int64, error) { + var id int64 + var err error + + if op.tx != nil { + err = op.tx.Model(&model.User{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + } else { + err = op.repo.db.Model(&model.User{}).Where("external_id = ?", op.externalID).Pluck("id", &id).Error + } + + return id, err +} diff --git a/internal/repository/user_repo/list.go b/internal/repository/user_repo/list.go new file mode 100644 index 0000000..d83914f --- /dev/null +++ b/internal/repository/user_repo/list.go @@ -0,0 +1,35 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type UserListOperation struct { + repo *UserRepository + tx *gorm.DB +} + +func (r *UserRepository) List() *UserListOperation { + return &UserListOperation{ + repo: r, + } +} + +func (op *UserListOperation) WithTx(tx *gorm.DB) *UserListOperation { + op.tx = tx + return op +} + +func (op *UserListOperation) Exec() ([]model.User, error) { + var users []model.User + var err error + + if op.tx != nil { + err = op.tx.Find(&users).Error + } else { + err = op.repo.db.Find(&users).Error + } + + return users, err +} diff --git a/internal/repository/user_repo/list_by_external_id.go b/internal/repository/user_repo/list_by_external_id.go new file mode 100644 index 0000000..d7caf4c --- /dev/null +++ b/internal/repository/user_repo/list_by_external_id.go @@ -0,0 +1,37 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type ListUserByExternalOperation struct { + repo *UserRepository + externalIDList []string + tx *gorm.DB +} + +func (r *UserRepository) ListByExternal(externalIDList []string) *ListUserByExternalOperation { + return &ListUserByExternalOperation{ + repo: r, + externalIDList: externalIDList, + } +} + +func (op *ListUserByExternalOperation) WithTx(tx *gorm.DB) *ListUserByExternalOperation { + op.tx = tx + return op +} + +func (op *ListUserByExternalOperation) Exec() ([]model.User, error) { + var users []model.User + var err error + + if op.tx != nil { + err = op.tx.Where("external_id IN ?", op.externalIDList).Find(&users).Error + } else { + err = op.repo.db.Where("external_id IN ?", op.externalIDList).Find(&users).Error + } + + return users, err +} diff --git a/internal/repository/user_repo/mget_by_id.go b/internal/repository/user_repo/mget_by_id.go new file mode 100644 index 0000000..f6dd2d8 --- /dev/null +++ b/internal/repository/user_repo/mget_by_id.go @@ -0,0 +1,51 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type MGetUserByIDOperation struct { + repo *UserRepository + userIDs []int64 + tx *gorm.DB +} + +func (r *UserRepository) MGetUserByID(userIDs []int64) *MGetUserByIDOperation { + return &MGetUserByIDOperation{ + repo: r, + userIDs: userIDs, + } +} + +func (op *MGetUserByIDOperation) WithTx(tx *gorm.DB) *MGetUserByIDOperation { + op.tx = tx + return op +} + +func (op *MGetUserByIDOperation) Exec() (map[int64]*model.User, error) { + if op.tx == nil { + op.tx = op.repo.db + } + result := make(map[int64]*model.User) + + if len(op.userIDs) == 0 { + return result, nil + } + + var users []model.User + var err error + + err = op.tx.Where("id IN ?", op.userIDs).Find(&users).Error + + if err != nil { + return nil, err + } + + for _, user := range users { + userCopy := user + result[user.ID] = &userCopy + } + + return result, nil +} diff --git a/internal/repository/user_repo/query_users.go b/internal/repository/user_repo/query_users.go new file mode 100644 index 0000000..8f0526a --- /dev/null +++ b/internal/repository/user_repo/query_users.go @@ -0,0 +1,187 @@ +package user_repo + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "sort" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/cache" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type QueryUsersOperation struct { + repo *UserRepository + tx *gorm.DB + keyword *string + userIDs []int64 + page int + pageSize int +} + +type userQueryCache struct { + UserIDs []int64 `json:"user_ids"` + Total int64 `json:"total"` +} + +const userQueryCacheTTL = time.Minute + +func (r *UserRepository) QueryUsers() *QueryUsersOperation { + return &QueryUsersOperation{ + repo: r, + } +} + +func (op *QueryUsersOperation) WithTx(tx *gorm.DB) *QueryUsersOperation { + op.tx = tx + return op +} + +func (op *QueryUsersOperation) WithKeyword(keyword *string) *QueryUsersOperation { + op.keyword = keyword + return op +} + +func (op *QueryUsersOperation) WithUserIDs(userIDs []int64) *QueryUsersOperation { + op.userIDs = userIDs + return op +} + +func (op *QueryUsersOperation) WithPagination(page, pageSize int) *QueryUsersOperation { + op.page = page + op.pageSize = pageSize + return op +} + +func (op *QueryUsersOperation) ExecWithTotal() ([]model.User, int64, error) { + if op.tx == nil { + op.tx = op.repo.db + } + + if op.tx == op.repo.db && op.page > 0 && op.pageSize > 0 { + if users, total, ok := op.tryCache(context.Background()); ok { + return users, total, nil + } + } + + query := op.tx.Model(&model.User{}) + if len(op.userIDs) > 0 { + query = query.Where("id IN ?", op.userIDs) + } + if op.keyword != nil { + trimmed := strings.TrimSpace(*op.keyword) + if trimmed != "" { + like := "%" + trimmed + "%" + query = query.Where("username ILIKE ? OR email ILIKE ? OR external_id ILIKE ?", like, like, like) + } + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if op.page > 0 && op.pageSize > 0 { + offset := (op.page - 1) * op.pageSize + query = query.Offset(offset).Limit(op.pageSize) + } + + var users []model.User + if err := query.Order("id DESC").Find(&users).Error; err != nil { + return nil, 0, err + } + + if op.tx == op.repo.db && op.page > 0 && op.pageSize > 0 { + op.storeCache(context.Background(), users, total) + } + + return users, total, nil +} + +func (op *QueryUsersOperation) tryCache(ctx context.Context) ([]model.User, int64, bool) { + version := op.getUserQueryVersion(ctx) + queryHash := buildUserQueryHash(op.keyword, op.userIDs) + cacheKey := key.KeyUserQueryList(fmt.Sprintf("%d:%s", version, queryHash), op.page, op.pageSize) + + var cached userQueryCache + ok, err := cache.GetJSON(ctx, cacheKey, &cached) + if err != nil || !ok { + if err != nil { + logrus.Warnf("user query cache read failed: %v", err) + } + return nil, 0, false + } + + if len(cached.UserIDs) == 0 { + return []model.User{}, cached.Total, true + } + + userMap, err := op.repo.MGetUserByID(cached.UserIDs).WithTx(op.tx).Exec() + if err != nil { + logrus.Warnf("user query cache mget failed: %v", err) + return nil, 0, false + } + + users := make([]model.User, 0, len(cached.UserIDs)) + for _, id := range cached.UserIDs { + if user, ok := userMap[id]; ok { + users = append(users, *user) + } + } + + return users, cached.Total, true +} + +func (op *QueryUsersOperation) storeCache(ctx context.Context, users []model.User, total int64) { + userIDs := make([]int64, 0, len(users)) + for _, user := range users { + userIDs = append(userIDs, user.ID) + } + + version := op.getUserQueryVersion(ctx) + queryHash := buildUserQueryHash(op.keyword, op.userIDs) + cacheKey := key.KeyUserQueryList(fmt.Sprintf("%d:%s", version, queryHash), op.page, op.pageSize) + if err := cache.SetJSON(ctx, cacheKey, &userQueryCache{UserIDs: userIDs, Total: total}, userQueryCacheTTL); err != nil { + logrus.Warnf("user query cache set failed: %v", err) + } +} + +func buildUserQueryHash(keyword *string, userIDs []int64) string { + var trimmed string + if keyword != nil { + trimmed = strings.TrimSpace(*keyword) + } + + sortedIDs := append([]int64{}, userIDs...) + sort.Slice(sortedIDs, func(i, j int) bool { return sortedIDs[i] < sortedIDs[j] }) + + builder := strings.Builder{} + builder.WriteString(trimmed) + builder.WriteString("|") + for i, id := range sortedIDs { + if i > 0 { + builder.WriteString(",") + } + builder.WriteString(fmt.Sprintf("%d", id)) + } + + sum := sha1.Sum([]byte(builder.String())) + return hex.EncodeToString(sum[:]) +} + +func (op *QueryUsersOperation) getUserQueryVersion(ctx context.Context) int64 { + val, err := op.repo.redis.Get(ctx, key.KeyUserQueryVersion()).Int64() + if err != nil { + return 1 + } + if val <= 0 { + return 1 + } + return val +} diff --git a/internal/repository/user_repo/search.go b/internal/repository/user_repo/search.go new file mode 100644 index 0000000..1b185c6 --- /dev/null +++ b/internal/repository/user_repo/search.go @@ -0,0 +1,39 @@ +package user_repo + +import ( + "github.com/XingfenD/yoresee_doc/internal/model" + "gorm.io/gorm" +) + +type UserSearchOperation struct { + repo *UserRepository + query string + tx *gorm.DB +} + +func (r *UserRepository) Search(query string) *UserSearchOperation { + return &UserSearchOperation{ + repo: r, + query: query, + } +} + +func (op *UserSearchOperation) WithTx(tx *gorm.DB) *UserSearchOperation { + op.tx = tx + return op +} + +func (op *UserSearchOperation) Exec() ([]model.User, error) { + var users []model.User + var err error + + if op.tx != nil { + db := op.tx.Where("username LIKE ? OR email LIKE ?", "%"+op.query+"%", "%"+op.query+"%") + err = db.Or("CAST(id AS CHAR) = ?", op.query).Find(&users).Error + } else { + db := op.repo.db.Where("username LIKE ? OR email LIKE ?", "%"+op.query+"%", "%"+op.query+"%") + err = db.Or("CAST(id AS CHAR) = ?", op.query).Find(&users).Error + } + + return users, err +} diff --git a/internal/repository/user_repo/update.go b/internal/repository/user_repo/update.go new file mode 100644 index 0000000..b26a557 --- /dev/null +++ b/internal/repository/user_repo/update.go @@ -0,0 +1,45 @@ +package user_repo + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "gorm.io/gorm" +) + +type UserUpdateOperation struct { + repo *UserRepository + user *model.User + tx *gorm.DB +} + +func (r *UserRepository) Update(user *model.User) *UserUpdateOperation { + return &UserUpdateOperation{ + repo: r, + user: user, + } +} + +func (op *UserUpdateOperation) WithTx(tx *gorm.DB) *UserUpdateOperation { + op.tx = tx + return op +} + +func (op *UserUpdateOperation) Exec() error { + if op.tx != nil { + if err := op.tx.Save(op.user).Error; err != nil { + return err + } + return op.clearQueryCache() + } + if err := op.repo.db.Save(op.user).Error; err != nil { + return err + } + return op.clearQueryCache() +} + +func (op *UserUpdateOperation) clearQueryCache() error { + _, _ = op.repo.redis.Incr(context.Background(), key.KeyUserQueryVersion()).Result() + return nil +} diff --git a/internal/repository/user_repo/user_repo.go b/internal/repository/user_repo/user_repo.go new file mode 100644 index 0000000..354dec1 --- /dev/null +++ b/internal/repository/user_repo/user_repo.go @@ -0,0 +1,15 @@ +package user_repo + +import ( + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type UserRepository struct { + db *gorm.DB + redis *redis.Client +} + +func NewUserRepository(db *gorm.DB, redis *redis.Client) *UserRepository { + return &UserRepository{db: db, redis: redis} +} diff --git a/internal/search/document_search.go b/internal/search/document_search.go new file mode 100644 index 0000000..c2196b1 --- /dev/null +++ b/internal/search/document_search.go @@ -0,0 +1,214 @@ +package search + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/XingfenD/yoresee_doc/pkg/storage" +) + +const defaultSearchIndexPrefix = "yoresee_doc" + +type DocumentSearchRequest struct { + Keyword string + UserID *int64 + KnowledgeID *int64 + ListOwnDoc bool + DocType *string + Tags []string + CreateTimeRangeStart *string + CreateTimeRangeEnd *string + UpdateTimeRangeStart *string + UpdateTimeRangeEnd *string + Size int +} + +func SearchDocumentIDs(ctx context.Context, req DocumentSearchRequest) ([]int64, error) { + if storage.ES == nil { + return nil, fmt.Errorf("elasticsearch client is nil") + } + return storage.ES.SearchIDs(ctx, DocumentIndexName(), BuildDocumentSearchBody(req)) +} + +func UpsertDocument(ctx context.Context, doc *model.Document) error { + if doc == nil { + return fmt.Errorf("document is nil") + } + if storage.ES == nil { + return fmt.Errorf("elasticsearch client is nil") + } + return storage.ES.UpsertDocument(ctx, DocumentIndexName(), strconv.FormatInt(doc.ID, 10), BuildDocumentIndexBody(doc)) +} + +func DocumentIndexName() string { + prefix := defaultSearchIndexPrefix + if config.GlobalConfig != nil { + configPrefix := strings.TrimSpace(config.GlobalConfig.Elasticsearch.IndexPrefix) + if configPrefix != "" { + prefix = configPrefix + } + } + return key.KeyESDocumentIndex(prefix) +} + +func BuildDocumentSearchBody(req DocumentSearchRequest) map[string]interface{} { + size := req.Size + if size <= 0 { + size = 5000 + } + if size > 10000 { + size = 10000 + } + + must := []map[string]interface{}{} + if strings.TrimSpace(req.Keyword) != "" { + must = append(must, map[string]interface{}{ + "multi_match": map[string]interface{}{ + "query": strings.TrimSpace(req.Keyword), + "fields": []string{"title^3", "summary^2", "content"}, + "type": "best_fields", + "operator": "and", + "fuzziness": "AUTO", + }, + }) + } + + filter := []map[string]interface{}{} + mustNot := []map[string]interface{}{} + if req.UserID != nil { + filter = append(filter, map[string]interface{}{ + "term": map[string]interface{}{"user_id": *req.UserID}, + }) + } + if req.ListOwnDoc { + mustNot = append(mustNot, map[string]interface{}{ + "exists": map[string]interface{}{"field": "knowledge_id"}, + }) + } + if req.KnowledgeID != nil { + filter = append(filter, map[string]interface{}{ + "term": map[string]interface{}{"knowledge_id": *req.KnowledgeID}, + }) + } + if req.DocType != nil && strings.TrimSpace(*req.DocType) != "" { + filter = append(filter, map[string]interface{}{ + "term": map[string]interface{}{"type": strings.TrimSpace(*req.DocType)}, + }) + } + for _, tag := range req.Tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + filter = append(filter, map[string]interface{}{ + "term": map[string]interface{}{"tags.keyword": tag}, + }) + } + + rangeCreated := map[string]interface{}{} + if req.CreateTimeRangeStart != nil && strings.TrimSpace(*req.CreateTimeRangeStart) != "" { + rangeCreated["gte"] = strings.TrimSpace(*req.CreateTimeRangeStart) + } + if req.CreateTimeRangeEnd != nil && strings.TrimSpace(*req.CreateTimeRangeEnd) != "" { + rangeCreated["lte"] = strings.TrimSpace(*req.CreateTimeRangeEnd) + } + if len(rangeCreated) > 0 { + filter = append(filter, map[string]interface{}{ + "range": map[string]interface{}{"created_at": rangeCreated}, + }) + } + + rangeUpdated := map[string]interface{}{} + if req.UpdateTimeRangeStart != nil && strings.TrimSpace(*req.UpdateTimeRangeStart) != "" { + rangeUpdated["gte"] = strings.TrimSpace(*req.UpdateTimeRangeStart) + } + if req.UpdateTimeRangeEnd != nil && strings.TrimSpace(*req.UpdateTimeRangeEnd) != "" { + rangeUpdated["lte"] = strings.TrimSpace(*req.UpdateTimeRangeEnd) + } + if len(rangeUpdated) > 0 { + filter = append(filter, map[string]interface{}{ + "range": map[string]interface{}{"updated_at": rangeUpdated}, + }) + } + + return map[string]interface{}{ + "size": size, + "_source": []string{ + "id", + }, + "query": map[string]interface{}{ + "bool": map[string]interface{}{ + "must": must, + "filter": filter, + "must_not": mustNot, + }, + }, + } +} + +func BuildDocumentIndexBody(doc *model.Document) map[string]interface{} { + payload := map[string]interface{}{ + "id": doc.ID, + "external_id": doc.ExternalID, + "title": doc.Title, + "summary": doc.Summary, + "content": doc.Content, + "type": doc.Type, + "user_id": doc.UserID, + "tags": doc.Tags, + "created_at": doc.CreatedAt.Format(time.RFC3339), + "updated_at": doc.UpdatedAt.Format(time.RFC3339), + } + if doc.KnowledgeID != nil { + payload["knowledge_id"] = *doc.KnowledgeID + } + return payload +} + +func BuildDocumentIndexMapping() map[string]interface{} { + return map[string]interface{}{ + "mappings": map[string]interface{}{ + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "long", + }, + "external_id": map[string]interface{}{ + "type": "keyword", + }, + "title": map[string]interface{}{ + "type": "text", + }, + "summary": map[string]interface{}{ + "type": "text", + }, + "content": map[string]interface{}{ + "type": "text", + }, + "type": map[string]interface{}{ + "type": "keyword", + }, + "user_id": map[string]interface{}{ + "type": "long", + }, + "knowledge_id": map[string]interface{}{ + "type": "long", + }, + "tags": map[string]interface{}{ + "type": "keyword", + }, + "created_at": map[string]interface{}{ + "type": "date", + }, + "updated_at": map[string]interface{}{ + "type": "date", + }, + }, + }, + } +} diff --git a/internal/service/auth_service/auth_service.go b/internal/service/auth_service/auth_service.go new file mode 100644 index 0000000..eecca18 --- /dev/null +++ b/internal/service/auth_service/auth_service.go @@ -0,0 +1,118 @@ +package auth_service + +import ( + "context" + "time" + + "github.com/XingfenD/yoresee_doc/internal/auth" + "github.com/XingfenD/yoresee_doc/internal/constant" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/media" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/service/config_service" + "github.com/XingfenD/yoresee_doc/internal/service/invitation_service" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type AuthService struct { + db *gorm.DB + userRepo *user_repo.UserRepository +} + +func NewAuthService(repos *repository.Repositories) *AuthService { + return &AuthService{ + db: repos.DB, + userRepo: repos.User, + } +} + +func (s *AuthService) Register(ctx context.Context, userCreate *dto.UserCreate) error { + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + mode := config_service.ConfigSvc.GetSystemRegisterMode(ctx) + if mode == constant.RegisterMode_Invite { + if userCreate.InvitationCode == nil || *userCreate.InvitationCode == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "the system enable invitation mode, but the invitation code is empty") + } + err := invitation_service.InvitationSvc.ValidateAndUseWithTx(tx, *userCreate.InvitationCode) + if err != nil { + logrus.Errorf("[Service layer: AuthService] ValidateAndUseWithTx failed, invitation_code=%s, err=%+v", *userCreate.InvitationCode, err) + _ = invitation_service.InvitationSvc.CreateInvitationRecord(&model.InvitationRecord{ + Code: *userCreate.InvitationCode, + UsedBy: userCreate.Email, + Status: "failed", + UsedAt: time.Now(), + }) + return status.GenErrWithCustomMsg(err, "invitation validation failed") + } + } + + _, err := s.userRepo.GetByEmail(userCreate.Email).WithTx(tx).Exec() + if err == nil { + return status.GenErrWithCustomMsg(status.StatusUserAlreadyExists, "email already registered") + } + + hashedPwd, err := utils.HashPassword(userCreate.Password) + if err != nil { + logrus.Errorf("[Service layer: AuthService] HashPassword failed, email=%s, err=%+v", userCreate.Email, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "hash password failed") + } + + user := &model.User{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextUser), + Username: userCreate.Username, + PasswordHash: hashedPwd, + Email: userCreate.Email, + Status: 1, + InvitationCode: userCreate.InvitationCode, + } + + if err := s.userRepo.Create(user).WithTx(tx).Exec(); err != nil { + logrus.Errorf("[Service layer: AuthService] Create user failed, email=%s, err=%+v", userCreate.Email, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "create user failed") + } + + if mode == constant.RegisterMode_Invite && userCreate.InvitationCode != nil && *userCreate.InvitationCode != "" { + _ = invitation_service.InvitationSvc.CreateInvitationRecordWithTx(tx, &model.InvitationRecord{ + Code: *userCreate.InvitationCode, + UsedByUserID: utils.Of(user.ID), + UsedBy: user.Email, + Status: "success", + UsedAt: time.Now(), + }) + } + + return nil + }) +} + +func (s *AuthService) Login(email string, password string) (string, *dto.UserResponse, error) { + user, err := s.userRepo.GetByEmail(email).Exec() + if err != nil { + return "", nil, status.StatusUserNotFound + } + if user.Status <= 0 { + return "", nil, status.GenErrWithCustomMsg(status.StatusPermissionDenied, "user is banned") + } + + if !utils.CheckPassword(password, user.PasswordHash) { + return "", nil, status.StatusInvalidPassword + } + + token, err := auth.GenerateToken(user.ExternalID, user.Username) + if err != nil { + logrus.Errorf("[Service layer: AuthService] GenerateToken failed, user_external_id=%s, err=%+v", user.ExternalID, err) + return "", nil, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "generate token failed") + } + + userResponse := dto.NewUserResponseFromModel(user) + userResponse.Avatar = media.BuildAvatarURL(userResponse.ExternalID, userResponse.AvatarObjectKey, userResponse.AvatarVersion) + + return token, userResponse, nil +} + +var AuthSvc *AuthService diff --git a/internal/service/auth_service/query_sidebar_display.go b/internal/service/auth_service/query_sidebar_display.go new file mode 100644 index 0000000..d3867fc --- /dev/null +++ b/internal/service/auth_service/query_sidebar_display.go @@ -0,0 +1,71 @@ +package auth_service + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/status" +) + +const ( + SideBarSceneHome = "home" + SideBarSceneUserInfo = "user_info" + SideBarSceneManage = "manage" +) + +var ( + homeTabs = []string{ + "home", + "search", + "documents", + "knowledge-base", + "templates", + } + userInfoTabs = []string{ + "home", + "user-center", + "user-notifications", + "user-invite", + "user-security", + } + manageTabsForAdmin = []string{ + "home", + "manage-user", + "manage-user-group", + "manage-organization", + "manage-invite", + "manage-security", + } + manageTabsForNormalUser = []string{ + "home", + } +) + +func (s *AuthService) QuerySideBarDisplay(scene string, isAdmin bool) ([]string, error) { + if scene == "" { + return nil, status.StatusParamError + } + switch scene { + case SideBarSceneHome: + return homeTabs, nil + case SideBarSceneUserInfo: + return userInfoTabs, nil + case SideBarSceneManage: + if isAdmin { + return manageTabsForAdmin, nil + } + return manageTabsForNormalUser, nil + default: + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "invalid scene") + } +} + +func (s *AuthService) IsAdmin(userExternalID string) (bool, error) { + if userExternalID == "" { + return false, status.StatusParamError + } + user, err := s.userRepo.GetByExternalID(userExternalID).Exec() + if err != nil || user == nil { + return false, status.StatusUserNotFound + } + return strings.EqualFold(user.Username, "admin"), nil +} diff --git a/internal/service/auth_service/query_topnav_display.go b/internal/service/auth_service/query_topnav_display.go new file mode 100644 index 0000000..0e1f4e5 --- /dev/null +++ b/internal/service/auth_service/query_topnav_display.go @@ -0,0 +1,28 @@ +package auth_service + +import "github.com/XingfenD/yoresee_doc/internal/status" + +const ( + TopNavMenuUserCenter = "user-center" + TopNavMenuSystemManage = "system-manage" +) + +var ( + topNavMenusForAdmin = []string{ + TopNavMenuUserCenter, + TopNavMenuSystemManage, + } + topNavMenusForNormalUser = []string{ + TopNavMenuUserCenter, + } +) + +func (s *AuthService) QueryTopNavDisplay(isAdmin bool) ([]string, error) { + if isAdmin { + return topNavMenusForAdmin, nil + } + if topNavMenusForNormalUser == nil { + return nil, status.StatusParamError + } + return topNavMenusForNormalUser, nil +} diff --git a/internal/service/auth_service/update_profile.go b/internal/service/auth_service/update_profile.go new file mode 100644 index 0000000..200f117 --- /dev/null +++ b/internal/service/auth_service/update_profile.go @@ -0,0 +1,136 @@ +package auth_service + +import ( + "bytes" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/media" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/storage" +) + +func (s *AuthService) UpdateProfile(userExternalID string, req *dto.UpdateProfileRequest) (*dto.UserResponse, error) { + if strings.TrimSpace(userExternalID) == "" || req == nil { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "user_external_id and request are required") + } + if req.Username == nil && req.Email == nil && req.Nickname == nil && req.Password == nil && req.AvatarFile == nil { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "no profile fields to update") + } + + user, err := s.userRepo.GetByExternalID(userExternalID).Exec() + if err != nil { + return nil, status.GenErrWithCustomMsg(status.StatusUserNotFound, "user not found") + } + + if req.Username != nil { + username := strings.TrimSpace(*req.Username) + if username == "" { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "username cannot be empty") + } + user.Username = username + } + + if req.Email != nil { + email := strings.TrimSpace(*req.Email) + if email == "" { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "email cannot be empty") + } + existingUser, getErr := s.userRepo.GetByEmail(email).Exec() + if getErr == nil && existingUser.ID != user.ID { + return nil, status.GenErrWithCustomMsg(status.StatusUserAlreadyExists, "email already registered") + } + user.Email = email + } + + if req.Nickname != nil { + user.Nickname = strings.TrimSpace(*req.Nickname) + } + + if req.Password != nil { + password := strings.TrimSpace(*req.Password) + if password == "" { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "password cannot be empty") + } + hashedPwd, hashErr := utils.HashPassword(password) + if hashErr != nil { + return nil, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "hash password failed") + } + user.PasswordHash = hashedPwd + } + + var oldAvatarObjectKey string + if req.AvatarFile != nil { + oldAvatarObjectKey = user.AvatarObjectKey + avatarObjectKey, nextVersion, uploadErr := uploadAvatar( + user.ExternalID, + user.AvatarVersion+1, + req.AvatarFile, + req.AvatarFilename, + req.AvatarContentType, + ) + if uploadErr != nil { + return nil, status.GenErrWithCustomMsg(uploadErr, "upload avatar failed") + } + now := time.Now() + user.AvatarObjectKey = avatarObjectKey + user.AvatarVersion = nextVersion + user.AvatarUpdatedAt = &now + user.Avatar = "" + } + + if err := s.userRepo.Update(user).Exec(); err != nil { + return nil, status.StatusWriteDBError + } + + if oldAvatarObjectKey != "" && oldAvatarObjectKey != user.AvatarObjectKey { + _ = storage.DeleteFile(config.GlobalConfig.Minio.Bucket, oldAvatarObjectKey) + } + + resp := dto.NewUserResponseFromModel(user) + resp.Avatar = media.BuildAvatarURL(resp.ExternalID, resp.AvatarObjectKey, resp.AvatarVersion) + return resp, nil +} + +func uploadAvatar(userExternalID string, avatarVersion int64, avatarFile []byte, filename, contentType *string) (string, int64, error) { + if len(avatarFile) == 0 { + return "", 0, status.GenErrWithCustomMsg(status.StatusParamError, "avatar file is empty") + } + if len(avatarFile) > media.MaxAvatarSize { + return "", 0, status.GenErrWithCustomMsg(status.StatusParamError, "avatar file exceeds 5MB limit") + } + if storage.MinioClient == nil { + return "", 0, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "minio client is not initialized") + } + + fileName := "avatar" + if filename != nil { + fileName = strings.TrimSpace(*filename) + } + + fileContentType := "application/octet-stream" + if contentType != nil { + fileContentType = strings.TrimSpace(*contentType) + } + fileContentType = media.NormalizeAvatarContentType(avatarFile, fileContentType) + if !media.IsSupportedAvatarContentType(fileContentType) { + return "", 0, status.GenErrWithCustomMsg(status.StatusParamError, "avatar content type is not supported") + } + + objectName := media.BuildAvatarObjectKey(userExternalID, avatarVersion, media.ResolveAvatarExt(fileName, fileContentType)) + + err := storage.PutFile( + config.GlobalConfig.Minio.Bucket, + objectName, + bytes.NewReader(avatarFile), + int64(len(avatarFile)), + fileContentType, + ) + if err != nil { + return "", 0, err + } + return objectName, avatarVersion, nil +} diff --git a/internal/service/comment_service/comment_service.go b/internal/service/comment_service/comment_service.go new file mode 100644 index 0000000..ad883de --- /dev/null +++ b/internal/service/comment_service/comment_service.go @@ -0,0 +1,257 @@ +package comment_service + +import ( + "context" + "encoding/json" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/constant" + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/comment_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/document_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/knowledge_base_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" +) + +type CommentService struct { + commentRepo *comment_repo.CommentRepository + documentRepo *document_repo.DocumentRepository + userRepo *user_repo.UserRepository + kbRepo *knowledge_base_repo.KnowledgeBaseRepository +} + +func NewCommentService(repos *repository.Repositories) *CommentService { + return &CommentService{ + commentRepo: repos.Comment, + documentRepo: repos.Document, + userRepo: repos.User, + kbRepo: repos.KnowledgeBase, + } +} + +func (s *CommentService) CreateComment(req *dto.CreateCommentRequest) (*model.DocumentComment, error) { + if req == nil || strings.TrimSpace(req.DocumentExternalID) == "" || strings.TrimSpace(req.Content) == "" { + return nil, status.StatusParamError + } + if strings.TrimSpace(req.CreatorExternalID) == "" { + return nil, status.StatusTokenInvalid + } + doc, err := s.documentRepo.GetByExternalID(req.DocumentExternalID).Exec(context.Background()) + if err != nil || doc == nil { + return nil, status.StatusDocumentNotFound + } + + creatorID, err := s.userRepo.GetIDByExternalID(req.CreatorExternalID).Exec() + if err != nil { + return nil, status.StatusUserNotFound + } + + parentID := int64(0) + var parentComment *model.DocumentComment + if req.ParentExternalID != nil && strings.TrimSpace(*req.ParentExternalID) != "" { + parent, err := s.commentRepo.GetByExternalID(*req.ParentExternalID).Exec() + if err != nil || parent == nil { + return nil, status.StatusReadDBError + } + parentID = parent.ID + parentComment = parent + } + + item := &model.DocumentComment{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextComment), + DocumentID: doc.ID, + ParentID: parentID, + CreatorID: creatorID, + Content: req.Content, + } + if req.AnchorID != nil && strings.TrimSpace(*req.AnchorID) != "" { + item.AnchorID = strings.TrimSpace(*req.AnchorID) + } else if parentComment != nil && strings.TrimSpace(parentComment.AnchorID) != "" { + item.AnchorID = parentComment.AnchorID + } + if strings.TrimSpace(item.AnchorID) == "" { + return nil, status.StatusParamError + } + if err := s.commentRepo.Create(item).Exec(); err != nil { + return nil, status.StatusWriteDBError + } + + notifyQuote := "" + if req.Quote != nil { + notifyQuote = strings.TrimSpace(*req.Quote) + } + s.notifyCommentTargets(doc, item, parentComment, notifyQuote, req.MentionedUserExternalIDs) + return item, nil +} + +func (s *CommentService) ListComments(req *dto.ListCommentsRequest) ([]model.DocumentComment, int64, error) { + if req == nil || strings.TrimSpace(req.DocumentExternalID) == "" { + return nil, 0, status.StatusParamError + } + doc, err := s.documentRepo.GetByExternalID(req.DocumentExternalID).Exec(context.Background()) + if err != nil || doc == nil { + return nil, 0, status.StatusDocumentNotFound + } + op := s.commentRepo.ListByDocument(doc.ID). + WithPagination(req.Page, req.PageSize) + list, total, err := op.ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: CommentService] list comments failed, document_external_id=%s, err=%+v", req.DocumentExternalID, err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusReadDBError, "list comments failed") + } + return list, total, nil +} + +func (s *CommentService) DeleteComment(req *dto.DeleteCommentRequest, isAdmin bool) error { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return status.StatusParamError + } + comment, err := s.commentRepo.GetByExternalID(req.ExternalID).Exec() + if err != nil || comment == nil { + return status.StatusReadDBError + } + if !isAdmin { + operatorID, err := s.userRepo.GetIDByExternalID(req.OperatorExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + if operatorID != comment.CreatorID { + return status.StatusPermissionDenied + } + } + if err := s.commentRepo.Delete(comment.ID).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +func (s *CommentService) UpdateComment(req *dto.UpdateCommentRequest, isAdmin bool) (*model.DocumentComment, error) { + if req == nil || strings.TrimSpace(req.ExternalID) == "" || strings.TrimSpace(req.Content) == "" { + return nil, status.StatusParamError + } + comment, err := s.commentRepo.GetByExternalID(req.ExternalID).Exec() + if err != nil || comment == nil { + return nil, status.StatusReadDBError + } + if !isAdmin { + operatorID, err := s.userRepo.GetIDByExternalID(req.OperatorExternalID).Exec() + if err != nil { + return nil, status.StatusUserNotFound + } + if operatorID != comment.CreatorID { + return nil, status.StatusPermissionDenied + } + } + if err := s.commentRepo.UpdateContentByID(comment.ID, req.Content).Exec(); err != nil { + return nil, status.StatusWriteDBError + } + comment.Content = req.Content + return comment, nil +} + +var CommentSvc *CommentService + +func (s *CommentService) notifyCommentTargets(doc *model.Document, comment *model.DocumentComment, parent *model.DocumentComment, quote string, mentionedUserExternalIDs []string) { + if doc == nil || comment == nil { + return + } + + // Resolve knowledge base external ID for navigation payload + kbExternalID := "" + if doc.KnowledgeID != nil && *doc.KnowledgeID != 0 { + kb, err := s.kbRepo.GetByID(*doc.KnowledgeID).Exec() + if err == nil && kb != nil { + kbExternalID = kb.ExternalID + } + } + + parentExternalID := "" + if parent != nil && parent.ID != 0 { + parentExternalID = parent.ExternalID + } + + payloadBase := map[string]any{ + "document_external_id": doc.ExternalID, + "comment_external_id": comment.ExternalID, + "document_title": doc.Title, + "parent_comment_external_id": parentExternalID, + "knowledge_base_external_id": kbExternalID, + "mentioned_user_external_ids": mentionedUserExternalIDs, + } + payloadJSON, _ := json.Marshal(payloadBase) + payloadStr := string(payloadJSON) + + publish := func(receivers []string, notifType, title string) { + if len(receivers) == 0 { + return + } + evt := domain_event.NotificationCreateEvent{ + ReceiverExternalIDs: receivers, + Type: notifType, + Title: title, + Content: comment.Content, + PayloadJSON: payloadStr, + } + if err := domain_event.PublishNotificationCreateEvent(context.Background(), evt); err != nil { + logrus.Errorf("publish %s notification failed: %v", notifType, err) + } + } + + // Build mention receiver set (highest priority — skip for other types) + mentionSet := map[string]struct{}{} + for _, id := range mentionedUserExternalIDs { + id = strings.TrimSpace(id) + if id != "" && id != comment.ExternalID { + mentionSet[id] = struct{}{} + } + } + + // Build reply receivers (parent comment creator, excluding commenter and mention receivers) + var replyReceivers []string + if parent != nil && parent.ID != 0 { + parentCreator, err := s.userRepo.GetByID(parent.CreatorID).Exec() + if err == nil && parentCreator != nil { + id := strings.TrimSpace(parentCreator.ExternalID) + if id != "" && parentCreator.ID != comment.CreatorID { + if _, isMentioned := mentionSet[id]; !isMentioned { + replyReceivers = append(replyReceivers, id) + } + } + } + } + + // Build comment receivers (document owner, excluding commenter, reply receivers, and mention receivers) + replySet := map[string]struct{}{} + for _, id := range replyReceivers { + replySet[id] = struct{}{} + } + var commentReceivers []string + if doc.UserID != comment.CreatorID { + owner, err := s.userRepo.GetByID(doc.UserID).Exec() + if err == nil && owner != nil { + id := strings.TrimSpace(owner.ExternalID) + if id != "" { + if _, isMentioned := mentionSet[id]; !isMentioned { + if _, isReply := replySet[id]; !isReply { + commentReceivers = append(commentReceivers, id) + } + } + } + } + } + + mentionReceivers := make([]string, 0, len(mentionSet)) + for id := range mentionSet { + mentionReceivers = append(mentionReceivers, id) + } + + publish(mentionReceivers, constant.NotificationType_Mention, constant.GetNotificationTitle(constant.NotificationType_Mention)) + publish(replyReceivers, constant.NotificationType_Reply, constant.GetNotificationTitle(constant.NotificationType_Reply)) + publish(commentReceivers, constant.NotificationType_Comment, constant.GetNotificationTitle(constant.NotificationType_Comment)) +} diff --git a/internal/service/config_service/config_service.go b/internal/service/config_service/config_service.go new file mode 100644 index 0000000..0637670 --- /dev/null +++ b/internal/service/config_service/config_service.go @@ -0,0 +1,45 @@ +package config_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/pkg/storage" +) + +type ConfigService struct { + SystemRegisterMode func() string `consul:"system.security.register_mode,default=invite"` + SystemRegisterLimit func() bool `consul:"system.security.register_limit"` +} + +func NewConfigService() *ConfigService { + if !storage.ConsulEnabled() { + return nil + } + s := &ConfigService{} + if err := storage.BindConsulConfig(s, storage.Consul); err != nil { + panic("consul service init failed") + } + return s +} + +func (s *ConfigService) GetSystemRegisterMode(ctx context.Context) string { + return s.SystemRegisterMode() +} + +func (s *ConfigService) GetSystemRegisterLimit(ctx context.Context) bool { + return s.SystemRegisterLimit() +} + +func (s *ConfigService) Set(ctx context.Context, key, value string) error { + if !storage.ConsulEnabled() { + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "consul is not enabled") + } + return storage.Consul.Set(ctx, key, value) +} + +var ConfigSvc *ConfigService + +func InitConfigService() { + ConfigSvc = NewConfigService() +} diff --git a/internal/service/document_service/attachment.go b/internal/service/document_service/attachment.go new file mode 100644 index 0000000..4852135 --- /dev/null +++ b/internal/service/document_service/attachment.go @@ -0,0 +1,198 @@ +package document_service + +import ( + "bytes" + "context" + "fmt" + "mime" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" +) + +const maxAttachmentSize = 5 * 1024 * 1024 + +func (s *DocumentService) UploadAttachment( + ctx context.Context, + userExternalID string, + documentExternalID string, + file []byte, + fileName string, + contentType string, +) (*dto.AttachmentResponse, error) { + if s == nil || strings.TrimSpace(userExternalID) == "" || strings.TrimSpace(documentExternalID) == "" { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "invalid upload attachment request") + } + if len(file) == 0 || len(file) > maxAttachmentSize { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "invalid attachment file") + } + if config.GlobalConfig == nil || strings.TrimSpace(config.GlobalConfig.Minio.Bucket) == "" { + return nil, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "attachment storage is not configured") + } + + documentID, err := s.documentRepo.GetIDByExternalID(documentExternalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query document id failed, document_external_id=%s, err=%+v", documentExternalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "query document failed") + } + if documentID <= 0 { + return nil, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document not found") + } + + userID, err := s.userRepo.GetIDByExternalID(userExternalID).Exec() + if err != nil || userID <= 0 { + logrus.Errorf("[Service layer: DocumentService] query user id failed, user_external_id=%s, err=%+v", userExternalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusUserNotFound, "user not found") + } + + safeName := strings.TrimSpace(fileName) + if safeName == "" { + safeName = "attachment.bin" + } + contentType = strings.TrimSpace(contentType) + if contentType == "" { + contentType = http.DetectContentType(file) + } + + attachmentExternalID := utils.GenerateExternalID(utils.ExternalIDContextAttachment) + objectName := fmt.Sprintf( + "attachments/%s/%s-%d%s", + documentExternalID, + attachmentExternalID, + time.Now().UnixNano(), + resolveAttachmentExt(safeName, contentType), + ) + + err = storage.PutFile( + config.GlobalConfig.Minio.Bucket, + objectName, + bytes.NewReader(file), + int64(len(file)), + contentType, + ) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] upload attachment to minio failed, document_external_id=%s, object_name=%s, err=%+v", documentExternalID, objectName, err) + return nil, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "upload attachment failed") + } + + attachment := &model.Attachment{ + ExternalID: attachmentExternalID, + DocumentID: documentID, + Name: safeName, + Path: objectName, + Size: int64(len(file)), + MimeType: contentType, + UserID: userID, + } + if err = s.attachmentRepo.Create(attachment); err != nil { + logrus.Errorf("[Service layer: DocumentService] save attachment meta failed, document_external_id=%s, attachment_external_id=%s, err=%+v", documentExternalID, attachmentExternalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusWriteDBError, "save attachment failed") + } + + return &dto.AttachmentResponse{ + ExternalID: attachment.ExternalID, + DocumentExternalID: documentExternalID, + Name: attachment.Name, + Size: attachment.Size, + MimeType: attachment.MimeType, + Path: attachment.Path, + URL: storage.BuildPublicObjectPath(attachment.Path), + CreatedAt: attachment.CreatedAt, + UpdatedAt: attachment.UpdatedAt, + }, nil +} + +func (s *DocumentService) ListAttachments(ctx context.Context, documentExternalID string) ([]*dto.AttachmentResponse, error) { + if s == nil || strings.TrimSpace(documentExternalID) == "" { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "invalid list attachments request") + } + if config.GlobalConfig == nil || strings.TrimSpace(config.GlobalConfig.Minio.Bucket) == "" { + return nil, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "attachment storage is not configured") + } + + documentID, err := s.documentRepo.GetIDByExternalID(documentExternalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query document id failed, document_external_id=%s, err=%+v", documentExternalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "query document failed") + } + if documentID <= 0 { + return nil, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document not found") + } + + attachments, err := s.attachmentRepo.ListByDocumentID(documentID) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] list attachments failed, document_external_id=%s, document_id=%d, err=%+v", documentExternalID, documentID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "list attachments failed") + } + + resp := make([]*dto.AttachmentResponse, 0, len(attachments)) + for _, attachment := range attachments { + resp = append(resp, &dto.AttachmentResponse{ + ExternalID: attachment.ExternalID, + DocumentExternalID: documentExternalID, + Name: attachment.Name, + Size: attachment.Size, + MimeType: attachment.MimeType, + Path: attachment.Path, + URL: storage.BuildPublicObjectPath(attachment.Path), + CreatedAt: attachment.CreatedAt, + UpdatedAt: attachment.UpdatedAt, + }) + } + return resp, nil +} + +func (s *DocumentService) DeleteAttachment(ctx context.Context, documentExternalID, attachmentExternalID string) error { + if s == nil || strings.TrimSpace(documentExternalID) == "" || strings.TrimSpace(attachmentExternalID) == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid delete attachment request") + } + if config.GlobalConfig == nil || strings.TrimSpace(config.GlobalConfig.Minio.Bucket) == "" { + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "attachment storage is not configured") + } + + documentID, err := s.documentRepo.GetIDByExternalID(documentExternalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query document id failed, document_external_id=%s, err=%+v", documentExternalID, err) + return status.GenErrWithCustomMsg(status.StatusReadDBError, "query document failed") + } + if documentID <= 0 { + return status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document not found") + } + + attachment, err := s.attachmentRepo.GetByExternalIDAndDocumentID(attachmentExternalID, documentID) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query attachment failed, document_external_id=%s, attachment_external_id=%s, err=%+v", documentExternalID, attachmentExternalID, err) + return status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "attachment not found") + } + + if err = storage.DeleteFile(config.GlobalConfig.Minio.Bucket, attachment.Path); err != nil { + logrus.Errorf("[Service layer: DocumentService] delete attachment object failed, attachment_external_id=%s, path=%s, err=%+v", attachmentExternalID, attachment.Path, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "delete attachment failed") + } + if err = s.attachmentRepo.DeleteByID(attachment.ID); err != nil { + logrus.Errorf("[Service layer: DocumentService] delete attachment meta failed, attachment_external_id=%s, err=%+v", attachmentExternalID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "delete attachment failed") + } + return nil +} + +func resolveAttachmentExt(filename, contentType string) string { + ext := strings.TrimSpace(strings.ToLower(filepath.Ext(filename))) + if ext != "" { + return ext + } + exts, err := mime.ExtensionsByType(contentType) + if err == nil && len(exts) > 0 { + return exts[0] + } + return ".bin" +} diff --git a/internal/service/document_service/convert.go b/internal/service/document_service/convert.go new file mode 100644 index 0000000..5075d24 --- /dev/null +++ b/internal/service/document_service/convert.go @@ -0,0 +1,74 @@ +package document_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository/document_repo" + internal_dto "github.com/XingfenD/yoresee_doc/internal/service/dto" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func (s *DocumentService) ConvertToDocumentResponse(doc *model.Document) *dto.DocumentMetaResponse { + return dto.NewDocumentMetaResponseFromModel(doc) +} + +func (s *DocumentService) buildListDocumentsOperation(req *internal_dto.DocumentsListReq) (*document_repo.DocumentsListOperation, error) { + if s == nil || s.documentRepo == nil { + return nil, status.StatusServiceInternalError + } + if req == nil { + return nil, status.StatusInternalParamsError + } + listOp := s.documentRepo.ListDocuments(&model.Document{}) + if req.MetaArgs != nil { + listOp = listOp.WithUserID(req.MetaArgs.UserID). + WithParentID(req.MetaArgs.ParentID). + WithKnowledgeID(req.MetaArgs.KnowledgeID). + WithListOwnDoc(req.ListOwnDoc). + WithDirectoryOnly(req.DirectoryOnly) + } + if req.SearchDocIDs != nil { + listOp = listOp.WithIDs(req.SearchDocIDs) + } + if req.FilterArgs != nil { + titleKeyword := req.FilterArgs.TitleKeyword + if req.SearchDocIDs != nil { + titleKeyword = nil + } + listOp = listOp.WithTitleKeyword(titleKeyword). + WithType(req.FilterArgs.DocType). + WithTags(req.FilterArgs.Tags). + WithCreateTimeRange(req.FilterArgs.CreateTimeRangeStart, req.FilterArgs.CreateTimeRangeEnd). + WithUpdateTimeRange(req.FilterArgs.UpdateTimeRangeStart, req.FilterArgs.UpdateTimeRangeEnd) + } + listOp = listOp.WithSort(req.SortArgs.Field, req.SortArgs.Desc). + WithPagination(req.Pagination.Page, req.Pagination.PageSize) + + return listOp, nil +} + +func (s *DocumentService) buildDocumentTree(rootDocs []model.Document, allDescendants []*model.Document) []*dto.DocumentMetaResponse { + docMap := make(map[int64]*dto.DocumentMetaResponse) + var rootResponses []*dto.DocumentMetaResponse + + for i := range rootDocs { + resp := s.ConvertToDocumentResponse(&rootDocs[i]) + docMap[rootDocs[i].ID] = resp + rootResponses = append(rootResponses, resp) + } + + for _, doc := range allDescendants { + childResp := s.ConvertToDocumentResponse(doc) + docMap[doc.ID] = childResp + + if doc.ParentID != 0 { + parentResp, exists := docMap[doc.ParentID] + if exists { + parentResp.Children = append(parentResp.Children, childResp) + parentResp.HasChildren = true + } + } + } + + return rootResponses +} diff --git a/internal/service/document_service/create.go b/internal/service/document_service/create.go new file mode 100644 index 0000000..d5b1c88 --- /dev/null +++ b/internal/service/document_service/create.go @@ -0,0 +1,127 @@ +package document_service + +import ( + "context" + "time" + + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func (s *DocumentService) Create(ctx context.Context, req *dto.CreateDocumentRequest) (*dto.CreateDocumentResponse, error) { + if err := validateCreateDocumentReq(req); err != nil { + logrus.Errorf("[Service layer: DocumentService] validateCreateDocumentReq failed, err=%+v", err) + return nil, status.GenErrWithCustomMsg(err, "invalid create document request") + } + + // TODO: redis support + docExternalID := utils.GenerateExternalID(utils.ExternalIDContextDocument) + err := utils.WithTransaction(s.db, func(tx *gorm.DB) error { + docModel := &model.Document{ + ExternalID: docExternalID, + Title: req.Title, + Type: doc_type_mapper.ToModelType(req.Type), + ContainerType: doc_container_mapper.ToModelType(req.ContainerType), + Summary: "", + IsPublic: req.IsPublic, + Content: "", + Path: "0", + Depth: 0, + } + + // query user_id + userID, err := s.userRepo.GetIDByExternalID(*req.CreatorExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusUserNotFound + } + + docModel.UserID = userID + + // query knowledge_id + if req.ContainerType == dto.ContainerType_KnowledgeBase { + kbID, err := s.kbRepo.GetIDByExternalID(*req.KnowledgeExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusKnowledgeBaseNotFound + } + docModel.KnowledgeID = &kbID + } + + // TODO: permission check for knowledgebase + + // query parent_id + if req.ParentExternalID != nil { + parentDocID, err := s.documentRepo.GetIDByExternalID(*req.ParentExternalID).WithTx(tx).Exec(ctx) + if err != nil { + return status.StatusDocumentNotFound + } + docModel.ParentID = parentDocID + } + + // apply template content if provided + if req.TemplateID != nil && *req.TemplateID > 0 { + tpl, err := s.templateRepo.GetByID(*req.TemplateID).WithTx(tx).Exec() + if err != nil || tpl == nil { + logrus.Errorf("template not found: err:%+v", err) + return status.GenErrWithCustomMsg(status.StatusParamError, "template not found") + } + docModel.Content = tpl.Content + } + + // create document meta + err = s.documentRepo.Create(docModel).WithTx(tx).Exec() + if err != nil { + status.GenErrWithCustomMsg(status.StatusWriteDBError, "create document meta failed") + return status.StatusWriteDBError + } + if err := s.documentRepo.UpdatePathDepth(docModel.ID, docModel.ParentID).WithTx(tx).Exec(); err != nil { + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "update document path depth failed") + } + + // create doc version + ver := &model.DocumentVersion{ + DocumentID: docModel.ID, + Content: docModel.Content, + UserID: userID, + Title: docModel.Title, + ChangeSummary: "Create the document", + } + err = s.docVersionRepo.Create(ver).WithTx(tx).Exec() + if err != nil { + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "create version failed") + } + + return nil + }) + + if err != nil { + logrus.Errorf("[Service layer: DocumentService] Create err: %+v", err) + return nil, status.StatusWriteDBError + } + + if createdDoc, err := s.documentRepo.GetByExternalID(docExternalID).Exec(ctx); err == nil { + if err := s.documentRepo.BumpSubtreeVersionsByPath(ctx, createdDoc.Path); err != nil { + logrus.Warnf("bump subtree version failed: %v", err) + } + } + if req.TemplateID != nil && req.CreatorExternalID != nil && *req.CreatorExternalID != "" { + _ = s.CreateRecentTemplate(&dto.CreateRecentTemplateRequest{ + UserExternalID: *req.CreatorExternalID, + TemplateID: *req.TemplateID, + AccessTime: time.Now(), + }) + } + if err := domain_event.PublishDocumentUpsertEvent(ctx, docExternalID); err != nil { + logrus.Warnf("[Service layer: DocumentService] publish search sync event failed, external_id=%s, err=%+v", docExternalID, err) + } + + return &dto.CreateDocumentResponse{ + ExternalID: docExternalID, + }, nil +} diff --git a/internal/service/document_service/create_recent_template.go b/internal/service/document_service/create_recent_template.go new file mode 100644 index 0000000..3fc4102 --- /dev/null +++ b/internal/service/document_service/create_recent_template.go @@ -0,0 +1,27 @@ +package document_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func (s *DocumentService) CreateRecentTemplate(req *dto.CreateRecentTemplateRequest) error { + if req == nil || req.UserExternalID == "" || req.TemplateID <= 0 { + return status.StatusInternalParamsError + } + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + + err = s.templateRepo.CreateRecentTemplate(&model.RecentTemplate{ + UserID: userID, + TemplateID: req.TemplateID, + AccessedAt: req.AccessTime, + }).Exec() + if err != nil { + return status.StatusWriteDBError + } + return nil +} diff --git a/internal/service/document_service/create_template.go b/internal/service/document_service/create_template.go new file mode 100644 index 0000000..2492959 --- /dev/null +++ b/internal/service/document_service/create_template.go @@ -0,0 +1,120 @@ +package document_service + +import ( + "context" + "encoding/json" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/mapper/template_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" +) + +type templatePayload struct { + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` + Tags []string `json:"tags"` +} + +func (s *DocumentService) CreateTemplate(ctx context.Context, req *dto.CreateTemplateRequest) error { + if err := validateCreateTemplateReq(req); err != nil { + logrus.Errorf("[Service layer: DocumentService] validateCreateTemplateReq failed, err=%+v", err) + return status.GenErrWithCustomMsg(err, "invalid create template request") + } + _ = ctx + + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + + var kbID *int64 + if template_container_mapper.RequiresKnowledgeBaseID(req.TargetContainer) { + id, err := s.kbRepo.GetIDByExternalID(*req.KnowledgeBaseExternalID).Exec() + if err != nil { + return status.StatusKnowledgeBaseNotFound + } + kbID = &id + } + + name, description, content, tags := parseTemplateContent(req.TemplateContent) + if strings.TrimSpace(content) == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "template content is empty") + } + + templateType := req.Type + if !doc_type_mapper.IsSupportedDTOType(templateType) { + templateType = doc_type_mapper.DefaultDTOType() + } + + scope := template_container_mapper.ToScope(req.TargetContainer) + isPublic := template_container_mapper.ToIsPublic(req.TargetContainer) + templateModel := &model.Template{ + Name: name, + Description: description, + DocumentType: doc_type_mapper.ToModelType(templateType), + Content: content, + UserID: userID, + Scope: scope, + KnowledgeBaseID: kbID, + IsPublic: isPublic, + Tags: tags, + } + + if err := s.templateRepo.Create(templateModel).Exec(); err != nil { + return status.StatusWriteDBError + } + + return nil +} + +func parseTemplateContent(raw string) (name, description, content string, tags []string) { + content = raw + var payload templatePayload + if err := json.Unmarshal([]byte(raw), &payload); err == nil { + if payload.Content != "" { + content = payload.Content + } + name = payload.Name + description = payload.Description + tags = payload.Tags + } + if strings.TrimSpace(name) == "" { + name = deriveTemplateName(content) + } + if len(name) > 100 { + name = truncateRunes(name, 100) + } + return name, description, content, tags +} + +func deriveTemplateName(content string) string { + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + trimmed = strings.TrimLeft(trimmed, "#*-+ ") + trimmed = strings.TrimSpace(trimmed) + if trimmed == "" { + continue + } + return trimmed + } + return "Untitled Template" +} + +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) +} diff --git a/internal/service/document_service/document_service.go b/internal/service/document_service/document_service.go new file mode 100644 index 0000000..9abe0c1 --- /dev/null +++ b/internal/service/document_service/document_service.go @@ -0,0 +1,53 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/attachment_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/document_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/document_version_repo" + doc_yjs_snapshot_repo "github.com/XingfenD/yoresee_doc/internal/repository/document_yjs_snapshot_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/knowledge_base_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/template_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "gorm.io/gorm" +) + +type DocumentService struct { + db *gorm.DB + documentRepo *document_repo.DocumentRepository + userRepo *user_repo.UserRepository + kbRepo *knowledge_base_repo.KnowledgeBaseRepository + docVersionRepo *document_version_repo.DocumentVersionRepository + snapshotRepo *doc_yjs_snapshot_repo.DocumentYjsSnapshotRepository + templateRepo *template_repo.TemplateRepository + attachmentRepo *attachment_repo.AttachmentRepository +} + +func NewDocumentService(repos *repository.Repositories) *DocumentService { + return &DocumentService{ + db: repos.DB, + documentRepo: repos.Document, + userRepo: repos.User, + kbRepo: repos.KnowledgeBase, + docVersionRepo: repos.DocumentVersion, + snapshotRepo: repos.DocumentYjsSnapshot, + templateRepo: repos.Template, + attachmentRepo: repos.Attachment, + } +} + +var DocumentSvc *DocumentService + +func (s *DocumentService) DeleteDocument(id int64) error { + return s.documentRepo.Delete(id).Exec() +} + +func (s *DocumentService) DeleteDocumentByExternalID(ctx context.Context, externalID string) error { + docID, err := s.documentRepo.GetIDByExternalID(externalID).Exec(ctx) + if err != nil { + return err + } + return s.DeleteDocument(docID) +} diff --git a/internal/service/document_service/document_version.go b/internal/service/document_service/document_version.go new file mode 100644 index 0000000..c894f36 --- /dev/null +++ b/internal/service/document_service/document_version.go @@ -0,0 +1,84 @@ +package document_service + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" +) + +func (s *DocumentService) ListDocumentVersions(ctx context.Context, documentExternalID string, page, pageSize int32) ([]*dto.DocumentVersionResponse, int64, error) { + if s == nil || strings.TrimSpace(documentExternalID) == "" { + return nil, 0, status.GenErrWithCustomMsg(status.StatusParamError, "invalid list document versions request") + } + + p := int(page) + if p <= 0 { + p = 1 + } + ps := int(pageSize) + if ps <= 0 { + ps = 20 + } + if ps > 100 { + ps = 100 + } + offset := (p - 1) * ps + + documentID, err := s.documentRepo.GetIDByExternalID(documentExternalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query document id for list versions failed, document_external_id=%s, err=%+v", documentExternalID, err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusReadDBError, "query document failed") + } + if documentID <= 0 { + return nil, 0, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document not found") + } + + items, total, err := s.docVersionRepo.ListByDocumentID(documentID, offset, ps) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] list document versions failed, document_external_id=%s, document_id=%d, err=%+v", documentExternalID, documentID, err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusReadDBError, "list document versions failed") + } + + resp := make([]*dto.DocumentVersionResponse, 0, len(items)) + for _, item := range items { + resp = append(resp, &dto.DocumentVersionResponse{ + Version: item.Version, + Title: item.Title, + ChangeSummary: item.ChangeSummary, + CreatedAt: item.CreatedAt, + }) + } + return resp, total, nil +} + +func (s *DocumentService) GetDocumentVersionContent(ctx context.Context, documentExternalID string, version int32) (*dto.DocumentVersionResponse, error) { + if s == nil || strings.TrimSpace(documentExternalID) == "" || version <= 0 { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "invalid get document version request") + } + + documentID, err := s.documentRepo.GetIDByExternalID(documentExternalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query document id for get version failed, document_external_id=%s, err=%+v", documentExternalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "query document failed") + } + if documentID <= 0 { + return nil, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document not found") + } + + item, err := s.docVersionRepo.GetByDocumentIDAndVersion(documentID, int(version)) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] get document version failed, document_external_id=%s, document_id=%d, version=%d, err=%+v", documentExternalID, documentID, version, err) + return nil, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document version not found") + } + + return &dto.DocumentVersionResponse{ + Version: item.Version, + Title: item.Title, + Content: item.Content, + ChangeSummary: item.ChangeSummary, + CreatedAt: item.CreatedAt, + }, nil +} diff --git a/internal/service/document_service/get_by_external_id.go b/internal/service/document_service/get_by_external_id.go new file mode 100644 index 0000000..d9e380d --- /dev/null +++ b/internal/service/document_service/get_by_external_id.go @@ -0,0 +1,19 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" +) + +func (s *DocumentService) GetDocumentByExternalID(ctx context.Context, externalID string) (*dto.DocumentResponse, error) { + docModel, err := s.documentRepo.GetByExternalID(externalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] GetDocumentByExternalID failed, external_id=%s, err=%+v", externalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document not found") + } + + return dto.NewDocumentResponseFromModel(docModel), nil +} diff --git a/internal/service/document_service/get_settings.go b/internal/service/document_service/get_settings.go new file mode 100644 index 0000000..da51233 --- /dev/null +++ b/internal/service/document_service/get_settings.go @@ -0,0 +1,28 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" +) + +func (s *DocumentService) GetDocumentSettings(ctx context.Context, req *dto.GetDocumentSettingsRequest) (*dto.DocumentSettingsResponse, error) { + if req == nil || req.ExternalID == "" { + return nil, status.StatusParamError + } + + // Settings page expects real-time value after save/update, so read from DB directly + // to avoid returning a stale cached model. + docModel, err := s.documentRepo.GetByExternalID(req.ExternalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] GetDocumentSettings failed, external_id=%s, err=%+v", req.ExternalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "document not found") + } + + // TODO: merge fields from document_setting table when that table is introduced. + return &dto.DocumentSettingsResponse{ + IsPublic: docModel.IsPublic, + }, nil +} diff --git a/internal/service/document_service/get_template.go b/internal/service/document_service/get_template.go new file mode 100644 index 0000000..18e214a --- /dev/null +++ b/internal/service/document_service/get_template.go @@ -0,0 +1,51 @@ +package document_service + +import ( + "errors" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/status" + "gorm.io/gorm" +) + +func (s *DocumentService) GetTemplateByID(templateID int64) (*dto.TemplateResponse, error) { + if templateID <= 0 { + return nil, status.StatusParamError + } + + tpl, err := s.templateRepo.GetByID(templateID).Exec() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "template not found") + } + return nil, status.StatusReadDBError + } + if tpl == nil { + return nil, status.GenErrWithCustomMsg(status.StatusParamError, "template not found") + } + + kbExternalID := "" + if tpl.KnowledgeBaseID != nil { + kbs, err := s.kbRepo.MGetKnowledgeBaseByIDs([]int64{*tpl.KnowledgeBaseID}).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + if len(kbs) > 0 { + kbExternalID = kbs[0].ExternalID + } + } + + return &dto.TemplateResponse{ + ID: tpl.ID, + Name: tpl.Name, + Description: tpl.Description, + Content: tpl.Content, + Type: doc_type_mapper.FromModelType(tpl.DocumentType), + Scope: tpl.Scope, + KnowledgeBaseExternalID: kbExternalID, + Tags: tpl.Tags, + CreatedAt: tpl.CreatedAt, + UpdatedAt: tpl.UpdatedAt, + }, nil +} diff --git a/internal/service/document_service/get_type.go b/internal/service/document_service/get_type.go new file mode 100644 index 0000000..733d396 --- /dev/null +++ b/internal/service/document_service/get_type.go @@ -0,0 +1,16 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func (s *DocumentService) GetDocumentTypeByExternalID(ctx context.Context, externalID string) (model.DocumentType, error) { + docModel, err := s.documentRepo.GetByExternalID(externalID).Exec(ctx) + if err != nil { + return "", status.StatusDocumentNotFound + } + return docModel.Type, nil +} diff --git a/internal/service/document_service/list_doc_with_children.go b/internal/service/document_service/list_doc_with_children.go new file mode 100644 index 0000000..d538c42 --- /dev/null +++ b/internal/service/document_service/list_doc_with_children.go @@ -0,0 +1,162 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" + + internal_dto "github.com/XingfenD/yoresee_doc/internal/service/dto" +) + +func (s *DocumentService) listDocuments(ctx context.Context, req *internal_dto.DocumentsListReq) ([]*dto.DocumentMetaResponse, int64, error) { + listOp, err := s.buildListDocumentsOperation(req) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] buildListDocumentsOperation failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build document list operation failed") + } + models, total, err := listOp.ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] list documents failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "list documents failed") + } + + // if children is not needed or depth less than 1, return directly + if req.Options == nil || !req.Options.IncludeChildren || (req.Options.Depth != nil && *req.Options.Depth < 1) { + return s.buildDocumentTree(models, nil), total, nil + } + + parentIDs := make([]int64, len(models)) + for i := range models { + parentIDs[i] = models[i].ID + } + + getOp := s.getDocumentsWithDescendants(parentIDs). + WithDirectoryOnly(req.DirectoryOnly) + if req.Options != nil { + getOp = getOp.WithDepth(req.Options.Depth) + } + + allDescendants, err := getOp.Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] getDocumentsWithDescendants failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "list document descendants failed") + } + + docs := s.buildDocumentTree(models, allDescendants) + return docs, total, nil +} + +func (s *DocumentService) ListDocumentsByExternal(ctx context.Context, req *dto.ListDocumentsByExternalRequest) ([]*dto.DocumentMetaResponse, int64, error) { + var userID *int64 + if req == nil { + return nil, 0, status.StatusInternalParamsError + } + if req.ExternalArgs != nil && req.ExternalArgs.UserExternalID != nil && *req.ExternalArgs.UserExternalID != "" { + id, err := s.userRepo.GetIDByExternalID(*req.ExternalArgs.UserExternalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query user id by external id failed, external_id=%s, err=%+v", *req.ExternalArgs.UserExternalID, err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusUserNotFound, "user not found") + } + userID = &id + } + + var parentID int64 + if req.ExternalArgs != nil && req.ExternalArgs.RootDocumentExternalID != nil && *req.ExternalArgs.RootDocumentExternalID != "" { + doc, err := s.documentRepo.GetByExternalID(*req.ExternalArgs.RootDocumentExternalID).Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query root document failed, external_id=%s, err=%+v", *req.ExternalArgs.RootDocumentExternalID, err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusDocumentNotFound, "root document not found") + } + parentID = doc.ID + } + + var knowledgeID *int64 + if req.ExternalArgs != nil && req.ExternalArgs.KnowledgeExternalID != nil && *req.ExternalArgs.KnowledgeExternalID != "" { + id, err := s.kbRepo.GetIDByExternalID(*req.ExternalArgs.KnowledgeExternalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query knowledge base id failed, external_id=%s, err=%+v", *req.ExternalArgs.KnowledgeExternalID, err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusKnowledgeBaseNotFound, "knowledge base not found") + } + knowledgeID = &id + } + + internalReq := &internal_dto.DocumentsListReq{ + MetaArgs: &internal_dto.DocumentsListMetaArgs{ + UserID: userID, + ParentID: &parentID, // default to root + KnowledgeID: knowledgeID, + }, + ListDocumentsBaseArgs: dto.ListDocumentsBaseArgs{ + ListOwnDoc: req.ListOwnDoc, + DirectoryOnly: req.DirectoryOnly, + }, + FilterArgs: req.FilterArgs, + SortArgs: req.SortArgs, + Pagination: req.Pagination, + Options: req.Options, + } + s.applyElasticsearchKeywordFilter(ctx, internalReq) + + return s.listDocuments(ctx, internalReq) +} + +type GetDocumentsWithDescendantsOption struct { + DirectoryOnly bool +} + +type GetDocumentsWithDescendantsOperation struct { + svc *DocumentService + parentIDs []int64 + depth *int + + directoryOnly bool +} + +func (s *DocumentService) getDocumentsWithDescendants(parentIDs []int64) *GetDocumentsWithDescendantsOperation { + return &GetDocumentsWithDescendantsOperation{ + svc: s, + parentIDs: parentIDs, + } +} + +func (op *GetDocumentsWithDescendantsOperation) WithDepth(depth *int) *GetDocumentsWithDescendantsOperation { + op.depth = depth + return op +} + +func (op *GetDocumentsWithDescendantsOperation) WithDirectoryOnly(with bool) *GetDocumentsWithDescendantsOperation { + op.directoryOnly = with + return op +} + +func (op *GetDocumentsWithDescendantsOperation) Exec(ctx context.Context) ([]*model.Document, error) { + if len(op.parentIDs) == 0 { + return []*model.Document{}, nil + } + + allDocs := make([]*model.Document, 0) + seen := make(map[int64]bool) + + for _, rootParentID := range op.parentIDs { + docs, err := op.svc.documentRepo.GetSubtree(rootParentID). + WithDepth(op.depth). + WithDirectoryOnly(op.directoryOnly). + Exec(ctx) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] get subtree failed, root_parent_id=%d, err=%+v", rootParentID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "get document subtree failed") + } + + for _, doc := range docs { + if !seen[doc.ID] { + seen[doc.ID] = true + allDocs = append(allDocs, doc) + } + } + } + + return allDocs, nil +} diff --git a/internal/service/document_service/list_recent_template.go b/internal/service/document_service/list_recent_template.go new file mode 100644 index 0000000..7fc9014 --- /dev/null +++ b/internal/service/document_service/list_recent_template.go @@ -0,0 +1,90 @@ +package document_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func (s *DocumentService) ListRecentTemplates(req *dto.ListRecentTemplatesRequest) ([]*dto.TemplateResponse, int64, error) { + if req == nil || req.UserExternalID == "" { + return nil, 0, status.StatusInternalParamsError + } + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return nil, 0, status.StatusUserNotFound + } + + // TODO: filter the recent templates by kb + recentRecords, total, err := s.templateRepo.ListRecentTemplates(userID). + WithTimeRange(req.StartTime, req.EndTime). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + if len(recentRecords) == 0 { + return []*dto.TemplateResponse{}, total, nil + } + + tplIDs := make([]int64, 0, len(recentRecords)) + for _, r := range recentRecords { + tplIDs = append(tplIDs, r.TemplateID) + } + + tplModels, err := s.templateRepo.MGetByIDs(tplIDs).Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + tplMap := make(map[int64]*model.Template, len(tplModels)) + for _, tpl := range tplModels { + tplMap[tpl.ID] = tpl + } + + // map knowledge_base external ids + kbExternalIDMap := make(map[int64]string) + kbIDs := make([]int64, 0) + for _, tpl := range tplModels { + if tpl.KnowledgeBaseID == nil || *tpl.KnowledgeBaseID == 0 { + continue + } + kbIDs = append(kbIDs, *tpl.KnowledgeBaseID) + } + if len(kbIDs) > 0 { + kbs, err := s.kbRepo.MGetKnowledgeBaseByIDs(kbIDs).Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + for _, kb := range kbs { + kbExternalIDMap[kb.ID] = kb.ExternalID + } + } + + resp := make([]*dto.TemplateResponse, 0, len(recentRecords)) + for _, record := range recentRecords { + tpl := tplMap[record.TemplateID] + if tpl == nil { + continue + } + kbExternalID := "" + if tpl.KnowledgeBaseID != nil { + kbExternalID = kbExternalIDMap[*tpl.KnowledgeBaseID] + } + resp = append(resp, &dto.TemplateResponse{ + ID: tpl.ID, + Name: tpl.Name, + Description: tpl.Description, + Content: tpl.Content, + Type: doc_type_mapper.FromModelType(tpl.DocumentType), + Scope: tpl.Scope, + KnowledgeBaseExternalID: kbExternalID, + Tags: tpl.Tags, + CreatedAt: tpl.CreatedAt, + UpdatedAt: tpl.UpdatedAt, + }) + } + + return resp, total, nil +} diff --git a/internal/service/document_service/list_template.go b/internal/service/document_service/list_template.go new file mode 100644 index 0000000..dc88c9f --- /dev/null +++ b/internal/service/document_service/list_template.go @@ -0,0 +1,182 @@ +package document_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/mapper/template_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/model" + internal_dto "github.com/XingfenD/yoresee_doc/internal/service/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/bytedance/gg/gslice" + "github.com/sirupsen/logrus" +) + +type templateListOperation struct { + req *internal_dto.TemplateListReq + srvc *DocumentService +} + +func (s *DocumentService) listTemplates(req *internal_dto.TemplateListReq) *templateListOperation { + return &templateListOperation{ + req: req, + srvc: s, + } +} + +func (op *templateListOperation) ExecWithTotal() ([]*dto.TemplateResponse, int64, error) { + listOp, err := op.srvc.buildListTemplateOperation(op.req) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] buildListTemplateOperation failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build template list operation failed") + } + templates, total, err := listOp.ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] list templates failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "list templates failed") + } + + kbExternalIDMap := make(map[int64]string) + kbIDs := make([]int64, 0) + for _, tmpl := range templates { + if tmpl.KnowledgeBaseID == nil || *tmpl.KnowledgeBaseID == 0 { + continue + } + kbIDs = append(kbIDs, *tmpl.KnowledgeBaseID) + } + kbIDs = gslice.Uniq(kbIDs) + if len(kbIDs) > 0 { + kbs, err := op.srvc.kbRepo.MGetKnowledgeBaseByIDs(kbIDs).Exec() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] list templates get knowledge bases failed: %+v", err) + return nil, 0, status.StatusReadDBError + } + for _, kb := range kbs { + kbExternalIDMap[kb.ID] = kb.ExternalID + } + } + + resp := make([]*dto.TemplateResponse, 0, len(templates)) + for _, tmpl := range templates { + kbExternalID := "" + if tmpl.KnowledgeBaseID != nil { + kbExternalID = kbExternalIDMap[*tmpl.KnowledgeBaseID] + } + resp = append(resp, &dto.TemplateResponse{ + ID: tmpl.ID, + Name: tmpl.Name, + Description: tmpl.Description, + Content: tmpl.Content, + Type: doc_type_mapper.FromModelType(tmpl.DocumentType), + Scope: tmpl.Scope, + KnowledgeBaseExternalID: kbExternalID, + Tags: tmpl.Tags, + CreatedAt: tmpl.CreatedAt, + UpdatedAt: tmpl.UpdatedAt, + }) + } + + return resp, total, nil +} + +func (s *DocumentService) ListTemplatesByExternal(req *dto.TemplateListByExternalRequest) ([]*dto.TemplateResponse, int64, error) { + var creatorID *int64 + if req.CreatorExternalID != "" { + id, err := s.userRepo.GetIDByExternalID(req.CreatorExternalID).Exec() + if err != nil { + return nil, 0, status.StatusUserNotFound + } + creatorID = &id + } + + internalReq, err := buildTemplateListReqFromExternal(req, creatorID) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] buildTemplateListReqFromExternal failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build template list request failed") + } + list, total, err := s.listTemplates(internalReq).ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] ListTemplatesByExternal failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "list templates failed") + } + return list, total, nil +} + +func buildTemplateListReqFromExternal(req *dto.TemplateListByExternalRequest, creatorID *int64) (*internal_dto.TemplateListReq, error) { + filter := &dto.TemplateListFilterArgs{} + if req.FilterArgs != nil { + *filter = *req.FilterArgs + } + + return &internal_dto.TemplateListReq{ + CreatorID: creatorID, + FilterArgs: filter, + SortArgs: req.SortArgs, + Pagination: req.Pagination, + }, nil +} + +func (s *DocumentService) buildListTemplateOperation(req *internal_dto.TemplateListReq) (*templateListOperationBuilder, error) { + builder := &templateListOperationBuilder{ + req: req, + srvc: s, + } + return builder, nil +} + +type templateListOperationBuilder struct { + req *internal_dto.TemplateListReq + srvc *DocumentService +} + +func (b *templateListOperationBuilder) ExecWithTotal() ([]*model.Template, int64, error) { + op := b.srvc.templateRepo.List(&model.Template{}) + + if b.req.CreatorID != nil { + op = op.WithUserID(b.req.CreatorID) + } + + if b.req.FilterArgs != nil { + if b.req.FilterArgs.TargetContainer != nil { + if template_container_mapper.RequiresKnowledgeBaseID(*b.req.FilterArgs.TargetContainer) && + (b.req.FilterArgs.KnowledgeBaseID == nil || *b.req.FilterArgs.KnowledgeBaseID == "") { + return nil, 0, status.StatusParamError + } + scope := template_container_mapper.ToScope(*b.req.FilterArgs.TargetContainer) + op = op.WithScope(&scope) + } + if b.req.FilterArgs.KnowledgeBaseID != nil && *b.req.FilterArgs.KnowledgeBaseID != "" { + kbID, err := b.srvc.kbRepo.GetIDByExternalID(*b.req.FilterArgs.KnowledgeBaseID).Exec() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] Get knowledge base id for template list failed, external_id=%s, err=%+v", *b.req.FilterArgs.KnowledgeBaseID, err) + return nil, 0, status.StatusKnowledgeBaseNotFound + } + op = op.WithKnowledgeBaseID(&kbID) + } + if b.req.FilterArgs.NameKeyword != nil { + op = op.WithNameKeyword(b.req.FilterArgs.NameKeyword) + } + if b.req.FilterArgs.Type != nil { + modelType := model.DocumentType(doc_type_mapper.ToModelType(*b.req.FilterArgs.Type)) + op = op.WithDocumentType(&modelType) + } + } + + sortField := b.req.SortArgs.Field + sortDesc := b.req.SortArgs.Desc + if sortField == "" { + sortField = "created_at" + sortDesc = true + } + op = op.WithSort(sortField, sortDesc) + + if b.req.Pagination.Page > 0 && b.req.Pagination.PageSize > 0 { + op = op.WithPagination(b.req.Pagination.Page, b.req.Pagination.PageSize) + } + + templates, total, err := op.ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: DocumentService] template repository list failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusReadDBError, "list templates failed") + } + return templates, total, nil +} diff --git a/internal/service/document_service/recent_document.go b/internal/service/document_service/recent_document.go new file mode 100644 index 0000000..0d920b7 --- /dev/null +++ b/internal/service/document_service/recent_document.go @@ -0,0 +1,73 @@ +package document_service + +import ( + "context" + "time" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func (s *DocumentService) RecordRecentDocument(req *dto.RecordRecentDocumentRequest) error { + if req == nil || req.UserExternalID == "" || req.DocumentExternalID == "" { + return status.StatusInternalParamsError + } + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + + docID, err := s.documentRepo.GetIDByExternalID(req.DocumentExternalID).Exec(context.Background()) + if err != nil || docID == 0 { + return status.StatusDocumentNotFound + } + + if err := s.documentRepo.UpsertRecentDocument(&model.RecentDocument{ + UserID: userID, + DocumentID: docID, + AccessedAt: time.Now(), + }).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +func (s *DocumentService) ListRecentDocuments(req *dto.ListRecentDocumentsRequest) ([]*dto.DocumentResponse, int64, error) { + if req == nil || req.UserExternalID == "" { + return nil, 0, status.StatusInternalParamsError + } + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return nil, 0, status.StatusUserNotFound + } + + recentRecords, total, err := s.documentRepo.ListRecentDocuments(userID). + WithTimeRange(req.StartTime, req.EndTime). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + if len(recentRecords) == 0 { + return []*dto.DocumentResponse{}, total, nil + } + + docIDs := make([]int64, 0, len(recentRecords)) + for _, record := range recentRecords { + docIDs = append(docIDs, record.DocumentID) + } + + docModels, err := s.documentRepo.MGetByIDs(docIDs) + if err != nil { + return nil, 0, status.StatusReadDBError + } + + resp := make([]*dto.DocumentResponse, 0, len(docModels)) + for _, doc := range docModels { + resp = append(resp, dto.NewDocumentResponseFromModel(doc)) + } + + return resp, total, nil +} diff --git a/internal/service/document_service/search_es.go b/internal/service/document_service/search_es.go new file mode 100644 index 0000000..b54d712 --- /dev/null +++ b/internal/service/document_service/search_es.go @@ -0,0 +1,57 @@ +package document_service + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/search" + internal_dto "github.com/XingfenD/yoresee_doc/internal/service/dto" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" +) + +func (s *DocumentService) applyElasticsearchKeywordFilter(ctx context.Context, req *internal_dto.DocumentsListReq) { + if req == nil || req.FilterArgs == nil || req.FilterArgs.TitleKeyword == nil { + return + } + keyword := strings.TrimSpace(*req.FilterArgs.TitleKeyword) + if keyword == "" { + return + } + if config.GlobalConfig == nil || !config.GlobalConfig.Elasticsearch.Enabled { + logrus.Warnf("[Service layer: DocumentService] elasticsearch is disabled, degrade to db keyword search, keyword=%s", keyword) + return + } + if storage.ES == nil { + logrus.Warnf("[Service layer: DocumentService] elasticsearch client is nil, degrade to db keyword search, keyword=%s", keyword) + return + } + + searchReq := search.DocumentSearchRequest{ + Keyword: keyword, + DocType: req.FilterArgs.DocType, + Tags: req.FilterArgs.Tags, + CreateTimeRangeStart: req.FilterArgs.CreateTimeRangeStart, + CreateTimeRangeEnd: req.FilterArgs.CreateTimeRangeEnd, + UpdateTimeRangeStart: req.FilterArgs.UpdateTimeRangeStart, + UpdateTimeRangeEnd: req.FilterArgs.UpdateTimeRangeEnd, + Size: 5000, + ListOwnDoc: req.ListOwnDoc, + } + if req.MetaArgs != nil { + searchReq.UserID = req.MetaArgs.UserID + searchReq.KnowledgeID = req.MetaArgs.KnowledgeID + } + + ids, err := search.SearchDocumentIDs(ctx, searchReq) + if err != nil { + logrus.Warnf("[Service layer: DocumentService] elasticsearch keyword search failed, keyword=%s, err=%+v", keyword, err) + return + } + if len(ids) == 0 { + logrus.Warnf("[Service layer: DocumentService] elasticsearch keyword search no hit, degrade to db keyword search, keyword=%s", keyword) + return + } + req.SearchDocIDs = ids +} diff --git a/internal/service/document_service/snap_shot.go b/internal/service/document_service/snap_shot.go new file mode 100644 index 0000000..4596234 --- /dev/null +++ b/internal/service/document_service/snap_shot.go @@ -0,0 +1,104 @@ +package document_service + +import ( + "context" + "errors" + + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/cache" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func (s *DocumentService) SaveDocumentYjsSnapshot(ctx context.Context, docExternalID string, state []byte) error { + if len(state) == 0 { + return status.StatusParamError + } + docID, err := s.documentRepo.GetIDByExternalID(docExternalID).Exec(ctx) + if err != nil { + return status.StatusDocumentNotFound + } + + if err := s.snapshotRepo.Save(docID, state).Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] SaveDocumentYjsSnapshot failed, doc_external_id=%s, doc_id=%d, err=%+v", docExternalID, docID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "save document snapshot failed") + } + return nil +} + +func (s *DocumentService) SaveDocumentSnapshotAndContent(ctx context.Context, docExternalID string, state []byte, content string) (bool, error) { + if len(state) == 0 { + return false, status.StatusParamError + } + + contentChanged := false + cacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, docExternalID) + err := cache.DoubleDelete( + context.Background(), + func() error { + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + docModel, err := s.documentRepo.GetByExternalID(docExternalID).WithTx(tx).Exec(ctx) + if err != nil { + return status.StatusDocumentNotFound + } + + if err := s.snapshotRepo.Save(docModel.ID, state).WithTx(tx).Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] SaveDocumentSnapshotAndContent save snapshot failed, doc_external_id=%s, doc_id=%d, err=%+v", docExternalID, docModel.ID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "save document snapshot failed") + } + + if docModel.Content == content { + return nil + } + + versionModel := &model.DocumentVersion{ + DocumentID: docModel.ID, + Title: docModel.Title, + Content: docModel.Content, + UserID: docModel.UserID, + ChangeSummary: "Sync collab snapshot", + } + if err := s.docVersionRepo.Create(versionModel).WithTx(tx).Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] SaveDocumentSnapshotAndContent create version failed, doc_external_id=%s, doc_id=%d, err=%+v", docExternalID, docModel.ID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "create document version failed") + } + + nextDoc := &model.Document{ + ID: docModel.ID, + Content: content, + } + if err := s.documentRepo.Update(nextDoc).WithTx(tx).UpdateContent().Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] SaveDocumentSnapshotAndContent update content failed, doc_external_id=%s, doc_id=%d, err=%+v", docExternalID, docModel.ID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "update document content failed") + } + contentChanged = true + return nil + }) + }, + cacheKey, + ) + if err != nil { + return false, err + } + return contentChanged, nil +} + +func (s *DocumentService) GetDocumentYjsSnapshot(ctx context.Context, docExternalID string) ([]byte, error) { + docID, err := s.documentRepo.GetIDByExternalID(docExternalID).Exec(ctx) + if err != nil { + return nil, status.StatusDocumentNotFound + } + + snapshot, err := s.snapshotRepo.GetByDocID(docID).Exec() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + logrus.Errorf("[Service layer: DocumentService] GetDocumentYjsSnapshot failed, doc_external_id=%s, doc_id=%d, err=%+v", docExternalID, docID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "get document snapshot failed") + } + return snapshot.YjsState, nil +} diff --git a/internal/service/document_service/update.go b/internal/service/document_service/update.go new file mode 100644 index 0000000..e0d5801 --- /dev/null +++ b/internal/service/document_service/update.go @@ -0,0 +1,142 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/cache" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func (s *DocumentService) Update(ctx context.Context, req *dto.UpdateDocumentRequest) (bool, error) { + if err := validateUpdateDocumentReq(req); err != nil { + logrus.Errorf("[Service layer: DocumentService] validateUpdateDocumentReq failed, err=%+v", err) + return false, status.GenErrWithCustomMsg(err, "invalid update document request") + } + + var ( + moved bool + oldPath string + newPath string + ) + + cacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, req.ExternalID) + err := cache.DoubleDelete( + context.Background(), + func() error { + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + oldDoc, err := s.documentRepo.GetByExternalID(req.ExternalID).WithTx(tx).Exec(ctx) + if err != nil { + return status.StatusDocumentNotFound + } + oldPath = oldDoc.Path + + docModel := &model.Document{ + ID: oldDoc.ID, + } + op := s.documentRepo.Update(docModel).WithTx(tx) + if req.Content != nil { + docModel.Content = *req.Content + op.UpdateContent() + } + if req.Title != nil { + docModel.Title = *req.Title + } + + var newParentID int64 + if req.ParentExternalID != nil { + parentID, err := s.documentRepo.GetIDByExternalID(*req.ParentExternalID).Exec(ctx) + if err != nil { + return status.StatusDocumentNotFound + } + docModel.ParentID = parentID + newParentID = parentID + moved = true + op = op.UpdateParentID() + } + + // create version + versionModel := &model.DocumentVersion{ + DocumentID: oldDoc.ID, + Title: oldDoc.Title, + Content: oldDoc.Content, + UserID: oldDoc.UserID, + ChangeSummary: "", + } + + if err := s.docVersionRepo.Create(versionModel).WithTx(tx).Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] create document version failed, document_id=%d, err=%+v", oldDoc.ID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "create document version failed") + } + + // update container relation + if req.MoveToContainer != nil { + if !doc_container_mapper.IsSupportedDTOType(*req.MoveToContainer) { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid move_to_container") + } + if doc_container_mapper.RequiresKnowledgeBaseID(*req.MoveToContainer) { + kbID, err := s.kbRepo.GetIDByExternalID(*req.KnowledgeBaseExternalID).Exec() + if err != nil { + return status.StatusKnowledgeBaseNotFound + } + docModel.KnowledgeID = &kbID + } else { + docModel.KnowledgeID = nil + } + docModel.ContainerType = doc_container_mapper.ToModelType(*req.MoveToContainer) + op = op.UpdateKnowledgeID().UpdateContainerType() + } + + if err := op.Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] update document failed, external_id=%s, err=%+v", req.ExternalID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "update document failed") + } + if moved { + if err := s.documentRepo.MoveSubtree(oldDoc.ID, newParentID).WithTx(tx).Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] move subtree failed, document_id=%d, new_parent_id=%d, err=%+v", oldDoc.ID, newParentID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "move document subtree failed") + } + path, err := s.documentRepo.GetPathByIDWithTx(tx, oldDoc.ID) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] query new path failed, document_id=%d, err=%+v", oldDoc.ID, err) + return status.GenErrWithCustomMsg(status.StatusReadDBError, "query document path failed") + } + newPath = path + } + + return nil + }) + }, + cacheKey, + ) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] Update failed, external_id=%s, err=%+v", req.ExternalID, err) + return false, status.GenErrWithCustomMsg(err, "update document failed") + } + + if moved { + if err := s.documentRepo.BumpSubtreeVersionsByPath(ctx, oldPath); err != nil { + logrus.Warnf("bump subtree version failed: %v", err) + return true, nil + } + if newPath != "" && newPath != oldPath { + if err := s.documentRepo.BumpSubtreeVersionsByPath(ctx, newPath); err != nil { + logrus.Warnf("bump subtree version failed: %v", err) + return true, nil + } + } + } + + if err := domain_event.PublishDocumentUpsertEvent(ctx, req.ExternalID); err != nil { + logrus.Warnf("[Service layer: DocumentService] publish search sync event failed, external_id=%s, err=%+v", req.ExternalID, err) + } + + return true, nil +} diff --git a/internal/service/document_service/update_meta.go b/internal/service/document_service/update_meta.go new file mode 100644 index 0000000..b6c991c --- /dev/null +++ b/internal/service/document_service/update_meta.go @@ -0,0 +1,81 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/cache" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func (s *DocumentService) UpdateDocumentMeta(ctx context.Context, req *dto.UpdateDocumentMetaRequest) (bool, error) { + if err := validateUpdateDocumentMetaReq(req); err != nil { + logrus.Errorf("[Service layer: DocumentService] validateUpdateDocumentMetaReq failed, err=%+v", err) + return false, status.GenErrWithCustomMsg(err, "invalid update document meta request") + } + + cacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, req.ExternalID) + err := cache.DoubleDelete( + context.Background(), + func() error { + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + oldDoc, err := s.documentRepo.GetByExternalID(req.ExternalID).WithTx(tx).Exec(ctx) + if err != nil { + return status.StatusDocumentNotFound + } + + docModel := &model.Document{ID: oldDoc.ID} + op := s.documentRepo.Update(docModel).WithTx(tx) + + if req.Title != nil { + docModel.Title = *req.Title + op.UpdateTitle() + } + if req.Summary != nil { + docModel.Summary = *req.Summary + op.UpdateSummary() + } + if req.Tags != nil { + docModel.Tags = *req.Tags + op.UpdateTags() + } + + if err := op.Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] update document meta failed, external_id=%s, err=%+v", req.ExternalID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "update document meta failed") + } + + versionModel := &model.DocumentVersion{ + DocumentID: oldDoc.ID, + Title: oldDoc.Title, + Content: oldDoc.Content, + UserID: oldDoc.UserID, + ChangeSummary: "Update document meta", + } + if err := s.docVersionRepo.Create(versionModel).WithTx(tx).Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] create document version failed, document_id=%d, err=%+v", oldDoc.ID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "create document version failed") + } + + return nil + }) + }, + cacheKey, + ) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] UpdateDocumentMeta failed, external_id=%s, err=%+v", req.ExternalID, err) + return false, status.GenErrWithCustomMsg(err, "update document meta failed") + } + + if err := domain_event.PublishDocumentUpsertEvent(ctx, req.ExternalID); err != nil { + logrus.Warnf("[Service layer: DocumentService] publish search sync event failed, external_id=%s, err=%+v", req.ExternalID, err) + } + + return true, nil +} diff --git a/internal/service/document_service/update_settings.go b/internal/service/document_service/update_settings.go new file mode 100644 index 0000000..06a075c --- /dev/null +++ b/internal/service/document_service/update_settings.go @@ -0,0 +1,59 @@ +package document_service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/cache" + "github.com/XingfenD/yoresee_doc/pkg/key" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func (s *DocumentService) UpdateDocumentSettings(ctx context.Context, req *dto.UpdateDocumentSettingsRequest) (*dto.DocumentSettingsResponse, error) { + if req == nil || req.ExternalID == "" { + return nil, status.StatusParamError + } + + cacheKey := key.KeyModelByExternalID(key.KeyObjectTypeEnum_Doc, req.ExternalID) + err := cache.DoubleDelete( + context.Background(), + func() error { + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + oldDoc, err := s.documentRepo.GetByExternalID(req.ExternalID).WithTx(tx).Exec(ctx) + if err != nil { + return status.StatusDocumentNotFound + } + + docModel := &model.Document{ + ID: oldDoc.ID, + IsPublic: req.IsPublic, + } + + if err := s.documentRepo.Update(docModel).WithTx(tx).UpdateIsPublic().Exec(); err != nil { + logrus.Errorf("[Service layer: DocumentService] update document settings failed, external_id=%s, err=%+v", req.ExternalID, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "update document settings failed") + } + + return nil + }) + }, + cacheKey, + ) + if err != nil { + logrus.Errorf("[Service layer: DocumentService] UpdateDocumentSettings failed, external_id=%s, err=%+v", req.ExternalID, err) + return nil, status.GenErrWithCustomMsg(err, "update document settings failed") + } + + if err := domain_event.PublishDocumentUpsertEvent(ctx, req.ExternalID); err != nil { + logrus.Warnf("[Service layer: DocumentService] publish search sync event failed, external_id=%s, err=%+v", req.ExternalID, err) + } + + return &dto.DocumentSettingsResponse{ + IsPublic: req.IsPublic, + }, nil +} diff --git a/internal/service/document_service/update_template_settings.go b/internal/service/document_service/update_template_settings.go new file mode 100644 index 0000000..105aebd --- /dev/null +++ b/internal/service/document_service/update_template_settings.go @@ -0,0 +1,74 @@ +package document_service + +import ( + "context" + "errors" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "gorm.io/gorm" +) + +func (s *DocumentService) UpdateTemplateSettings(ctx context.Context, req *dto.UpdateTemplateSettingsRequest) error { + if err := validateUpdateTemplateSettingsReq(req); err != nil { + return status.GenErrWithCustomMsg(err, "invalid update template settings request") + } + _ = ctx + + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + + tpl, err := s.templateRepo.GetByID(req.TemplateID).Exec() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return status.GenErrWithCustomMsg(status.StatusParamError, "template not found") + } + return status.StatusReadDBError + } + if tpl == nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "template not found") + } + if tpl.UserID != userID { + return status.StatusPermissionDenied + } + + updateOp := s.templateRepo.Update(tpl) + + if req.Name != nil { + nextName := strings.TrimSpace(*req.Name) + if nextName == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "template name is empty") + } + if len([]rune(nextName)) > 100 { + nextName = truncateRunes(nextName, 100) + } + tpl.Name = nextName + updateOp.UpdateName() + } + + if req.Description != nil { + tpl.Description = strings.TrimSpace(*req.Description) + updateOp.UpdateDescription() + } + + if req.IsPublic != nil { + tpl.IsPublic = *req.IsPublic + if *req.IsPublic { + tpl.Scope = "system" + } else if tpl.KnowledgeBaseID != nil { + tpl.Scope = "knowledge_base" + } else { + tpl.Scope = "private" + } + updateOp.UpdateScope().UpdateIsPublic() + } + + if err := updateOp.Exec(); err != nil { + return status.StatusWriteDBError + } + + return nil +} diff --git a/internal/service/document_service/validate.go b/internal/service/document_service/validate.go new file mode 100644 index 0000000..d4cb770 --- /dev/null +++ b/internal/service/document_service/validate.go @@ -0,0 +1,117 @@ +package document_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + doc_container_mapper "github.com/XingfenD/yoresee_doc/internal/mapper/doc_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/mapper/template_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func validateCreateDocumentReq(req *dto.CreateDocumentRequest) error { + if req == nil { + return status.StatusInternalParamsError + } + if !doc_container_mapper.IsSupportedDTOType(req.ContainerType) { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid document container type") + } + if req.ContainerType == dto.ContainerType_Own && req.KnowledgeExternalID != nil { + return status.GenErrWithCustomMsg(status.StatusInternalParamsError, "KnowledgeExternalID not nil when container_type is own") + } + if req.ContainerType == dto.ContainerType_KnowledgeBase && req.KnowledgeExternalID == nil { + return status.GenErrWithCustomMsg(status.StatusInternalParamsError, "KnowledgeExternalID is nil when container_type is knowledge_base") + } + + if !doc_type_mapper.IsSupportedDTOType(req.Type) { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid document type") + } + + return nil +} + +func validateUpdateDocumentReq(req *dto.UpdateDocumentRequest) error { + if req == nil { + return status.StatusInternalParamsError + } + if req.ExternalID == "" { + return status.GenErrWithCustomMsg(status.StatusInternalParamsError, "external_id is zero value") + } + + if req.Content == nil && req.KnowledgeBaseExternalID == nil && req.ParentExternalID == nil && req.Title == nil && req.MoveToContainer == nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "no update field") + } + + if req.MoveToContainer != nil { + if !doc_container_mapper.IsSupportedDTOType(*req.MoveToContainer) { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid move_to_container") + } + if *req.MoveToContainer == dto.ContainerType_Own && req.KnowledgeBaseExternalID != nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "KnowledgeBaseExternalID not nil when moving to own") + } + if *req.MoveToContainer == dto.ContainerType_KnowledgeBase && req.KnowledgeBaseExternalID == nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "KnowledgeBaseExternalID is nil when moving to knowledge_base") + } + } + if req.MoveToContainer == nil && req.KnowledgeBaseExternalID != nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "MoveToContainer is nil when KnowledgeBaseExternalID provided") + } + + if req.MoveToContainer != nil && *req.MoveToContainer == dto.ContainerType_Own && req.ParentExternalID != nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "ParentExternalID not nil when moving to own") + } + + return nil +} + +func validateUpdateDocumentMetaReq(req *dto.UpdateDocumentMetaRequest) error { + if req == nil { + return status.StatusInternalParamsError + } + if req.ExternalID == "" { + return status.GenErrWithCustomMsg(status.StatusInternalParamsError, "external_id is zero value") + } + if req.Title == nil && req.Summary == nil && req.Tags == nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "no update field") + } + return nil +} + +func validateCreateTemplateReq(req *dto.CreateTemplateRequest) error { + if req == nil { + return status.StatusInternalParamsError + } + if req.UserExternalID == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "user external id is empty") + } + if req.TemplateContent == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "template content is empty") + } + if req.Type != "" && !doc_type_mapper.IsSupportedDTOType(req.Type) { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid template type") + } + if !template_container_mapper.IsSupportedDTOType(req.TargetContainer) { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid template container") + } + if template_container_mapper.RequiresKnowledgeBaseID(req.TargetContainer) { + if req.KnowledgeBaseExternalID == nil || *req.KnowledgeBaseExternalID == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "knowledge_base_id is empty") + } + } + return nil +} + +func validateUpdateTemplateSettingsReq(req *dto.UpdateTemplateSettingsRequest) error { + if req == nil { + return status.StatusInternalParamsError + } + if req.UserExternalID == "" { + return status.GenErrWithCustomMsg(status.StatusParamError, "user external id is empty") + } + if req.TemplateID <= 0 { + return status.GenErrWithCustomMsg(status.StatusParamError, "template id is invalid") + } + if req.Name == nil && req.Description == nil && req.IsPublic == nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "no update field") + } + return nil +} diff --git a/internal/service/dto/service_internal_dto.go b/internal/service/dto/service_internal_dto.go new file mode 100644 index 0000000..d5ca256 --- /dev/null +++ b/internal/service/dto/service_internal_dto.go @@ -0,0 +1,33 @@ +package dto + +import "github.com/XingfenD/yoresee_doc/internal/dto" + +type DocumentsListMetaArgs struct { + UserID *int64 `json:"user_id"` + ParentID *int64 `json:"parent_id"` + KnowledgeID *int64 `json:"knowledge_id"` +} + +type DocumentsListReq struct { + MetaArgs *DocumentsListMetaArgs `json:"meta_args"` + dto.ListDocumentsBaseArgs + FilterArgs *dto.DocumentsListFilterArgs `json:"filter_args"` + SearchDocIDs []int64 `json:"search_doc_ids"` + SortArgs dto.SortArgs `json:"sort_args"` + Pagination dto.Pagination `json:"pagination"` + Options *dto.RecursiveOptions `json:"options"` +} + +type KnowledgeBaseListReq struct { + CreatorID *int64 `json:"creator_id"` + FilterArgs *dto.KnowledgeBaseListFilterArgs `json:"filter_args"` + SortArgs dto.SortArgs `json:"sort_args"` + Pagination dto.Pagination `json:"pagination"` +} + +type TemplateListReq struct { + CreatorID *int64 `json:"creator_id"` + FilterArgs *dto.TemplateListFilterArgs `json:"filter_args"` + SortArgs dto.SortArgs `json:"sort_args"` + Pagination dto.Pagination `json:"pagination"` +} diff --git a/internal/service/init.go b/internal/service/init.go new file mode 100644 index 0000000..ac03718 --- /dev/null +++ b/internal/service/init.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/service/auth_service" + "github.com/XingfenD/yoresee_doc/internal/service/comment_service" + "github.com/XingfenD/yoresee_doc/internal/service/config_service" + "github.com/XingfenD/yoresee_doc/internal/service/document_service" + svc_iface "github.com/XingfenD/yoresee_doc/internal/service/interface" + "github.com/XingfenD/yoresee_doc/internal/service/invitation_service" + "github.com/XingfenD/yoresee_doc/internal/service/knowledge_base_service" + "github.com/XingfenD/yoresee_doc/internal/service/membership_service" + "github.com/XingfenD/yoresee_doc/internal/service/mq_service" + "github.com/XingfenD/yoresee_doc/internal/service/notification_service" + "github.com/XingfenD/yoresee_doc/internal/service/setting_service" + "github.com/XingfenD/yoresee_doc/internal/service/user_service" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/pkg/mq" + "github.com/sirupsen/logrus" +) + +func RegisterTopicConsumer(h svc_iface.TopicConsumer) error { + return mq_service.MQSvc.Consume( + context.Background(), + mq.BackendRedis, + mq.ConsumeOptions{ + Topic: h.Topic(), + Mode: mq.ConsumeModeFanout, + AutoAck: true, + OnError: mq.ErrorActionDrop, + }, + func(ctx context.Context, message mq.Message) error { + return h.Consume()(message.Body) + }, + ) +} + +func Init(cfg *config.Config, repos *repository.Repositories) error { + auth_service.AuthSvc = auth_service.NewAuthService(repos) + user_service.UserSvc = user_service.NewUserService(repos) + document_service.DocumentSvc = document_service.NewDocumentService(repos) + comment_service.CommentSvc = comment_service.NewCommentService(repos) + knowledge_base_service.KnowledgeBaseSvc = knowledge_base_service.NewKnowledgeBaseService(repos) + membership_service.MembershipSvc = membership_service.NewMembershipService(repos) + invitation_service.InvitationSvc = invitation_service.NewInvitationService(repos) + notification_service.NotificationSvc = notification_service.NewNotificationService(repos) + + config_service.InitConfigService() + setting_service.InitSettingService() + if err := InitMQTopicConsumer(); err != nil { + logrus.Errorf("[Service layer] InitMQTopicConsumer failed, err=%+v", err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "initialize message queue topic consumer failed") + } + + return nil +} + +func InitMQTopicConsumer() error { + return nil +} diff --git a/internal/service/interface/mq_handler.go b/internal/service/interface/mq_handler.go new file mode 100644 index 0000000..67d162d --- /dev/null +++ b/internal/service/interface/mq_handler.go @@ -0,0 +1,17 @@ +package svc_iface + +type HandleFunc func(data []byte) error + +type TopicConsumer interface { + Topic() string + Consume() HandleFunc +} + +type TopicProducer interface { + Topic() string +} + +// type MQTopicHandler interface { +// TopicProducer +// TopicConsumer +// } diff --git a/internal/service/invitation_service/invitation_service.go b/internal/service/invitation_service/invitation_service.go new file mode 100644 index 0000000..b7d8ed7 --- /dev/null +++ b/internal/service/invitation_service/invitation_service.go @@ -0,0 +1,237 @@ +package invitation_service + +import ( + "crypto/rand" + "encoding/hex" + "time" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/invitation_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InvitationService struct { + invitationRepo *invitation_repo.InvitationRepository + userRepo *user_repo.UserRepository +} + +func NewInvitationService(repos *repository.Repositories) *InvitationService { + return &InvitationService{ + invitationRepo: repos.Invitation, + userRepo: repos.User, + } +} + +func (s *InvitationService) Generate(userID int64, maxUsedCnt *int64, expiresAt *time.Time, note *string) (*model.Invitation, error) { + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + logrus.Errorf("[Service layer: InvitationService] generate random code failed, err=%+v", err) + return nil, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "generate invitation code failed") + } + code := hex.EncodeToString(bytes) + + invitation := &model.Invitation{ + Code: code, + CreatedBy: userID, + ExpiresAt: expiresAt, + MaxUsedCnt: maxUsedCnt, + Note: note, + } + + if err := s.invitationRepo.Create(invitation).Exec(); err != nil { + return nil, status.StatusWriteDBError + } + return invitation, nil +} + +func (s *InvitationService) ListByCreator(userID int64) ([]model.Invitation, error) { + list, err := s.invitationRepo.List(&model.Invitation{CreatedBy: userID}).Exec() + if err != nil { + logrus.Errorf("[Service layer: InvitationService] ListByCreator failed, user_id=%d, err=%+v", userID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "list invitations failed") + } + return list, nil +} + +func (s *InvitationService) ListInvitations(req *dto.ListInvitationsReq) ([]model.Invitation, int64, error) { + + list, total, err := s.invitationRepo.List(&model.Invitation{}). + WithCreatorID(req.CreatorID). + WithKeyword(req.Keyword). + WithMaxUsedCnt(req.MaxUsedCnt). + WithExpiresAtRange(req.ExpiresAtStart, req.ExpiresAtEnd). + WithCreatedAtRange(req.CreatedAtStart, req.CreatedAtEnd). + WithDisabled(req.Disabled). + WithSort(req.SortArgs.Field, req.SortArgs.Desc). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: InvitationService] ListInvitations failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusReadDBError, "list invitations failed") + } + return list, total, nil +} + +func (s *InvitationService) CreateInvitation(req *dto.CreateInvitationRequest) (*model.Invitation, error) { + if req == nil || req.CreatorExternalID == "" { + return nil, status.StatusParamError + } + // TODO: normal user can't create invitation codes + userID, err := s.userRepo.GetIDByExternalID(req.CreatorExternalID).Exec() + if err != nil { + return nil, status.StatusUserNotFound + } + return s.Generate(userID, req.MaxUsedCnt, req.ExpiresAt, req.Note) +} + +func (s *InvitationService) UpdateInvitation(req *dto.UpdateInvitationRequest) error { + if req == nil || req.Code == "" { + return status.StatusParamError + } + return s.UpdateCode(req.Code, req.ExpiresAt, req.MaxUsedCnt, req.Disabled, req.Note) +} + +func (s *InvitationService) DeleteInvitation(req *dto.DeleteInvitationRequest) error { + if req == nil || req.Code == "" { + return status.StatusParamError + } + inv, err := s.invitationRepo.GetByCode(req.Code).Exec() + if err != nil { + return status.StatusInvitationInvalid + } + if err := s.invitationRepo.Delete(inv.ID).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +func (s *InvitationService) CreateInvitationRecord(record *model.InvitationRecord) error { + if record == nil || record.Code == "" || record.Status == "" { + return status.StatusParamError + } + if record.UsedAt.IsZero() { + record.UsedAt = time.Now() + } + if err := s.invitationRepo.CreateRecord(record).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +func (s *InvitationService) CreateInvitationRecordWithTx(tx *gorm.DB, record *model.InvitationRecord) error { + if record == nil || record.Code == "" || record.Status == "" { + return status.StatusParamError + } + if record.UsedAt.IsZero() { + record.UsedAt = time.Now() + } + if err := s.invitationRepo.CreateRecord(record).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +func (s *InvitationService) ListInvitationRecords(req *dto.ListInvitationRecordsRequest) ([]model.InvitationRecord, int64, error) { + if req == nil { + return nil, 0, status.StatusParamError + } + return s.invitationRepo.ListRecords(). + WithCode(req.Code). + WithStatus(req.Status). + WithUsedAtRange(req.UsedAtStart, req.UsedAtEnd). + WithCreatorID(req.CreatorID). + WithKeyword(req.Keyword). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + ExecWithTotal() +} + +func (s *InvitationService) ValidateAndUse(code string) error { + ok, err := s.invitationRepo.ValidateAndUse(code).Exec() + if err != nil { + return status.StatusWriteDBError + } + if ok { + return nil + } + + invitation, err := s.invitationRepo.GetByCode(code).Exec() + if err != nil { + return status.StatusInvitationInvalid + } + + if invitation.DeletedAt != nil { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code deleted") + } + if invitation.Disabled == true { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code disabled") + } + if invitation.ExpiresAt != nil && invitation.ExpiresAt.Before(time.Now()) { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code expired") + } + if invitation.MaxUsedCnt != nil && invitation.UsedCnt >= *invitation.MaxUsedCnt { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code used up") + } + + return status.StatusInvitationInvalid +} + +func (s *InvitationService) ValidateAndUseWithTx(tx *gorm.DB, code string) error { + ok, err := s.invitationRepo.ValidateAndUse(code).WithTx(tx).Exec() + if err != nil { + return status.StatusWriteDBError + } + if ok { + return nil + } + + invitation, err := s.invitationRepo.GetByCode(code).WithTx(tx).Exec() + if err != nil { + return status.StatusInvitationInvalid + } + + if invitation.DeletedAt != nil { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code deleted") + } + if invitation.Disabled == true { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code disabled") + } + if invitation.ExpiresAt != nil && invitation.ExpiresAt.Before(time.Now()) { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code expired") + } + if invitation.MaxUsedCnt != nil && invitation.UsedCnt >= *invitation.MaxUsedCnt { + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code used up") + } + + return status.StatusInvitationInvalid +} + +func (s *InvitationService) UpdateCode(code string, newExpireTime *time.Time, newMaxUsedCnt *int64, isDisabled *bool, note *string) error { + invitation, err := s.invitationRepo.GetByCode(code).Exec() + if err != nil { + logrus.Errorf("[Service layer: InvitationService] GetByCode failed, code=%s, err=%+v", code, err) + return status.GenErrWithCustomMsg(status.StatusInvitationInvalid, "invitation code invalid") + } + if newExpireTime != nil { + invitation.ExpiresAt = newExpireTime + } + if newMaxUsedCnt != nil { + invitation.MaxUsedCnt = newMaxUsedCnt + } + if isDisabled != nil { + invitation.Disabled = *isDisabled + } + if note != nil { + invitation.Note = note + } + if err := s.invitationRepo.Update(invitation).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +var InvitationSvc *InvitationService diff --git a/internal/service/knowledge_base_service/convert.go b/internal/service/knowledge_base_service/convert.go new file mode 100644 index 0000000..0fac1e8 --- /dev/null +++ b/internal/service/knowledge_base_service/convert.go @@ -0,0 +1,41 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository/knowledge_base_repo" + internal_dto "github.com/XingfenD/yoresee_doc/internal/service/dto" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func buildKnowledgeBaseListReqFromExternal(req *dto.KnowledgeBaseListByExternalRequest, creatorID *int64) *internal_dto.KnowledgeBaseListReq { + if req == nil { + return nil + } + + return &internal_dto.KnowledgeBaseListReq{ + CreatorID: creatorID, + FilterArgs: req.FilterArgs, + SortArgs: req.SortArgs, + Pagination: req.Pagination, + } +} + +func (s *KnowledgeBaseService) buildListKnowledgeBaseOperation(req *internal_dto.KnowledgeBaseListReq) (*knowledge_base_repo.ListKnowledgeBaseOperation, error) { + if s == nil || s.knowledgeBaseRepo == nil { + return nil, status.StatusServiceInternalError + } + if req == nil { + return nil, status.StatusInternalParamsError + } + op := s.knowledgeBaseRepo.List(&model.KnowledgeBase{}).WithCreatorID(req.CreatorID) + if req.FilterArgs != nil { + op = op.WithIsPublic(req.FilterArgs.IsPublic). + WithNameKeyword(req.FilterArgs.NameKeyword). + WithCreateTimeRange(req.FilterArgs.CreateTimeRangeStart, req.FilterArgs.CreateTimeRangeEnd). + WithUpdateTimeRange(req.FilterArgs.UpdateTimeRangeStart, req.FilterArgs.UpdateTimeRangeEnd) + } + op = op.WithSort(req.SortArgs.Field, req.SortArgs.Desc). + WithPagination(req.Pagination.Page, req.Pagination.PageSize) + return op, nil +} diff --git a/internal/service/knowledge_base_service/create.go b/internal/service/knowledge_base_service/create.go new file mode 100644 index 0000000..c0f89cd --- /dev/null +++ b/internal/service/knowledge_base_service/create.go @@ -0,0 +1,34 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" +) + +func (s *KnowledgeBaseService) Create(req *dto.CreateKnowledgeBaseRequest) (*dto.CreateKnowledgeBaseResponse, error) { + if req == nil || req.Name == "" { + return nil, status.StatusParamError + } + + userID, err := s.userRepo.GetIDByExternalID(req.CreatorExternalID).Exec() + if err != nil { + return nil, status.StatusUserNotFound + } + + kbModel := &model.KnowledgeBase{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDKnowledgeBase), + Name: req.Name, + Description: req.Description, + Cover: req.Cover, + IsPublic: req.IsPublic, + CreatorUserID: userID, + } + + if err := s.knowledgeBaseRepo.Create(kbModel).Exec(); err != nil { + return nil, status.StatusWriteDBError + } + + return &dto.CreateKnowledgeBaseResponse{ExternalID: kbModel.ExternalID}, nil +} diff --git a/internal/service/knowledge_base_service/create_recent_record.go b/internal/service/knowledge_base_service/create_recent_record.go new file mode 100644 index 0000000..847b287 --- /dev/null +++ b/internal/service/knowledge_base_service/create_recent_record.go @@ -0,0 +1,31 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func (s *KnowledgeBaseService) CreateRecentKnowledgeBase(req *dto.CreateRecentKnowledgeBaseRequest) error { + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + + knowledgeBaseID, err := s.knowledgeBaseRepo.GetIDByExternalID(req.KnowledgeBaseExternalID).Exec() + if err != nil { + return status.StatusKnowledgeBaseNotFound + } + + err = s.knowledgeBaseRepo.CreateRecentKnowledgeBase(&model.RecentKnowledgeBase{ + UserID: userID, + KnowledgeBaseID: knowledgeBaseID, + AccessedAt: req.AssessTime, + }).Exec() + + if err != nil { + return status.StatusWriteDBError + } + + return nil +} diff --git a/internal/service/knowledge_base_service/get_by_external.go b/internal/service/knowledge_base_service/get_by_external.go new file mode 100644 index 0000000..a9efcff --- /dev/null +++ b/internal/service/knowledge_base_service/get_by_external.go @@ -0,0 +1,72 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" +) + +type KnowledgeBaseGetByExternalIDOperation struct { + withUserExtend bool + withDocumentExtend bool + req *dto.KnowledgeBaseGetByExternalIDRequest + srvc *KnowledgeBaseService +} + +func (s *KnowledgeBaseService) GetByExternalID(req *dto.KnowledgeBaseGetByExternalIDRequest) *KnowledgeBaseGetByExternalIDOperation { + return &KnowledgeBaseGetByExternalIDOperation{ + req: req, + srvc: s, + } +} + +func (op *KnowledgeBaseGetByExternalIDOperation) WithExtend() *KnowledgeBaseGetByExternalIDOperation { + op.withUserExtend = true + op.withDocumentExtend = true + return op +} + +func (op *KnowledgeBaseGetByExternalIDOperation) WithUserExtend() *KnowledgeBaseGetByExternalIDOperation { + op.withUserExtend = true + return op +} + +func (op *KnowledgeBaseGetByExternalIDOperation) WithDocumentExtend() *KnowledgeBaseGetByExternalIDOperation { + op.withDocumentExtend = true + return op +} + +func (op *KnowledgeBaseGetByExternalIDOperation) Exec() (*dto.KnowledgeBaseResponse, error) { + kbModel, err := op.srvc.knowledgeBaseRepo.GetByExternalID(op.req.KnowledgeBaseExternalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: KnowledgeBaseService] GetByExternalID failed, external_id=%s, err=%+v", op.req.KnowledgeBaseExternalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusKnowledgeBaseNotFound, "knowledge base not found") + } + extendDTO := &dto.KnowledgeBaseExtend{} + + if op.withUserExtend { + userModel, err := op.srvc.userRepo.GetByID(kbModel.CreatorUserID).Exec() + if err != nil { + logrus.Errorf("[Service layer: KnowledgeBaseService] Get creator user failed, user_id=%d, err=%+v", kbModel.CreatorUserID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "query creator failed") + } + extendDTO.CreatorUserExternalID = userModel.ExternalID + extendDTO.CreatorName = userModel.Username + } + + if op.withDocumentExtend { + ids := []int64{ + kbModel.ID, + } + count, err := op.srvc.knowledgeBaseRepo.MGetKnowledgeBaseDocumentsCount(ids).Exec() + if err != nil { + logrus.Errorf("[Service layer: KnowledgeBaseService] MGetKnowledgeBaseDocumentsCount failed, kb_id=%d, err=%+v", kbModel.ID, err) + return nil, status.GenErrWithCustomMsg(status.StatusReadDBError, "query document count failed") + } + extendDTO.DocumentsCount = count[kbModel.ID] + } + + return dto.NewKnowledgeBaseResponseFromModel( + kbModel, extendDTO, + ), nil +} diff --git a/internal/service/knowledge_base_service/knowledge_base_service.go b/internal/service/knowledge_base_service/knowledge_base_service.go new file mode 100644 index 0000000..660ff0e --- /dev/null +++ b/internal/service/knowledge_base_service/knowledge_base_service.go @@ -0,0 +1,30 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/knowledge_base_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +type KnowledgeBaseService struct { + knowledgeBaseRepo *knowledge_base_repo.KnowledgeBaseRepository + userRepo *user_repo.UserRepository +} + +func NewKnowledgeBaseService(repos *repository.Repositories) *KnowledgeBaseService { + return &KnowledgeBaseService{ + knowledgeBaseRepo: repos.KnowledgeBase, + userRepo: repos.User, + } +} + +func (s *KnowledgeBaseService) GetIDByExternalID(externalID string) (int64, error) { + id, err := s.knowledgeBaseRepo.GetIDByExternalID(externalID).Exec() + if err != nil { + return 0, status.StatusKnowledgeBaseNotFound + } + return id, nil +} + +var KnowledgeBaseSvc *KnowledgeBaseService diff --git a/internal/service/knowledge_base_service/list.go b/internal/service/knowledge_base_service/list.go new file mode 100644 index 0000000..52901cf --- /dev/null +++ b/internal/service/knowledge_base_service/list.go @@ -0,0 +1,142 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + internal_dto "github.com/XingfenD/yoresee_doc/internal/service/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/bytedance/gg/gslice" + "github.com/sirupsen/logrus" +) + +type knowledgeBaseListOperation struct { + req *internal_dto.KnowledgeBaseListReq + srvc *KnowledgeBaseService + + withDocumentExtend bool + withUserExtend bool +} + +func (s *KnowledgeBaseService) list(req *internal_dto.KnowledgeBaseListReq) *knowledgeBaseListOperation { + return &knowledgeBaseListOperation{ + req: req, + srvc: s, + } +} + +func (op *knowledgeBaseListOperation) WithDocumentExtend() *knowledgeBaseListOperation { + op.withDocumentExtend = true + return op +} + +func (op *knowledgeBaseListOperation) WithUserExtend() *knowledgeBaseListOperation { + op.withUserExtend = true + return op +} + +func (op *knowledgeBaseListOperation) documentExtend(kbModels []*model.KnowledgeBase, kbExtendMapByID map[int64]*dto.KnowledgeBaseExtend) error { + if op.withDocumentExtend { + kbIDs := gslice.Map(kbModels, func(kbModel *model.KnowledgeBase) int64 { + return kbModel.ID + }) + countMapByKbID, err := op.srvc.knowledgeBaseRepo.MGetKnowledgeBaseDocumentsCount(kbIDs).Exec() + if err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: MGetKnowledgeBaseDocumentsCount failed, err: %+v", err) + return status.GenErrWithCustomMsg(status.StatusReadDBError, "query knowledge base document count failed") + } + for id, count := range countMapByKbID { + if _, ok := kbExtendMapByID[id]; !ok { + kbExtendMapByID[id] = &dto.KnowledgeBaseExtend{} + } + kbExtendMapByID[id].DocumentsCount = count + } + } + return nil +} + +func (op *knowledgeBaseListOperation) userExtend(kbModels []*model.KnowledgeBase, kbExtendMapByID map[int64]*dto.KnowledgeBaseExtend) error { + if op.withUserExtend { + // collect all user_id + allUserID := gslice.Map(kbModels, func(kb *model.KnowledgeBase) int64 { + return kb.CreatorUserID + }) + uniqUserID := gslice.Uniq(allUserID) + usersMapByUserID, err := op.srvc.userRepo.MGetUserByID(uniqUserID).Exec() + if err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: MGetUserByID failed, err: %+v", err) + return status.GenErrWithCustomMsg(status.StatusReadDBError, "query creator users failed") + } + for _, kbModel := range kbModels { + if _, ok := kbExtendMapByID[kbModel.ID]; !ok { + kbExtendMapByID[kbModel.ID] = &dto.KnowledgeBaseExtend{} + } + if user, ok := usersMapByUserID[kbModel.CreatorUserID]; ok { + kbExtendMapByID[kbModel.ID].CreatorUserExternalID = user.ExternalID + kbExtendMapByID[kbModel.ID].CreatorName = user.Username + } + } + } + return nil +} + +func (op *knowledgeBaseListOperation) Exec() ([]*dto.KnowledgeBaseResponse, error) { + listOp, err := op.srvc.buildListKnowledgeBaseOperation(op.req) + if err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: buildListKnowledgeBaseOperation failed, err: %+v", err) + return nil, status.GenErrWithCustomMsg(err, "build knowledge base list operation failed") + } + kbModels, err := listOp.Exec() + if err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: list operation exec failed, err: %+v", err) + return nil, status.GenErrWithCustomMsg(err, "list knowledge bases failed") + } + + kbExtendMapByID := make(map[int64]*dto.KnowledgeBaseExtend) + + if err := op.documentExtend(kbModels, kbExtendMapByID); err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: documentExtend failed, err: %+v", err) + return nil, status.GenErrWithCustomMsg(err, "build knowledge base document extension failed") + } + if err := op.userExtend(kbModels, kbExtendMapByID); err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: userExtend failed, err: %+v", err) + return nil, status.GenErrWithCustomMsg(err, "build knowledge base user extension failed") + } + + knowledgeBases := make([]*dto.KnowledgeBaseResponse, 0, len(kbModels)) + for _, kb := range kbModels { + knowledgeBases = append(knowledgeBases, dto.NewKnowledgeBaseResponseFromModel(kb, kbExtendMapByID[kb.ID])) + } + + return knowledgeBases, nil +} + +func (op *knowledgeBaseListOperation) ExecWithTotal() ([]*dto.KnowledgeBaseResponse, int64, error) { + listOp, err := op.srvc.buildListKnowledgeBaseOperation(op.req) + if err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: buildListKnowledgeBaseOperation failed, err: %+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build knowledge base list operation failed") + } + kbModels, total, err := listOp.ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: list operation exec with total failed, err: %+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "list knowledge bases failed") + } + + kbExtendMapByID := make(map[int64]*dto.KnowledgeBaseExtend) + + if err := op.documentExtend(kbModels, kbExtendMapByID); err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: documentExtend failed, err: %+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build knowledge base document extension failed") + } + if err := op.userExtend(kbModels, kbExtendMapByID); err != nil { + logrus.Errorf("[Service layer: knowledgeBaseList]: userExtend failed, err: %+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build knowledge base user extension failed") + } + + knowledgeBases := make([]*dto.KnowledgeBaseResponse, 0, len(kbModels)) + for _, kb := range kbModels { + knowledgeBases = append(knowledgeBases, dto.NewKnowledgeBaseResponseFromModel(kb, kbExtendMapByID[kb.ID])) + } + + return knowledgeBases, total, nil +} diff --git a/internal/service/knowledge_base_service/list_by_external.go b/internal/service/knowledge_base_service/list_by_external.go new file mode 100644 index 0000000..39f9ec5 --- /dev/null +++ b/internal/service/knowledge_base_service/list_by_external.go @@ -0,0 +1,27 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" +) + +func (s *KnowledgeBaseService) ListByExternal(req *dto.KnowledgeBaseListByExternalRequest) ([]*dto.KnowledgeBaseResponse, int64, error) { + var creatorID *int64 + if req.CreatorExternalID != "" { + id, err := s.userRepo.GetIDByExternalID(req.CreatorExternalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: KnowledgeBaseService] GetIDByExternalID failed, creator_external_id=%s, err=%+v", req.CreatorExternalID, err) + return nil, 0, status.StatusUserNotFound + } + creatorID = &id + } + + routerReq := buildKnowledgeBaseListReqFromExternal(req, creatorID) + list, total, err := s.list(routerReq).WithDocumentExtend().WithUserExtend().ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: KnowledgeBaseService] ListByExternal failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "list knowledge bases failed") + } + return list, total, nil +} diff --git a/internal/service/knowledge_base_service/list_recent.go b/internal/service/knowledge_base_service/list_recent.go new file mode 100644 index 0000000..9a7003d --- /dev/null +++ b/internal/service/knowledge_base_service/list_recent.go @@ -0,0 +1,65 @@ +package knowledge_base_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" +) + +func (s *KnowledgeBaseService) ListRecentKnowledgeBases(req *dto.ListRecentKnowledgeBasesRequest) ([]*dto.KnowledgeBaseResponse, int64, error) { + if req == nil || req.UserExternalID == "" { + return nil, 0, status.StatusInternalParamsError + } + + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return nil, 0, status.StatusUserNotFound + } + + recentRecords, total, err := s.knowledgeBaseRepo.ListRecentKnowledgeBases(userID). + WithTimeRange(req.StartTime, req.EndTime). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + if len(recentRecords) == 0 { + return []*dto.KnowledgeBaseResponse{}, total, nil + } + + kbIDs := make([]int64, 0, len(recentRecords)) + for _, r := range recentRecords { + kbIDs = append(kbIDs, r.KnowledgeBaseID) + } + + kbModels, err := s.knowledgeBaseRepo.MGetKnowledgeBaseByIDs(kbIDs).Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + kbMap := make(map[int64]*model.KnowledgeBase, len(kbModels)) + for _, kb := range kbModels { + kbMap[kb.ID] = kb + } + + kbExtendMapByID := make(map[int64]*dto.KnowledgeBaseExtend) + op := &knowledgeBaseListOperation{ + srvc: s, + withDocumentExtend: true, + withUserExtend: true, + } + _ = op.documentExtend(kbModels, kbExtendMapByID) + _ = op.userExtend(kbModels, kbExtendMapByID) + + resp := make([]*dto.KnowledgeBaseResponse, 0, len(recentRecords)) + for _, record := range recentRecords { + kb := kbMap[record.KnowledgeBaseID] + if kb == nil { + continue + } + resp = append(resp, dto.NewKnowledgeBaseResponseFromModel(kb, kbExtendMapByID[kb.ID])) + } + + return resp, total, nil +} diff --git a/internal/service/membership_service/membership_service.go b/internal/service/membership_service/membership_service.go new file mode 100644 index 0000000..a6827a4 --- /dev/null +++ b/internal/service/membership_service/membership_service.go @@ -0,0 +1,122 @@ +package membership_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/membership_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type MembershipService struct { + db *gorm.DB + repo *membership_repo.MembershipRepository + useRepo *user_repo.UserRepository +} + +func NewMembershipService(repos *repository.Repositories) *MembershipService { + return &MembershipService{ + db: repos.DB, + repo: repos.Membership, + useRepo: repos.User, + } +} + +func (s *MembershipService) GetMembershipIDByExternalID(req *dto.MembershipBaseRequest) (int64, error) { + var membershipID int64 + if req.Type == dto.MembershipType_OrgNode { + orgNodeID, err := s.repo.GetOrgNodeIDByExternalID(req.MembershipExternalID).Exec() + if err != nil { + return 0, status.StatusMembershipMetaNotFound + } + membershipID = orgNodeID + } else if req.Type == dto.MembershipType_UserGroup { + userGroupID, err := s.repo.GetUserGroupIDByExternalID(req.MembershipExternalID).Exec() + if err != nil { + return 0, status.StatusMembershipMetaNotFound + } + membershipID = userGroupID + } else { + return 0, status.StatusInvalidMembershipType + } + return membershipID, nil +} + +func (s *MembershipService) CreateMembershipRelation(membership *dto.CreateMembershipRelationRequest) error { + userID, err := s.useRepo.GetIDByExternalID(membership.UserExternalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: MembershipService] GetIDByExternalID failed, user_external_id=%s, err=%+v", membership.UserExternalID, err) + return status.StatusUserNotFound + } + + membershipID, err := s.GetMembershipIDByExternalID(&membership.MembershipBaseRequest) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] GetMembershipIDByExternalID failed, membership_external_id=%s, type=%d, err=%+v", membership.MembershipExternalID, membership.Type, err) + return status.GenErrWithCustomMsg(err, "membership target not found") + } + model := &model.MembershipRelation{ + UserID: userID, + MembershipID: membershipID, + Type: model.MembershipType(membership.Type), + } + + if err := s.repo.CreateMembership(model).Exec(); err != nil { + logrus.Errorf("[Service layer: MembershipService] CreateMembership failed, user_id=%d, membership_id=%d, type=%d, err=%+v", userID, membershipID, membership.Type, err) + return status.GenErrWithCustomMsg(status.StatusWriteDBError, "create membership relation failed") + } + return nil +} + +func (s *MembershipService) GetUserGroupMeta(externalID string) (*model.UserGroupMeta, error) { + meta, err := s.repo.GetUserGroupByExternalID(externalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: MembershipService] GetUserGroupMeta failed, external_id=%s, err=%+v", externalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusMembershipMetaNotFound, "user group not found") + } + return meta, nil +} + +func (s *MembershipService) GetOrgNodeMeta(externalID string) (*model.OrgNodeMeta, error) { + meta, err := s.repo.GetOrgNodeByExternalID(externalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: MembershipService] GetOrgNodeMeta failed, external_id=%s, err=%+v", externalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusMembershipMetaNotFound, "org node not found") + } + return meta, nil +} + +// func (s *MembershipService) GetMembershipRelation(req *dto.MembershipBaseRequest) (*dto.MembershipRelationResponse, error) { +// var membershipMetaResponse *dto.MembershipMetaResponse +// var membershipID int64 +// switch req.Type { +// case model.MembershipType_UserGroup: +// userGroupMeta, err := s.GetUserGroupMeta(req.MembershipExternalID) +// if err != nil { +// return nil, err +// } +// membershipMetaResponse = dto.NewMembershipMetaResponseFromUserGroupMetaModel(userGroupMeta) +// membershipID = userGroupMeta.ID +// case model.MembershipType_OrgNode: +// orgNodeMeta, err := s.GetOrgNodeMeta(req.MembershipExternalID) +// if err != nil { +// return nil, err +// } +// membershipMetaResponse = dto.NewMembershipMetaResponseFromOrgNodeMetaModel(orgNodeMeta) +// membershipID = orgNodeMeta.ID +// default: +// return nil, status.StatusInvalidMembershipType +// } +// model := &model.MembershipRelation{ +// Type: req.Type, +// MembershipID: membershipID, +// } +// memberships, err := s.repo.ListMembership(model).Exec() +// if err != nil { +// return nil, err +// } +// } + +var MembershipSvc *MembershipService diff --git a/internal/service/membership_service/org_node.go b/internal/service/membership_service/org_node.go new file mode 100644 index 0000000..e94fbbd --- /dev/null +++ b/internal/service/membership_service/org_node.go @@ -0,0 +1,473 @@ +package membership_service + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/bytedance/gg/gslice" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func (s *MembershipService) ListOrgNodes(req *dto.ListOrgNodesRequest) ([]*dto.OrgNodeResponse, int64, error) { + if req == nil { + return nil, 0, status.StatusParamError + } + req.Pagination = normalizePagination(req.Pagination) + + var parentID int64 = 0 + if req.ParentExternalID != "" { + parent, err := s.repo.GetOrgNodeByExternalID(req.ParentExternalID).Exec() + if err != nil { + return nil, 0, status.StatusMembershipMetaNotFound + } + parentID = parent.ID + } + + nodes, total, err := s.repo.QueryOrgNode(). + WithParentID(parentID). + WithKeyword(req.Keyword). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + ExecWithTotal() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + responses, err := s.buildOrgNodeResponses(nodes, req.IncludeChildren) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] buildOrgNodeResponses failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build org node response failed") + } + return responses, total, nil +} + +func (s *MembershipService) GetOrgNode(req *dto.GetOrgNodeRequest) (*dto.OrgNodeResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return nil, status.StatusParamError + } + node, err := s.repo.GetOrgNodeByExternalID(req.ExternalID).Exec() + if err != nil { + return nil, status.StatusMembershipMetaNotFound + } + + if req.IncludeChildren { + return s.buildOrgNodeWithChildren(node) + } + + responses, err := s.buildOrgNodeResponses([]model.OrgNodeMeta{*node}, false) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] buildOrgNodeResponses failed, external_id=%s, err=%+v", req.ExternalID, err) + return nil, status.GenErrWithCustomMsg(err, "build org node response failed") + } + if len(responses) == 0 { + return nil, status.StatusMembershipMetaNotFound + } + return responses[0], nil +} + +func (s *MembershipService) CreateOrgNode(req *dto.CreateOrgNodeRequest) (string, error) { + if req == nil || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.CreatorUserExternalID) == "" { + return "", status.StatusParamError + } + + var createdExternalID string + err := utils.WithTransaction(s.db, func(tx *gorm.DB) error { + creatorID, err := s.useRepo.GetIDByExternalID(req.CreatorUserExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusUserNotFound + } + + var parentID int64 = 0 + var parentPath string = "" + if req.ParentExternalID != "" { + parent, err := s.repo.GetOrgNodeByExternalID(req.ParentExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusMembershipMetaNotFound + } + parentID = parent.ID + parentPath = parent.Path + } + + node := &model.OrgNodeMeta{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextOrgNode), + ParentID: parentID, + Name: strings.TrimSpace(req.Name), + Description: strings.TrimSpace(req.Description), + CreatorID: creatorID, + } + + if err := s.repo.CreateOrgNode(node).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + + node.Path = buildOrgNodePath(parentPath, node.ID) + if err := s.repo.UpdateOrgNode(node).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + + userIDs, err := s.resolveUserExternalIDsToIDs(req.MemberUserExternalIDs, tx) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] resolveUserExternalIDsToIDs failed, err=%+v", err) + return status.GenErrWithCustomMsg(err, "resolve user ids failed") + } + if err := s.syncOrgNodeMembers(tx, node.ID, userIDs); err != nil { + logrus.Errorf("[Service layer: MembershipService] syncOrgNodeMembers failed, node_id=%d, err=%+v", node.ID, err) + return status.GenErrWithCustomMsg(err, "sync org node members failed") + } + + createdExternalID = node.ExternalID + return nil + }) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] CreateOrgNode transaction failed, err=%+v", err) + return "", status.GenErrWithCustomMsg(err, "create org node failed") + } + return createdExternalID, nil +} + +func (s *MembershipService) UpdateOrgNode(req *dto.UpdateOrgNodeRequest) error { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return status.StatusParamError + } + if req.Name == nil && req.Description == nil && !req.SyncMembers { + return status.StatusParamError + } + + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + node, err := s.repo.GetOrgNodeByExternalID(req.ExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusMembershipMetaNotFound + } + + shouldSave := false + if req.Name != nil { + newName := strings.TrimSpace(*req.Name) + if newName == "" { + return status.StatusParamError + } + node.Name = newName + shouldSave = true + } + if req.Description != nil { + node.Description = strings.TrimSpace(*req.Description) + shouldSave = true + } + if shouldSave { + if err := s.repo.UpdateOrgNode(node).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + } + + if req.SyncMembers { + userIDs, err := s.resolveUserExternalIDsToIDs(req.MemberUserExternalIDs, tx) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] resolveUserExternalIDsToIDs failed, external_id=%s, err=%+v", req.ExternalID, err) + return status.GenErrWithCustomMsg(err, "resolve user ids failed") + } + if err := s.syncOrgNodeMembers(tx, node.ID, userIDs); err != nil { + logrus.Errorf("[Service layer: MembershipService] syncOrgNodeMembers failed, node_id=%d, err=%+v", node.ID, err) + return status.GenErrWithCustomMsg(err, "sync org node members failed") + } + } + return nil + }) +} + +func (s *MembershipService) DeleteOrgNode(req *dto.DeleteOrgNodeRequest) error { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return status.StatusParamError + } + + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + node, err := s.repo.GetOrgNodeByExternalID(req.ExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusMembershipMetaNotFound + } + + children, err := s.repo.QueryOrgNode().WithParentID(node.ID).Exec() + if err != nil { + return status.StatusReadDBError + } + if len(children) > 0 { + return status.StatusOrgNodeHasChildren + } + + if err := tx.Where("type = ? AND membership_id = ?", model.MembershipType_OrgNode, node.ID). + Delete(&model.MembershipRelation{}).Error; err != nil { + return status.StatusWriteDBError + } + if err := s.repo.DeleteOrgNode(node).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil + }) +} + +func (s *MembershipService) MoveOrgNode(req *dto.MoveOrgNodeRequest) error { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return status.StatusParamError + } + + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + node, err := s.repo.GetOrgNodeByExternalID(req.ExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusMembershipMetaNotFound + } + + var newParentID int64 = 0 + var newParentPath string = "" + if req.NewParentExternalID != "" { + newParent, err := s.repo.GetOrgNodeByExternalID(req.NewParentExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusMembershipMetaNotFound + } + newParentID = newParent.ID + newParentPath = newParent.Path + + if newParentID == node.ID { + return status.StatusParamError + } + + descendants, err := s.repo.QueryOrgNodeByPathPrefix(node.Path).WithTx(tx).Exec() + if err != nil { + return status.StatusReadDBError + } + for _, descendant := range descendants { + if descendant.ID == newParentID { + return status.StatusOrgNodeCannotMoveToDescendant + } + } + } + + node.ParentID = newParentID + oldPath := node.Path + node.Path = buildOrgNodePath(newParentPath, node.ID) + + if err := s.repo.UpdateOrgNode(node).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + + descendants, err := s.repo.QueryOrgNodeByPathPrefix(oldPath).WithTx(tx).Exec() + if err != nil { + return status.StatusReadDBError + } + for _, descendant := range descendants { + if descendant.ID == node.ID { + continue + } + descendant.Path = strings.Replace(descendant.Path, oldPath, node.Path, 1) + if err := s.repo.UpdateOrgNode(&descendant).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + } + + return nil + }) +} + +func (s *MembershipService) ListOrgNodeMembers(req *dto.ListOrgNodeMembersRequest) ([]*dto.UserResponse, int64, error) { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return nil, 0, status.StatusParamError + } + req.Pagination = normalizePagination(req.Pagination) + + node, err := s.repo.GetOrgNodeByExternalID(req.ExternalID).Exec() + if err != nil { + return nil, 0, status.StatusMembershipMetaNotFound + } + + relations, err := s.repo.ListMembership(&model.MembershipRelation{ + Type: model.MembershipType_OrgNode, + MembershipID: node.ID, + }).Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + if len(relations) == 0 { + return []*dto.UserResponse{}, 0, nil + } + + memberIDs := make([]int64, 0, len(relations)) + for _, relation := range relations { + memberIDs = append(memberIDs, relation.UserID) + } + + query := s.useRepo.QueryUsers(). + WithPagination(req.Pagination.Page, req.Pagination.PageSize) + if req.Keyword != nil && strings.TrimSpace(*req.Keyword) != "" { + query = query.WithKeyword(req.Keyword) + } + query = query.WithUserIDs(memberIDs) + + users, total, err := query.ExecWithTotal() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + resp := make([]*dto.UserResponse, 0, len(users)) + for i := range users { + user := users[i] + resp = append(resp, dto.NewUserResponseFromModel(&user)) + } + + return resp, total, nil +} + +func buildOrgNodePath(parentPath string, nodeID int64) string { + label := "n" + utils.Int64ToString(nodeID) + if strings.TrimSpace(parentPath) == "" { + return label + } + return parentPath + "." + label +} + +func (s *MembershipService) buildOrgNodeResponses(nodes []model.OrgNodeMeta, includeChildren bool) ([]*dto.OrgNodeResponse, error) { + if len(nodes) == 0 { + return []*dto.OrgNodeResponse{}, nil + } + + nodeIDs := make([]int64, 0, len(nodes)) + parentIDs := make([]int64, 0, len(nodes)) + creatorIDs := make([]int64, 0, len(nodes)) + for _, n := range nodes { + nodeIDs = append(nodeIDs, n.ID) + parentIDs = append(parentIDs, n.ParentID) + creatorIDs = append(creatorIDs, n.CreatorID) + } + parentIDs = gslice.Uniq(parentIDs) + creatorIDs = gslice.Uniq(creatorIDs) + + relations, err := s.repo.ListMembershipByTypeAndIDs(model.MembershipType_OrgNode, nodeIDs).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + + parentMap, err := s.repo.MGetOrgNodeByID(parentIDs).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + + creatorMap, err := s.useRepo.MGetUserByID(creatorIDs).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + + nodeMemberUserIDs := make(map[int64][]int64, len(nodeIDs)) + nodeMemberSeen := make(map[int64]map[int64]struct{}, len(nodeIDs)) + for _, relation := range relations { + if _, ok := nodeMemberSeen[relation.MembershipID]; !ok { + nodeMemberSeen[relation.MembershipID] = map[int64]struct{}{} + } + if _, exists := nodeMemberSeen[relation.MembershipID][relation.UserID]; exists { + continue + } + nodeMemberSeen[relation.MembershipID][relation.UserID] = struct{}{} + + nodeMemberUserIDs[relation.MembershipID] = append(nodeMemberUserIDs[relation.MembershipID], relation.UserID) + } + + responses := make([]*dto.OrgNodeResponse, 0, len(nodes)) + for _, n := range nodes { + resp := &dto.OrgNodeResponse{ + ExternalID: n.ExternalID, + Name: n.Name, + Path: n.Path, + Description: n.Description, + MemberCount: len(nodeMemberUserIDs[n.ID]), + } + if parent := parentMap[n.ParentID]; parent != nil { + resp.ParentExternalID = parent.ExternalID + } + if creator := creatorMap[n.CreatorID]; creator != nil { + resp.CreatorUserExternalID = creator.ExternalID + } + responses = append(responses, resp) + } + + if includeChildren { + for i, resp := range responses { + node := nodes[i] + children, err := s.repo.QueryOrgNode().WithParentID(node.ID).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + if len(children) > 0 { + childResponses, err := s.buildOrgNodeResponses(children, true) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] buildOrgNodeResponses children failed, node_id=%d, err=%+v", node.ID, err) + return nil, status.GenErrWithCustomMsg(err, "build org node children response failed") + } + resp.Children = childResponses + } + } + } + + return responses, nil +} + +func (s *MembershipService) buildOrgNodeWithChildren(node *model.OrgNodeMeta) (*dto.OrgNodeResponse, error) { + responses, err := s.buildOrgNodeResponses([]model.OrgNodeMeta{*node}, true) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] buildOrgNodeResponses tree failed, node_external_id=%s, err=%+v", node.ExternalID, err) + return nil, status.GenErrWithCustomMsg(err, "build org node tree failed") + } + if len(responses) == 0 { + return nil, status.StatusMembershipMetaNotFound + } + return responses[0], nil +} + +func (s *MembershipService) syncOrgNodeMembers(tx *gorm.DB, orgNodeID int64, targetUserIDs []int64) error { + existingRelations, err := s.repo.ListMembership(&model.MembershipRelation{ + Type: model.MembershipType_OrgNode, + MembershipID: orgNodeID, + }).WithTx(tx).Exec() + if err != nil { + return status.StatusReadDBError + } + + existingUserIDs := map[int64]struct{}{} + for _, relation := range existingRelations { + existingUserIDs[relation.UserID] = struct{}{} + } + targetUserIDSet := map[int64]struct{}{} + for _, userID := range targetUserIDs { + targetUserIDSet[userID] = struct{}{} + } + + toCreate := make([]*model.MembershipRelation, 0) + for userID := range targetUserIDSet { + if _, ok := existingUserIDs[userID]; ok { + continue + } + toCreate = append(toCreate, &model.MembershipRelation{ + Type: model.MembershipType_OrgNode, + UserID: userID, + MembershipID: orgNodeID, + }) + } + + toDeleteUserIDs := make([]int64, 0) + for userID := range existingUserIDs { + if _, ok := targetUserIDSet[userID]; ok { + continue + } + toDeleteUserIDs = append(toDeleteUserIDs, userID) + } + + if len(toCreate) > 0 { + if err := s.repo.BatchCreateMembership(toCreate).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + } + if len(toDeleteUserIDs) > 0 { + if err := tx.Where("type = ? AND membership_id = ? AND user_id IN ?", model.MembershipType_OrgNode, orgNodeID, toDeleteUserIDs). + Delete(&model.MembershipRelation{}).Error; err != nil { + return status.StatusWriteDBError + } + } + return nil +} diff --git a/internal/service/membership_service/user_group.go b/internal/service/membership_service/user_group.go new file mode 100644 index 0000000..560313d --- /dev/null +++ b/internal/service/membership_service/user_group.go @@ -0,0 +1,443 @@ +package membership_service + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/auth" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/bytedance/gg/gslice" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +const ( + defaultPage = 1 + defaultPageSize = 20 + maxPageSize = 200 +) + +func normalizePagination(p dto.Pagination) dto.Pagination { + if p.Page <= 0 { + p.Page = defaultPage + } + if p.PageSize <= 0 { + p.PageSize = defaultPageSize + } + if p.PageSize > maxPageSize { + p.PageSize = maxPageSize + } + return p +} + +func (s *MembershipService) ListUsers(req *dto.ListUsersRequest) ([]*dto.UserResponse, int64, error) { + if req == nil { + return nil, 0, status.StatusParamError + } + req.Pagination = normalizePagination(req.Pagination) + + users, total, err := s.useRepo.QueryUsers(). + WithKeyword(req.Keyword). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + ExecWithTotal() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + resp := make([]*dto.UserResponse, 0, len(users)) + for i := range users { + user := users[i] + resp = append(resp, dto.NewUserResponseFromModel(&user)) + } + return resp, total, nil +} + +func (s *MembershipService) UpdateUser(req *dto.UpdateUserRequest) error { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return status.StatusParamError + } + if req.Username == nil && req.Email == nil && req.Nickname == nil && req.Status == nil { + return status.StatusParamError + } + + user, err := s.useRepo.GetByExternalID(req.ExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + oldStatus := user.Status + + if req.Username != nil { + username := strings.TrimSpace(*req.Username) + if username == "" { + return status.StatusParamError + } + user.Username = username + } + if req.Email != nil { + email := strings.TrimSpace(*req.Email) + if email == "" { + return status.StatusParamError + } + user.Email = email + } + if req.Nickname != nil { + user.Nickname = strings.TrimSpace(*req.Nickname) + } + if req.Status != nil { + user.Status = int(*req.Status) + } + + if err := s.useRepo.Update(user).Exec(); err != nil { + return status.StatusWriteDBError + } + if req.Status != nil && oldStatus > 0 && user.Status <= 0 { + if err := auth.BlacklistUserJWTs(user.ExternalID); err != nil { + logrus.Errorf("[Service layer: MembershipService] blacklist user jwt tokens failed, user_external_id=%s, err=%+v", user.ExternalID, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "ban user failed: blacklist jwt tokens failed") + } + } + return nil +} + +func (s *MembershipService) ListUserGroupMembers(req *dto.ListUserGroupMembersRequest) ([]*dto.UserResponse, int64, error) { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return nil, 0, status.StatusParamError + } + req.Pagination = normalizePagination(req.Pagination) + + group, err := s.repo.GetUserGroupByExternalID(req.ExternalID).Exec() + if err != nil { + return nil, 0, status.StatusMembershipMetaNotFound + } + + relations, err := s.repo.ListMembership(&model.MembershipRelation{ + Type: model.MembershipType_UserGroup, + MembershipID: group.ID, + }).Exec() + if err != nil { + return nil, 0, status.StatusReadDBError + } + if len(relations) == 0 { + return []*dto.UserResponse{}, 0, nil + } + + memberIDs := make([]int64, 0, len(relations)) + for _, relation := range relations { + memberIDs = append(memberIDs, relation.UserID) + } + + query := s.useRepo.QueryUsers(). + WithPagination(req.Pagination.Page, req.Pagination.PageSize) + if req.Keyword != nil && strings.TrimSpace(*req.Keyword) != "" { + query = query.WithKeyword(req.Keyword) + } + query = query.WithUserIDs(memberIDs) + + users, total, err := query.ExecWithTotal() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + resp := make([]*dto.UserResponse, 0, len(users)) + for i := range users { + user := users[i] + resp = append(resp, dto.NewUserResponseFromModel(&user)) + } + + return resp, total, nil +} + +func (s *MembershipService) ListUserGroups(req *dto.ListUserGroupsRequest) ([]*dto.UserGroupResponse, int64, error) { + if req == nil { + return nil, 0, status.StatusParamError + } + req.Pagination = normalizePagination(req.Pagination) + + groups, total, err := s.repo.QueryUserGroup(). + WithKeyword(req.Keyword). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + ExecWithTotal() + if err != nil { + return nil, 0, status.StatusReadDBError + } + + responses, err := s.buildUserGroupResponses(groups, false) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] buildUserGroupResponses failed, err=%+v", err) + return nil, 0, status.GenErrWithCustomMsg(err, "build user group response failed") + } + return responses, total, nil +} + +func (s *MembershipService) GetUserGroup(req *dto.GetUserGroupRequest) (*dto.UserGroupResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return nil, status.StatusParamError + } + group, err := s.repo.GetUserGroupByExternalID(req.ExternalID).Exec() + if err != nil { + return nil, status.StatusMembershipMetaNotFound + } + + responses, err := s.buildUserGroupResponses([]model.UserGroupMeta{*group}, false) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] buildUserGroupResponses failed, external_id=%s, err=%+v", req.ExternalID, err) + return nil, status.GenErrWithCustomMsg(err, "build user group response failed") + } + if len(responses) == 0 { + return nil, status.StatusMembershipMetaNotFound + } + return responses[0], nil +} + +func (s *MembershipService) CreateUserGroup(req *dto.CreateUserGroupRequest) (string, error) { + if req == nil || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.CreatorUserExternalID) == "" { + return "", status.StatusParamError + } + + var createdExternalID string + err := utils.WithTransaction(s.db, func(tx *gorm.DB) error { + creatorID, err := s.useRepo.GetIDByExternalID(req.CreatorUserExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusUserNotFound + } + + group := &model.UserGroupMeta{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextUserGroup), + Name: strings.TrimSpace(req.Name), + Description: strings.TrimSpace(req.Description), + CreatorID: creatorID, + } + if err := s.repo.CreateUserGroup(group).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + + userIDs, err := s.resolveUserExternalIDsToIDs(req.MemberUserExternalIDs, tx) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] resolveUserExternalIDsToIDs failed, err=%+v", err) + return status.GenErrWithCustomMsg(err, "resolve user ids failed") + } + if err := s.syncUserGroupMembers(tx, group.ID, userIDs); err != nil { + logrus.Errorf("[Service layer: MembershipService] syncUserGroupMembers failed, group_id=%d, err=%+v", group.ID, err) + return status.GenErrWithCustomMsg(err, "sync user group members failed") + } + + createdExternalID = group.ExternalID + return nil + }) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] CreateUserGroup transaction failed, err=%+v", err) + return "", status.GenErrWithCustomMsg(err, "create user group failed") + } + return createdExternalID, nil +} + +func (s *MembershipService) UpdateUserGroup(req *dto.UpdateUserGroupRequest) error { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return status.StatusParamError + } + if req.Name == nil && req.Description == nil && !req.SyncMembers { + return status.StatusParamError + } + + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + group, err := s.repo.GetUserGroupByExternalID(req.ExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusMembershipMetaNotFound + } + + shouldSave := false + if req.Name != nil { + newName := strings.TrimSpace(*req.Name) + if newName == "" { + return status.StatusParamError + } + group.Name = newName + shouldSave = true + } + if req.Description != nil { + group.Description = strings.TrimSpace(*req.Description) + shouldSave = true + } + if shouldSave { + if err := s.repo.UpdateUserGroup(group).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + } + + if req.SyncMembers { + userIDs, err := s.resolveUserExternalIDsToIDs(req.MemberUserExternalIDs, tx) + if err != nil { + logrus.Errorf("[Service layer: MembershipService] resolveUserExternalIDsToIDs failed, external_id=%s, err=%+v", req.ExternalID, err) + return status.GenErrWithCustomMsg(err, "resolve user ids failed") + } + if err := s.syncUserGroupMembers(tx, group.ID, userIDs); err != nil { + logrus.Errorf("[Service layer: MembershipService] syncUserGroupMembers failed, group_id=%d, err=%+v", group.ID, err) + return status.GenErrWithCustomMsg(err, "sync user group members failed") + } + } + return nil + }) +} + +func (s *MembershipService) DeleteUserGroup(req *dto.DeleteUserGroupRequest) error { + if req == nil || strings.TrimSpace(req.ExternalID) == "" { + return status.StatusParamError + } + + return utils.WithTransaction(s.db, func(tx *gorm.DB) error { + group, err := s.repo.GetUserGroupByExternalID(req.ExternalID).WithTx(tx).Exec() + if err != nil { + return status.StatusMembershipMetaNotFound + } + + if err := tx.Where("type = ? AND membership_id = ?", model.MembershipType_UserGroup, group.ID). + Delete(&model.MembershipRelation{}).Error; err != nil { + return status.StatusWriteDBError + } + if err := s.repo.DeleteUserGroup(group).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil + }) +} + +func (s *MembershipService) buildUserGroupResponses(groups []model.UserGroupMeta, includeMembers bool) ([]*dto.UserGroupResponse, error) { + if len(groups) == 0 { + return []*dto.UserGroupResponse{}, nil + } + + groupIDs := make([]int64, 0, len(groups)) + creatorIDs := make([]int64, 0, len(groups)) + for _, g := range groups { + groupIDs = append(groupIDs, g.ID) + creatorIDs = append(creatorIDs, g.CreatorID) + } + creatorIDs = gslice.Uniq(creatorIDs) + + relations, err := s.repo.ListMembershipByTypeAndIDs(model.MembershipType_UserGroup, groupIDs).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + + creatorMap, err := s.useRepo.MGetUserByID(creatorIDs).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + + groupMemberUserIDs := make(map[int64][]int64, len(groupIDs)) + groupMemberSeen := make(map[int64]map[int64]struct{}, len(groupIDs)) + for _, relation := range relations { + if _, ok := groupMemberSeen[relation.MembershipID]; !ok { + groupMemberSeen[relation.MembershipID] = map[int64]struct{}{} + } + if _, exists := groupMemberSeen[relation.MembershipID][relation.UserID]; exists { + continue + } + groupMemberSeen[relation.MembershipID][relation.UserID] = struct{}{} + + groupMemberUserIDs[relation.MembershipID] = append(groupMemberUserIDs[relation.MembershipID], relation.UserID) + } + + responses := make([]*dto.UserGroupResponse, 0, len(groups)) + for _, g := range groups { + resp := &dto.UserGroupResponse{ + ExternalID: g.ExternalID, + Name: g.Name, + Description: g.Description, + MemberCount: len(groupMemberUserIDs[g.ID]), + } + if creator := creatorMap[g.CreatorID]; creator != nil { + resp.CreatorUserExternalID = creator.ExternalID + } + responses = append(responses, resp) + } + return responses, nil +} + +func (s *MembershipService) resolveUserExternalIDsToIDs(externalIDs []string, tx *gorm.DB) ([]int64, error) { + if len(externalIDs) == 0 { + return []int64{}, nil + } + + deduped := make([]string, 0, len(externalIDs)) + seen := map[string]struct{}{} + for _, ext := range externalIDs { + ext = strings.TrimSpace(ext) + if ext == "" { + return nil, status.StatusParamError + } + if _, ok := seen[ext]; ok { + continue + } + seen[ext] = struct{}{} + deduped = append(deduped, ext) + } + + users, err := s.useRepo.ListByExternal(deduped).WithTx(tx).Exec() + if err != nil { + return nil, status.StatusReadDBError + } + if len(users) != len(deduped) { + return nil, status.StatusUserNotFound + } + + userIDs := make([]int64, 0, len(users)) + for _, user := range users { + userIDs = append(userIDs, user.ID) + } + return userIDs, nil +} + +func (s *MembershipService) syncUserGroupMembers(tx *gorm.DB, groupID int64, targetUserIDs []int64) error { + existingRelations, err := s.repo.ListMembership(&model.MembershipRelation{ + Type: model.MembershipType_UserGroup, + MembershipID: groupID, + }).WithTx(tx).Exec() + if err != nil { + return status.StatusReadDBError + } + + existingUserIDs := map[int64]struct{}{} + for _, relation := range existingRelations { + existingUserIDs[relation.UserID] = struct{}{} + } + targetUserIDSet := map[int64]struct{}{} + for _, userID := range targetUserIDs { + targetUserIDSet[userID] = struct{}{} + } + + toCreate := make([]*model.MembershipRelation, 0) + for userID := range targetUserIDSet { + if _, ok := existingUserIDs[userID]; ok { + continue + } + toCreate = append(toCreate, &model.MembershipRelation{ + Type: model.MembershipType_UserGroup, + UserID: userID, + MembershipID: groupID, + }) + } + + toDeleteUserIDs := make([]int64, 0) + for userID := range existingUserIDs { + if _, ok := targetUserIDSet[userID]; ok { + continue + } + toDeleteUserIDs = append(toDeleteUserIDs, userID) + } + + if len(toCreate) > 0 { + if err := s.repo.BatchCreateMembership(toCreate).WithTx(tx).Exec(); err != nil { + return status.StatusWriteDBError + } + } + if len(toDeleteUserIDs) > 0 { + if err := tx.Where("type = ? AND membership_id = ? AND user_id IN ?", model.MembershipType_UserGroup, groupID, toDeleteUserIDs). + Delete(&model.MembershipRelation{}).Error; err != nil { + return status.StatusWriteDBError + } + } + return nil +} diff --git a/internal/service/mq_service/mq_service.go b/internal/service/mq_service/mq_service.go new file mode 100644 index 0000000..54dfc79 --- /dev/null +++ b/internal/service/mq_service/mq_service.go @@ -0,0 +1,206 @@ +package mq_service + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/config" + svc_iface "github.com/XingfenD/yoresee_doc/internal/service/interface" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/mq" + "github.com/bytedance/sonic" + "github.com/sirupsen/logrus" +) + +type MQService struct { + backend mq.Backend + queues map[mq.Backend]mq.MessageQueue +} + +var MQSvc = &MQService{ + backend: mq.BackendRedis, + queues: map[mq.Backend]mq.MessageQueue{}, +} + +func (srvc *MQService) IsInitialized() bool { + return srvc != nil && len(srvc.queues) > 0 +} + +func (srvc *MQService) Init(cfg *config.MQConfig) error { + if srvc == nil { + return status.StatusMQNotInitialized + } + + var options *mq.Options + if cfg != nil { + options = &mq.Options{RabbitMQURL: cfg.RabbitMQ.URL} + } + queues, err := mq.NewMessageQueues(options) + if err != nil { + logrus.Errorf("[Service layer: MQService] Init failed, err=%+v", err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "init message queue failed") + } + srvc.queues = queues + return nil +} + +func (srvc *MQService) Close() error { + if srvc == nil { + return status.StatusMQNotInitialized + } + + var firstErr error + for backend, q := range srvc.queues { + if q == nil { + continue + } + if err := q.Close(); err != nil { + logrus.Errorf("[Service layer: MQService] Close backend failed, backend=%s, err=%+v", backend, err) + if firstErr == nil { + firstErr = err + } + } + } + srvc.queues = map[mq.Backend]mq.MessageQueue{} + return firstErr +} + +func (srvc *MQService) Publish(ctx context.Context, backend mq.Backend, msg mq.PublishMessage) error { + if !srvc.IsInitialized() { + return status.StatusMQNotInitialized + } + + msg.Topic = strings.TrimSpace(msg.Topic) + if msg.Topic == "" { + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "publish message failed") + } + + q, err := srvc.getQueue(backend) + if err != nil { + logrus.Errorf("[Service layer: MQService] Publish getQueue failed, backend=%s, err=%+v", backend, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "publish message failed") + } + + if err := q.Publish(ctx, msg); err != nil { + logrus.Errorf("[Service layer: MQService] Publish failed, backend=%s, topic=%s, err=%+v", backend, msg.Topic, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "publish message failed") + } + return nil +} + +func (srvc *MQService) Consume(ctx context.Context, backend mq.Backend, opts mq.ConsumeOptions, handler func(context.Context, mq.Message) error) error { + if !srvc.IsInitialized() { + return status.StatusMQNotInitialized + } + opts = normalizeConsumeOptions(opts) + if opts.Topic == "" { + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "subscribe message failed") + } + if err := validateConsumeOptions(opts); err != nil { + logrus.Errorf("[Service layer: MQService] Consume options invalid, backend=%s, topic=%s, err=%+v", backend, opts.Topic, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "subscribe message failed") + } + + q, err := srvc.getQueue(backend) + if err != nil { + logrus.Errorf("[Service layer: MQService] Consume getQueue failed, backend=%s, topic=%s, err=%+v", backend, opts.Topic, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "subscribe message failed") + } + + if err := q.Consume(ctx, opts, handler); err != nil { + logrus.Errorf("[Service layer: MQService] Consume failed, backend=%s, topic=%s, err=%+v", backend, opts.Topic, err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "subscribe message failed") + } + return nil +} + +func PublishByProducer[T any](ctx context.Context, producer svc_iface.TopicProducer, taskData T) error { + if !MQSvc.IsInitialized() { + return status.StatusMQNotInitialized + } + data, err := sonic.Marshal(taskData) + if err != nil { + logrus.Errorf("[Service layer: MQService] marshal failed, producer=%s, err=%+v", producer.Topic(), err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "marshal message failed") + } + return MQSvc.Publish(ctx, MQSvc.backend, mq.PublishMessage{ + Topic: producer.Topic(), + Body: data, + }) +} + +func PublishByProducerTo[T any](ctx context.Context, backend mq.Backend, producer svc_iface.TopicProducer, taskData T) error { + if !MQSvc.IsInitialized() { + return status.StatusMQNotInitialized + } + data, err := sonic.Marshal(taskData) + if err != nil { + logrus.Errorf("[Service layer: MQService] marshal failed, backend=%s, producer=%s, err=%+v", backend, producer.Topic(), err) + return status.GenErrWithCustomMsg(status.StatusServiceInternalError, "marshal message failed") + } + return MQSvc.Publish(ctx, backend, mq.PublishMessage{ + Topic: producer.Topic(), + Body: data, + }) +} + +func normalizeConsumeOptions(opts mq.ConsumeOptions) mq.ConsumeOptions { + n := opts + n.Topic = strings.TrimSpace(n.Topic) + n.Group = strings.TrimSpace(n.Group) + n.Consumer = strings.TrimSpace(n.Consumer) + if n.Mode == "" { + n.Mode = mq.ConsumeModeFanout + } + if n.OnError == "" { + n.OnError = mq.ErrorActionRequeue + } + + if n.Mode == mq.ConsumeModeGroup { + n.Group = utils.NormalizeToken(n.Group, "default") + } + + consumer := n.Consumer + if consumer == "" { + hostName, _ := os.Hostname() + consumer = fmt.Sprintf( + "%s-%s-%d", + utils.NormalizeToken(n.Topic, "default"), + utils.NormalizeToken(hostName, "default"), + os.Getpid(), + ) + } + n.Consumer = utils.NormalizeToken(consumer, "default") + + return n +} + +func validateConsumeOptions(opts mq.ConsumeOptions) error { + switch opts.Mode { + case mq.ConsumeModeFanout, mq.ConsumeModeGroup: + default: + return fmt.Errorf("invalid consume mode: %s", opts.Mode) + } + + switch opts.OnError { + case mq.ErrorActionDrop, mq.ErrorActionRequeue: + default: + return fmt.Errorf("invalid error action: %s", opts.OnError) + } + + return nil +} + +func (srvc *MQService) getQueue(backend mq.Backend) (mq.MessageQueue, error) { + if srvc == nil || len(srvc.queues) == 0 { + return nil, fmt.Errorf("message queue not initialized") + } + q, ok := srvc.queues[backend] + if !ok || q == nil { + return nil, fmt.Errorf("message queue backend not initialized: %s", backend) + } + return q, nil +} diff --git a/internal/service/notification_service/notification_service.go b/internal/service/notification_service/notification_service.go new file mode 100644 index 0000000..4c6b92e --- /dev/null +++ b/internal/service/notification_service/notification_service.go @@ -0,0 +1,127 @@ +package notification_service + +import ( + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/notification_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/sirupsen/logrus" +) + +type NotificationService struct { + notificationRepo *notification_repo.NotificationRepository + userRepo *user_repo.UserRepository +} + +func NewNotificationService(repos *repository.Repositories) *NotificationService { + return &NotificationService{ + notificationRepo: repos.Notification, + userRepo: repos.User, + } +} + +func (s *NotificationService) CreateNotifications(req *dto.CreateNotificationRequest) error { + if req == nil || len(req.ReceiverExternalIDs) == 0 { + return status.StatusParamError + } + if strings.TrimSpace(req.Type) == "" { + return status.StatusParamError + } + + users, err := s.userRepo.ListByExternal(req.ReceiverExternalIDs).Exec() + if err != nil { + return status.StatusReadDBError + } + if len(users) == 0 { + return status.StatusUserNotFound + } + + userIDMap := make(map[string]int64, len(users)) + for _, user := range users { + userIDMap[user.ExternalID] = user.ID + } + + items := make([]model.Notification, 0, len(req.ReceiverExternalIDs)) + for _, externalID := range req.ReceiverExternalIDs { + id, ok := userIDMap[externalID] + if !ok { + return status.StatusUserNotFound + } + items = append(items, model.Notification{ + ExternalID: utils.GenerateExternalID(utils.ExternalIDContextNotification), + ReceiverID: id, + Type: req.Type, + Status: "unread", + Title: req.Title, + Content: req.Content, + Payload: req.PayloadJSON, + }) + } + + if err := s.notificationRepo.CreateBatch(items).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +func (s *NotificationService) ListNotifications(req *dto.ListNotificationsRequest) ([]model.Notification, int64, error) { + if req == nil || strings.TrimSpace(req.UserExternalID) == "" { + return nil, 0, status.StatusParamError + } + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return nil, 0, status.StatusUserNotFound + } + list, total, err := s.notificationRepo.List(userID). + WithStatus(req.Status). + WithPagination(req.Pagination.Page, req.Pagination.PageSize). + ExecWithTotal() + if err != nil { + logrus.Errorf("[Service layer: NotificationService] list notifications failed, user_external_id=%s, err=%+v", req.UserExternalID, err) + return nil, 0, status.GenErrWithCustomMsg(status.StatusReadDBError, "list notifications failed") + } + + for i := range list { + if strings.TrimSpace(list[i].ExternalID) == "" { + externalID := utils.GenerateExternalID(utils.ExternalIDContextNotification) + list[i].ExternalID = externalID + _ = s.notificationRepo.UpdateExternalID(list[i].ID, externalID).Exec() + } + } + return list, total, nil +} + +func (s *NotificationService) MarkRead(req *dto.MarkNotificationsReadRequest) error { + if req == nil || strings.TrimSpace(req.UserExternalID) == "" { + return status.StatusParamError + } + userID, err := s.userRepo.GetIDByExternalID(req.UserExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + if err := s.notificationRepo.MarkRead(userID, req.ExternalIDs).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +func (s *NotificationService) MarkAllRead(userExternalID string) error { + if strings.TrimSpace(userExternalID) == "" { + return status.StatusParamError + } + userID, err := s.userRepo.GetIDByExternalID(userExternalID).Exec() + if err != nil { + return status.StatusUserNotFound + } + if err := s.notificationRepo.MarkAllRead(userID).Exec(); err != nil { + return status.StatusWriteDBError + } + return nil +} + +var NotificationSvc *NotificationService diff --git a/internal/service/permission_service/permission_service.go b/internal/service/permission_service/permission_service.go new file mode 100644 index 0000000..6d43c33 --- /dev/null +++ b/internal/service/permission_service/permission_service.go @@ -0,0 +1 @@ +package service diff --git a/internal/service/setting_service/setting_service.go b/internal/service/setting_service/setting_service.go new file mode 100644 index 0000000..01abeed --- /dev/null +++ b/internal/service/setting_service/setting_service.go @@ -0,0 +1,169 @@ +package setting_service + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/constant" + "github.com/XingfenD/yoresee_doc/internal/service/config_service" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/sirupsen/logrus" +) + +type SettingOption struct { + Label string + LabelKey string + Value string +} + +type SettingUI struct { + Component string + Options []SettingOption + Placeholder string + PlaceholderKey string +} + +type SettingItem struct { + Key string + Label string + LabelKey string + Description string + DescriptionKey string + Type string + UI SettingUI + Value string + DefaultValue string + Required bool + Readonly bool +} + +type SettingGroup struct { + Key string + Title string + TitleKey string + Items []SettingItem +} + +type SettingUpdate struct { + Key string + Value string +} + +type SettingService struct{} + +var SettingSvc *SettingService + +func InitSettingService() { + SettingSvc = &SettingService{} +} + +func (s *SettingService) GetSettings(ctx context.Context, scene string) ([]SettingGroup, error) { + if scene == "" || scene == "system" || scene == "manage" { + return s.buildSystemSecuritySettings(ctx), nil + } + return []SettingGroup{}, nil +} + +func (s *SettingService) UpdateSettings(ctx context.Context, updates []SettingUpdate) error { + for _, update := range updates { + key := strings.TrimSpace(update.Key) + if key == "" { + continue + } + switch key { + case systemRegisterModeKey(): + val := strings.TrimSpace(update.Value) + if val != constant.RegisterMode_Open && val != constant.RegisterMode_Invite { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid register mode") + } + if err := config_service.ConfigSvc.Set(ctx, key, val); err != nil { + logrus.Errorf("[Service layer: SettingService] set register mode failed, key=%s, value=%s, err=%+v", key, val, err) + return status.GenErrWithCustomMsg(err, "update register mode failed") + } + storage.Consul.ClearCacheKey(key) + case systemRegisterLimitKey(): + val := strings.ToLower(strings.TrimSpace(update.Value)) + boolVal := val == "true" || val == "1" || val == "on" + writeVal := constant.RegisterLimit_Off + if boolVal { + writeVal = constant.RegisterLimit_On + } + if err := config_service.ConfigSvc.Set(ctx, key, writeVal); err != nil { + logrus.Errorf("[Service layer: SettingService] set register limit failed, key=%s, value=%s, err=%+v", key, writeVal, err) + return status.GenErrWithCustomMsg(err, "update register limit failed") + } + storage.Consul.ClearCacheKey(key) + default: + return status.GenErrWithCustomMsg(status.StatusParamError, "unknown setting key") + } + } + return nil +} + +func (s *SettingService) buildSystemSecuritySettings(ctx context.Context) []SettingGroup { + registerMode := config_service.ConfigSvc.GetSystemRegisterMode(ctx) + registerLimit := config_service.ConfigSvc.GetSystemRegisterLimit(ctx) + limitValue := "false" + if registerLimit { + limitValue = "true" + } + + return []SettingGroup{ + { + Key: "security", + TitleKey: "system.security.registration", + Items: []SettingItem{ + { + Key: systemRegisterModeKey(), + LabelKey: "system.security.registrationMode", + Type: "string", + Value: registerMode, + DefaultValue: constant.RegisterMode_Invite, + Required: true, + UI: SettingUI{ + Component: "radio", + Options: []SettingOption{ + { + LabelKey: "system.security.freeRegister", + Value: constant.RegisterMode_Open, + }, + { + LabelKey: "system.security.inviteOnly", + Value: constant.RegisterMode_Invite, + }, + }, + }, + }, + { + Key: systemRegisterLimitKey(), + LabelKey: "system.security.registerLimit", + DescriptionKey: "system.security.registerLimitDesc", + Type: "bool", + Value: limitValue, + DefaultValue: "false", + UI: SettingUI{ + Component: "switch", + }, + }, + }, + }, + } +} + +func systemRegisterModeKey() string { + return utils.GenConfigKey( + constant.ConfigKey_First_System, + constant.ConfigKey_Second_Security, + constant.ConfigKey_Third_RegisterMode, + ) +} + +func systemRegisterLimitKey() string { + return utils.GenConfigKey( + constant.ConfigKey_First_System, + constant.ConfigKey_Second_Security, + constant.ConfigKey_Third_RegisterLimit, + ) +} diff --git a/internal/service/user_service/user_service.go b/internal/service/user_service/user_service.go new file mode 100644 index 0000000..d63b4cd --- /dev/null +++ b/internal/service/user_service/user_service.go @@ -0,0 +1,48 @@ +package user_service + +import ( + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/sirupsen/logrus" +) + +type UserService struct { + userRepo *user_repo.UserRepository +} + +func NewUserService(repos *repository.Repositories) *UserService { + return &UserService{ + userRepo: repos.User, + } +} + +func (s *UserService) GetByExternalID(externalID string) (*dto.UserResponse, error) { + userModel, err := s.userRepo.GetByExternalID(externalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: UserService] GetByExternalID failed, external_id=%s, err=%+v", externalID, err) + return nil, status.GenErrWithCustomMsg(status.StatusUserNotFound, "user not found") + } + return dto.NewUserResponseFromModel(userModel), nil +} + +func (s *UserService) GetByID(id int64) (*dto.UserResponse, error) { + userModel, err := s.userRepo.GetByID(id).Exec() + if err != nil { + logrus.Errorf("[Service layer: UserService] GetByID failed, id=%d, err=%+v", id, err) + return nil, status.GenErrWithCustomMsg(status.StatusUserNotFound, "user not found") + } + return dto.NewUserResponseFromModel(userModel), nil +} + +func (s *UserService) GetIDByExternalID(externalID string) (int64, error) { + id, err := s.userRepo.GetIDByExternalID(externalID).Exec() + if err != nil { + logrus.Errorf("[Service layer: UserService] GetIDByExternalID failed, external_id=%s, err=%+v", externalID, err) + return 0, status.GenErrWithCustomMsg(status.StatusUserNotFound, "user not found") + } + return id, nil +} + +var UserSvc *UserService diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..d12032b --- /dev/null +++ b/internal/status/status.go @@ -0,0 +1,62 @@ +package status + +type Status struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (s *Status) Error() string { + return s.Message +} + +func NewStatusErr(code int, msg string) error { + return &Status{ + Code: code, + Message: msg, + } +} + +func GenErrWithCustomMsg(err error, msg string) error { + statusErr, ok := err.(*Status) + if !ok { + return StatusServiceInternalError + } + return &Status{ + Code: statusErr.Code, + Message: msg, + } +} + +var ( + StatusSuccess = NewStatusErr(0, "success") + + StatusParamError = NewStatusErr(40000, "invalid parameter") + StatusTokenInvalid = NewStatusErr(40001, "invalid token") + StatusTokenExpired = NewStatusErr(40002, "token expired") + StatusPermissionDenied = NewStatusErr(40003, "permission denied") + + StatusUserNotFound = NewStatusErr(40100, "user not found") + StatusUserAlreadyExists = NewStatusErr(40101, "user already exists") + StatusInvalidPassword = NewStatusErr(40102, "invalid password") + StatusDocumentNotFound = NewStatusErr(40120, "document not found") + + StatusMembershipMetaNotFound = NewStatusErr(40140, "membership meta not found") + StatusInvalidMembershipType = NewStatusErr(40141, "invalid membership type") + StatusOrgNodeHasChildren = NewStatusErr(40142, "org node has children") + StatusOrgNodeCannotMoveToDescendant = NewStatusErr(40143, "org node cannot move to descendant") + + StatusKnowledgeBaseNotFound = NewStatusErr(40150, "knowledge base not found") + + StatusInvitationInvalid = NewStatusErr(40200, "invitation invalid") + + StatusPermissionDenied_DocumentRead = NewStatusErr(40300, "permission denied for reading document") + + StatusServiceInternalError = NewStatusErr(50000, "service internal error") + StatusWriteDBError = NewStatusErr(50001, "write db error") + StatusReadDBError = NewStatusErr(50002, "read db error") + StatusMQNotInitialized = NewStatusErr(50002, "message queue not initialized") + StatusRedisNotInitialized = NewStatusErr(50003, "redis not initialized") + + StatusInternalParamsError = NewStatusErr(50010, "invalid internal arguments") + StatusTypeAssertFailed = NewStatusErr(50011, "type assert failed") +) diff --git a/internal/transport/connectserver/handlers.go b/internal/transport/connectserver/handlers.go new file mode 100644 index 0000000..9fdeb37 --- /dev/null +++ b/internal/transport/connectserver/handlers.go @@ -0,0 +1,781 @@ +package connectserver + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/transport/grpcserver" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +func registerHandlers(mux *http.ServeMux, opts []connect.HandlerOption, repos *repository.Repositories) { + authSvc := grpcserver.NewAuthServiceServer() + docSvc := grpcserver.NewDocumentServiceServer() + kbSvc := grpcserver.NewKnowledgeBaseServiceServer() + sysSvc := grpcserver.NewSystemServiceServer() + settingSvc := grpcserver.NewSettingServiceServer() + memberSvc := grpcserver.NewMembershipServiceServer() + inviteSvc := grpcserver.NewInvitationServiceServer(repos.User) + notifySvc := grpcserver.NewNotificationServiceServer() + commentSvc := grpcserver.NewCommentServiceServer(repos.User, repos.Document) + + mux.Handle(pb.AuthService_Login_FullMethodName, connect.NewUnaryHandler( + pb.AuthService_Login_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.AuthLoginRequest]) (*connect.Response[pb.AuthLoginResponse], error) { + resp, err := authSvc.Login(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.AuthService_Register_FullMethodName, connect.NewUnaryHandler( + pb.AuthService_Register_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.AuthRegisterRequest]) (*connect.Response[pb.AuthRegisterResponse], error) { + resp, err := authSvc.Register(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.AuthService_UpdateProfile_FullMethodName, connect.NewUnaryHandler( + pb.AuthService_UpdateProfile_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateProfileRequest]) (*connect.Response[pb.UpdateProfileResponse], error) { + resp, err := authSvc.UpdateProfile(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.AuthService_QuerySideBarDisplay_FullMethodName, connect.NewUnaryHandler( + pb.AuthService_QuerySideBarDisplay_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.QuerySideBarDisplayRequest]) (*connect.Response[pb.QuerySideBarDisplayResponse], error) { + resp, err := authSvc.QuerySideBarDisplay(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.AuthService_QueryTopNavDisplay_FullMethodName, connect.NewUnaryHandler( + pb.AuthService_QueryTopNavDisplay_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.QueryTopNavDisplayRequest]) (*connect.Response[pb.QueryTopNavDisplayResponse], error) { + resp, err := authSvc.QueryTopNavDisplay(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_ListDocuments_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_ListDocuments_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListDocumentsRequest]) (*connect.Response[pb.ListDocumentsResponse], error) { + resp, err := docSvc.ListDocuments(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_GetDocumentContent_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_GetDocumentContent_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetDocumentContentRequest]) (*connect.Response[pb.GetDocumentContentResponse], error) { + resp, err := docSvc.GetDocumentContent(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_GetDocumentSettings_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_GetDocumentSettings_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetDocumentSettingsRequest]) (*connect.Response[pb.GetDocumentSettingsResponse], error) { + resp, err := docSvc.GetDocumentSettings(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_RecordRecentDocument_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_RecordRecentDocument_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.RecordRecentDocumentRequest]) (*connect.Response[pb.RecordRecentDocumentResponse], error) { + resp, err := docSvc.RecordRecentDocument(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_ListRecentDocuments_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_ListRecentDocuments_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListRecentDocumentsRequest]) (*connect.Response[pb.ListRecentDocumentsResponse], error) { + resp, err := docSvc.ListRecentDocuments(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_GetDocumentYjsSnapshot_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_GetDocumentYjsSnapshot_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetDocumentYjsSnapshotRequest]) (*connect.Response[pb.GetDocumentYjsSnapshotResponse], error) { + resp, err := docSvc.GetDocumentYjsSnapshot(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_SaveDocumentYjsSnapshot_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_SaveDocumentYjsSnapshot_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.SaveDocumentYjsSnapshotRequest]) (*connect.Response[pb.SaveDocumentYjsSnapshotResponse], error) { + resp, err := docSvc.SaveDocumentYjsSnapshot(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_GetOwnDocuments_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_GetOwnDocuments_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetOwnDocumentsRequest]) (*connect.Response[pb.GetOwnDocumentsResponse], error) { + resp, err := docSvc.GetOwnDocuments(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_CreateDocument_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_CreateDocument_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateDocumentRequest]) (*connect.Response[pb.CreateDocumentResponse], error) { + resp, err := docSvc.CreateDocument(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_UpdateDocumentMeta_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_UpdateDocumentMeta_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateDocumentMetaRequest]) (*connect.Response[pb.UpdateDocumentMetaResponse], error) { + resp, err := docSvc.UpdateDocumentMeta(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_UpdateDocumentSettings_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_UpdateDocumentSettings_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateDocumentSettingsRequest]) (*connect.Response[pb.UpdateDocumentSettingsResponse], error) { + resp, err := docSvc.UpdateDocumentSettings(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_UploadDocumentAttachment_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_UploadDocumentAttachment_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UploadDocumentAttachmentRequest]) (*connect.Response[pb.UploadDocumentAttachmentResponse], error) { + resp, err := docSvc.UploadDocumentAttachment(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_ListDocumentAttachments_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_ListDocumentAttachments_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListDocumentAttachmentsRequest]) (*connect.Response[pb.ListDocumentAttachmentsResponse], error) { + resp, err := docSvc.ListDocumentAttachments(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_DeleteDocumentAttachment_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_DeleteDocumentAttachment_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.DeleteDocumentAttachmentRequest]) (*connect.Response[pb.DeleteDocumentAttachmentResponse], error) { + resp, err := docSvc.DeleteDocumentAttachment(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_ListDocumentVersions_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_ListDocumentVersions_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListDocumentVersionsRequest]) (*connect.Response[pb.ListDocumentVersionsResponse], error) { + resp, err := docSvc.ListDocumentVersions(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + mux.Handle(pb.DocumentService_GetDocumentVersionContent_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_GetDocumentVersionContent_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetDocumentVersionContentRequest]) (*connect.Response[pb.GetDocumentVersionContentResponse], error) { + resp, err := docSvc.GetDocumentVersionContent(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.KnowledgeBaseService_ListKnowledgeBases_FullMethodName, connect.NewUnaryHandler( + pb.KnowledgeBaseService_ListKnowledgeBases_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListKnowledgeBasesRequest]) (*connect.Response[pb.ListKnowledgeBasesResponse], error) { + resp, err := kbSvc.ListKnowledgeBases(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.KnowledgeBaseService_GetKnowledgeBase_FullMethodName, connect.NewUnaryHandler( + pb.KnowledgeBaseService_GetKnowledgeBase_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetKnowledgeBaseRequest]) (*connect.Response[pb.GetKnowledgeBaseResponse], error) { + resp, err := kbSvc.GetKnowledgeBase(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.KnowledgeBaseService_CreateKnowledgeBase_FullMethodName, connect.NewUnaryHandler( + pb.KnowledgeBaseService_CreateKnowledgeBase_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateKnowledgeBaseRequest]) (*connect.Response[pb.CreateKnowledgeBaseResponse], error) { + resp, err := kbSvc.CreateKnowledgeBase(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.KnowledgeBaseService_ListRecentKnowledgeBases_FullMethodName, connect.NewUnaryHandler( + pb.KnowledgeBaseService_ListRecentKnowledgeBases_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListRecentKnowledgeBasesRequest]) (*connect.Response[pb.ListRecentKnowledgeBasesResponse], error) { + resp, err := kbSvc.ListRecentKnowledgeBases(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.SystemService_Health_FullMethodName, connect.NewUnaryHandler( + pb.SystemService_Health_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.HealthRequest]) (*connect.Response[pb.HealthResponse], error) { + resp, err := sysSvc.Health(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.SystemService_SystemInfo_FullMethodName, connect.NewUnaryHandler( + pb.SystemService_SystemInfo_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.SystemInfoRequest]) (*connect.Response[pb.SystemInfoResponse], error) { + resp, err := sysSvc.SystemInfo(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.SettingService_GetSettings_FullMethodName, connect.NewUnaryHandler( + pb.SettingService_GetSettings_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetSettingsRequest]) (*connect.Response[pb.GetSettingsResponse], error) { + resp, err := settingSvc.GetSettings(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.SettingService_UpdateSettings_FullMethodName, connect.NewUnaryHandler( + pb.SettingService_UpdateSettings_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateSettingsRequest]) (*connect.Response[pb.UpdateSettingsResponse], error) { + resp, err := settingSvc.UpdateSettings(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_ListUserGroups_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_ListUserGroups_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListUserGroupsRequest]) (*connect.Response[pb.ListUserGroupsResponse], error) { + resp, err := memberSvc.ListUserGroups(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_GetUserGroup_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_GetUserGroup_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetUserGroupRequest]) (*connect.Response[pb.GetUserGroupResponse], error) { + resp, err := memberSvc.GetUserGroup(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_CreateUserGroup_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_CreateUserGroup_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateUserGroupRequest]) (*connect.Response[pb.CreateUserGroupResponse], error) { + resp, err := memberSvc.CreateUserGroup(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_UpdateUserGroup_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_UpdateUserGroup_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateUserGroupRequest]) (*connect.Response[pb.UpdateUserGroupResponse], error) { + resp, err := memberSvc.UpdateUserGroup(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_DeleteUserGroup_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_DeleteUserGroup_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.DeleteUserGroupRequest]) (*connect.Response[pb.DeleteUserGroupResponse], error) { + resp, err := memberSvc.DeleteUserGroup(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_ListUsers_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_ListUsers_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListUsersRequest]) (*connect.Response[pb.ListUsersResponse], error) { + resp, err := memberSvc.ListUsers(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_UpdateUser_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_UpdateUser_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateUserRequest]) (*connect.Response[pb.UpdateUserResponse], error) { + resp, err := memberSvc.UpdateUser(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_ListUserGroupMembers_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_ListUserGroupMembers_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListUserGroupMembersRequest]) (*connect.Response[pb.ListUserGroupMembersResponse], error) { + resp, err := memberSvc.ListUserGroupMembers(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_ListOrgNodes_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_ListOrgNodes_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListOrgNodesRequest]) (*connect.Response[pb.ListOrgNodesResponse], error) { + resp, err := memberSvc.ListOrgNodes(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_GetOrgNode_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_GetOrgNode_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetOrgNodeRequest]) (*connect.Response[pb.GetOrgNodeResponse], error) { + resp, err := memberSvc.GetOrgNode(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_CreateOrgNode_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_CreateOrgNode_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateOrgNodeRequest]) (*connect.Response[pb.CreateOrgNodeResponse], error) { + resp, err := memberSvc.CreateOrgNode(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_UpdateOrgNode_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_UpdateOrgNode_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateOrgNodeRequest]) (*connect.Response[pb.UpdateOrgNodeResponse], error) { + resp, err := memberSvc.UpdateOrgNode(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_DeleteOrgNode_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_DeleteOrgNode_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.DeleteOrgNodeRequest]) (*connect.Response[pb.DeleteOrgNodeResponse], error) { + resp, err := memberSvc.DeleteOrgNode(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_MoveOrgNode_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_MoveOrgNode_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.MoveOrgNodeRequest]) (*connect.Response[pb.MoveOrgNodeResponse], error) { + resp, err := memberSvc.MoveOrgNode(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.MembershipService_ListOrgNodeMembers_FullMethodName, connect.NewUnaryHandler( + pb.MembershipService_ListOrgNodeMembers_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListOrgNodeMembersRequest]) (*connect.Response[pb.ListOrgNodeMembersResponse], error) { + resp, err := memberSvc.ListOrgNodeMembers(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.InvitationService_CreateInvitation_FullMethodName, connect.NewUnaryHandler( + pb.InvitationService_CreateInvitation_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateInvitationRequest]) (*connect.Response[pb.CreateInvitationResponse], error) { + resp, err := inviteSvc.CreateInvitation(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.InvitationService_ListInvitations_FullMethodName, connect.NewUnaryHandler( + pb.InvitationService_ListInvitations_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListInvitationsRequest]) (*connect.Response[pb.ListInvitationsResponse], error) { + resp, err := inviteSvc.ListInvitations(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.InvitationService_UpdateInvitation_FullMethodName, connect.NewUnaryHandler( + pb.InvitationService_UpdateInvitation_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateInvitationRequest]) (*connect.Response[pb.UpdateInvitationResponse], error) { + resp, err := inviteSvc.UpdateInvitation(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.InvitationService_DeleteInvitation_FullMethodName, connect.NewUnaryHandler( + pb.InvitationService_DeleteInvitation_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.DeleteInvitationRequest]) (*connect.Response[pb.DeleteInvitationResponse], error) { + resp, err := inviteSvc.DeleteInvitation(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.InvitationService_ListInvitationRecords_FullMethodName, connect.NewUnaryHandler( + pb.InvitationService_ListInvitationRecords_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListInvitationRecordsRequest]) (*connect.Response[pb.ListInvitationRecordsResponse], error) { + resp, err := inviteSvc.ListInvitationRecords(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.NotificationService_CreateNotification_FullMethodName, connect.NewUnaryHandler( + pb.NotificationService_CreateNotification_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateNotificationRequest]) (*connect.Response[pb.CreateNotificationResponse], error) { + resp, err := notifySvc.CreateNotification(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.NotificationService_ListNotifications_FullMethodName, connect.NewUnaryHandler( + pb.NotificationService_ListNotifications_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListNotificationsRequest]) (*connect.Response[pb.ListNotificationsResponse], error) { + resp, err := notifySvc.ListNotifications(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.NotificationService_MarkNotificationsRead_FullMethodName, connect.NewUnaryHandler( + pb.NotificationService_MarkNotificationsRead_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.MarkNotificationsReadRequest]) (*connect.Response[pb.MarkNotificationsReadResponse], error) { + resp, err := notifySvc.MarkNotificationsRead(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.NotificationService_MarkAllNotificationsRead_FullMethodName, connect.NewUnaryHandler( + pb.NotificationService_MarkAllNotificationsRead_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.MarkAllNotificationsReadRequest]) (*connect.Response[pb.MarkAllNotificationsReadResponse], error) { + resp, err := notifySvc.MarkAllNotificationsRead(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.CommentService_CreateDocumentComment_FullMethodName, connect.NewUnaryHandler( + pb.CommentService_CreateDocumentComment_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateDocumentCommentRequest]) (*connect.Response[pb.CreateDocumentCommentResponse], error) { + resp, err := commentSvc.CreateDocumentComment(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.CommentService_ListDocumentComments_FullMethodName, connect.NewUnaryHandler( + pb.CommentService_ListDocumentComments_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListDocumentCommentsRequest]) (*connect.Response[pb.ListDocumentCommentsResponse], error) { + resp, err := commentSvc.ListDocumentComments(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.CommentService_DeleteDocumentComment_FullMethodName, connect.NewUnaryHandler( + pb.CommentService_DeleteDocumentComment_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.DeleteDocumentCommentRequest]) (*connect.Response[pb.DeleteDocumentCommentResponse], error) { + resp, err := commentSvc.DeleteDocumentComment(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.CommentService_UpdateDocumentComment_FullMethodName, connect.NewUnaryHandler( + pb.CommentService_UpdateDocumentComment_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateDocumentCommentRequest]) (*connect.Response[pb.UpdateDocumentCommentResponse], error) { + resp, err := commentSvc.UpdateDocumentComment(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_UpdateDocument_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_UpdateDocument_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateDocumentRequest]) (*connect.Response[pb.UpdateDocumentResponse], error) { + resp, err := docSvc.UpdateDocument(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_CreateTemplate_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_CreateTemplate_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.CreateTemplateRequest]) (*connect.Response[pb.CreateTemplateResponse], error) { + resp, err := docSvc.CreateTemplate(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_GetTemplate_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_GetTemplate_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.GetTemplateRequest]) (*connect.Response[pb.GetTemplateResponse], error) { + resp, err := docSvc.GetTemplate(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_ListTemplates_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_ListTemplates_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListTemplatesRequest]) (*connect.Response[pb.ListTemplatesResponse], error) { + resp, err := docSvc.ListTemplates(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_ListRecentTemplates_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_ListRecentTemplates_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.ListRecentTemplatesRequest]) (*connect.Response[pb.ListRecentTemplatesResponse], error) { + resp, err := docSvc.ListRecentTemplates(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_UpdateTemplateSettings_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_UpdateTemplateSettings_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.UpdateTemplateSettingsRequest]) (*connect.Response[pb.UpdateTemplateSettingsResponse], error) { + resp, err := docSvc.UpdateTemplateSettings(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) + + mux.Handle(pb.DocumentService_DeleteDocument_FullMethodName, connect.NewUnaryHandler( + pb.DocumentService_DeleteDocument_FullMethodName, + func(ctx context.Context, req *connect.Request[pb.DeleteDocumentRequest]) (*connect.Response[pb.DeleteDocumentResponse], error) { + resp, err := docSvc.DeleteDocument(ctx, req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + opts..., + )) +} diff --git a/internal/transport/connectserver/interceptors.go b/internal/transport/connectserver/interceptors.go new file mode 100644 index 0000000..14ccef2 --- /dev/null +++ b/internal/transport/connectserver/interceptors.go @@ -0,0 +1,39 @@ +package connectserver + +import ( + "context" + + "connectrpc.com/connect" + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/middleware" +) + +func UnaryAuthInterceptor(allowUnauth map[string]struct{}) connect.UnaryInterceptorFunc { + return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if _, ok := allowUnauth[req.Spec().Procedure]; ok { + return next(ctx, req) + } + + internalKey := req.Header().Get("x-internal-key") + if internalKey != "" && config.GlobalConfig.Backend.InternalRPCKey != "" && internalKey == config.GlobalConfig.Backend.InternalRPCKey { + return next(ctx, req) + } + + authHeader := req.Header().Get("Authorization") + if authHeader == "" { + authHeader = req.Header().Get("authorization") + } + + claims, err := middleware.JWTAuth.ValidateAuthorizationHeader(authHeader) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + + ctx = context.WithValue(ctx, "user_external_id", claims.ExternalID) + ctx = context.WithValue(ctx, "username", claims.Username) + + return next(ctx, req) + } + }) +} diff --git a/internal/transport/connectserver/probe.go b/internal/transport/connectserver/probe.go new file mode 100644 index 0000000..34ec1c2 --- /dev/null +++ b/internal/transport/connectserver/probe.go @@ -0,0 +1,72 @@ +package connectserver + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type probeResponse struct { + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +func registerProbeRoutes(mux *http.ServeMux) { + mux.HandleFunc("/health", handleHealthProbe) + mux.HandleFunc("/readyz", handleReadinessProbe) + mux.HandleFunc("/livez", handleLivenessProbe) +} + +func handleHealthProbe(w http.ResponseWriter, _ *http.Request) { + writeProbeResponse(w, http.StatusOK, "ok", "") +} + +func handleLivenessProbe(w http.ResponseWriter, _ *http.Request) { + writeProbeResponse(w, http.StatusOK, "ok", "") +} + +func handleReadinessProbe(w http.ResponseWriter, _ *http.Request) { + if err := readinessErr(); err != nil { + writeProbeResponse(w, http.StatusServiceUnavailable, "not_ready", err.Error()) + return + } + writeProbeResponse(w, http.StatusOK, "ok", "") +} + +func readinessErr() error { + if draining.Load() { + return fmt.Errorf("server is draining") + } + if readyDB == nil { + return fmt.Errorf("database is not initialized") + } + if readyKVS == nil { + return fmt.Errorf("redis is not initialized") + } + + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + + sqlDB, err := readyDB.DB() + if err != nil { + return fmt.Errorf("database handle unavailable: %w", err) + } + if err := sqlDB.PingContext(ctx); err != nil { + return fmt.Errorf("database ping failed: %w", err) + } + if err := readyKVS.Ping(ctx).Err(); err != nil { + return fmt.Errorf("redis ping failed: %w", err) + } + return nil +} + +func writeProbeResponse(w http.ResponseWriter, statusCode int, status, detail string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(probeResponse{ + Status: status, + Detail: detail, + }) +} diff --git a/internal/transport/connectserver/server.go b/internal/transport/connectserver/server.go new file mode 100644 index 0000000..e8efdbb --- /dev/null +++ b/internal/transport/connectserver/server.go @@ -0,0 +1,91 @@ +package connectserver + +import ( + "errors" + "fmt" + "net/http" + "sync/atomic" + + "connectrpc.com/connect" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/redis/go-redis/v9" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" + "github.com/sirupsen/logrus" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "gorm.io/gorm" +) + +var ( + draining atomic.Bool + readyDB *gorm.DB + readyKVS *redis.Client +) + +func SetDraining(v bool) { + draining.Store(v) +} + +func Start(grpcPort, grpcWebPort int, db *gorm.DB, kvs *redis.Client, repos *repository.Repositories) (*http.Server, *http.Server, error) { + readyDB = db + readyKVS = kvs + allowUnauth := map[string]struct{}{ + pb.AuthService_Login_FullMethodName: {}, + pb.AuthService_Register_FullMethodName: {}, + pb.SystemService_Health_FullMethodName: {}, + pb.SystemService_SystemInfo_FullMethodName: {}, + } + + interceptor := UnaryAuthInterceptor(allowUnauth) + opts := []connect.HandlerOption{ + connect.WithInterceptors(interceptor), + } + + mux := http.NewServeMux() + registerHandlers(mux, opts, repos) + registerProbeRoutes(mux) + + handler := withCORS(mux) + h2cHandler := h2c.NewHandler(handler, &http2.Server{}) + + grpcAddr := fmt.Sprintf(":%d", grpcPort) + grpcServer := &http.Server{Addr: grpcAddr, Handler: h2cHandler} + go func() { + if err := grpcServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logrus.Errorf("grpc server exited unexpectedly: %v", err) + } + }() + + grpcWebAddr := fmt.Sprintf(":%d", grpcWebPort) + grpcWebServer := &http.Server{Addr: grpcWebAddr, Handler: h2cHandler} + go func() { + if err := grpcWebServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logrus.Errorf("grpc-web server exited unexpectedly: %v", err) + } + }() + + return grpcServer, grpcWebServer, nil +} + +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + setConnectCorsHeaders(w, r) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} + +func setConnectCorsHeaders(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + origin = "*" + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Accept,Accept-Language,Content-Type,Content-Length,X-Grpc-Web,X-User-Agent,Grpc-Timeout,Authorization,Connect-Protocol-Version") + w.Header().Set("Access-Control-Allow-Methods", "POST,GET,OPTIONS") + w.Header().Set("Access-Control-Expose-Headers", "Grpc-Status,Grpc-Message,Grpc-Status-Details-Bin,Connect-Protocol-Version") +} diff --git a/internal/transport/grpcserver/auth_service.go b/internal/transport/grpcserver/auth_service.go new file mode 100644 index 0000000..514fe64 --- /dev/null +++ b/internal/transport/grpcserver/auth_service.go @@ -0,0 +1,133 @@ +package grpcserver + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/service/auth_service" + "github.com/XingfenD/yoresee_doc/internal/status" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type AuthServiceServer struct { + pb.UnimplementedAuthServiceServer +} + +func NewAuthServiceServer() *AuthServiceServer { + return &AuthServiceServer{} +} + +func (s *AuthServiceServer) Login(ctx context.Context, req *pb.AuthLoginRequest) (*pb.AuthLoginResponse, error) { + if req == nil || req.Email == "" || req.Password == "" { + return &pb.AuthLoginResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + token, user, err := auth_service.AuthSvc.Login(req.Email, req.Password) + if err != nil { + return &pb.AuthLoginResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.AuthLoginResponse{ + Base: baseResponseFromErr(nil), + Token: token, + User: toUserResponse(user), + }, nil +} + +func (s *AuthServiceServer) Register(ctx context.Context, req *pb.AuthRegisterRequest) (*pb.AuthRegisterResponse, error) { + if req == nil || req.Username == "" || req.Password == "" || req.Email == "" { + return &pb.AuthRegisterResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + userCreate := &dto.UserCreate{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + } + if req.InvitationCode != nil { + userCreate.InvitationCode = req.InvitationCode + } + + err := auth_service.AuthSvc.Register(ctx, userCreate) + if err != nil { + return &pb.AuthRegisterResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.AuthRegisterResponse{Base: baseResponseFromErr(nil)}, nil +} + +func (s *AuthServiceServer) UpdateProfile(ctx context.Context, req *pb.UpdateProfileRequest) (*pb.UpdateProfileResponse, error) { + if req == nil { + return &pb.UpdateProfileResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.UpdateProfileResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + serviceReq := &dto.UpdateProfileRequest{ + Username: req.Username, + Email: req.Email, + Nickname: req.Nickname, + Password: req.Password, + AvatarFile: req.AvatarFile, + AvatarFilename: req.AvatarFilename, + AvatarContentType: req.AvatarContentType, + } + + user, err := auth_service.AuthSvc.UpdateProfile(userExternalID, serviceReq) + if err != nil { + return &pb.UpdateProfileResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UpdateProfileResponse{ + Base: baseResponseFromErr(nil), + User: toUserResponse(user), + }, nil +} + +func (s *AuthServiceServer) QuerySideBarDisplay(ctx context.Context, req *pb.QuerySideBarDisplayRequest) (*pb.QuerySideBarDisplayResponse, error) { + if req == nil || req.Scene == "" { + return &pb.QuerySideBarDisplayResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.QuerySideBarDisplayResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + isAdmin, err := auth_service.AuthSvc.IsAdmin(userExternalID) + if err != nil { + return &pb.QuerySideBarDisplayResponse{Base: baseResponseFromErr(err)}, nil + } + + displayTabs, err := auth_service.AuthSvc.QuerySideBarDisplay(req.Scene, isAdmin) + if err != nil { + return &pb.QuerySideBarDisplayResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.QuerySideBarDisplayResponse{ + Base: baseResponseFromErr(nil), + DisplayTabs: displayTabs, + }, nil +} + +func (s *AuthServiceServer) QueryTopNavDisplay(ctx context.Context, req *pb.QueryTopNavDisplayRequest) (*pb.QueryTopNavDisplayResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.QueryTopNavDisplayResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + isAdmin, err := auth_service.AuthSvc.IsAdmin(userExternalID) + if err != nil { + return &pb.QueryTopNavDisplayResponse{Base: baseResponseFromErr(err)}, nil + } + + menus, err := auth_service.AuthSvc.QueryTopNavDisplay(isAdmin) + if err != nil { + return &pb.QueryTopNavDisplayResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.QueryTopNavDisplayResponse{ + Base: baseResponseFromErr(nil), + DisplayMenus: menus, + }, nil +} diff --git a/internal/transport/grpcserver/comment_service.go b/internal/transport/grpcserver/comment_service.go new file mode 100644 index 0000000..857eb94 --- /dev/null +++ b/internal/transport/grpcserver/comment_service.go @@ -0,0 +1,184 @@ +package grpcserver + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/media" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/repository/document_repo" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/service/auth_service" + "github.com/XingfenD/yoresee_doc/internal/service/comment_service" + "github.com/XingfenD/yoresee_doc/internal/status" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type CommentServiceServer struct { + pb.UnimplementedCommentServiceServer + userRepo *user_repo.UserRepository + docRepo *document_repo.DocumentRepository +} + +func NewCommentServiceServer(userRepo *user_repo.UserRepository, docRepo *document_repo.DocumentRepository) *CommentServiceServer { + return &CommentServiceServer{ + userRepo: userRepo, + docRepo: docRepo, + } +} + +func (s *CommentServiceServer) CreateDocumentComment(ctx context.Context, req *pb.CreateDocumentCommentRequest) (*pb.CreateDocumentCommentResponse, error) { + if req == nil { + return &pb.CreateDocumentCommentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.CreateDocumentCommentResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + var parentExternalID *string + if req.ParentExternalId != nil && strings.TrimSpace(req.GetParentExternalId()) != "" { + parentExternalID = req.ParentExternalId + } + var anchorID *string + if req.AnchorId != nil && strings.TrimSpace(req.GetAnchorId()) != "" { + anchorID = req.AnchorId + } + var quote *string + if req.Quote != nil && strings.TrimSpace(req.GetQuote()) != "" { + quote = req.Quote + } + + comment, err := comment_service.CommentSvc.CreateComment(&dto.CreateCommentRequest{ + DocumentExternalID: req.DocumentExternalId, + Content: req.Content, + ParentExternalID: parentExternalID, + AnchorID: anchorID, + Quote: quote, + CreatorExternalID: userExternalID, + MentionedUserExternalIDs: req.MentionedUserExternalIds, + }) + if err != nil { + return &pb.CreateDocumentCommentResponse{Base: baseResponseFromErr(err)}, nil + } + + creator, _ := s.userRepo.GetByExternalID(userExternalID).Exec() + resp := toCommentResponse(comment, req.DocumentExternalId, req.GetParentExternalId(), creator) + return &pb.CreateDocumentCommentResponse{Base: baseResponseFromErr(nil), Comment: resp}, nil +} + +func (s *CommentServiceServer) ListDocumentComments(ctx context.Context, req *pb.ListDocumentCommentsRequest) (*pb.ListDocumentCommentsResponse, error) { + if req == nil { + return &pb.ListDocumentCommentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + comments, total, err := comment_service.CommentSvc.ListComments(&dto.ListCommentsRequest{ + DocumentExternalID: req.DocumentExternalId, + Page: int(req.Page), + PageSize: int(req.PageSize), + }) + if err != nil { + return &pb.ListDocumentCommentsResponse{Base: baseResponseFromErr(err)}, nil + } + + creatorIDs := make([]int64, 0, len(comments)) + for _, item := range comments { + creatorIDs = append(creatorIDs, item.CreatorID) + } + creatorMap, _ := s.userRepo.MGetUserByID(creatorIDs).Exec() + + parentMap := make(map[int64]string, len(comments)) + for _, item := range comments { + parentMap[item.ID] = item.ExternalID + } + + respItems := make([]*pb.DocumentComment, 0, len(comments)) + for _, item := range comments { + parentExternalID := "" + if item.ParentID != 0 { + parentExternalID = parentMap[item.ParentID] + } + respItems = append(respItems, toCommentResponse(&item, req.DocumentExternalId, parentExternalID, creatorMap[item.CreatorID])) + } + + return &pb.ListDocumentCommentsResponse{ + Base: baseResponseFromErr(nil), + Comments: respItems, + Total: total, + }, nil +} + +func (s *CommentServiceServer) DeleteDocumentComment(ctx context.Context, req *pb.DeleteDocumentCommentRequest) (*pb.DeleteDocumentCommentResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.DeleteDocumentCommentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.DeleteDocumentCommentResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + isAdmin, _ := auth_service.AuthSvc.IsAdmin(userExternalID) + if err := comment_service.CommentSvc.DeleteComment(&dto.DeleteCommentRequest{ + ExternalID: req.ExternalId, + OperatorExternalID: userExternalID, + }, isAdmin); err != nil { + return &pb.DeleteDocumentCommentResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.DeleteDocumentCommentResponse{Base: baseResponseFromErr(nil)}, nil +} + +func (s *CommentServiceServer) UpdateDocumentComment(ctx context.Context, req *pb.UpdateDocumentCommentRequest) (*pb.UpdateDocumentCommentResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" || strings.TrimSpace(req.Content) == "" { + return &pb.UpdateDocumentCommentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.UpdateDocumentCommentResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + isAdmin, _ := auth_service.AuthSvc.IsAdmin(userExternalID) + comment, err := comment_service.CommentSvc.UpdateComment(&dto.UpdateCommentRequest{ + ExternalID: req.ExternalId, + Content: req.Content, + OperatorExternalID: userExternalID, + }, isAdmin) + if err != nil { + return &pb.UpdateDocumentCommentResponse{Base: baseResponseFromErr(err)}, nil + } + docExternalID := "" + if comment != nil && comment.DocumentID != 0 { + if docs, err := s.docRepo.MGetByIDs([]int64{comment.DocumentID}); err == nil && len(docs) > 0 { + docExternalID = docs[0].ExternalID + } + } + creator, _ := s.userRepo.GetByExternalID(userExternalID).Exec() + resp := toCommentResponse(comment, docExternalID, "", creator) + return &pb.UpdateDocumentCommentResponse{Base: baseResponseFromErr(nil), Comment: resp}, nil +} + +func toCommentResponse(comment *model.DocumentComment, docExternalID, parentExternalID string, creator *model.User) *pb.DocumentComment { + if comment == nil { + return nil + } + resp := &pb.DocumentComment{ + ExternalId: comment.ExternalID, + DocumentExternalId: docExternalID, + ParentExternalId: parentExternalID, + Content: comment.Content, + AnchorId: comment.AnchorID, + Quote: "", + CreatorUserExternalId: "", + CreatorName: "", + CreatorAvatar: "", + CreatedAt: timeToString(comment.CreatedAt), + } + if creator != nil { + resp.CreatorUserExternalId = creator.ExternalID + if creator.Nickname != "" { + resp.CreatorName = creator.Nickname + } else { + resp.CreatorName = creator.Username + } + resp.CreatorAvatar = media.BuildAvatarURL(creator.ExternalID, creator.AvatarObjectKey, creator.AvatarVersion) + } + return resp +} diff --git a/internal/transport/grpcserver/convert.go b/internal/transport/grpcserver/convert.go new file mode 100644 index 0000000..8531740 --- /dev/null +++ b/internal/transport/grpcserver/convert.go @@ -0,0 +1,204 @@ +package grpcserver + +import ( + "time" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/media" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +func baseResponseFromErr(err error) *pb.BaseResponse { + if err == nil { + return baseResponseFromStatus(status.StatusSuccess) + } + return baseResponseFromStatus(err) +} + +func baseResponseFromStatus(err error) *pb.BaseResponse { + st, ok := err.(*status.Status) + if !ok { + st = status.StatusServiceInternalError.(*status.Status) + } + return &pb.BaseResponse{ + Code: int32(st.Code), + Message: st.Message, + } +} + +func timeToString(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func toDocumentResponse(doc *dto.DocumentMetaResponse) *pb.DocumentResponse { + if doc == nil { + return nil + } + resp := &pb.DocumentResponse{ + ExternalId: doc.ExternalID, + Title: doc.Title, + Type: doc_type_mapper.ToProtoType(doc.Type), + Summary: doc.Summary, + Tags: doc.Tags, + ViewCount: int32(doc.ViewCount), + EditCount: int32(doc.EditCount), + CreatedAt: timeToString(doc.CreatedAt), + UpdatedAt: timeToString(doc.UpdatedAt), + HasChildren: doc.HasChildren, + IsPublic: doc.IsPublic, + } + if len(doc.Children) > 0 { + resp.Children = make([]*pb.DocumentResponse, 0, len(doc.Children)) + for _, child := range doc.Children { + resp.Children = append(resp.Children, toDocumentResponse(child)) + } + } + return resp +} + +func toKnowledgeBaseResponse(kb *dto.KnowledgeBaseResponse) *pb.KnowledgeBaseResponse { + if kb == nil { + return nil + } + return &pb.KnowledgeBaseResponse{ + ExternalId: kb.ExternalID, + Name: kb.Name, + Description: kb.Description, + Cover: kb.Cover, + IsPublic: kb.IsPublic, + CreatedAt: timeToString(kb.CreatedAt), + UpdatedAt: timeToString(kb.UpdatedAt), + DeletedAt: timeToString(kb.DeletedAt), + CreatorUserExternalId: kb.CreatorUserExternalID, + CreatorName: kb.CreatorName, + DocumentsCount: kb.DocumentsCount, + } +} + +func toUserResponse(user *dto.UserResponse) *pb.UserResponse { + if user == nil { + return nil + } + resp := &pb.UserResponse{ + ExternalId: user.ExternalID, + Username: user.Username, + Email: user.Email, + Nickname: user.Nickname, + Avatar: media.BuildAvatarURL(user.ExternalID, user.AvatarObjectKey, user.AvatarVersion), + Status: int32(user.Status), + CreatedAt: timeToString(user.CreatedAt), + UpdatedAt: timeToString(user.UpdatedAt), + } + if user.InvitationCode != nil { + resp.InvitationCode = user.InvitationCode + } + return resp +} + +func toTemplateResponse(tpl *dto.TemplateResponse) *pb.TemplateResponse { + if tpl == nil { + return nil + } + return &pb.TemplateResponse{ + Id: tpl.ID, + Name: tpl.Name, + Description: tpl.Description, + Content: tpl.Content, + Type: doc_type_mapper.ToProtoType(tpl.Type), + Scope: tpl.Scope, + KnowledgeBaseExternalId: tpl.KnowledgeBaseExternalID, + Tags: tpl.Tags, + CreatedAt: timeToString(tpl.CreatedAt), + UpdatedAt: timeToString(tpl.UpdatedAt), + } +} + +func toAttachmentResponse(attachment *dto.AttachmentResponse) *pb.AttachmentResponse { + if attachment == nil { + return nil + } + return &pb.AttachmentResponse{ + ExternalId: attachment.ExternalID, + DocumentExternalId: attachment.DocumentExternalID, + Name: attachment.Name, + Size: attachment.Size, + MimeType: attachment.MimeType, + Path: attachment.Path, + Url: attachment.URL, + CreatedAt: timeToString(attachment.CreatedAt), + UpdatedAt: timeToString(attachment.UpdatedAt), + } +} + +func toDocumentVersionResponse(item *dto.DocumentVersionResponse) *pb.DocumentVersionResponse { + if item == nil { + return nil + } + return &pb.DocumentVersionResponse{ + Version: int32(item.Version), + Title: item.Title, + Content: item.Content, + ChangeSummary: item.ChangeSummary, + CreatedAt: timeToString(item.CreatedAt), + } +} + +func toUserGroupResponse(group *dto.UserGroupResponse) *pb.UserGroupResponse { + if group == nil { + return nil + } + resp := &pb.UserGroupResponse{ + ExternalId: group.ExternalID, + Name: group.Name, + Description: group.Description, + CreatorUserExternalId: group.CreatorUserExternalID, + MemberCount: int32(group.MemberCount), + } + return resp +} + +func toInvitationResponse(inv *dto.InvitationResponse) *pb.InvitationResponse { + if inv == nil { + return nil + } + resp := &pb.InvitationResponse{ + Code: inv.Code, + CreatedByExternalId: inv.CreatedByExternalID, + CreatedByName: inv.CreatedByName, + UsedCnt: inv.UsedCnt, + Disabled: inv.Disabled, + CreatedAt: timeToString(inv.CreatedAt), + } + if inv.MaxUsedCnt != nil { + resp.MaxUsedCnt = inv.MaxUsedCnt + } + if inv.ExpiresAt != nil { + resp.ExpiresAt = utils.Of(timeToString(*inv.ExpiresAt)) + } + if inv.Note != nil { + resp.Note = inv.Note + } + return resp +} + +func toInvitationRecordResponse(rec *dto.InvitationRecordResponse) *pb.InvitationRecordResponse { + if rec == nil { + return nil + } + resp := &pb.InvitationRecordResponse{ + Code: rec.Code, + UsedBy: rec.UsedBy, + UsedAt: timeToString(rec.UsedAt), + Status: rec.Status, + } + if rec.UsedByExternalID != "" { + resp.UsedByExternalId = utils.Of(rec.UsedByExternalID) + } + return resp +} diff --git a/internal/transport/grpcserver/document_service.go b/internal/transport/grpcserver/document_service.go new file mode 100644 index 0000000..dac581a --- /dev/null +++ b/internal/transport/grpcserver/document_service.go @@ -0,0 +1,712 @@ +package grpcserver + +import ( + "context" + "time" + + "github.com/XingfenD/yoresee_doc/internal/dto" + doc_container_mapper "github.com/XingfenD/yoresee_doc/internal/mapper/doc_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/mapper/doc_type_mapper" + "github.com/XingfenD/yoresee_doc/internal/mapper/template_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/service/document_service" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type DocumentServiceServer struct { + pb.UnimplementedDocumentServiceServer +} + +func NewDocumentServiceServer() *DocumentServiceServer { + return &DocumentServiceServer{} +} + +func (s *DocumentServiceServer) ListDocuments(ctx context.Context, req *pb.ListDocumentsRequest) (*pb.ListDocumentsResponse, error) { + if req == nil { + return &pb.ListDocumentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + filterArgs := &dto.DocumentsListFilterArgs{ + TitleKeyword: req.TitleKeyword, + DocType: req.Type, + Tags: req.Tags, + } + if req.CreateTimeRange != nil { + filterArgs.CreateTimeRangeStart = req.CreateTimeRange.Start + filterArgs.CreateTimeRangeEnd = req.CreateTimeRange.End + } + if req.UpdateTimeRange != nil { + filterArgs.UpdateTimeRangeStart = req.UpdateTimeRange.Start + filterArgs.UpdateTimeRangeEnd = req.UpdateTimeRange.End + } + + sortArgs := dto.SortArgs{Field: "created_at", Desc: true} + if req.OrderBy != nil { + sortArgs.Field = req.GetOrderBy() + } + if req.OrderDesc != nil { + sortArgs.Desc = req.GetOrderDesc() + } + + serviceReq := &dto.ListDocumentsByExternalRequest{ + ExternalArgs: &dto.DocumentsListExternalArgs{ + UserExternalID: req.UserExternalId, + RootDocumentExternalID: req.RootDocumentExternalId, + KnowledgeExternalID: req.KnowledgeBaseExternalId, + }, + FilterArgs: filterArgs, + SortArgs: sortArgs, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + if req.Options != nil { + serviceReq.Options = &dto.RecursiveOptions{ + IncludeChildren: req.Options.IncludeChildren, + Recursive: req.Options.Recursive, + Depth: utils.Of(int(req.Options.Depth)), + } + } + + docs, total, err := document_service.DocumentSvc.ListDocumentsByExternal(ctx, serviceReq) + if err != nil { + return &pb.ListDocumentsResponse{Base: baseResponseFromErr(err)}, nil + } + + respDocs := make([]*pb.DocumentResponse, 0, len(docs)) + for _, doc := range docs { + respDocs = append(respDocs, toDocumentResponse(doc)) + } + + return &pb.ListDocumentsResponse{ + Base: baseResponseFromErr(nil), + Documents: respDocs, + TotalCount: total, + }, nil +} + +func (s *DocumentServiceServer) GetDocumentContent(ctx context.Context, req *pb.GetDocumentContentRequest) (*pb.GetDocumentContentResponse, error) { + if req == nil || req.DocumentExternalId == "" { + return &pb.GetDocumentContentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + document, err := document_service.DocumentSvc.GetDocumentByExternalID(ctx, req.DocumentExternalId) + if err != nil { + return &pb.GetDocumentContentResponse{Base: baseResponseFromErr(status.StatusDocumentNotFound)}, nil + } + + return &pb.GetDocumentContentResponse{ + Base: baseResponseFromErr(nil), + Document: toDocumentResponse(&document.DocumentMetaResponse), + Content: document.Content, + }, nil +} + +func (s *DocumentServiceServer) GetDocumentSettings(ctx context.Context, req *pb.GetDocumentSettingsRequest) (*pb.GetDocumentSettingsResponse, error) { + if req == nil || req.DocumentExternalId == "" { + return &pb.GetDocumentSettingsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + settings, err := document_service.DocumentSvc.GetDocumentSettings(ctx, &dto.GetDocumentSettingsRequest{ + ExternalID: req.DocumentExternalId, + }) + if err != nil { + return &pb.GetDocumentSettingsResponse{Base: baseResponseFromErr(status.StatusDocumentNotFound)}, nil + } + + return &pb.GetDocumentSettingsResponse{ + Base: baseResponseFromErr(nil), + IsPublic: utils.Of(settings.IsPublic), + }, nil +} + +func (s *DocumentServiceServer) RecordRecentDocument(ctx context.Context, req *pb.RecordRecentDocumentRequest) (*pb.RecordRecentDocumentResponse, error) { + if req == nil || req.DocumentExternalId == "" { + return &pb.RecordRecentDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.RecordRecentDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := document_service.DocumentSvc.RecordRecentDocument(&dto.RecordRecentDocumentRequest{ + UserExternalID: userExternalID, + DocumentExternalID: req.DocumentExternalId, + }); err != nil { + return &pb.RecordRecentDocumentResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.RecordRecentDocumentResponse{Base: baseResponseFromErr(nil)}, nil +} + +func (s *DocumentServiceServer) ListRecentDocuments(ctx context.Context, req *pb.ListRecentDocumentsRequest) (*pb.ListRecentDocumentsResponse, error) { + if req == nil { + return &pb.ListRecentDocumentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.ListRecentDocumentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + var startTime *time.Time + if req.StartTime != nil && *req.StartTime != "" { + t, err := time.Parse(time.RFC3339, *req.StartTime) + if err != nil { + return &pb.ListRecentDocumentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + startTime = &t + } + var endTime *time.Time + if req.EndTime != nil && *req.EndTime != "" { + t, err := time.Parse(time.RFC3339, *req.EndTime) + if err != nil { + return &pb.ListRecentDocumentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + endTime = &t + } + + serviceReq := &dto.ListRecentDocumentsRequest{ + UserExternalID: userExternalID, + StartTime: startTime, + EndTime: endTime, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + documents, total, err := document_service.DocumentSvc.ListRecentDocuments(serviceReq) + if err != nil { + return &pb.ListRecentDocumentsResponse{Base: baseResponseFromErr(err)}, nil + } + + respDocs := make([]*pb.DocumentResponse, 0, len(documents)) + for _, doc := range documents { + respDocs = append(respDocs, toDocumentResponse(&doc.DocumentMetaResponse)) + } + + return &pb.ListRecentDocumentsResponse{ + Base: baseResponseFromErr(nil), + Documents: respDocs, + Total: total, + }, nil +} + +func (s *DocumentServiceServer) GetDocumentYjsSnapshot(ctx context.Context, req *pb.GetDocumentYjsSnapshotRequest) (*pb.GetDocumentYjsSnapshotResponse, error) { + if req == nil || req.DocumentExternalId == "" { + return &pb.GetDocumentYjsSnapshotResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + state, err := document_service.DocumentSvc.GetDocumentYjsSnapshot(ctx, req.DocumentExternalId) + if err != nil { + return &pb.GetDocumentYjsSnapshotResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.GetDocumentYjsSnapshotResponse{ + Base: baseResponseFromErr(nil), + State: state, + }, nil +} + +func (s *DocumentServiceServer) SaveDocumentYjsSnapshot(ctx context.Context, req *pb.SaveDocumentYjsSnapshotRequest) (*pb.SaveDocumentYjsSnapshotResponse, error) { + if req == nil || req.DocumentExternalId == "" || len(req.State) == 0 { + return &pb.SaveDocumentYjsSnapshotResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + if err := document_service.DocumentSvc.SaveDocumentYjsSnapshot(ctx, req.DocumentExternalId, req.State); err != nil { + return &pb.SaveDocumentYjsSnapshotResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.SaveDocumentYjsSnapshotResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *DocumentServiceServer) GetOwnDocuments(ctx context.Context, req *pb.GetOwnDocumentsRequest) (*pb.GetOwnDocumentsResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.GetOwnDocumentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + queryReq := &dto.ListDocumentsByExternalRequest{ + ExternalArgs: &dto.DocumentsListExternalArgs{ + UserExternalID: &userExternalID, + }, + ListDocumentsBaseArgs: dto.ListDocumentsBaseArgs{ + ListOwnDoc: true, + DirectoryOnly: req.DirectoryOnly, + }, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + Options: &dto.RecursiveOptions{ + IncludeChildren: true, + Recursive: true, + }, + } + + docs, count, err := document_service.DocumentSvc.ListDocumentsByExternal(ctx, queryReq) + if err != nil { + return &pb.GetOwnDocumentsResponse{Base: baseResponseFromErr(err)}, nil + } + + respDocs := make([]*pb.DocumentResponse, 0, len(docs)) + for _, doc := range docs { + respDocs = append(respDocs, toDocumentResponse(doc)) + } + + return &pb.GetOwnDocumentsResponse{ + Base: baseResponseFromErr(nil), + Documents: respDocs, + TotalCount: count, + }, nil +} + +func (s *DocumentServiceServer) CreateDocument(ctx context.Context, req *pb.CreateDocumentRequest) (*pb.CreateDocumentResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.CreateDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.Title == "" { + return &pb.CreateDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + dtoReq := &dto.CreateDocumentRequest{ + Title: req.Title, + Type: doc_type_mapper.FromProtoType(req.Type), + IsPublic: req.GetIsPublic(), + CreatorExternalID: utils.Of(userExternalID), + ParentExternalID: req.ParentExternalId, + } + if req.TemplateId != nil && *req.TemplateId > 0 { + dtoReq.TemplateID = req.TemplateId + } + + containerType, ok := doc_container_mapper.FromProtoType(req.ContainerType) + if !ok { + return &pb.CreateDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + dtoReq.ContainerType = containerType + if containerType == dto.ContainerType_KnowledgeBase { + dtoReq.KnowledgeExternalID = req.KnowledgeBaseExternalId + } + + resp, err := document_service.DocumentSvc.Create(ctx, dtoReq) + if err != nil { + return &pb.CreateDocumentResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.CreateDocumentResponse{ + Base: baseResponseFromErr(nil), + ExternalId: resp.ExternalID, + }, nil +} + +func (s *DocumentServiceServer) UpdateDocument(ctx context.Context, req *pb.UpdateDocumentRequest) (*pb.UpdateDocumentResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.UpdateDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := validateUpdateDocumentRequest(req); err != nil { + return &pb.UpdateDocumentResponse{Base: baseResponseFromErr(err)}, nil + } + + serviceReq := &dto.UpdateDocumentRequest{ + ExternalID: req.ExternalId, + Title: req.Title, + ParentExternalID: req.ParentExternalId, + KnowledgeBaseExternalID: req.KnowledgeBaseExternalId, + Content: req.Content, + } + + if req.MoveToContainer != nil { + moveToContainer, ok := doc_container_mapper.FromProtoType(*req.MoveToContainer) + if !ok { + return &pb.UpdateDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + serviceReq.MoveToContainer = utils.Of(moveToContainer) + } + + if _, err := document_service.DocumentSvc.Update(ctx, serviceReq); err != nil { + return &pb.UpdateDocumentResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UpdateDocumentResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *DocumentServiceServer) UpdateDocumentMeta(ctx context.Context, req *pb.UpdateDocumentMetaRequest) (*pb.UpdateDocumentMetaResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.UpdateDocumentMetaResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.ExternalId == "" { + return &pb.UpdateDocumentMetaResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req.Title == nil && req.Summary == nil && req.Tags == nil { + return &pb.UpdateDocumentMetaResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + serviceReq := &dto.UpdateDocumentMetaRequest{ + ExternalID: req.ExternalId, + Title: req.Title, + Summary: req.Summary, + } + if req.Tags != nil { + tags := req.Tags + serviceReq.Tags = &tags + } + + if _, err := document_service.DocumentSvc.UpdateDocumentMeta(ctx, serviceReq); err != nil { + return &pb.UpdateDocumentMetaResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UpdateDocumentMetaResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *DocumentServiceServer) UpdateDocumentSettings(ctx context.Context, req *pb.UpdateDocumentSettingsRequest) (*pb.UpdateDocumentSettingsResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.UpdateDocumentSettingsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.ExternalId == "" || req.IsPublic == nil { + return &pb.UpdateDocumentSettingsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + serviceReq := &dto.UpdateDocumentSettingsRequest{ + ExternalID: req.ExternalId, + IsPublic: req.GetIsPublic(), + } + updated, err := document_service.DocumentSvc.UpdateDocumentSettings(ctx, serviceReq) + if err != nil { + return &pb.UpdateDocumentSettingsResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UpdateDocumentSettingsResponse{ + Base: baseResponseFromErr(nil), + IsPublic: utils.Of(updated.IsPublic), + }, nil +} + +func (s *DocumentServiceServer) UploadDocumentAttachment(ctx context.Context, req *pb.UploadDocumentAttachmentRequest) (*pb.UploadDocumentAttachmentResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.UploadDocumentAttachmentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.DocumentExternalId == "" || len(req.FileContent) == 0 { + return &pb.UploadDocumentAttachmentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + attachment, err := document_service.DocumentSvc.UploadAttachment( + ctx, + userExternalID, + req.DocumentExternalId, + req.FileContent, + req.FileName, + req.GetContentType(), + ) + if err != nil { + return &pb.UploadDocumentAttachmentResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UploadDocumentAttachmentResponse{ + Base: baseResponseFromErr(nil), + Attachment: toAttachmentResponse(attachment), + }, nil +} + +func (s *DocumentServiceServer) ListDocumentAttachments(ctx context.Context, req *pb.ListDocumentAttachmentsRequest) (*pb.ListDocumentAttachmentsResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.ListDocumentAttachmentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.DocumentExternalId == "" { + return &pb.ListDocumentAttachmentsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + attachments, err := document_service.DocumentSvc.ListAttachments(ctx, req.DocumentExternalId) + if err != nil { + return &pb.ListDocumentAttachmentsResponse{Base: baseResponseFromErr(err)}, nil + } + respAttachments := make([]*pb.AttachmentResponse, 0, len(attachments)) + for _, attachment := range attachments { + respAttachments = append(respAttachments, toAttachmentResponse(attachment)) + } + + return &pb.ListDocumentAttachmentsResponse{ + Base: baseResponseFromErr(nil), + Attachments: respAttachments, + }, nil +} + +func (s *DocumentServiceServer) DeleteDocumentAttachment(ctx context.Context, req *pb.DeleteDocumentAttachmentRequest) (*pb.DeleteDocumentAttachmentResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.DeleteDocumentAttachmentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.DocumentExternalId == "" || req.AttachmentExternalId == "" { + return &pb.DeleteDocumentAttachmentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + if err := document_service.DocumentSvc.DeleteAttachment(ctx, req.DocumentExternalId, req.AttachmentExternalId); err != nil { + return &pb.DeleteDocumentAttachmentResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.DeleteDocumentAttachmentResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *DocumentServiceServer) ListDocumentVersions(ctx context.Context, req *pb.ListDocumentVersionsRequest) (*pb.ListDocumentVersionsResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.ListDocumentVersionsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.DocumentExternalId == "" { + return &pb.ListDocumentVersionsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + items, total, err := document_service.DocumentSvc.ListDocumentVersions(ctx, req.DocumentExternalId, req.Page, req.PageSize) + if err != nil { + return &pb.ListDocumentVersionsResponse{Base: baseResponseFromErr(err)}, nil + } + respItems := make([]*pb.DocumentVersionResponse, 0, len(items)) + for _, item := range items { + respItems = append(respItems, toDocumentVersionResponse(item)) + } + return &pb.ListDocumentVersionsResponse{ + Base: baseResponseFromErr(nil), + Versions: respItems, + Total: total, + }, nil +} + +func (s *DocumentServiceServer) GetDocumentVersionContent(ctx context.Context, req *pb.GetDocumentVersionContentRequest) (*pb.GetDocumentVersionContentResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.GetDocumentVersionContentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.DocumentExternalId == "" || req.Version <= 0 { + return &pb.GetDocumentVersionContentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + item, err := document_service.DocumentSvc.GetDocumentVersionContent(ctx, req.DocumentExternalId, req.Version) + if err != nil { + return &pb.GetDocumentVersionContentResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.GetDocumentVersionContentResponse{ + Base: baseResponseFromErr(nil), + Version: toDocumentVersionResponse(item), + }, nil +} + +func (s *DocumentServiceServer) CreateTemplate(ctx context.Context, req *pb.CreateTemplateRequest) (*pb.CreateTemplateResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.CreateTemplateResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + if req == nil { + return &pb.CreateTemplateResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + serviceReq := &dto.CreateTemplateRequest{ + UserExternalID: userExternalID, + TargetContainer: template_container_mapper.FromProtoType(req.TargetContainer), + TemplateContent: req.TemplateContent, + Type: doc_type_mapper.FromProtoType(req.Type), + } + if req.KnowledgeBaseId != "" { + serviceReq.KnowledgeBaseExternalID = utils.Of(req.KnowledgeBaseId) + } + + if err := document_service.DocumentSvc.CreateTemplate(ctx, serviceReq); err != nil { + return &pb.CreateTemplateResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.CreateTemplateResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *DocumentServiceServer) GetTemplate(ctx context.Context, req *pb.GetTemplateRequest) (*pb.GetTemplateResponse, error) { + if req == nil || req.TemplateId <= 0 { + return &pb.GetTemplateResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.GetTemplateResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + resp, err := document_service.DocumentSvc.GetTemplateByID(req.TemplateId) + if err != nil { + return &pb.GetTemplateResponse{Base: baseResponseFromErr(err)}, nil + } + if req.RecordRecentLog { + _ = document_service.DocumentSvc.CreateRecentTemplate(&dto.CreateRecentTemplateRequest{ + UserExternalID: userExternalID, + TemplateID: req.TemplateId, + AccessTime: time.Now(), + }) + } + return &pb.GetTemplateResponse{ + Base: baseResponseFromErr(nil), + Template: toTemplateResponse(resp), + }, nil +} + +func (s *DocumentServiceServer) ListRecentTemplates(ctx context.Context, req *pb.ListRecentTemplatesRequest) (*pb.ListRecentTemplatesResponse, error) { + if req == nil { + return &pb.ListRecentTemplatesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.ListRecentTemplatesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + var startTime *time.Time + if req.StartTime != nil && *req.StartTime != "" { + t, err := time.Parse(time.RFC3339, *req.StartTime) + if err != nil { + return &pb.ListRecentTemplatesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + startTime = &t + } + var endTime *time.Time + if req.EndTime != nil && *req.EndTime != "" { + t, err := time.Parse(time.RFC3339, *req.EndTime) + if err != nil { + return &pb.ListRecentTemplatesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + endTime = &t + } + + serviceReq := &dto.ListRecentTemplatesRequest{ + UserExternalID: userExternalID, + StartTime: startTime, + EndTime: endTime, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + templates, total, err := document_service.DocumentSvc.ListRecentTemplates(serviceReq) + if err != nil { + return &pb.ListRecentTemplatesResponse{Base: baseResponseFromErr(err)}, nil + } + respTemplates := make([]*pb.TemplateResponse, 0, len(templates)) + for _, tpl := range templates { + respTemplates = append(respTemplates, toTemplateResponse(tpl)) + } + return &pb.ListRecentTemplatesResponse{ + Base: baseResponseFromErr(nil), + Templates: respTemplates, + Total: total, + }, nil +} + +func (s *DocumentServiceServer) ListTemplates(ctx context.Context, req *pb.ListTemplatesRequest) (*pb.ListTemplatesResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.ListTemplatesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil { + return &pb.ListTemplatesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + filterArgs := &dto.TemplateListFilterArgs{ + NameKeyword: req.NameKeyword, + KnowledgeBaseID: req.KnowledgeBaseId, + } + if req.TargetContainer != nil { + container := template_container_mapper.FromProtoType(req.GetTargetContainer()) + filterArgs.TargetContainer = utils.Of(container) + } + if req.Type != nil { + filterArgs.Type = utils.Of(doc_type_mapper.FromProtoType(req.GetType())) + } + + sortArgs := dto.SortArgs{Field: "created_at", Desc: true} + if req.OrderBy != nil { + sortArgs.Field = req.GetOrderBy() + } + if req.OrderDesc != nil { + sortArgs.Desc = req.GetOrderDesc() + } + + serviceReq := &dto.TemplateListByExternalRequest{ + FilterArgs: filterArgs, + SortArgs: sortArgs, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + if req.OnlyMine { + serviceReq.CreatorExternalID = userExternalID + } + if req.TargetContainer != nil && template_container_mapper.FromProtoType(req.GetTargetContainer()) == dto.TemplateContainerOwn { + serviceReq.CreatorExternalID = userExternalID + } + + templates, total, err := document_service.DocumentSvc.ListTemplatesByExternal(serviceReq) + if err != nil { + return &pb.ListTemplatesResponse{Base: baseResponseFromErr(err)}, nil + } + + respTemplates := make([]*pb.TemplateResponse, 0, len(templates)) + for _, tpl := range templates { + respTemplates = append(respTemplates, toTemplateResponse(tpl)) + } + + return &pb.ListTemplatesResponse{ + Base: baseResponseFromErr(nil), + Templates: respTemplates, + Total: total, + }, nil +} + +func (s *DocumentServiceServer) UpdateTemplateSettings(ctx context.Context, req *pb.UpdateTemplateSettingsRequest) (*pb.UpdateTemplateSettingsResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.UpdateTemplateSettingsResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + if req == nil || req.TemplateId <= 0 { + return &pb.UpdateTemplateSettingsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + serviceReq := &dto.UpdateTemplateSettingsRequest{ + UserExternalID: userExternalID, + TemplateID: req.TemplateId, + Name: req.Name, + Description: req.Description, + IsPublic: req.IsPublic, + } + + if err := document_service.DocumentSvc.UpdateTemplateSettings(ctx, serviceReq); err != nil { + return &pb.UpdateTemplateSettingsResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UpdateTemplateSettingsResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *DocumentServiceServer) DeleteDocument(ctx context.Context, req *pb.DeleteDocumentRequest) (*pb.DeleteDocumentResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.DeleteDocumentResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + if req == nil || req.DocumentExternalId == "" { + return &pb.DeleteDocumentResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + if err := document_service.DocumentSvc.DeleteDocumentByExternalID(ctx, req.DocumentExternalId); err != nil { + return &pb.DeleteDocumentResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.DeleteDocumentResponse{ + Base: baseResponseFromErr(nil), + }, nil +} diff --git a/internal/transport/grpcserver/interceptors.go b/internal/transport/grpcserver/interceptors.go new file mode 100644 index 0000000..eaf8118 --- /dev/null +++ b/internal/transport/grpcserver/interceptors.go @@ -0,0 +1,47 @@ +package grpcserver + +import ( + "context" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/middleware" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func UnaryAuthInterceptor(allowUnauth map[string]struct{}) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + if _, ok := allowUnauth[info.FullMethod]; ok { + return handler(ctx, req) + } + + if md, ok := metadata.FromIncomingContext(ctx); ok { + if key := config.GlobalConfig.Backend.InternalRPCKey; key != "" { + if values := md.Get("x-internal-key"); len(values) > 0 && values[0] == key { + return handler(ctx, req) + } + } + } + + authHeader := "" + if md, ok := metadata.FromIncomingContext(ctx); ok { + if values := md.Get("authorization"); len(values) > 0 { + authHeader = values[0] + } else if values := md.Get("Authorization"); len(values) > 0 { + authHeader = values[0] + } + } + + claims, err := middleware.JWTAuth.ValidateAuthorizationHeader(authHeader) + if err != nil { + return nil, status.Error(codes.Unauthenticated, err.Error()) + } + + ctx = context.WithValue(ctx, "user_external_id", claims.ExternalID) + ctx = context.WithValue(ctx, "username", claims.Username) + + return handler(ctx, req) + } +} diff --git a/internal/transport/grpcserver/invitation_service.go b/internal/transport/grpcserver/invitation_service.go new file mode 100644 index 0000000..5089ea4 --- /dev/null +++ b/internal/transport/grpcserver/invitation_service.go @@ -0,0 +1,323 @@ +package grpcserver + +import ( + "context" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/repository/user_repo" + "github.com/XingfenD/yoresee_doc/internal/service/auth_service" + "github.com/XingfenD/yoresee_doc/internal/service/invitation_service" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type InvitationServiceServer struct { + pb.UnimplementedInvitationServiceServer + userRepo *user_repo.UserRepository +} + +func NewInvitationServiceServer(userRepo *user_repo.UserRepository) *InvitationServiceServer { + return &InvitationServiceServer{ + userRepo: userRepo, + } +} + +func (s *InvitationServiceServer) requireAdmin(ctx context.Context) error { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return status.StatusTokenInvalid + } + isAdmin, err := auth_service.AuthSvc.IsAdmin(userExternalID) + if err != nil { + return err + } + if !isAdmin { + return status.StatusPermissionDenied + } + return nil +} + +func parseOptionalTime(input *string) (*time.Time, error) { + if input == nil || strings.TrimSpace(*input) == "" { + return nil, nil + } + tm, err := time.Parse(time.RFC3339, *input) + if err != nil { + return nil, err + } + return &tm, nil +} + +func (s *InvitationServiceServer) CreateInvitation(ctx context.Context, req *pb.CreateInvitationRequest) (*pb.CreateInvitationResponse, error) { + if req == nil { + return &pb.CreateInvitationResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.CreateInvitationResponse{Base: baseResponseFromErr(err)}, nil + } + + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.CreateInvitationResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + expiresAt, err := parseOptionalTime(req.ExpiresAt) + if err != nil { + return &pb.CreateInvitationResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + inv, err := invitation_service.InvitationSvc.CreateInvitation(&dto.CreateInvitationRequest{ + CreatorExternalID: userExternalID, + MaxUsedCnt: req.MaxUsedCnt, + ExpiresAt: expiresAt, + Note: req.Note, + }) + if err != nil { + return &pb.CreateInvitationResponse{Base: baseResponseFromErr(err)}, nil + } + + user, _ := s.userRepo.GetByExternalID(userExternalID).Exec() + resp := &dto.InvitationResponse{ + Code: inv.Code, + CreatedByExternalID: userExternalID, + CreatedByName: "", + UsedCnt: inv.UsedCnt, + MaxUsedCnt: inv.MaxUsedCnt, + ExpiresAt: inv.ExpiresAt, + CreatedAt: inv.CreatedAt, + Disabled: inv.Disabled, + Note: inv.Note, + } + if user != nil { + if user.Nickname != "" { + resp.CreatedByName = user.Nickname + } else { + resp.CreatedByName = user.Username + } + } + + return &pb.CreateInvitationResponse{ + Base: baseResponseFromErr(nil), + Invitation: toInvitationResponse(resp), + }, nil +} + +func (s *InvitationServiceServer) ListInvitations(ctx context.Context, req *pb.ListInvitationsRequest) (*pb.ListInvitationsResponse, error) { + if req == nil { + return &pb.ListInvitationsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + onlyMine := req.OnlyMine != nil && req.GetOnlyMine() + if !onlyMine { + if err := s.requireAdmin(ctx); err != nil { + return &pb.ListInvitationsResponse{Base: baseResponseFromErr(err)}, nil + } + } + + var creatorID *int64 + if onlyMine { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.ListInvitationsResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + id, err := s.userRepo.GetIDByExternalID(userExternalID).Exec() + if err != nil { + return &pb.ListInvitationsResponse{Base: baseResponseFromErr(status.StatusUserNotFound)}, nil + } + creatorID = utils.Of(id) + } + + sortArgs := dto.SortArgs{Field: "created_at", Desc: true} + if req.OrderBy != nil { + sortArgs.Field = req.GetOrderBy() + } + if req.OrderDesc != nil { + sortArgs.Desc = req.GetOrderDesc() + } + + listReq := &dto.ListInvitationsReq{ + CreatorID: creatorID, + MaxUsedCnt: req.MaxUsedCnt, + ExpiresAtStart: req.ExpiresAtStart, + ExpiresAtEnd: req.ExpiresAtEnd, + CreatedAtStart: req.CreatedAtStart, + CreatedAtEnd: req.CreatedAtEnd, + Disabled: req.Disabled, + OnlyMine: req.OnlyMine, + Keyword: req.Keyword, + SortArgs: sortArgs, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + + invitations, total, err := invitation_service.InvitationSvc.ListInvitations(listReq) + if err != nil { + return &pb.ListInvitationsResponse{Base: baseResponseFromErr(err)}, nil + } + + creatorIDs := make([]int64, 0, len(invitations)) + for _, inv := range invitations { + creatorIDs = append(creatorIDs, inv.CreatedBy) + } + creatorMap, _ := s.userRepo.MGetUserByID(creatorIDs).Exec() + + respInvites := make([]*pb.InvitationResponse, 0, len(invitations)) + for _, inv := range invitations { + createdByExternalID := "" + createdByName := "" + if creator := creatorMap[inv.CreatedBy]; creator != nil { + createdByExternalID = creator.ExternalID + if creator.Nickname != "" { + createdByName = creator.Nickname + } else { + createdByName = creator.Username + } + } + resp := &dto.InvitationResponse{ + Code: inv.Code, + CreatedByExternalID: createdByExternalID, + CreatedByName: createdByName, + UsedCnt: inv.UsedCnt, + MaxUsedCnt: inv.MaxUsedCnt, + ExpiresAt: inv.ExpiresAt, + CreatedAt: inv.CreatedAt, + Disabled: inv.Disabled, + Note: inv.Note, + } + respInvites = append(respInvites, toInvitationResponse(resp)) + } + + return &pb.ListInvitationsResponse{ + Base: baseResponseFromErr(nil), + Invitations: respInvites, + Total: total, + }, nil +} + +func (s *InvitationServiceServer) UpdateInvitation(ctx context.Context, req *pb.UpdateInvitationRequest) (*pb.UpdateInvitationResponse, error) { + if req == nil || strings.TrimSpace(req.Code) == "" { + return &pb.UpdateInvitationResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.UpdateInvitationResponse{Base: baseResponseFromErr(err)}, nil + } + + expiresAt, err := parseOptionalTime(req.ExpiresAt) + if err != nil { + return &pb.UpdateInvitationResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + if err := invitation_service.InvitationSvc.UpdateInvitation(&dto.UpdateInvitationRequest{ + Code: req.Code, + MaxUsedCnt: req.MaxUsedCnt, + ExpiresAt: expiresAt, + Disabled: req.Disabled, + Note: req.Note, + }); err != nil { + return &pb.UpdateInvitationResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UpdateInvitationResponse{Base: baseResponseFromErr(nil)}, nil +} + +func (s *InvitationServiceServer) DeleteInvitation(ctx context.Context, req *pb.DeleteInvitationRequest) (*pb.DeleteInvitationResponse, error) { + if req == nil || strings.TrimSpace(req.Code) == "" { + return &pb.DeleteInvitationResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.DeleteInvitationResponse{Base: baseResponseFromErr(err)}, nil + } + + if err := invitation_service.InvitationSvc.DeleteInvitation(&dto.DeleteInvitationRequest{Code: req.Code}); err != nil { + return &pb.DeleteInvitationResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.DeleteInvitationResponse{Base: baseResponseFromErr(nil)}, nil +} + +func (s *InvitationServiceServer) ListInvitationRecords(ctx context.Context, req *pb.ListInvitationRecordsRequest) (*pb.ListInvitationRecordsResponse, error) { + if req == nil { + return &pb.ListInvitationRecordsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + onlyMine := req.OnlyMine != nil && req.GetOnlyMine() + if !onlyMine { + if err := s.requireAdmin(ctx); err != nil { + return &pb.ListInvitationRecordsResponse{Base: baseResponseFromErr(err)}, nil + } + } + + var creatorID *int64 + if onlyMine { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.ListInvitationRecordsResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + id, err := s.userRepo.GetIDByExternalID(userExternalID).Exec() + if err != nil { + return &pb.ListInvitationRecordsResponse{Base: baseResponseFromErr(status.StatusUserNotFound)}, nil + } + creatorID = utils.Of(id) + } + + listReq := &dto.ListInvitationRecordsRequest{ + Code: req.Code, + Status: req.Status, + UsedAtStart: req.UsedAtStart, + UsedAtEnd: req.UsedAtEnd, + CreatorID: creatorID, + OnlyMine: req.OnlyMine, + Keyword: req.Keyword, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + + records, total, err := invitation_service.InvitationSvc.ListInvitationRecords(listReq) + if err != nil { + return &pb.ListInvitationRecordsResponse{Base: baseResponseFromErr(err)}, nil + } + + userIDs := make([]int64, 0, len(records)) + for _, r := range records { + if r.UsedByUserID != nil { + userIDs = append(userIDs, *r.UsedByUserID) + } + } + userMap, _ := s.userRepo.MGetUserByID(userIDs).Exec() + + respRecords := make([]*pb.InvitationRecordResponse, 0, len(records)) + for _, r := range records { + usedBy := r.UsedBy + usedByExternalID := "" + if r.UsedByUserID != nil { + if user := userMap[*r.UsedByUserID]; user != nil { + usedByExternalID = user.ExternalID + if user.Nickname != "" { + usedBy = user.Nickname + } else if user.Username != "" { + usedBy = user.Username + } + } + } + resp := &dto.InvitationRecordResponse{ + Code: r.Code, + UsedBy: usedBy, + UsedByExternalID: usedByExternalID, + UsedAt: r.UsedAt, + Status: r.Status, + } + respRecords = append(respRecords, toInvitationRecordResponse(resp)) + } + + return &pb.ListInvitationRecordsResponse{ + Base: baseResponseFromErr(nil), + Records: respRecords, + Total: total, + }, nil +} diff --git a/internal/transport/grpcserver/knowledge_base_service.go b/internal/transport/grpcserver/knowledge_base_service.go new file mode 100644 index 0000000..54fd7d3 --- /dev/null +++ b/internal/transport/grpcserver/knowledge_base_service.go @@ -0,0 +1,225 @@ +package grpcserver + +import ( + "context" + "time" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/service/document_service" + "github.com/XingfenD/yoresee_doc/internal/service/knowledge_base_service" + "github.com/XingfenD/yoresee_doc/internal/status" + "github.com/XingfenD/yoresee_doc/internal/utils" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type KnowledgeBaseServiceServer struct { + pb.UnimplementedKnowledgeBaseServiceServer +} + +func NewKnowledgeBaseServiceServer() *KnowledgeBaseServiceServer { + return &KnowledgeBaseServiceServer{} +} + +func (s *KnowledgeBaseServiceServer) ListKnowledgeBases(ctx context.Context, req *pb.ListKnowledgeBasesRequest) (*pb.ListKnowledgeBasesResponse, error) { + if req == nil { + return &pb.ListKnowledgeBasesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.ListKnowledgeBasesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + filterArgs := &dto.KnowledgeBaseListFilterArgs{ + IsPublic: req.IsPublic, + NameKeyword: req.NameKeyword, + } + if req.CreateTimeRange != nil { + filterArgs.CreateTimeRangeStart = req.CreateTimeRange.Start + filterArgs.CreateTimeRangeEnd = req.CreateTimeRange.End + } + if req.UpdateTimeRange != nil { + filterArgs.UpdateTimeRangeStart = req.UpdateTimeRange.Start + filterArgs.UpdateTimeRangeEnd = req.UpdateTimeRange.End + } + + sortArgs := dto.SortArgs{Field: "created_at", Desc: true} + if req.OrderBy != nil { + sortArgs.Field = req.GetOrderBy() + } + if req.OrderDesc != nil { + sortArgs.Desc = req.GetOrderDesc() + } + + serviceReq := &dto.KnowledgeBaseListByExternalRequest{ + CreatorExternalID: "", + FilterArgs: filterArgs, + SortArgs: sortArgs, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + if req.OnlyMine { + serviceReq.CreatorExternalID = userExternalID + } + + kbs, total, err := knowledge_base_service.KnowledgeBaseSvc.ListByExternal(serviceReq) + if err != nil { + return &pb.ListKnowledgeBasesResponse{Base: baseResponseFromErr(err)}, nil + } + + respKBs := make([]*pb.KnowledgeBaseResponse, 0, len(kbs)) + for _, kb := range kbs { + respKBs = append(respKBs, toKnowledgeBaseResponse(kb)) + } + + return &pb.ListKnowledgeBasesResponse{ + Base: baseResponseFromErr(nil), + KnowledgeBases: respKBs, + Total: total, + }, nil +} + +func (s *KnowledgeBaseServiceServer) GetKnowledgeBase(ctx context.Context, req *pb.GetKnowledgeBaseRequest) (*pb.GetKnowledgeBaseResponse, error) { + if req == nil || req.KnowledgeBaseExternalId == "" { + return &pb.GetKnowledgeBaseResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.GetKnowledgeBaseResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + kbDTO, err := knowledge_base_service.KnowledgeBaseSvc.GetByExternalID(&dto.KnowledgeBaseGetByExternalIDRequest{ + KnowledgeBaseExternalID: req.KnowledgeBaseExternalId, + }).WithExtend().Exec() + if err != nil { + return &pb.GetKnowledgeBaseResponse{Base: baseResponseFromErr(status.StatusKnowledgeBaseNotFound)}, nil + } + + if req.RecordRecentLog { + knowledge_base_service.KnowledgeBaseSvc.CreateRecentKnowledgeBase(&dto.CreateRecentKnowledgeBaseRequest{ + UserExternalID: userExternalID, + KnowledgeBaseExternalID: req.KnowledgeBaseExternalId, + AssessTime: time.Now(), + }) + } + + svcReq := &dto.ListDocumentsByExternalRequest{ + ExternalArgs: &dto.DocumentsListExternalArgs{ + KnowledgeExternalID: utils.Of(kbDTO.ExternalID), + }, + ListDocumentsBaseArgs: dto.ListDocumentsBaseArgs{ + DirectoryOnly: req.DirectoryOnly, + }, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + Options: &dto.RecursiveOptions{ + IncludeChildren: true, + Recursive: true, + }, + } + documents, totalCount, err := document_service.DocumentSvc.ListDocumentsByExternal( + ctx, + svcReq, + ) + if err != nil { + return &pb.GetKnowledgeBaseResponse{Base: baseResponseFromErr(status.StatusDocumentNotFound)}, nil + } + + respDocs := make([]*pb.DocumentResponse, 0, len(documents)) + for _, doc := range documents { + respDocs = append(respDocs, toDocumentResponse(doc)) + } + + return &pb.GetKnowledgeBaseResponse{ + Base: baseResponseFromErr(nil), + KnowledgeBase: toKnowledgeBaseResponse(kbDTO), + Documents: respDocs, + TotalCount: totalCount, + }, nil +} + +func (s *KnowledgeBaseServiceServer) CreateKnowledgeBase(ctx context.Context, req *pb.CreateKnowledgeBaseRequest) (*pb.CreateKnowledgeBaseResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.CreateKnowledgeBaseResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if req == nil || req.Name == "" { + return &pb.CreateKnowledgeBaseResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + resp, err := knowledge_base_service.KnowledgeBaseSvc.Create(&dto.CreateKnowledgeBaseRequest{ + CreatorExternalID: userExternalID, + Name: req.Name, + Description: req.Description, + Cover: req.Cover, + IsPublic: req.IsPublic, + }) + if err != nil { + return &pb.CreateKnowledgeBaseResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.CreateKnowledgeBaseResponse{ + Base: baseResponseFromErr(nil), + ExternalId: resp.ExternalID, + }, nil +} + +func (s *KnowledgeBaseServiceServer) ListRecentKnowledgeBases(ctx context.Context, req *pb.ListRecentKnowledgeBasesRequest) (*pb.ListRecentKnowledgeBasesResponse, error) { + if req == nil { + return &pb.ListRecentKnowledgeBasesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.ListRecentKnowledgeBasesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + var startTime *time.Time + if req.StartTime != nil && *req.StartTime != "" { + t, err := time.Parse(time.RFC3339, *req.StartTime) + if err != nil { + return &pb.ListRecentKnowledgeBasesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + startTime = &t + } + + var endTime *time.Time + if req.EndTime != nil && *req.EndTime != "" { + t, err := time.Parse(time.RFC3339, *req.EndTime) + if err != nil { + return &pb.ListRecentKnowledgeBasesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + endTime = &t + } + + serviceReq := &dto.ListRecentKnowledgeBasesRequest{ + UserExternalID: userExternalID, + StartTime: startTime, + EndTime: endTime, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + + kbs, total, err := knowledge_base_service.KnowledgeBaseSvc.ListRecentKnowledgeBases(serviceReq) + if err != nil { + return &pb.ListRecentKnowledgeBasesResponse{Base: baseResponseFromErr(err)}, nil + } + + respKBs := make([]*pb.KnowledgeBaseResponse, 0, len(kbs)) + for _, kb := range kbs { + respKBs = append(respKBs, toKnowledgeBaseResponse(kb)) + } + + return &pb.ListRecentKnowledgeBasesResponse{ + Base: baseResponseFromErr(nil), + KnowledgeBases: respKBs, + Total: total, + }, nil +} diff --git a/internal/transport/grpcserver/membership_service.go b/internal/transport/grpcserver/membership_service.go new file mode 100644 index 0000000..2ff5fe6 --- /dev/null +++ b/internal/transport/grpcserver/membership_service.go @@ -0,0 +1,446 @@ +package grpcserver + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/service/auth_service" + "github.com/XingfenD/yoresee_doc/internal/service/membership_service" + "github.com/XingfenD/yoresee_doc/internal/status" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type MembershipServiceServer struct { + pb.UnimplementedMembershipServiceServer +} + +func NewMembershipServiceServer() *MembershipServiceServer { + return &MembershipServiceServer{} +} + +func (s *MembershipServiceServer) requireAdmin(ctx context.Context) error { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return status.StatusTokenInvalid + } + isAdmin, err := auth_service.AuthSvc.IsAdmin(userExternalID) + if err != nil { + return err + } + if !isAdmin { + return status.StatusPermissionDenied + } + return nil +} + +func (s *MembershipServiceServer) ListUserGroups(ctx context.Context, req *pb.ListUserGroupsRequest) (*pb.ListUserGroupsResponse, error) { + if req == nil { + return &pb.ListUserGroupsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.ListUserGroupsResponse{Base: baseResponseFromErr(err)}, nil + } + + serviceReq := &dto.ListUserGroupsRequest{ + Keyword: req.Keyword, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + groups, total, err := membership_service.MembershipSvc.ListUserGroups(serviceReq) + if err != nil { + return &pb.ListUserGroupsResponse{Base: baseResponseFromErr(err)}, nil + } + + respGroups := make([]*pb.UserGroupResponse, 0, len(groups)) + for _, group := range groups { + respGroups = append(respGroups, toUserGroupResponse(group)) + } + + return &pb.ListUserGroupsResponse{ + Base: baseResponseFromErr(nil), + UserGroups: respGroups, + Total: total, + }, nil +} + +func (s *MembershipServiceServer) GetUserGroup(ctx context.Context, req *pb.GetUserGroupRequest) (*pb.GetUserGroupResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.GetUserGroupResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.GetUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + + group, err := membership_service.MembershipSvc.GetUserGroup(&dto.GetUserGroupRequest{ + ExternalID: req.ExternalId, + }) + if err != nil { + return &pb.GetUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.GetUserGroupResponse{ + Base: baseResponseFromErr(nil), + UserGroup: toUserGroupResponse(group), + }, nil +} + +func (s *MembershipServiceServer) CreateUserGroup(ctx context.Context, req *pb.CreateUserGroupRequest) (*pb.CreateUserGroupResponse, error) { + if req == nil || strings.TrimSpace(req.Name) == "" { + return &pb.CreateUserGroupResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.CreateUserGroupResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.CreateUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + + externalID, err := membership_service.MembershipSvc.CreateUserGroup(&dto.CreateUserGroupRequest{ + CreatorUserExternalID: userExternalID, + Name: req.Name, + Description: req.Description, + MemberUserExternalIDs: req.MemberUserExternalIds, + }) + if err != nil { + return &pb.CreateUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.CreateUserGroupResponse{ + Base: baseResponseFromErr(nil), + ExternalId: externalID, + }, nil +} + +func (s *MembershipServiceServer) UpdateUserGroup(ctx context.Context, req *pb.UpdateUserGroupRequest) (*pb.UpdateUserGroupResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.UpdateUserGroupResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.UpdateUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + + err := membership_service.MembershipSvc.UpdateUserGroup(&dto.UpdateUserGroupRequest{ + ExternalID: req.ExternalId, + Name: req.Name, + Description: req.Description, + SyncMembers: req.SyncMembers, + MemberUserExternalIDs: req.MemberUserExternalIds, + }) + if err != nil { + return &pb.UpdateUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.UpdateUserGroupResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *MembershipServiceServer) DeleteUserGroup(ctx context.Context, req *pb.DeleteUserGroupRequest) (*pb.DeleteUserGroupResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.DeleteUserGroupResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.DeleteUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + + if err := membership_service.MembershipSvc.DeleteUserGroup(&dto.DeleteUserGroupRequest{ + ExternalID: req.ExternalId, + }); err != nil { + return &pb.DeleteUserGroupResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.DeleteUserGroupResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *MembershipServiceServer) ListUsers(ctx context.Context, req *pb.ListUsersRequest) (*pb.ListUsersResponse, error) { + if req == nil { + return &pb.ListUsersResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.ListUsersResponse{Base: baseResponseFromErr(err)}, nil + } + + serviceReq := &dto.ListUsersRequest{ + Keyword: req.Keyword, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + users, total, err := membership_service.MembershipSvc.ListUsers(serviceReq) + if err != nil { + return &pb.ListUsersResponse{Base: baseResponseFromErr(err)}, nil + } + + respUsers := make([]*pb.UserResponse, 0, len(users)) + for _, user := range users { + respUsers = append(respUsers, toUserResponse(user)) + } + + return &pb.ListUsersResponse{ + Base: baseResponseFromErr(nil), + Users: respUsers, + Total: total, + }, nil +} + +func (s *MembershipServiceServer) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.UpdateUserResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.UpdateUserResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.UpdateUserResponse{Base: baseResponseFromErr(err)}, nil + } + + serviceReq := &dto.UpdateUserRequest{ + ExternalID: req.ExternalId, + Username: req.Username, + Email: req.Email, + Nickname: req.Nickname, + Status: req.Status, + } + if err := membership_service.MembershipSvc.UpdateUser(serviceReq); err != nil { + return &pb.UpdateUserResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.UpdateUserResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *MembershipServiceServer) ListUserGroupMembers(ctx context.Context, req *pb.ListUserGroupMembersRequest) (*pb.ListUserGroupMembersResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.ListUserGroupMembersResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.ListUserGroupMembersResponse{Base: baseResponseFromErr(err)}, nil + } + + serviceReq := &dto.ListUserGroupMembersRequest{ + ExternalID: req.ExternalId, + Keyword: req.Keyword, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + users, total, err := membership_service.MembershipSvc.ListUserGroupMembers(serviceReq) + if err != nil { + return &pb.ListUserGroupMembersResponse{Base: baseResponseFromErr(err)}, nil + } + + respUsers := make([]*pb.UserResponse, 0, len(users)) + for _, user := range users { + respUsers = append(respUsers, toUserResponse(user)) + } + + return &pb.ListUserGroupMembersResponse{ + Base: baseResponseFromErr(nil), + Users: respUsers, + Total: total, + }, nil +} + +func (s *MembershipServiceServer) ListOrgNodes(ctx context.Context, req *pb.ListOrgNodesRequest) (*pb.ListOrgNodesResponse, error) { + if req == nil { + return &pb.ListOrgNodesResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.ListOrgNodesResponse{Base: baseResponseFromErr(err)}, nil + } + + serviceReq := &dto.ListOrgNodesRequest{ + ParentExternalID: req.ParentExternalId, + Keyword: req.Keyword, + IncludeChildren: req.IncludeChildren, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + nodes, total, err := membership_service.MembershipSvc.ListOrgNodes(serviceReq) + if err != nil { + return &pb.ListOrgNodesResponse{Base: baseResponseFromErr(err)}, nil + } + + respNodes := make([]*pb.OrgNodeResponse, 0, len(nodes)) + for _, node := range nodes { + respNodes = append(respNodes, toOrgNodeResponse(node)) + } + + return &pb.ListOrgNodesResponse{ + Base: baseResponseFromErr(nil), + OrgNodes: respNodes, + Total: total, + }, nil +} + +func (s *MembershipServiceServer) GetOrgNode(ctx context.Context, req *pb.GetOrgNodeRequest) (*pb.GetOrgNodeResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.GetOrgNodeResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.GetOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + node, err := membership_service.MembershipSvc.GetOrgNode(&dto.GetOrgNodeRequest{ + ExternalID: req.ExternalId, + IncludeChildren: req.IncludeChildren, + }) + if err != nil { + return &pb.GetOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.GetOrgNodeResponse{ + Base: baseResponseFromErr(nil), + OrgNode: toOrgNodeResponse(node), + }, nil +} + +func (s *MembershipServiceServer) CreateOrgNode(ctx context.Context, req *pb.CreateOrgNodeRequest) (*pb.CreateOrgNodeResponse, error) { + if req == nil || strings.TrimSpace(req.Name) == "" { + return &pb.CreateOrgNodeResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return &pb.CreateOrgNodeResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.CreateOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + externalID, err := membership_service.MembershipSvc.CreateOrgNode(&dto.CreateOrgNodeRequest{ + CreatorUserExternalID: userExternalID, + ParentExternalID: req.ParentExternalId, + Name: req.Name, + Description: req.Description, + MemberUserExternalIDs: req.MemberUserExternalIds, + }) + if err != nil { + return &pb.CreateOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.CreateOrgNodeResponse{ + Base: baseResponseFromErr(nil), + ExternalId: externalID, + }, nil +} + +func (s *MembershipServiceServer) UpdateOrgNode(ctx context.Context, req *pb.UpdateOrgNodeRequest) (*pb.UpdateOrgNodeResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.UpdateOrgNodeResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.UpdateOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + err := membership_service.MembershipSvc.UpdateOrgNode(&dto.UpdateOrgNodeRequest{ + ExternalID: req.ExternalId, + Name: req.Name, + Description: req.Description, + SyncMembers: req.SyncMembers, + MemberUserExternalIDs: req.MemberUserExternalIds, + }) + if err != nil { + return &pb.UpdateOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.UpdateOrgNodeResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *MembershipServiceServer) DeleteOrgNode(ctx context.Context, req *pb.DeleteOrgNodeRequest) (*pb.DeleteOrgNodeResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.DeleteOrgNodeResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.DeleteOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + if err := membership_service.MembershipSvc.DeleteOrgNode(&dto.DeleteOrgNodeRequest{ + ExternalID: req.ExternalId, + }); err != nil { + return &pb.DeleteOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.DeleteOrgNodeResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *MembershipServiceServer) MoveOrgNode(ctx context.Context, req *pb.MoveOrgNodeRequest) (*pb.MoveOrgNodeResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.MoveOrgNodeResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.MoveOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + if err := membership_service.MembershipSvc.MoveOrgNode(&dto.MoveOrgNodeRequest{ + ExternalID: req.ExternalId, + NewParentExternalID: req.NewParentExternalId, + }); err != nil { + return &pb.MoveOrgNodeResponse{Base: baseResponseFromErr(err)}, nil + } + + return &pb.MoveOrgNodeResponse{ + Base: baseResponseFromErr(nil), + }, nil +} + +func (s *MembershipServiceServer) ListOrgNodeMembers(ctx context.Context, req *pb.ListOrgNodeMembersRequest) (*pb.ListOrgNodeMembersResponse, error) { + if req == nil || strings.TrimSpace(req.ExternalId) == "" { + return &pb.ListOrgNodeMembersResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.ListOrgNodeMembersResponse{Base: baseResponseFromErr(err)}, nil + } + + serviceReq := &dto.ListOrgNodeMembersRequest{ + ExternalID: req.ExternalId, + Keyword: req.Keyword, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + } + users, total, err := membership_service.MembershipSvc.ListOrgNodeMembers(serviceReq) + if err != nil { + return &pb.ListOrgNodeMembersResponse{Base: baseResponseFromErr(err)}, nil + } + + respUsers := make([]*pb.UserResponse, 0, len(users)) + for _, user := range users { + respUsers = append(respUsers, toUserResponse(user)) + } + + return &pb.ListOrgNodeMembersResponse{ + Base: baseResponseFromErr(nil), + Users: respUsers, + Total: total, + }, nil +} + +func toOrgNodeResponse(node *dto.OrgNodeResponse) *pb.OrgNodeResponse { + resp := &pb.OrgNodeResponse{ + ExternalId: node.ExternalID, + ParentExternalId: node.ParentExternalID, + Name: node.Name, + Path: node.Path, + Description: node.Description, + CreatorUserExternalId: node.CreatorUserExternalID, + MemberCount: int32(node.MemberCount), + } + if node.Children != nil { + resp.Children = make([]*pb.OrgNodeResponse, 0, len(node.Children)) + for _, child := range node.Children { + resp.Children = append(resp.Children, toOrgNodeResponse(child)) + } + } + return resp +} diff --git a/internal/transport/grpcserver/notification_service.go b/internal/transport/grpcserver/notification_service.go new file mode 100644 index 0000000..ca59bfc --- /dev/null +++ b/internal/transport/grpcserver/notification_service.go @@ -0,0 +1,159 @@ +package grpcserver + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/domain_event" + "github.com/XingfenD/yoresee_doc/internal/dto" + "github.com/XingfenD/yoresee_doc/internal/model" + "github.com/XingfenD/yoresee_doc/internal/service/auth_service" + "github.com/XingfenD/yoresee_doc/internal/service/notification_service" + "github.com/XingfenD/yoresee_doc/internal/status" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type NotificationServiceServer struct { + pb.UnimplementedNotificationServiceServer +} + +func NewNotificationServiceServer() *NotificationServiceServer { + return &NotificationServiceServer{} +} + +func (s *NotificationServiceServer) CreateNotification(ctx context.Context, req *pb.CreateNotificationRequest) (*pb.CreateNotificationResponse, error) { + if req == nil { + return &pb.CreateNotificationResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.CreateNotificationResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + receiverExternalIDs := req.ReceiverExternalIds + if len(receiverExternalIDs) == 0 { + receiverExternalIDs = []string{userExternalID} + } + + if !onlySelf(receiverExternalIDs, userExternalID) { + isAdmin, err := auth_service.AuthSvc.IsAdmin(userExternalID) + if err != nil { + return &pb.CreateNotificationResponse{Base: baseResponseFromErr(err)}, nil + } + if !isAdmin { + return &pb.CreateNotificationResponse{Base: baseResponseFromStatus(status.StatusPermissionDenied)}, nil + } + } + + evt := domain_event.NotificationCreateEvent{ + ReceiverExternalIDs: receiverExternalIDs, + Type: req.Type, + Title: req.Title, + Content: req.Content, + PayloadJSON: req.PayloadJson, + } + if err := domain_event.PublishNotificationCreateEvent(ctx, evt); err != nil { + return &pb.CreateNotificationResponse{Base: baseResponseFromErr(status.StatusMQNotInitialized)}, nil + } + + return &pb.CreateNotificationResponse{Base: baseResponseFromErr(nil)}, nil +} + +func (s *NotificationServiceServer) ListNotifications(ctx context.Context, req *pb.ListNotificationsRequest) (*pb.ListNotificationsResponse, error) { + if req == nil { + return &pb.ListNotificationsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.ListNotificationsResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + var statusFilter *string + if req.Status != nil && strings.TrimSpace(req.GetStatus()) != "" { + statusFilter = req.Status + } + + list, total, err := notification_service.NotificationSvc.ListNotifications(&dto.ListNotificationsRequest{ + UserExternalID: userExternalID, + Status: statusFilter, + Pagination: dto.Pagination{ + Page: int(req.Page), + PageSize: int(req.PageSize), + }, + }) + if err != nil { + return &pb.ListNotificationsResponse{Base: baseResponseFromErr(err)}, nil + } + + respItems := make([]*pb.Notification, 0, len(list)) + for _, item := range list { + respItems = append(respItems, toNotificationResponse(&item)) + } + + return &pb.ListNotificationsResponse{ + Base: baseResponseFromErr(nil), + Notifications: respItems, + Total: total, + }, nil +} + +func (s *NotificationServiceServer) MarkNotificationsRead(ctx context.Context, req *pb.MarkNotificationsReadRequest) (*pb.MarkNotificationsReadResponse, error) { + if req == nil { + return &pb.MarkNotificationsReadResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.MarkNotificationsReadResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + + err := notification_service.NotificationSvc.MarkRead(&dto.MarkNotificationsReadRequest{ + UserExternalID: userExternalID, + ExternalIDs: req.ExternalIds, + }) + if err != nil { + return &pb.MarkNotificationsReadResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.MarkNotificationsReadResponse{Base: baseResponseFromErr(nil)}, nil +} + +func (s *NotificationServiceServer) MarkAllNotificationsRead(ctx context.Context, req *pb.MarkAllNotificationsReadRequest) (*pb.MarkAllNotificationsReadResponse, error) { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || strings.TrimSpace(userExternalID) == "" { + return &pb.MarkAllNotificationsReadResponse{Base: baseResponseFromStatus(status.StatusTokenInvalid)}, nil + } + if err := notification_service.NotificationSvc.MarkAllRead(userExternalID); err != nil { + return &pb.MarkAllNotificationsReadResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.MarkAllNotificationsReadResponse{Base: baseResponseFromErr(nil)}, nil +} + +func toNotificationResponse(item *model.Notification) *pb.Notification { + if item == nil { + return nil + } + return &pb.Notification{ + ExternalId: item.ExternalID, + Type: item.Type, + Status: item.Status, + Title: item.Title, + Content: item.Content, + Payload: item.Payload, + CreatedAt: timeToString(item.CreatedAt), + } +} + +func onlySelf(list []string, self string) bool { + if len(list) == 0 { + return true + } + for _, id := range list { + if strings.TrimSpace(id) == "" { + continue + } + if id != self { + return false + } + } + return true +} diff --git a/internal/transport/grpcserver/server.go b/internal/transport/grpcserver/server.go new file mode 100644 index 0000000..84ef1d5 --- /dev/null +++ b/internal/transport/grpcserver/server.go @@ -0,0 +1,158 @@ +package grpcserver + +import ( + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/repository" + "github.com/XingfenD/yoresee_doc/internal/status" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" + "github.com/improbable-eng/grpc-web/go/grpcweb" + "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +func Start(grpcPort, grpcWebPort int, repos *repository.Repositories) (*grpc.Server, error) { + allowUnauth := map[string]struct{}{ + "/yoresee_doc.v1.AuthService/Login": {}, + "/yoresee_doc.v1.AuthService/Register": {}, + "/yoresee_doc.v1.SystemService/Health": {}, + "/yoresee_doc.v1.SystemService/SystemInfo": {}, + } + + grpcServer := grpc.NewServer( + grpc.UnaryInterceptor(UnaryAuthInterceptor(allowUnauth)), + ) + + pb.RegisterAuthServiceServer(grpcServer, NewAuthServiceServer()) + pb.RegisterDocumentServiceServer(grpcServer, NewDocumentServiceServer()) + pb.RegisterKnowledgeBaseServiceServer(grpcServer, NewKnowledgeBaseServiceServer()) + pb.RegisterSystemServiceServer(grpcServer, NewSystemServiceServer()) + pb.RegisterMembershipServiceServer(grpcServer, NewMembershipServiceServer()) + pb.RegisterInvitationServiceServer(grpcServer, NewInvitationServiceServer(repos.User)) + + grpcListener, err := net.Listen("tcp", fmt.Sprintf(":%d", grpcPort)) + if err != nil { + return nil, status.GenErrWithCustomMsg(status.StatusServiceInternalError, "listen grpc port failed") + } + + go func() { + if serveErr := grpcServer.Serve(grpcListener); serveErr != nil { + logrus.Errorf("grpc server exited with error: %v", serveErr) + } + }() + + grpcWebServer := grpcweb.WrapServer(grpcServer, + grpcweb.WithOriginFunc(isOriginAllowed), + ) + + grpcWebHandler := grpcWebHTTPHandler(grpcWebServer) + grpcWebAddr := fmt.Sprintf(":%d", grpcWebPort) + go func() { + if serveErr := http.ListenAndServe(grpcWebAddr, grpcWebHandler); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + logrus.Errorf("grpc-web server exited with error: %v", serveErr) + } + }() + + return grpcServer, nil +} + +func grpcWebHTTPHandler(wrapped *grpcweb.WrappedGrpcServer) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + setGrpcWebCorsHeaders(w, r) + if wrapped.IsGrpcWebRequest(r) || wrapped.IsGrpcWebSocketRequest(r) { + wrapped.ServeHTTP(w, r) + return + } + if r.Method == http.MethodOptions && wrapped.IsAcceptableGrpcCorsRequest(r) { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) +} + +func setGrpcWebCorsHeaders(w http.ResponseWriter, r *http.Request) { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if allowedOrigin := resolveAllowedOrigin(origin); allowedOrigin != "" { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + } + w.Header().Set("Access-Control-Allow-Credentials", strconv.FormatBool(corsAllowCredentials())) + w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsAllowedHeaders(), ",")) + w.Header().Set("Access-Control-Allow-Methods", strings.Join(corsAllowedMethods(), ",")) + w.Header().Set("Access-Control-Expose-Headers", "Grpc-Status,Grpc-Message,Grpc-Status-Details-Bin") + if maxAge := corsMaxAge(); maxAge > 0 { + w.Header().Set("Access-Control-Max-Age", strconv.Itoa(maxAge)) + } +} + +func isOriginAllowed(origin string) bool { + origin = strings.TrimSpace(origin) + if origin == "" { + return false + } + allowedOrigins := corsAllowedOrigins() + if len(allowedOrigins) == 0 { + return true + } + for _, allowed := range allowedOrigins { + allowed = strings.TrimSpace(allowed) + if allowed == "*" || strings.EqualFold(allowed, origin) { + return true + } + } + return false +} + +func resolveAllowedOrigin(origin string) string { + if origin == "" { + if len(corsAllowedOrigins()) == 0 { + return "*" + } + return "" + } + if isOriginAllowed(origin) { + return origin + } + return "" +} + +func corsAllowedOrigins() []string { + if config.GlobalConfig == nil { + return nil + } + return config.GlobalConfig.Backend.Security.CORS.AllowedOrigins +} + +func corsAllowedMethods() []string { + if config.GlobalConfig != nil && len(config.GlobalConfig.Backend.Security.CORS.AllowedMethods) > 0 { + return config.GlobalConfig.Backend.Security.CORS.AllowedMethods + } + return []string{"POST", "GET", "OPTIONS"} +} + +func corsAllowedHeaders() []string { + if config.GlobalConfig != nil && len(config.GlobalConfig.Backend.Security.CORS.AllowedHeaders) > 0 { + return config.GlobalConfig.Backend.Security.CORS.AllowedHeaders + } + return []string{"Accept", "Accept-Language", "Content-Type", "Content-Length", "X-Grpc-Web", "X-User-Agent", "Grpc-Timeout", "Authorization"} +} + +func corsAllowCredentials() bool { + if config.GlobalConfig != nil { + return config.GlobalConfig.Backend.Security.CORS.AllowCredentials + } + return true +} + +func corsMaxAge() int { + if config.GlobalConfig != nil { + return config.GlobalConfig.Backend.Security.CORS.MaxAge + } + return 0 +} diff --git a/internal/transport/grpcserver/setting_service.go b/internal/transport/grpcserver/setting_service.go new file mode 100644 index 0000000..d76c048 --- /dev/null +++ b/internal/transport/grpcserver/setting_service.go @@ -0,0 +1,117 @@ +package grpcserver + +import ( + "context" + "strings" + + "github.com/XingfenD/yoresee_doc/internal/service/auth_service" + "github.com/XingfenD/yoresee_doc/internal/service/setting_service" + "github.com/XingfenD/yoresee_doc/internal/status" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type SettingServiceServer struct { + pb.UnimplementedSettingServiceServer +} + +func NewSettingServiceServer() *SettingServiceServer { + return &SettingServiceServer{} +} + +func (s *SettingServiceServer) requireAdmin(ctx context.Context) error { + userExternalID, ok := ctx.Value("user_external_id").(string) + if !ok || userExternalID == "" { + return status.StatusTokenInvalid + } + isAdmin, err := auth_service.AuthSvc.IsAdmin(userExternalID) + if err != nil { + return err + } + if !isAdmin { + return status.StatusPermissionDenied + } + return nil +} + +func (s *SettingServiceServer) GetSettings(ctx context.Context, req *pb.GetSettingsRequest) (*pb.GetSettingsResponse, error) { + if req == nil { + return &pb.GetSettingsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.GetSettingsResponse{Base: baseResponseFromErr(err)}, nil + } + + scene := strings.TrimSpace(req.Scene) + groups, err := setting_service.SettingSvc.GetSettings(ctx, scene) + if err != nil { + return &pb.GetSettingsResponse{Base: baseResponseFromErr(err)}, nil + } + + respGroups := make([]*pb.SettingGroup, 0, len(groups)) + for _, group := range groups { + items := make([]*pb.SettingItem, 0, len(group.Items)) + for _, item := range group.Items { + options := make([]*pb.SettingOption, 0, len(item.UI.Options)) + for _, opt := range item.UI.Options { + options = append(options, &pb.SettingOption{ + Label: opt.Label, + LabelKey: opt.LabelKey, + Value: opt.Value, + }) + } + items = append(items, &pb.SettingItem{ + Key: item.Key, + Label: item.Label, + LabelKey: item.LabelKey, + Description: item.Description, + DescriptionKey: item.DescriptionKey, + Type: item.Type, + Ui: &pb.SettingUI{ + Component: item.UI.Component, + Options: options, + Placeholder: item.UI.Placeholder, + PlaceholderKey: item.UI.PlaceholderKey, + }, + Value: item.Value, + DefaultValue: item.DefaultValue, + Required: item.Required, + Readonly: item.Readonly, + }) + } + respGroups = append(respGroups, &pb.SettingGroup{ + Key: group.Key, + Title: group.Title, + TitleKey: group.TitleKey, + Items: items, + }) + } + + return &pb.GetSettingsResponse{ + Base: baseResponseFromErr(nil), + Groups: respGroups, + }, nil +} + +func (s *SettingServiceServer) UpdateSettings(ctx context.Context, req *pb.UpdateSettingsRequest) (*pb.UpdateSettingsResponse, error) { + if req == nil { + return &pb.UpdateSettingsResponse{Base: baseResponseFromStatus(status.StatusParamError)}, nil + } + if err := s.requireAdmin(ctx); err != nil { + return &pb.UpdateSettingsResponse{Base: baseResponseFromErr(err)}, nil + } + + updates := make([]setting_service.SettingUpdate, 0, len(req.Updates)) + for _, update := range req.Updates { + if update == nil { + continue + } + updates = append(updates, setting_service.SettingUpdate{ + Key: update.Key, + Value: update.Value, + }) + } + if err := setting_service.SettingSvc.UpdateSettings(ctx, updates); err != nil { + return &pb.UpdateSettingsResponse{Base: baseResponseFromErr(err)}, nil + } + return &pb.UpdateSettingsResponse{Base: baseResponseFromErr(nil)}, nil +} diff --git a/internal/transport/grpcserver/system_service.go b/internal/transport/grpcserver/system_service.go new file mode 100644 index 0000000..e698fdc --- /dev/null +++ b/internal/transport/grpcserver/system_service.go @@ -0,0 +1,35 @@ +package grpcserver + +import ( + "context" + "time" + + "github.com/XingfenD/yoresee_doc/internal/config" + "github.com/XingfenD/yoresee_doc/internal/service/config_service" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +type SystemServiceServer struct { + pb.UnimplementedSystemServiceServer +} + +func NewSystemServiceServer() *SystemServiceServer { + return &SystemServiceServer{} +} + +func (s *SystemServiceServer) Health(ctx context.Context, req *pb.HealthRequest) (*pb.HealthResponse, error) { + return &pb.HealthResponse{ + Base: baseResponseFromErr(nil), + Timestamp: time.Now().Format(time.RFC3339), + Status: "healthy", + Version: "1.0.0", + }, nil +} + +func (s *SystemServiceServer) SystemInfo(ctx context.Context, req *pb.SystemInfoRequest) (*pb.SystemInfoResponse, error) { + return &pb.SystemInfoResponse{ + Base: baseResponseFromErr(nil), + SystemName: config.GlobalConfig.Backend.SystemName, + SystemRegisterMode: config_service.ConfigSvc.GetSystemRegisterMode(ctx), + }, nil +} diff --git a/internal/transport/grpcserver/validate.go b/internal/transport/grpcserver/validate.go new file mode 100644 index 0000000..b9516b1 --- /dev/null +++ b/internal/transport/grpcserver/validate.go @@ -0,0 +1,33 @@ +package grpcserver + +import ( + doc_container_mapper "github.com/XingfenD/yoresee_doc/internal/mapper/doc_container_mapper" + "github.com/XingfenD/yoresee_doc/internal/status" + pb "github.com/XingfenD/yoresee_doc/pkg/gen/yoresee_doc/v1" +) + +func validateUpdateDocumentRequest(req *pb.UpdateDocumentRequest) error { + if req == nil { + return status.StatusParamError + } + if req.Content == nil && req.KnowledgeBaseExternalId == nil && req.ParentExternalId == nil && req.Title == nil && req.MoveToContainer == nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "no update fields") + } + if req.MoveToContainer != nil { + containerType, ok := doc_container_mapper.FromProtoType(*req.MoveToContainer) + if !ok { + return status.GenErrWithCustomMsg(status.StatusParamError, "invalid move_to_container") + } + requiresKnowledgeBaseID := doc_container_mapper.RequiresKnowledgeBaseID(containerType) + if !requiresKnowledgeBaseID && req.KnowledgeBaseExternalId != nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "KnowledgeBaseExternalId not nil") + } + if requiresKnowledgeBaseID && req.KnowledgeBaseExternalId == nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "KnowledgeBaseExternalId is nil") + } + } + if req.MoveToContainer == nil && req.KnowledgeBaseExternalId != nil { + return status.GenErrWithCustomMsg(status.StatusParamError, "MoveToContainer is nil") + } + return nil +} diff --git a/internal/types/common_types.go b/internal/types/common_types.go new file mode 100644 index 0000000..2ac421b --- /dev/null +++ b/internal/types/common_types.go @@ -0,0 +1,6 @@ +package types + +type TimeRange struct { + Start *string `json:"start" form:"start"` + End *string `json:"end" form:"end"` +} diff --git a/internal/utils/encoding.go b/internal/utils/encoding.go new file mode 100644 index 0000000..3e4ab85 --- /dev/null +++ b/internal/utils/encoding.go @@ -0,0 +1,7 @@ +package utils + +import "encoding/base64" + +func DecodeBase64(value string) ([]byte, error) { + return base64.StdEncoding.DecodeString(value) +} diff --git a/internal/utils/password.go b/internal/utils/password.go new file mode 100644 index 0000000..fd52eee --- /dev/null +++ b/internal/utils/password.go @@ -0,0 +1,23 @@ +package utils + +import ( + "github.com/XingfenD/yoresee_doc/internal/config" + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + cost := bcrypt.DefaultCost + if config.GlobalConfig != nil { + c := config.GlobalConfig.Backend.Security.PasswordHashCost + if c >= bcrypt.MinCost && c <= bcrypt.MaxCost { + cost = c + } + } + bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost) + return string(bytes), err +} + +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/internal/utils/shutdown.go b/internal/utils/shutdown.go new file mode 100644 index 0000000..b450469 --- /dev/null +++ b/internal/utils/shutdown.go @@ -0,0 +1,36 @@ +package utils + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/sirupsen/logrus" +) + +type ShutdownHook struct { + Name string + Fn func() error +} + +func ShutdownContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) +} + +func WaitForShutdownSignal() { + ctx, stop := ShutdownContext() + defer stop() + <-ctx.Done() +} + +func RunShutdownHooks(hooks ...ShutdownHook) { + for _, hook := range hooks { + if hook.Fn == nil { + continue + } + if err := hook.Fn(); err != nil { + logrus.Errorf("Close %s failed: %v", hook.Name, err) + } + } +} diff --git a/internal/utils/snowflake.go b/internal/utils/snowflake.go new file mode 100644 index 0000000..c527ca5 --- /dev/null +++ b/internal/utils/snowflake.go @@ -0,0 +1,45 @@ +package utils + +import ( + "crypto/md5" + "encoding/hex" + "sync" + + "github.com/bwmarrin/snowflake" +) + +var ( + node *snowflake.Node + nodeOnce sync.Once + nodeErr error +) + +type ExternalIDContext string + +const ( + ExternalIDContextUser ExternalIDContext = "user" + ExternalIDKnowledgeBase ExternalIDContext = "knowledge_base" + ExternalIDContextDocument ExternalIDContext = "document" + ExternalIDContextUserGroup ExternalIDContext = "user_group" + ExternalIDContextOrgNode ExternalIDContext = "org_node" + ExternalIDContextComment ExternalIDContext = "comment" + ExternalIDContextNotification ExternalIDContext = "notification" + ExternalIDContextAttachment ExternalIDContext = "attachment" +) + +func GenerateExternalID(context ExternalIDContext) string { + initSnowflakeNode() + if nodeErr != nil { + panic(nodeErr) + } + id := node.Generate() + combined := string(context) + ":" + id.String() + hash := md5.Sum([]byte(combined)) + return hex.EncodeToString(hash[:]) +} + +func initSnowflakeNode() { + nodeOnce.Do(func() { + node, nodeErr = snowflake.NewNode(1) + }) +} diff --git a/internal/utils/string.go b/internal/utils/string.go new file mode 100644 index 0000000..55e5dad --- /dev/null +++ b/internal/utils/string.go @@ -0,0 +1,43 @@ +package utils + +import ( + "strconv" + "strings" +) + +func Int64ToString(i int64) string { + return strconv.FormatInt(i, 10) +} + +func ParseInt64(value string) (int64, error) { + return strconv.ParseInt(strings.TrimSpace(value), 10, 64) +} + +func NormalizeToken(raw, fallback string) string { + fallback = strings.TrimSpace(fallback) + if fallback == "" { + fallback = "default" + } + + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + + var b strings.Builder + for _, r := range raw { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '.' || r == '-' || r == '_' { + b.WriteRune(r) + continue + } + b.WriteByte('-') + } + + if b.Len() == 0 { + return fallback + } + return b.String() +} diff --git a/internal/utils/transaction.go b/internal/utils/transaction.go new file mode 100644 index 0000000..b7d4b02 --- /dev/null +++ b/internal/utils/transaction.go @@ -0,0 +1,28 @@ +package utils + +import ( + "gorm.io/gorm" +) + +// WithTransaction 执行一个包含事务的操作 +func WithTransaction(db *gorm.DB, fn func(tx *gorm.DB) error) error { + tx := db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } + }() + + if err := fn(tx); err != nil { + tx.Rollback() + return err + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return err + } + + return nil +} diff --git a/internal/utils/util.go b/internal/utils/util.go new file mode 100644 index 0000000..86f0fa7 --- /dev/null +++ b/internal/utils/util.go @@ -0,0 +1,22 @@ +package utils + +import ( + "os" + "strings" +) + +func Of[T any](v T) *T { + return &v +} + +func GenConfigKey(parts ...string) string { + return strings.Join(parts, ".") +} + +func GetEnvVar(key, defaultValue string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return strings.TrimSpace(defaultValue) + } + return value +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..0bf7709 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,21 @@ +package utils + +import "testing" + +func TestGetEnvVar(t *testing.T) { + t.Run("use env value", func(t *testing.T) { + t.Setenv("GET_ENV_VAR_TEST", " value ") + got := GetEnvVar("GET_ENV_VAR_TEST", "default") + if got != "value" { + t.Fatalf("expected value, got %q", got) + } + }) + + t.Run("fallback to default", func(t *testing.T) { + t.Setenv("GET_ENV_VAR_TEST", "") + got := GetEnvVar("GET_ENV_VAR_TEST", " default ") + if got != "default" { + t.Fatalf("expected default, got %q", got) + } + }) +} diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..aedf761 --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,67 @@ +package cache + +import ( + "context" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/bytedance/sonic" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" +) + +func GetJSON(ctx context.Context, key string, dst interface{}) (bool, error) { + b, err := storage.KVS.Get(ctx, key).Bytes() + if err == redis.Nil { + return false, nil + } + if err != nil { + return false, err + } + if err := sonic.Unmarshal(b, dst); err != nil { + return false, err + } + return true, nil +} + +func SetJSON(ctx context.Context, key string, v interface{}, ttl time.Duration) error { + b, err := sonic.Marshal(v) + if err != nil { + return err + } + return storage.KVS.Set(ctx, key, b, ttl).Err() +} + +func DeleteByPattern(ctx context.Context, pattern string) error { + iter := storage.KVS.Scan(ctx, 0, pattern, 100).Iterator() + for iter.Next(ctx) { + _ = storage.KVS.Del(ctx, iter.Val()).Err() + } + return iter.Err() +} + +func DoubleDelete(ctx context.Context, writeDB func() error, keys ...string) error { + if err := deleteKeys(ctx, keys); err != nil { + logrus.Warn("First cache deletion failed: ", err) + } + + if err := writeDB(); err != nil { + return err + } + + go func() { + time.Sleep(100 * time.Millisecond) + if err := deleteKeys(ctx, keys); err != nil { + logrus.Warn("Second cache deletion failed: ", err) + } + }() + + return nil +} + +func deleteKeys(ctx context.Context, keys []string) error { + if len(keys) == 0 { + return nil + } + return storage.KVS.Del(ctx, keys...).Err() +} diff --git a/pkg/constant/topic.go b/pkg/constant/topic.go new file mode 100644 index 0000000..b39540e --- /dev/null +++ b/pkg/constant/topic.go @@ -0,0 +1,13 @@ +package constant + +const ( + NotificationTopicDefault = "notification.create" +) + +const ( + DirtyDocTopicDefault = "collab.dirty_docs" +) + +const ( + SearchSyncTopicDefault = "search.sync.document" +) diff --git a/pkg/errs/errs.go b/pkg/errs/errs.go new file mode 100644 index 0000000..9560ba3 --- /dev/null +++ b/pkg/errs/errs.go @@ -0,0 +1,84 @@ +package errs + +import ( + "errors" + "fmt" +) + +var ( + ErrTopicEmpty = errors.New("topic is empty") + ErrRedisClientNotInitialized = errors.New("redis client not initialized") + ErrInvalidConsumeMode = errors.New("invalid consume mode") + ErrInvalidErrorAction = errors.New("invalid error action") + + ErrRabbitMQConnectFailed = errors.New("failed to connect to RabbitMQ") + ErrRabbitMQOpenChannelFailed = errors.New("failed to open a channel") + ErrRabbitMQDeclareExchange = errors.New("failed to declare an exchange") + ErrRabbitMQPublishMessage = errors.New("failed to publish a message") + ErrRabbitMQDeclareQueue = errors.New("failed to declare a queue") + ErrRabbitMQBindQueue = errors.New("failed to bind a queue") + ErrRabbitMQRegisterConsumer = errors.New("failed to register a consumer") + ErrRabbitMQConsumerChanClosed = errors.New("consumer channel closed") + + ErrInitRedisClient = errors.New("init redis client failed") + + ErrElasticAddressesEmpty = errors.New("elasticsearch addresses are empty") + ErrElasticPingFailed = errors.New("ping elasticsearch failed") + ErrElasticClientNil = errors.New("elasticsearch client is nil") + ErrElasticIndexOrDocID = errors.New("index or docID is empty") + ErrElasticIndexEmpty = errors.New("index is empty") + ErrElasticStatus = errors.New("elasticsearch status error") + ErrElasticStatusDecode = errors.New("elasticsearch status decode error") + ErrElasticStatusWithBody = errors.New("elasticsearch status body error") + + ErrMinioInitClient = errors.New("init minio client failed") + ErrMinioCheckBucketExists = errors.New("check bucket exists failed") + ErrMinioCreateBucket = errors.New("create bucket failed") + ErrMinioUploadFile = errors.New("upload file failed") + ErrMinioGeneratePresigned = errors.New("generate presigned url failed") + + ErrConsulKVGet = errors.New("consul kv get failed") + ErrConsulKVSet = errors.New("consul kv set failed") + ErrConsulBindTargetNil = errors.New("consul config bind target is nil") + ErrConsulClientNil = errors.New("consul client is nil") + ErrConsulBindTargetInvalid = errors.New("consul config bind target must be pointer to struct") + ErrConsulFieldMustBeFunc = errors.New("field must be func()T") + ErrConsulFieldTagInvalid = errors.New("field tag invalid") + ErrConsulTagEmpty = errors.New("empty tag") + ErrConsulTagMissingKey = errors.New("missing key") + ErrConsulUnsupportedType = errors.New("unsupported type") + + ErrInvalidGormLogLevel = errors.New("invalid gorm log level") + ErrPostgresOptionsNil = errors.New("postgres options is nil") + + ErrLockAcquireFailed = errors.New("failed to acquire lock") + ErrLockNotHeld = errors.New("lock not held or expired") +) + +func Wrap(base error, cause error) error { + if cause == nil { + return base + } + return fmt.Errorf("%w: %v", base, cause) +} + +func Detail(base error, detail string) error { + if detail == "" { + return base + } + return fmt.Errorf("%w: %s", base, detail) +} + +func Detailf(base error, format string, args ...any) error { + return Detail(base, fmt.Sprintf(format, args...)) +} + +func DetailWrap(base error, detail string, cause error) error { + if cause == nil { + return Detail(base, detail) + } + if detail == "" { + return Wrap(base, cause) + } + return fmt.Errorf("%w: %s: %v", base, detail, cause) +} diff --git a/pkg/key/key.go b/pkg/key/key.go new file mode 100644 index 0000000..e00b1c2 --- /dev/null +++ b/pkg/key/key.go @@ -0,0 +1,91 @@ +package key + +import ( + "fmt" +) + +type KeyObjectTypeEnum int + +const ( + KeyObjectTypeEnum_User KeyObjectTypeEnum = iota + KeyObjectTypeEnum_Doc + KeyObjectTypeEnum_KnowledgeBase +) + +var objStringMap = map[KeyObjectTypeEnum]string{ + KeyObjectTypeEnum_User: "user", + KeyObjectTypeEnum_Doc: "doc", + KeyObjectTypeEnum_KnowledgeBase: "doc", +} + +func KeyIDByExternalID(obj KeyObjectTypeEnum, externalID string) string { + for k, v := range objStringMap { + if k == obj { + return fmt.Sprintf("dms:%s:extid:%s", v, externalID) + } + } + + return fmt.Sprintf("dms:unknownobj_%d:extid:%s", int(obj), externalID) +} + +func KeyModelByExternalID(obj KeyObjectTypeEnum, externalID string) string { + for k, v := range objStringMap { + if k == obj { + return fmt.Sprintf("dms:%s:model:%s", v, externalID) + } + } + + return fmt.Sprintf("dms:unknownobj_%d:model:%s", int(obj), externalID) +} + +func KeyDocSubtreeVersion(path string) string { + return fmt.Sprintf("dms:doc:version:%s", path) +} + +func KeyDocSubtree(path string, version int64, depth *int) string { + depthKey := "all" + if depth != nil { + depthKey = fmt.Sprintf("%d", *depth) + } + return fmt.Sprintf("dms:doc:subtree:%s:v%d:%s", path, version, depthKey) +} + +func KeyUserQueryList(queryHash string, page, pageSize int) string { + return fmt.Sprintf("dms:user:query:%s:p%d:s%d", queryHash, page, pageSize) +} + +func KeyUserQueryPrefix() string { + return "dms:user:query:*" +} + +func KeyUserQueryVersion() string { + return "dms:user:query:version" +} + +func KeyJWTActiveToken(tokenHash string) string { + return fmt.Sprintf("dms:auth:jwt:active:%s", tokenHash) +} + +func KeyJWTBlacklistToken(tokenHash string) string { + return fmt.Sprintf("dms:auth:jwt:blacklist:%s", tokenHash) +} + +func KeyJWTUserTokenSet(userExternalID string) string { + return fmt.Sprintf("dms:auth:jwt:user_tokens:%s", userExternalID) +} + +func KeyESDocumentIndex(prefix string) string { + return fmt.Sprintf("%s_documents", prefix) +} + +func KeyCollabDocUpdates(docID string) string { + return fmt.Sprintf("collab:yjs:doc:updates:%s", docID) +} + +func KeyCollabRoom(docID string) string { + return fmt.Sprintf("collab:room:doc-%s", docID) +} + +func KeyCollabDirtyDocSet() string { + return "collab:yjs:dirty:doc" +} diff --git a/pkg/lock/redis_lock.go b/pkg/lock/redis_lock.go new file mode 100644 index 0000000..569aed3 --- /dev/null +++ b/pkg/lock/redis_lock.go @@ -0,0 +1,58 @@ +package lock + +import ( + "context" + "fmt" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" + "github.com/XingfenD/yoresee_doc/pkg/storage" +) + +// distributed lock, depredated by usage of singleflight +type DistributedLock struct { + key string + value string + expiration time.Duration +} + +func NewDistributedLock(key string, expiration time.Duration) *DistributedLock { + return &DistributedLock{ + key: key, + value: fmt.Sprintf("%d", time.Now().UnixNano()), + expiration: expiration, + } +} + +func (dl *DistributedLock) Lock(ctx context.Context) error { + success, err := storage.SetNX(ctx, dl.key, dl.value, dl.expiration) + if err != nil { + return err + } + if !success { + return errs.Detail(errs.ErrLockAcquireFailed, dl.key) + } + return nil +} + +func (dl *DistributedLock) TryLock(ctx context.Context) (bool, error) { + return storage.SetNX(ctx, dl.key, dl.value, dl.expiration) +} + +func (dl *DistributedLock) Unlock(ctx context.Context) error { + script := ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + ` + result, err := storage.GetRedis().Eval(ctx, script, []string{dl.key}, dl.value).Result() + if err != nil { + return err + } + if result.(int64) == 0 { + return errs.Detail(errs.ErrLockNotHeld, dl.key) + } + return nil +} diff --git a/pkg/lock/retry_lock.go b/pkg/lock/retry_lock.go new file mode 100644 index 0000000..ee88683 --- /dev/null +++ b/pkg/lock/retry_lock.go @@ -0,0 +1,49 @@ +package lock + +import ( + "context" + "time" +) + +const LockCachePrefix = "lock:" + +type RetryLockFunc func(ctx context.Context) (interface{}, error) + +type CheckFunc func(ctx context.Context) (result interface{}, completed bool, err error) + +func AcquireWithRetry(ctx context.Context, lockKey string, expiration time.Duration, + maxRetries int, retryInterval time.Duration, checkFn CheckFunc, execFn RetryLockFunc) (interface{}, error) { + + distributedLock := NewDistributedLock(lockKey, expiration) + + err := distributedLock.Lock(ctx) + if err == nil { + defer distributedLock.Unlock(ctx) + + return execFn(ctx) + } + + for i := 0; i < maxRetries; i++ { + time.Sleep(retryInterval) + + if checkFn != nil { + result, completed, err := checkFn(ctx) + if err == nil && completed { + return result, nil + } + } + + acquired, lockErr := distributedLock.TryLock(ctx) + if lockErr != nil { + continue + } + + if acquired { + defer distributedLock.Unlock(ctx) + + return execFn(ctx) + } + } + + return nil, err +} diff --git a/pkg/mq/mq_api.go b/pkg/mq/mq_api.go new file mode 100644 index 0000000..fde6860 --- /dev/null +++ b/pkg/mq/mq_api.go @@ -0,0 +1,86 @@ +package mq + +import ( + "context" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" +) + +type Message struct { + ID string + Topic string + Key string + Body []byte + Headers map[string]string + Timestamp time.Time +} + +type PublishMessage struct { + Topic string + Key string + Body []byte + Headers map[string]string +} + +type ConsumeMode string + +const ( + ConsumeModeFanout ConsumeMode = "fanout" + ConsumeModeGroup ConsumeMode = "group" +) + +type ErrorAction string + +const ( + ErrorActionDrop ErrorAction = "drop" + ErrorActionRequeue ErrorAction = "requeue" +) + +type ConsumeOptions struct { + Topic string + Mode ConsumeMode + Group string + Consumer string + AutoAck bool + OnError ErrorAction +} + +func (o ConsumeOptions) normalize() ConsumeOptions { + n := o + n.Topic = strings.TrimSpace(n.Topic) + n.Group = strings.TrimSpace(n.Group) + n.Consumer = strings.TrimSpace(n.Consumer) + if n.Mode == "" { + n.Mode = ConsumeModeFanout + } + if n.OnError == "" { + n.OnError = ErrorActionRequeue + } + return n +} + +func (o ConsumeOptions) validate() error { + switch o.Mode { + case ConsumeModeFanout, ConsumeModeGroup: + default: + return errs.Detailf(errs.ErrInvalidConsumeMode, "%s", o.Mode) + } + + switch o.OnError { + case ErrorActionDrop, ErrorActionRequeue: + default: + return errs.Detailf(errs.ErrInvalidErrorAction, "%s", o.OnError) + } + + return nil +} + +type MessageHandler func(ctx context.Context, msg Message) error + +type MessageQueue interface { + Publish(ctx context.Context, msg PublishMessage) error + Consume(ctx context.Context, opts ConsumeOptions, handler MessageHandler) error + Close() error +} diff --git a/pkg/mq/mq_impl.go b/pkg/mq/mq_impl.go new file mode 100644 index 0000000..e3313fb --- /dev/null +++ b/pkg/mq/mq_impl.go @@ -0,0 +1,29 @@ +package mq + +import "strings" + +type Backend string + +const ( + BackendRedis Backend = "redis" + BackendRabbitMQ Backend = "rabbitmq" +) + +type Options struct { + RabbitMQURL string +} + +func NewMessageQueues(cfg *Options) (map[Backend]MessageQueue, error) { + mqs := map[Backend]MessageQueue{} + mqs[BackendRedis] = NewRedisMQ() + + if cfg != nil && strings.TrimSpace(cfg.RabbitMQURL) != "" { + rmq, err := NewRabbitMQ(RabbitMQConfig{URL: cfg.RabbitMQURL}) + if err != nil { + return mqs, err + } + mqs[BackendRabbitMQ] = rmq + } + + return mqs, nil +} diff --git a/pkg/mq/rabbitmq_mq.go b/pkg/mq/rabbitmq_mq.go new file mode 100644 index 0000000..b4a9af5 --- /dev/null +++ b/pkg/mq/rabbitmq_mq.go @@ -0,0 +1,236 @@ +package mq + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" + "github.com/rabbitmq/amqp091-go" + "github.com/sirupsen/logrus" +) + +type RabbitMQ struct { + conn *amqp091.Connection + channel *amqp091.Channel +} + +type RabbitMQConfig struct { + URL string +} + +func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) { + conn, err := amqp091.Dial(config.URL) + if err != nil { + return nil, errs.Wrap(errs.ErrRabbitMQConnectFailed, err) + } + + channel, err := conn.Channel() + if err != nil { + conn.Close() + return nil, errs.Wrap(errs.ErrRabbitMQOpenChannelFailed, err) + } + + return &RabbitMQ{ + conn: conn, + channel: channel, + }, nil +} + +func (rmq *RabbitMQ) Publish(ctx context.Context, msg PublishMessage) error { + topic := strings.TrimSpace(msg.Topic) + if topic == "" { + return errs.ErrTopicEmpty + } + if err := rmq.declareTopicExchange(topic); err != nil { + return errs.Wrap(errs.ErrRabbitMQDeclareExchange, err) + } + + if err := rmq.channel.PublishWithContext( + ctx, + topic, + "", + false, + false, + amqp091.Publishing{ + ContentType: "application/json", + Body: msg.Body, + MessageId: strings.TrimSpace(msg.Key), + Headers: toAMQPHeaders(msg.Headers), + Timestamp: time.Now(), + }, + ); err != nil { + return errs.Wrap(errs.ErrRabbitMQPublishMessage, err) + } + return nil +} + +func (rmq *RabbitMQ) Consume(ctx context.Context, opts ConsumeOptions, handler MessageHandler) error { + opts = opts.normalize() + topic := strings.TrimSpace(opts.Topic) + if topic == "" { + return errs.ErrTopicEmpty + } + + if err := rmq.declareTopicExchange(topic); err != nil { + return errs.Wrap(errs.ErrRabbitMQDeclareExchange, err) + } + + queueName, durable, autoDelete, exclusive := consumeQueueConfig(topic, opts) + queue, err := rmq.channel.QueueDeclare( + queueName, + durable, + autoDelete, + exclusive, + false, + nil, + ) + if err != nil { + return errs.Wrap(errs.ErrRabbitMQDeclareQueue, err) + } + + if err := rmq.channel.QueueBind( + queue.Name, + "", + topic, + false, + nil, + ); err != nil { + return errs.Wrap(errs.ErrRabbitMQBindQueue, err) + } + + consumerTag := buildConsumerTag(opts.Consumer, topic) + msgs, err := rmq.channel.Consume( + queue.Name, + consumerTag, + opts.AutoAck, + false, + false, + false, + nil, + ) + if err != nil { + return errs.Wrap(errs.ErrRabbitMQRegisterConsumer, err) + } + + logrus.Infof("RabbitMQ consume started, topic=%s, queue=%s, mode=%s, group=%s, auto_ack=%v", + topic, queue.Name, opts.Mode, opts.Group, opts.AutoAck, + ) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-msgs: + if !ok { + return errs.Detailf(errs.ErrRabbitMQConsumerChanClosed, "topic=%s, queue=%s", topic, queue.Name) + } + + message := Message{ + ID: d.MessageId, + Topic: topic, + Key: d.MessageId, + Body: d.Body, + Headers: fromAMQPHeaders(d.Headers), + Timestamp: d.Timestamp, + } + if message.Timestamp.IsZero() { + message.Timestamp = time.Now() + } + + handleErr := handler(context.Background(), message) + if opts.AutoAck { + if handleErr != nil { + logrus.Errorf("RabbitMQ consume handler failed (auto-ack), topic=%s, queue=%s, err=%v", topic, queue.Name, handleErr) + } + continue + } + + if handleErr != nil { + requeue := opts.OnError == ErrorActionRequeue + if nackErr := d.Nack(false, requeue); nackErr != nil { + logrus.Errorf("RabbitMQ nack failed, topic=%s, queue=%s, requeue=%v, err=%v", topic, queue.Name, requeue, nackErr) + } + logrus.Errorf("RabbitMQ consume handler failed, topic=%s, queue=%s, requeue=%v, err=%v", topic, queue.Name, requeue, handleErr) + continue + } + + if ackErr := d.Ack(false); ackErr != nil { + logrus.Errorf("RabbitMQ ack failed, topic=%s, queue=%s, err=%v", topic, queue.Name, ackErr) + } + } + } +} + +func (rmq *RabbitMQ) declareTopicExchange(topic string) error { + return rmq.channel.ExchangeDeclare( + topic, + "topic", + true, + false, + false, + false, + nil, + ) +} + +func consumeQueueConfig(topic string, opts ConsumeOptions) (queueName string, durable, autoDelete, exclusive bool) { + if opts.Mode != ConsumeModeGroup { + return "", false, true, true + } + group := strings.TrimSpace(opts.Group) + if group == "" { + group = "default" + } + return fmt.Sprintf("%s.%s", topic, group), true, false, false +} + +func buildConsumerTag(consumer, topic string) string { + consumer = strings.TrimSpace(consumer) + if consumer == "" { + hostName, _ := os.Hostname() + consumer = fmt.Sprintf("%s-%s-%d", strings.TrimSpace(topic), strings.TrimSpace(hostName), os.Getpid()) + } + return consumer +} + +func toAMQPHeaders(headers map[string]string) amqp091.Table { + if len(headers) == 0 { + return nil + } + table := amqp091.Table{} + for key, val := range headers { + key = strings.TrimSpace(key) + if key == "" { + continue + } + table[key] = val + } + if len(table) == 0 { + return nil + } + return table +} + +func fromAMQPHeaders(table amqp091.Table) map[string]string { + if len(table) == 0 { + return nil + } + headers := make(map[string]string, len(table)) + for key, val := range table { + headers[key] = fmt.Sprint(val) + } + return headers +} + +func (rmq *RabbitMQ) Close() error { + if rmq.channel != nil { + rmq.channel.Close() + } + if rmq.conn != nil { + rmq.conn.Close() + } + return nil +} diff --git a/pkg/mq/redis_mq.go b/pkg/mq/redis_mq.go new file mode 100644 index 0000000..729e9d8 --- /dev/null +++ b/pkg/mq/redis_mq.go @@ -0,0 +1,73 @@ +package mq + +import ( + "context" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" + "github.com/XingfenD/yoresee_doc/pkg/storage" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" +) + +type RedisMQ struct { + client *redis.Client +} + +func NewRedisMQ() *RedisMQ { + return &RedisMQ{ + client: storage.GetRedis(), + } +} + +func (rmq *RedisMQ) Publish(ctx context.Context, msg PublishMessage) error { + topic := strings.TrimSpace(msg.Topic) + if topic == "" { + return errs.ErrTopicEmpty + } + if rmq.client == nil { + return errs.ErrRedisClientNotInitialized + } + return rmq.client.Publish(ctx, topic, msg.Body).Err() +} + +func (rmq *RedisMQ) Consume(ctx context.Context, opts ConsumeOptions, handler MessageHandler) error { + opts = opts.normalize() + topic := strings.TrimSpace(opts.Topic) + if topic == "" { + return errs.ErrTopicEmpty + } + if rmq.client == nil { + return errs.ErrRedisClientNotInitialized + } + if opts.Mode == ConsumeModeGroup && strings.TrimSpace(opts.Group) != "" { + logrus.Warnf("Redis Pub/Sub does not support true group consume, fallback to fanout mode, topic=%s, group=%s", topic, opts.Group) + } + + pubsub := rmq.client.Subscribe(ctx, topic) + defer pubsub.Close() + + ch := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case msg, ok := <-ch: + if !ok { + return nil + } + if err := handler(context.Background(), Message{ + Topic: topic, + Body: []byte(msg.Payload), + Timestamp: time.Now(), + }); err != nil { + logrus.Errorf("Redis consume handler failed, topic=%s, err=%v", topic, err) + } + } + } +} + +func (rmq *RedisMQ) Close() error { + return nil +} diff --git a/pkg/storage/consul.go b/pkg/storage/consul.go new file mode 100644 index 0000000..d44ce12 --- /dev/null +++ b/pkg/storage/consul.go @@ -0,0 +1,337 @@ +package storage + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" +) + +type ConsulKVClient struct { + baseURL string + token string + datacenter string + prefix string + httpClient *http.Client + cacheTTL time.Duration +} + +var Consul *ConsulKVClient + +func NewConsulClient(cfg *ConsulOptions) *ConsulKVClient { + if cfg == nil || !cfg.Enabled { + return nil + } + + scheme := strings.TrimSpace(cfg.Scheme) + if scheme == "" { + scheme = "http" + } + address := strings.TrimSpace(cfg.Address) + if address == "" { + address = "127.0.0.1:8500" + } + + return &ConsulKVClient{ + baseURL: fmt.Sprintf("%s://%s", scheme, address), + token: cfg.Token, + datacenter: cfg.Datacenter, + prefix: strings.Trim(cfg.Prefix, "/"), + httpClient: &http.Client{Timeout: 5 * time.Second}, + cacheTTL: 5 * time.Second, + } +} + +func ConsulEnabled() bool { + return Consul != nil +} + +func (c *ConsulKVClient) resolveKey(key string) string { + key = strings.Trim(key, "/") + if c.prefix == "" { + return key + } + return path.Join(c.prefix, key) +} + +func (c *ConsulKVClient) buildURL(key string, raw bool) string { + fullKey := c.resolveKey(key) + u, _ := url.Parse(c.baseURL) + u.Path = path.Join(u.Path, "/v1/kv", fullKey) + q := u.Query() + if raw { + q.Set("raw", "") + } + if c.datacenter != "" { + q.Set("dc", c.datacenter) + } + u.RawQuery = q.Encode() + return u.String() +} + +func (c *ConsulKVClient) setHeaders(req *http.Request) { + if c.token != "" { + req.Header.Set("X-Consul-Token", c.token) + } +} + +func (c *ConsulKVClient) CacheTTL() time.Duration { + if c == nil || c.cacheTTL <= 0 { + return 0 + } + return c.cacheTTL +} + +func (c *ConsulKVClient) ClearCache() { + if c == nil { + return + } + consulBindCache.Range(func(key, value any) bool { + entry, ok := value.(*cacheEntry) + if !ok { + return true + } + entry.mu.Lock() + entry.cache = cachedValue{} + entry.mu.Unlock() + return true + }) +} + +func (c *ConsulKVClient) ClearCacheKey(key string) { + if c == nil { + return + } + val, ok := consulBindCache.Load(key) + if !ok { + return + } + entry, ok := val.(*cacheEntry) + if !ok { + return + } + entry.mu.Lock() + entry.cache = cachedValue{} + entry.mu.Unlock() +} + +func (c *ConsulKVClient) Get(ctx context.Context, key string) (string, bool, error) { + if c == nil { + return "", false, nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.buildURL(key, true), nil) + if err != nil { + return "", false, err + } + c.setHeaders(req) + resp, err := c.httpClient.Do(req) + if err != nil { + return "", false, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", false, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return "", false, errs.Detail(errs.ErrConsulKVGet, string(body)) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", false, err + } + return string(body), true, nil +} + +func (c *ConsulKVClient) Set(ctx context.Context, key, value string) error { + if c == nil { + return nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.buildURL(key, false), bytes.NewBufferString(value)) + if err != nil { + return err + } + c.setHeaders(req) + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return errs.Detail(errs.ErrConsulKVSet, string(body)) + } + return nil +} + +type consulTag struct { + Key string + Default string + IsJSON bool +} + +type cachedValue struct { + value string + found bool + fetchedAt time.Time +} + +type cacheEntry struct { + mu sync.Mutex + cache cachedValue +} + +var consulBindCache sync.Map + +func BindConsulConfig(target interface{}, client *ConsulKVClient) error { + if target == nil { + return errs.ErrConsulBindTargetNil + } + if client == nil { + return errs.ErrConsulClientNil + } + + val := reflect.ValueOf(target) + if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct { + return errs.ErrConsulBindTargetInvalid + } + val = val.Elem() + typ := val.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("consul") + if tag == "" { + continue + } + if field.Type.Kind() != reflect.Func || field.Type.NumIn() != 0 || field.Type.NumOut() != 1 { + return errs.Detail(errs.ErrConsulFieldMustBeFunc, field.Name) + } + parsed, err := parseConsulTag(tag) + if err != nil { + return errs.DetailWrap(errs.ErrConsulFieldTagInvalid, field.Name, err) + } + + outType := field.Type.Out(0) + cache := &cachedValue{} + entry := &cacheEntry{cache: *cache} + consulBindCache.Store(parsed.Key, entry) + + fn := reflect.MakeFunc(field.Type, func(args []reflect.Value) []reflect.Value { + entry.mu.Lock() + defer entry.mu.Unlock() + + now := time.Now() + ttl := client.CacheTTL() + if ttl > 0 && entry.cache.fetchedAt.Add(ttl).After(now) { + val, err := convertConsulValue(entry.cache.value, entry.cache.found, parsed, outType) + if err == nil { + return []reflect.Value{val} + } + } + + raw, found, err := client.Get(context.Background(), parsed.Key) + if err != nil { + val, _ := convertConsulValue(entry.cache.value, entry.cache.found, parsed, outType) + return []reflect.Value{val} + } + entry.cache.value = raw + entry.cache.found = found + entry.cache.fetchedAt = now + + val, err := convertConsulValue(raw, found, parsed, outType) + if err != nil { + val, _ = convertConsulValue(entry.cache.value, entry.cache.found, parsed, outType) + } + return []reflect.Value{val} + }) + + val.Field(i).Set(fn) + } + + return nil +} + +func parseConsulTag(tag string) (consulTag, error) { + tag = strings.TrimSpace(tag) + if tag == "" { + return consulTag{}, errs.ErrConsulTagEmpty + } + parts := strings.Split(tag, ",") + result := consulTag{ + Key: strings.TrimSpace(parts[0]), + } + if result.Key == "" { + return consulTag{}, errs.ErrConsulTagMissingKey + } + for _, part := range parts[1:] { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if part == "json" { + result.IsJSON = true + continue + } + if strings.HasPrefix(part, "default=") { + result.Default = strings.TrimPrefix(part, "default=") + } + } + return result, nil +} + +func convertConsulValue(raw string, found bool, tag consulTag, outType reflect.Type) (reflect.Value, error) { + if !found || strings.TrimSpace(raw) == "" { + raw = tag.Default + } + if tag.IsJSON || outType.Kind() == reflect.Struct || outType.Kind() == reflect.Map || outType.Kind() == reflect.Slice { + target := reflect.New(outType).Interface() + if err := json.Unmarshal([]byte(raw), target); err != nil { + return reflect.Zero(outType), err + } + return reflect.ValueOf(target).Elem(), nil + } + + switch outType.Kind() { + case reflect.String: + return reflect.ValueOf(raw).Convert(outType), nil + case reflect.Bool: + val, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return reflect.Zero(outType), err + } + return reflect.ValueOf(val).Convert(outType), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + val, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return reflect.Zero(outType), err + } + return reflect.ValueOf(val).Convert(outType), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64) + if err != nil { + return reflect.Zero(outType), err + } + return reflect.ValueOf(val).Convert(outType), nil + case reflect.Float32, reflect.Float64: + val, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return reflect.Zero(outType), err + } + return reflect.ValueOf(val).Convert(outType), nil + default: + return reflect.Zero(outType), errs.Detailf(errs.ErrConsulUnsupportedType, "%s", outType.Kind()) + } +} diff --git a/pkg/storage/db.go b/pkg/storage/db.go new file mode 100644 index 0000000..4ee407c --- /dev/null +++ b/pkg/storage/db.go @@ -0,0 +1,82 @@ +package storage + +import ( + "fmt" + "strings" + + "github.com/XingfenD/yoresee_doc/pkg/errs" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func OpenPostgres(cfg *PostgresOptions) (*gorm.DB, error) { + if cfg == nil { + return nil, errs.ErrPostgresOptionsNil + } + + dsn := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name, + ) + + gormLogLevel, err := resolveGormLogLevel(cfg.GormLogLevel) + if err != nil { + return nil, err + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(gormLogLevel), + }) + if err != nil { + return nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + if cfg.MaxIdleConns > 0 { + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + } + if cfg.MaxOpenConns > 0 { + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + } + + return db, nil +} + +func resolveGormLogLevel(levelText string) (logger.LogLevel, error) { + levelText = strings.TrimSpace(levelText) + if levelText == "" { + levelText = "info" + } + + switch strings.ToLower(levelText) { + case "silent": + return logger.Silent, nil + case "error": + return logger.Error, nil + case "warn", "warning": + return logger.Warn, nil + case "info", "debug", "trace": + return logger.Info, nil + case "fatal", "panic": + return logger.Error, nil + default: + return logger.Info, errs.Detail(errs.ErrInvalidGormLogLevel, levelText) + } +} + +func ClosePostgres() error { + if DB == nil { + return nil + } + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.Close() +} diff --git a/pkg/storage/elasticsearch.go b/pkg/storage/elasticsearch.go new file mode 100644 index 0000000..c54d85b --- /dev/null +++ b/pkg/storage/elasticsearch.go @@ -0,0 +1,295 @@ +package storage + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" +) + +type ElasticsearchClient struct { + addresses []string + username string + password string + client *http.Client +} + +var ES *ElasticsearchClient + +func NewElasticsearchClient(cfg *ElasticsearchOptions) (*ElasticsearchClient, error) { + if cfg == nil || !cfg.Enabled { + return nil, nil + } + + addresses := make([]string, 0, len(cfg.Addresses)) + for _, addr := range cfg.Addresses { + trimmed := strings.TrimSpace(addr) + if trimmed == "" { + continue + } + addresses = append(addresses, strings.TrimRight(trimmed, "/")) + } + if len(addresses) == 0 { + return nil, errs.ErrElasticAddressesEmpty + } + + timeout := time.Duration(cfg.Timeout) * time.Second + if timeout <= 0 { + timeout = 5 * time.Second + } + + client := &ElasticsearchClient{ + addresses: addresses, + username: cfg.Username, + password: cfg.Password, + client: &http.Client{ + Timeout: timeout, + }, + } + if err := client.Ping(context.Background()); err != nil { + return nil, errs.Wrap(errs.ErrElasticPingFailed, err) + } + return client, nil +} + +func (c *ElasticsearchClient) Ping(ctx context.Context) error { + if c == nil { + return errs.ErrElasticClientNil + } + var lastErr error + for _, address := range c.addresses { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) + if err != nil { + lastErr = err + continue + } + if c.username != "" { + req.SetBasicAuth(c.username, c.password) + } + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + continue + } + body := struct { + Tagline string `json:"tagline"` + }{} + decodeErr := json.NewDecoder(resp.Body).Decode(&body) + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 && decodeErr == nil { + return nil + } + lastErr = errs.Detailf(errs.ErrElasticStatusDecode, "status=%d decodeErr=%v", resp.StatusCode, decodeErr) + } + return lastErr +} + +func (c *ElasticsearchClient) UpsertDocument(ctx context.Context, index string, docID string, body map[string]interface{}) error { + if c == nil { + return errs.ErrElasticClientNil + } + if strings.TrimSpace(index) == "" || strings.TrimSpace(docID) == "" { + return errs.ErrElasticIndexOrDocID + } + payload, err := json.Marshal(body) + if err != nil { + return err + } + + var lastErr error + for _, address := range c.addresses { + url := fmt.Sprintf("%s/%s/_doc/%s", address, index, docID) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload)) + if err != nil { + lastErr = err + continue + } + req.Header.Set("Content-Type", "application/json") + if c.username != "" { + req.SetBasicAuth(c.username, c.password) + } + + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + continue + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + lastErr = errs.Detailf(errs.ErrElasticStatus, "%d", resp.StatusCode) + } + return lastErr +} + +func (c *ElasticsearchClient) SearchIDs(ctx context.Context, index string, searchBody map[string]interface{}) ([]int64, error) { + if c == nil { + return nil, errs.ErrElasticClientNil + } + payload, err := json.Marshal(searchBody) + if err != nil { + return nil, err + } + + var lastErr error + for _, address := range c.addresses { + url := fmt.Sprintf("%s/%s/_search", address, index) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + lastErr = err + continue + } + httpReq.Header.Set("Content-Type", "application/json") + if c.username != "" { + httpReq.SetBasicAuth(c.username, c.password) + } + + resp, err := c.client.Do(httpReq) + if err != nil { + lastErr = err + continue + } + bodyBytes, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + lastErr = errs.Detailf(errs.ErrElasticStatusWithBody, "status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + continue + } + + var parsed struct { + Hits struct { + Hits []struct { + ID string `json:"_id"` + Source struct { + ID int64 `json:"id"` + } `json:"_source"` + } `json:"hits"` + } `json:"hits"` + } + if err = json.Unmarshal(bodyBytes, &parsed); err != nil { + lastErr = err + continue + } + + ids := make([]int64, 0, len(parsed.Hits.Hits)) + for _, hit := range parsed.Hits.Hits { + if hit.Source.ID > 0 { + ids = append(ids, hit.Source.ID) + continue + } + id, parseErr := strconv.ParseInt(hit.ID, 10, 64) + if parseErr != nil || id <= 0 { + continue + } + ids = append(ids, id) + } + return ids, nil + } + return nil, lastErr +} + +func (c *ElasticsearchClient) IndexExists(ctx context.Context, index string) (bool, error) { + if c == nil { + return false, errs.ErrElasticClientNil + } + index = strings.TrimSpace(index) + if index == "" { + return false, errs.ErrElasticIndexEmpty + } + + var lastErr error + for _, address := range c.addresses { + url := fmt.Sprintf("%s/%s", address, index) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + lastErr = err + continue + } + if c.username != "" { + req.SetBasicAuth(c.username, c.password) + } + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + continue + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true, nil + } + lastErr = errs.Detailf(errs.ErrElasticStatus, "%d", resp.StatusCode) + } + return false, lastErr +} + +func (c *ElasticsearchClient) CreateIndex(ctx context.Context, index string, body map[string]interface{}) error { + if c == nil { + return errs.ErrElasticClientNil + } + index = strings.TrimSpace(index) + if index == "" { + return errs.ErrElasticIndexEmpty + } + + payload := []byte("{}") + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return err + } + payload = encoded + } + + var lastErr error + for _, address := range c.addresses { + url := fmt.Sprintf("%s/%s", address, index) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload)) + if err != nil { + lastErr = err + continue + } + req.Header.Set("Content-Type", "application/json") + if c.username != "" { + req.SetBasicAuth(c.username, c.password) + } + + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + continue + } + bodyBytes, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + continue + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + lastErr = errs.Detailf(errs.ErrElasticStatusWithBody, "status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + } + return lastErr +} + +func CloseElasticsearch() error { + ES = nil + return nil +} diff --git a/pkg/storage/minio.go b/pkg/storage/minio.go new file mode 100644 index 0000000..23408cc --- /dev/null +++ b/pkg/storage/minio.go @@ -0,0 +1,118 @@ +package storage + +import ( + "context" + "fmt" + "io" + "path" + "strings" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +var MinioClient *minio.Client + +func NewMinioClient(cfg *MinioOptions) (*minio.Client, error) { + if cfg == nil { + return nil, errs.Detail(errs.ErrMinioInitClient, "minio options is nil") + } + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, errs.Wrap(errs.ErrMinioInitClient, err) + } + return client, nil +} + +func EnsureBucket(ctx context.Context, client *minio.Client, bucketName string) error { + if client == nil { + return errs.Detail(errs.ErrMinioInitClient, "minio client is nil") + } + exists, err := client.BucketExists(ctx, bucketName) + if err != nil { + return errs.Wrap(errs.ErrMinioCheckBucketExists, err) + } + if !exists { + if err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{}); err != nil { + return errs.Wrap(errs.ErrMinioCreateBucket, err) + } + } + return nil +} + +func EnsureBucketPublicRead(ctx context.Context, client *minio.Client, bucketName string) error { + if client == nil { + return errs.Detail(errs.ErrMinioInitClient, "minio client is nil") + } + cleanBucketName := strings.TrimSpace(bucketName) + if cleanBucketName == "" { + return errs.Detail(errs.ErrMinioCreateBucket, "bucket name is empty") + } + + policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Sid":"PublicReadObjects","Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, cleanBucketName) + if err := client.SetBucketPolicy(ctx, cleanBucketName, policy); err != nil { + return errs.Wrap(errs.ErrMinioCreateBucket, err) + } + return nil +} + +func UploadFile(bucketName, objectName string, reader io.Reader, objectSize int64, contentType string) (string, error) { + if err := PutFile(bucketName, objectName, reader, objectSize, contentType); err != nil { + return "", errs.Wrap(errs.ErrMinioUploadFile, err) + } + + ctx := context.Background() + presignedURL, err := MinioClient.PresignedGetObject(ctx, bucketName, objectName, 7*24*time.Hour, nil) + if err != nil { + return "", errs.Wrap(errs.ErrMinioGeneratePresigned, err) + } + + return presignedURL.String(), nil +} + +func PutFile(bucketName, objectName string, reader io.Reader, objectSize int64, contentType string) error { + ctx := context.Background() + _, err := MinioClient.PutObject(ctx, bucketName, objectName, reader, objectSize, minio.PutObjectOptions{ + ContentType: contentType, + }) + return err +} + +func GetFile(bucketName, objectName string) (*minio.Object, error) { + ctx := context.Background() + return MinioClient.GetObject(ctx, bucketName, objectName, minio.GetObjectOptions{}) +} + +func DeleteFile(bucketName, objectName string) error { + ctx := context.Background() + return MinioClient.RemoveObject(ctx, bucketName, objectName, minio.RemoveObjectOptions{}) +} + +func GetPresignedURL(bucketName, objectName string, expires time.Duration) (string, error) { + ctx := context.Background() + presignedURL, err := MinioClient.PresignedGetObject(ctx, bucketName, objectName, expires, nil) + if err != nil { + return "", errs.Wrap(errs.ErrMinioGeneratePresigned, err) + } + return presignedURL.String(), nil +} + +func BuildPublicObjectPath(objectName string) string { + cleanObjectName := strings.TrimSpace(objectName) + if cleanObjectName == "" { + return "" + } + cleanObjectName = strings.TrimLeft(cleanObjectName, "/") + if strings.HasPrefix(cleanObjectName, "storage/") { + cleanObjectName = strings.TrimPrefix(cleanObjectName, "storage/") + } + if cleanObjectName == "" { + return "" + } + return path.Join("/storage", cleanObjectName) +} diff --git a/pkg/storage/options.go b/pkg/storage/options.go new file mode 100644 index 0000000..0ed7ad0 --- /dev/null +++ b/pkg/storage/options.go @@ -0,0 +1,43 @@ +package storage + +type PostgresOptions struct { + Host string + Port int + User string + Password string + Name string + MaxIdleConns int + MaxOpenConns int + GormLogLevel string +} + +type RedisOptions struct { + Host string + Port int + Password string + DB int +} + +type MinioOptions struct { + Endpoint string + AccessKey string + SecretKey string + UseSSL bool +} + +type ElasticsearchOptions struct { + Enabled bool + Addresses []string + Username string + Password string + Timeout int +} + +type ConsulOptions struct { + Enabled bool + Address string + Scheme string + Token string + Datacenter string + Prefix string +} diff --git a/pkg/storage/redis.go b/pkg/storage/redis.go new file mode 100644 index 0000000..c29a5ec --- /dev/null +++ b/pkg/storage/redis.go @@ -0,0 +1,78 @@ +package storage + +import ( + "context" + "fmt" + "time" + + "github.com/XingfenD/yoresee_doc/pkg/errs" + redis "github.com/redis/go-redis/v9" +) + +var KVS *redis.Client + +func NewRedisClient(cfg *RedisOptions) (*redis.Client, error) { + if cfg == nil { + return nil, errs.Detail(errs.ErrInitRedisClient, "redis options is nil") + } + + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if _, err := client.Ping(ctx).Result(); err != nil { + _ = client.Close() + return nil, errs.Wrap(errs.ErrInitRedisClient, err) + } + return client, nil +} + +func GetRedis() *redis.Client { + return KVS +} + +func CloseRedis() error { + if KVS == nil { + return nil + } + return KVS.Close() +} + +func SetCache(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + return KVS.Set(ctx, key, value, expiration).Err() +} + +func GetCache(ctx context.Context, key string) (string, error) { + return KVS.Get(ctx, key).Result() +} + +func DeleteCache(ctx context.Context, key string) error { + return KVS.Del(ctx, key).Err() +} + +func ClearCacheByPattern(ctx context.Context, pattern string) error { + keys, err := KVS.Keys(ctx, pattern).Result() + if err != nil { + return err + } + if len(keys) > 0 { + return KVS.Del(ctx, keys...).Err() + } + return nil +} + +func SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) (bool, error) { + return KVS.SetNX(ctx, key, value, expiration).Result() +} + +func SetCacheForever(ctx context.Context, key string, value interface{}) error { + if err := KVS.Set(ctx, key, value, 0).Err(); err != nil { + return err + } + return KVS.Persist(ctx, key).Err() +}