init push

This commit is contained in:
2026-05-21 19:52:45 +08:00
commit e3f75311ab
1280 changed files with 179173 additions and 0 deletions

65
backend/handler/base.go Normal file
View File

@@ -0,0 +1,65 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/pkg/captcha"
)
type BaseHandler struct {
Router *echo.Echo
baseLogger *log.Logger
config *config.Config
ShareAuthMiddleware *middleware.ShareAuthMiddleware
V1Auth middleware.AuthMiddleware
Captcha *captcha.Captcha
}
func NewBaseHandler(echo *echo.Echo, logger *log.Logger, config *config.Config, v1Auth middleware.AuthMiddleware, shareAuthMiddleware *middleware.ShareAuthMiddleware, cap *captcha.Captcha) *BaseHandler {
return &BaseHandler{
Router: echo,
baseLogger: logger.WithModule("http_base_handler"),
config: config,
ShareAuthMiddleware: shareAuthMiddleware,
V1Auth: v1Auth,
Captcha: cap,
}
}
func (h *BaseHandler) NewResponseWithData(c echo.Context, data any) error {
return c.JSON(http.StatusOK, domain.PWResponse{
Success: true,
Data: data,
})
}
func (h *BaseHandler) NewResponseWithErrCode(c echo.Context, resp domain.PWResponseErrCode) error {
return c.JSON(http.StatusOK, resp)
}
func (h *BaseHandler) NewResponseWithError(c echo.Context, msg string, err error) error {
traceID := ""
if h.config.GetBool("apm.enabled") {
span := trace.SpanFromContext(c.Request().Context())
traceID = span.SpanContext().TraceID().String()
span.SetAttributes(attribute.String("error", fmt.Sprintf("%+v", err)), attribute.String("msg", msg))
} else {
traceID = uuid.New().String()
}
h.baseLogger.LogAttrs(c.Request().Context(), slog.LevelError, msg, slog.String("trace_id", traceID), slog.Any("error", err))
return c.JSON(http.StatusOK, domain.PWResponse{
Success: false,
Message: fmt.Sprintf("%s [trace_id: %s]", msg, traceID),
})
}

134
backend/handler/mq/cron.go Normal file
View File

@@ -0,0 +1,134 @@
package mq
import (
"context"
"time"
"github.com/robfig/cron/v3"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/repo/pg"
"github.com/chaitin/panda-wiki/usecase"
)
type CronHandler struct {
logger *log.Logger
statRepo *pg.StatRepository
nodeRepo *pg.NodeRepository
statUseCase *usecase.StatUseCase
nodeUseCase *usecase.NodeUsecase
}
func NewCronHandler(logger *log.Logger, statRepo *pg.StatRepository, nodeRepo *pg.NodeRepository, statUseCase *usecase.StatUseCase, nodeUseCase *usecase.NodeUsecase) (*CronHandler, error) {
h := &CronHandler{
statRepo: statRepo,
nodeRepo: nodeRepo,
statUseCase: statUseCase,
nodeUseCase: nodeUseCase,
logger: logger.WithModule("handler.mq.cron"),
}
cron := cron.New()
// 每小时 */10 分执行聚合统计数据任务
if _, err := cron.AddFunc("*/10 */1 * * *", h.AggregateHourlyStats); err != nil {
h.logger.Error("failed to add cron job for aggregating hourly stats", log.Error(err))
return nil, err
}
h.logger.Info("add cron job", log.String("cron_id", "aggregate_hourly_stats"))
// 每小时1分执行清理旧数据任务
if _, err := cron.AddFunc("1 */1 * * *", h.RemoveOldStatData); err != nil {
h.logger.Error("failed to add cron job for removing old data", log.Error(err))
return nil, err
}
h.logger.Info("add cron job", log.String("cron_id", "remove_old_stat_data"))
// 每天0点执行清理90天前的小时统计数据
if _, err := cron.AddFunc("3 0 * * *", h.CleanupOldHourlyStats); err != nil {
h.logger.Error("failed to add cron job for cleaning up old hourly stats", log.Error(err))
return nil, err
}
h.logger.Info("add cron job", log.String("cron_id", "cleanup_old_hourly_stats"))
// 启动时先异步跑一次
go func() {
if err := h.nodeUseCase.SyncRagNodeStatus(context.Background()); err != nil {
h.logger.Error("initial sync rag node status failed", log.Error(err))
}
}()
if _, err := cron.AddFunc("26 * * * *", h.SyncRagNodeStatus); err != nil {
h.logger.Error("failed to sync rag node status", log.Error(err))
return nil, err
}
h.logger.Info("add cron job", log.String("cron_id", "sync_rag_node_status"))
// 每天2点执行清理30天前的node_release_backup数据
if _, err := cron.AddFunc("0 2 * * *", h.CleanupOldNodeReleaseBackups); err != nil {
h.logger.Error("failed to add cron job for cleaning up old node release backups", log.Error(err))
return nil, err
}
h.logger.Info("add cron job", log.String("cron_id", "cleanup_old_node_release_backups"))
cron.Start()
h.logger.Info("start cron jobs")
return h, nil
}
func (h *CronHandler) RemoveOldStatData() {
h.logger.Info("remove old stat data start")
// 零点时同步数据至node_stats持久化
if time.Now().Hour() == 0 {
if err := h.statUseCase.MigrateYesterdayPVToNodeStats(context.Background()); err != nil {
h.logger.Error("migrate yesterday PV data to node_stats failed", log.Error(err))
} else {
h.logger.Info("migrate yesterday PV data to node_stats successful")
}
}
err := h.statRepo.RemoveOldData(context.Background())
if err != nil {
h.logger.Error("remove old stat data failed", log.Error(err))
}
h.logger.Info("remove old stat data successful")
}
func (h *CronHandler) AggregateHourlyStats() {
h.logger.Info("aggregate hourly stats start")
err := h.statUseCase.AggregateHourlyStats(context.Background())
if err != nil {
h.logger.Error("aggregate hourly stats failed", log.Error(err))
return
}
h.logger.Info("aggregate hourly stats successful")
}
func (h *CronHandler) CleanupOldHourlyStats() {
h.logger.Info("cleanup old hourly stats start")
err := h.statUseCase.CleanupOldHourlyStats(context.Background())
if err != nil {
h.logger.Error("cleanup old hourly stats failed", log.Error(err))
return
}
h.logger.Info("cleanup old hourly stats successful")
}
func (h *CronHandler) SyncRagNodeStatus() {
h.logger.Info("sync rag node status")
err := h.nodeUseCase.SyncRagNodeStatus(context.Background())
if err != nil {
h.logger.Error("sync rag node status failed", log.Error(err))
return
}
h.logger.Info("sync rag node status successful")
}
func (h *CronHandler) CleanupOldNodeReleaseBackups() {
h.logger.Info("cleanup old node release backups start")
before := time.Now().AddDate(0, 0, -30)
if err := h.nodeRepo.DeleteOldNodeReleaseBackups(context.Background(), before); err != nil {
h.logger.Error("cleanup old node release backups failed", log.Error(err))
return
}
h.logger.Info("cleanup old node release backups successful")
}

View File

@@ -0,0 +1,37 @@
package mq
import (
"github.com/google/wire"
"github.com/chaitin/panda-wiki/repo/ipdb"
"github.com/chaitin/panda-wiki/repo/mq"
"github.com/chaitin/panda-wiki/repo/pg"
"github.com/chaitin/panda-wiki/store/rag"
"github.com/chaitin/panda-wiki/store/s3"
"github.com/chaitin/panda-wiki/usecase"
)
type MQHandlers struct {
RAGMQHandler *RAGMQHandler
RagDocUpdateHandler *RagDocUpdateHandler
StatCronHandler *CronHandler
}
var ProviderSet = wire.NewSet(
pg.ProviderSet,
rag.ProviderSet,
mq.ProviderSet,
ipdb.ProviderSet,
s3.ProviderSet,
usecase.NewLLMUsecase,
usecase.NewStatUseCase,
usecase.NewNodeUsecase,
usecase.NewModelUsecase,
NewRAGMQHandler,
NewRagDocUpdateHandler,
NewCronHandler,
wire.Struct(new(MQHandlers), "*"),
)

171
backend/handler/mq/rag.go Normal file
View File

@@ -0,0 +1,171 @@
package mq
import (
"context"
"encoding/json"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/mq"
"github.com/chaitin/panda-wiki/mq/types"
"github.com/chaitin/panda-wiki/repo/pg"
"github.com/chaitin/panda-wiki/store/rag"
"github.com/chaitin/panda-wiki/usecase"
)
type RAGMQHandler struct {
consumer mq.MQConsumer
logger *log.Logger
rag rag.RAGService
nodeRepo *pg.NodeRepository
kbRepo *pg.KnowledgeBaseRepository
llmUsecase *usecase.LLMUsecase
modelUsecase *usecase.ModelUsecase
}
func NewRAGMQHandler(consumer mq.MQConsumer, logger *log.Logger, rag rag.RAGService, nodeRepo *pg.NodeRepository, kbRepo *pg.KnowledgeBaseRepository, llmUsecase *usecase.LLMUsecase, modelUsecase *usecase.ModelUsecase) (*RAGMQHandler, error) {
h := &RAGMQHandler{
consumer: consumer,
logger: logger.WithModule("mq.rag"),
rag: rag,
nodeRepo: nodeRepo,
kbRepo: kbRepo,
llmUsecase: llmUsecase,
modelUsecase: modelUsecase,
}
if err := consumer.RegisterHandler(domain.VectorTaskTopic, h.HandleNodeContentVectorRequest); err != nil {
return nil, err
}
return h, nil
}
func (h *RAGMQHandler) HandleNodeContentVectorRequest(ctx context.Context, msg types.Message) error {
var request domain.NodeReleaseVectorRequest
err := json.Unmarshal(msg.GetData(), &request)
if err != nil {
h.logger.Error("unmarshal node content vector request failed", log.Error(err))
return nil
}
switch request.Action {
case "update_group_ids":
h.logger.Info("update node group request", log.Any("request", request), log.Any("group_id", request.GroupIds))
kb, err := h.kbRepo.GetKnowledgeBaseByID(ctx, request.KBID)
if err != nil {
h.logger.Error("get kb failed", log.Error(err))
return nil
}
if err := h.rag.UpdateDocumentGroupIDs(ctx, kb.DatasetID, request.DocID, request.GroupIds); err != nil {
h.logger.Error("update node group failed", log.Error(err))
return nil
}
h.logger.Info("update node group success", log.Any("doc_id", request.DocID), log.Any("group_ids", request.GroupIds))
case "upsert":
h.logger.Debug("upsert node content vector request", "request", request)
nodeRelease, err := h.nodeRepo.GetNodeReleaseWithDirPathByID(ctx, request.NodeReleaseID)
if err != nil {
h.logger.Error("get node content by ids failed", log.Error(err))
return nil
}
if nodeRelease.Type == domain.NodeTypeFolder {
h.logger.Info("node is folder, skip upsert", log.Any("node_release_id", request.NodeReleaseID))
return nil
}
kb, err := h.kbRepo.GetKnowledgeBaseByID(ctx, request.KBID)
if err != nil {
h.logger.Error("get kb failed", log.Error(err), log.String("kb_id", request.KBID))
return nil
}
groupIds, err := h.nodeRepo.GetNodeAuthGroupIdsByNodeId(ctx, nodeRelease.NodeID, consts.NodePermNameAnswerable)
if err != nil {
h.logger.Error("get groupIds failed", log.Error(err), log.String("kb_id", request.KBID))
return nil
}
// upsert node content chunks
docID, err := h.rag.UpsertRecords(ctx, &rag.UpsertRecordsRequest{
ID: nodeRelease.ID,
Title: nodeRelease.Name,
DatasetID: kb.DatasetID,
DocID: nodeRelease.DocID,
Content: nodeRelease.Content,
GroupIDs: groupIds,
})
if err != nil {
h.logger.Error("upsert node content vector failed", log.Error(err))
return nil
}
// update node doc_id
if err := h.nodeRepo.UpdateNodeReleaseDocID(ctx, request.NodeReleaseID, docID); err != nil {
h.logger.Error("update node doc_id failed", log.String("node_id", request.NodeReleaseID), log.Error(err))
return nil
}
// delete old RAG records
// get old doc_ids by node_id
oldDocIDs, err := h.nodeRepo.GetOldNodeDocIDsByNodeID(ctx, nodeRelease.ID, nodeRelease.NodeID)
if err != nil {
h.logger.Error("get old doc_ids by node_id failed", log.String("node_id", nodeRelease.NodeID), log.Error(err))
return nil
}
if len(oldDocIDs) > 0 {
// delete old RAG records
if err := h.rag.DeleteRecords(ctx, kb.DatasetID, oldDocIDs); err != nil {
h.logger.Error("delete old RAG records failed", log.String("kb_id", kb.ID), log.Error(err))
return nil
}
}
h.logger.Info("upsert node content vector success", log.Any("updated_ids", request.NodeReleaseID))
case "delete":
h.logger.Info("delete node content vector request", log.Any("request", request))
kb, err := h.kbRepo.GetKnowledgeBaseByID(ctx, request.KBID)
if err != nil {
h.logger.Error("get kb failed", log.Error(err))
return nil
}
if err := h.rag.DeleteRecords(ctx, kb.DatasetID, []string{request.DocID}); err != nil {
h.logger.Error("delete node content vector failed", log.Error(err))
return nil
}
h.logger.Info("delete node content vector success", log.Any("deleted_id", request.NodeReleaseID), log.Any("deleted_doc_id", request.DocID))
case "summary":
h.logger.Info("summary node content vector request", log.Any("request", request))
node, err := h.nodeRepo.GetNodeByID(ctx, request.NodeID)
if err != nil {
h.logger.Error("get node by id failed", log.Error(err))
return nil
}
if node.Type == domain.NodeTypeFolder {
h.logger.Info("node is folder, skip summary", log.Any("node_id", request.NodeID))
return nil
}
model, err := h.modelUsecase.GetChatModel(ctx)
if err != nil {
h.logger.Error("get chat model failed", log.Error(err))
return nil
}
summary, err := h.llmUsecase.SummaryNode(ctx, request.KBID, model, node.Name, node.Content)
if err != nil {
h.logger.Error("summary node content failed", log.Error(err))
return nil
}
if err := h.nodeRepo.UpdateNodeSummary(ctx, request.KBID, request.NodeID, summary); err != nil {
h.logger.Error("update node summary failed", log.Error(err))
return nil
}
if node.Status == domain.NodeStatusPublished {
if err := h.nodeRepo.UpdateNodeStatus(ctx, request.KBID, request.NodeID, domain.NodeStatusDraft); err != nil {
h.logger.Error("update node status failed", log.Error(err))
return nil
}
}
h.logger.Info("summary node content vector success", log.Any("summary_id", request.NodeReleaseID), log.Any("summary", summary))
}
return nil
}

View File

@@ -0,0 +1,67 @@
package mq
import (
"context"
"encoding/json"
"time"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/mq"
"github.com/chaitin/panda-wiki/mq/types"
"github.com/chaitin/panda-wiki/repo/pg"
)
type RagDocUpdateHandler struct {
consumer mq.MQConsumer
logger *log.Logger
nodeRepo *pg.NodeRepository
}
func NewRagDocUpdateHandler(consumer mq.MQConsumer, logger *log.Logger, nodeRepo *pg.NodeRepository) (*RagDocUpdateHandler, error) {
h := &RagDocUpdateHandler{
consumer: consumer,
logger: logger.WithModule("mq.rag_doc_update"),
nodeRepo: nodeRepo,
}
if err := consumer.RegisterHandler(domain.RagDocUpdateTopic, h.HandleRagDocUpdate); err != nil {
return nil, err
}
return h, nil
}
func (h *RagDocUpdateHandler) HandleRagDocUpdate(ctx context.Context, msg types.Message) error {
var event domain.RagDocInfoUpdateEvent
err := json.Unmarshal(msg.GetData(), &event)
if err != nil {
h.logger.Error("unmarshal rag doc update event failed", log.Error(err))
return err
}
h.logger.Info("received rag doc update event",
log.String("doc_id", event.ID),
log.String("status", event.Status),
log.String("message", event.Message))
nodeId, err := h.nodeRepo.GetNodeIdByDocId(ctx, event.ID)
if err != nil {
h.logger.Error("failed to get node id by doc id",
log.String("doc_id", event.ID),
log.Error(err))
return err
}
if err := h.nodeRepo.Update(ctx, nodeId, map[string]interface{}{
"rag_info": domain.RagInfo{
Status: consts.NodeRagInfoStatus(event.Status),
Message: event.Message,
SyncedAt: time.Now(),
},
}); err != nil {
return err
}
h.logger.Debug("node rag update success", log.String("doc_id", event.ID))
return nil
}

View File

@@ -0,0 +1,246 @@
package share
import (
"context"
"net/http"
"github.com/labstack/echo/v4"
wechat_v2 "github.com/silenceper/wechat/v2"
"github.com/silenceper/wechat/v2/cache"
offConfig "github.com/silenceper/wechat/v2/officialaccount/config"
"github.com/silenceper/wechat/v2/officialaccount/message"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareAppHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.AppUsecase
}
func NewShareAppHandler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
usecase *usecase.AppUsecase,
) *ShareAppHandler {
h := &ShareAppHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.app"),
usecase: usecase,
}
share := e.Group("share/v1/app",
func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type, Origin, Accept")
if c.Request().Method == "OPTIONS" {
return c.NoContent(http.StatusOK)
}
return next(c)
}
})
share.GET("/web/info", h.GetWebAppInfo)
share.GET("/widget/info", h.GetWidgetAppInfo)
share.GET("/wechat/info", h.WechatAppInfo)
// wechat official account
share.GET("/wechat/official_account", h.VerifyUrlWechatOfficialAccount)
share.POST("/wechat/official_account", h.WechatHandlerOfficialAccount)
return h
}
// GetWebAppInfo
//
// @Summary GetAppInfo
// @Description GetAppInfo
// @Tags share_app
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Success 200 {object} domain.Response{data=domain.AppInfoResp}
// @Router /share/v1/app/web/info [get]
func (h *ShareAppHandler) GetWebAppInfo(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
ctx := context.WithValue(c.Request().Context(), consts.ContextKeyEdition, consts.GetLicenseEdition(c))
appInfo, err := h.usecase.ShareGetWebAppInfo(ctx, kbID, domain.GetAuthID(c))
if err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
return h.NewResponseWithData(c, appInfo)
}
// GetWidgetAppInfo
//
// @Summary GetWidgetAppInfo
// @Description GetWidgetAppInfo
// @Tags share_app
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Success 200 {object} domain.Response
// @Router /share/v1/app/widget/info [get]
func (h *ShareAppHandler) GetWidgetAppInfo(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
appInfo, err := h.usecase.GetWidgetAppInfo(c.Request().Context(), kbID)
if err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
return h.NewResponseWithData(c, appInfo)
}
// WechatAppInfo
//
// @Summary WechatAppInfo
// @Description WechatAppInfo
// @Tags share_chat
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Success 200 {object} domain.Response{data=v1.WechatAppInfoResp}
// @Router /share/v1/app/wechat/info [get]
func (h *ShareAppHandler) WechatAppInfo(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
appInfo, err := h.usecase.GetWechatAppInfo(c.Request().Context(), kbID)
if err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
return h.NewResponseWithData(c, appInfo)
}
func (h *ShareAppHandler) VerifyUrlWechatOfficialAccount(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
ctx := c.Request().Context()
// get wechat official account info
appInfo, err := h.usecase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWechatOfficialAccount)
if err != nil {
h.logger.Error("get app detail failed")
return h.NewResponseWithError(c, "GetAppDetailByKBIDAndAppType failed", err)
}
if appInfo.Settings.WechatOfficialAccountIsEnabled != nil && !*appInfo.Settings.WechatOfficialAccountIsEnabled {
return h.NewResponseWithError(c, "wechat official account is not enabled", err)
}
wc := wechat_v2.NewWechat()
memory := cache.NewMemory()
cfg := &offConfig.Config{
AppID: appInfo.Settings.WechatOfficialAccountAppID,
AppSecret: appInfo.Settings.WechatOfficialAccountAppSecret,
Token: appInfo.Settings.WechatOfficialAccountToken,
EncodingAESKey: appInfo.Settings.WechatOfficialAccountEncodingAESKey,
Cache: memory,
}
officialAccount := wc.GetOfficialAccount(cfg)
server := officialAccount.GetServer(c.Request(), c.Response().Writer)
// success
err = server.Serve()
if err != nil {
return h.NewResponseWithError(c, "serve message failed", err)
}
return nil
}
func (h *ShareAppHandler) WechatHandlerOfficialAccount(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
ctx := c.Request().Context()
// get wechat official account info
appInfo, err := h.usecase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWechatOfficialAccount)
if err != nil {
h.logger.Error("get app detail failed")
return h.NewResponseWithError(c, "GetAppDetailByKBIDAndAppType failed", err)
}
if appInfo.Settings.WechatOfficialAccountIsEnabled != nil && !*appInfo.Settings.WechatOfficialAccountIsEnabled {
return h.NewResponseWithError(c, "wechat official account is not enabled", err)
}
wc := wechat_v2.NewWechat()
memory := cache.NewMemory()
cfg := &offConfig.Config{
AppID: appInfo.Settings.WechatOfficialAccountAppID,
AppSecret: appInfo.Settings.WechatOfficialAccountAppSecret,
Token: appInfo.Settings.WechatOfficialAccountToken,
EncodingAESKey: appInfo.Settings.WechatOfficialAccountEncodingAESKey,
Cache: memory,
}
officialAccount := wc.GetOfficialAccount(cfg)
server := officialAccount.GetServer(c.Request(), c.Response().Writer)
// message handler
server.SetMessageHandler(func(msg *message.MixMessage) *message.Reply {
h.logger.Info("received message:", log.Any("msgtype", msg.MsgType), log.Any("fromUserName", msg.FromUserName), log.String("content", msg.Content), log.Any("event type", msg.Event))
switch msg.MsgType {
case message.MsgTypeText:
// text消息
userOpenID := msg.FromUserName
userContent := msg.Content
h.logger.Info("user_open_id user_content", log.Any("user_open_id", userOpenID), log.Any("user content", userContent))
// 异步发送
go func(openID, content string) {
ctx := context.Background()
// send content to ai
result, err := h.usecase.GetWechatOfficialAccountResponse(ctx, officialAccount, kbID, openID, content)
if err != nil {
h.logger.Error("get wechat official account response failed", log.Error(err))
return
}
// send response to user --> 需要开启客服消息权限
err = h.usecase.SendCustomerServiceMessage(officialAccount, string(userOpenID), result)
if err != nil {
h.logger.Error("send to customer service failed", log.Error(err))
}
}(string(userOpenID), userContent)
return &message.Reply{MsgType: message.MsgTypeText, MsgData: message.NewText("您的问题已经收到,正在努力思考中,请稍候...")}
case message.MsgTypeEvent:
if msg.Event == message.EventSubscribe {
return &message.Reply{MsgType: message.MsgTypeText, MsgData: message.NewText("感谢关注,欢迎提问!")} // 立即回复简单信息
}
return nil
default:
h.logger.Info("unknown message type", log.Any("message type", msg.MsgType))
return &message.Reply{MsgType: message.MsgTypeText, MsgData: message.NewText("未知消息类型,请发送正确的类型...")}
}
})
// success
err = server.Serve()
if err != nil {
h.logger.Error("serve message failed", log.Error(err))
return h.NewResponseWithError(c, "serve message failed", err)
}
// send message to user
err = server.Send()
if err != nil {
h.logger.Error("send message failed", log.Error(err))
return h.NewResponseWithError(c, "send message failed", err)
}
return nil
}

View File

@@ -0,0 +1,183 @@
package share
import (
"context"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/share/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareAuthHandler struct {
*handler.BaseHandler
logger *log.Logger
kbUsecase *usecase.KnowledgeBaseUsecase
authUsecase *usecase.AuthUsecase
}
func NewShareAuthHandler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
kbUsecase *usecase.KnowledgeBaseUsecase,
authUsecase *usecase.AuthUsecase,
) *ShareAuthHandler {
h := &ShareAuthHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.auth"),
kbUsecase: kbUsecase,
authUsecase: authUsecase,
}
shareAuthMiddleware := middleware.NewShareAuthMiddleware(logger, kbUsecase)
share := e.Group("share/v1/auth", shareAuthMiddleware.CheckForbidden)
share.GET("/get", h.AuthGet)
share.POST("/login/simple", h.AuthLoginSimple)
share.POST("/github", h.AuthGitHub)
return h
}
// AuthGet auth获取
//
// @Tags share_auth
// @Summary AuthGet
// @Description AuthGet
// @ID v1-AuthGet
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb_id"
// @Param param query v1.AuthGetReq true "para"
// @Success 200 {object} domain.PWResponse{data=v1.AuthGetResp}
// @Router /share/v1/auth/get [get]
func (h *ShareAuthHandler) AuthGet(c echo.Context) error {
ctx := c.Request().Context()
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
kb, err := h.kbUsecase.GetKnowledgeBase(ctx, kbID)
if err != nil {
return h.NewResponseWithError(c, "failed to get knowledge base detail", err)
}
resp := &v1.AuthGetResp{
AuthType: kb.AccessSettings.GetAuthType(),
SourceType: kb.AccessSettings.SourceType,
LicenseEdition: consts.GetLicenseEdition(c),
}
return h.NewResponseWithData(c, resp)
}
// AuthLoginSimple 简单口令登录
//
// @Tags share_auth
// @Summary AuthLoginSimple
// @Description AuthLoginSimple
// @ID v1-AuthLoginSimple
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb_id"
// @Param param body v1.AuthLoginSimpleReq true "para"
// @Success 200 {object} domain.Response
// @Router /share/v1/auth/login/simple [post]
func (h *ShareAuthHandler) AuthLoginSimple(c echo.Context) error {
ctx := c.Request().Context()
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
var req v1.AuthLoginSimpleReq
if err := c.Bind(&req); err != nil {
h.logger.Error("parse request failed", log.Error(err))
return h.NewResponseWithError(c, "AuthGet bind failed", nil)
}
kb, err := h.kbUsecase.GetKnowledgeBase(ctx, kbID)
if err != nil {
return h.NewResponseWithError(c, "failed to get knowledge base detail", err)
}
if !kb.AccessSettings.SimpleAuth.Enabled {
return h.NewResponseWithError(c, "simple auth is not enabled", nil)
}
if req.Password != kb.AccessSettings.SimpleAuth.Password {
return h.NewResponseWithError(c, "simple auth password is incorrect", nil)
}
s := c.Get(domain.SessionCacheKey)
if s == nil {
return h.NewResponseWithError(c, "get session cache key failed", nil)
}
store := s.(sessions.Store)
newSess := sessions.NewSession(store, domain.SessionName)
newSess.IsNew = true
newSess.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30,
HttpOnly: true,
}
newSess.Values["kb_id"] = kb.ID
if err := newSess.Save(c.Request(), c.Response()); err != nil {
return h.NewResponseWithError(c, "save session failed", nil)
}
return h.NewResponseWithData(c, nil)
}
// AuthGitHub GitHub登录
//
// @Tags ShareAuth
// @Summary GitHub登录
// @Description GitHub登录
// @ID v1-AuthGitHub
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Param param body v1.AuthGitHubReq true "para"
// @Success 200 {object} domain.PWResponse{data=v1.AuthGitHubResp}
// @Router /share/v1/auth/github [post]
func (h *ShareAuthHandler) AuthGitHub(c echo.Context) error {
ctx := context.WithValue(c.Request().Context(), consts.ContextKeyEdition, consts.GetLicenseEdition(c))
var req v1.AuthGitHubReq
if err := c.Bind(&req); err != nil {
return err
}
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
req.KbID = kbID
valid, err := h.authUsecase.ValidateRedirectUrl(ctx, req.KbID, req.RedirectUrl)
if err != nil || !valid {
return h.NewResponseWithError(c, "invalid redirect url", err)
}
url, err := h.authUsecase.GenerateGitHubAuthUrl(ctx, req)
if err != nil {
return h.NewResponseWithError(c, "GenerateGitHubAuthUrl failed", err)
}
return h.NewResponseWithData(c, v1.AuthGitHubResp{
Url: url,
})
}

View File

@@ -0,0 +1,91 @@
package share
import (
"net/http"
gocap "github.com/ackcoder/go-cap"
"github.com/getsentry/sentry-go"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
)
type ShareCaptchaHandler struct {
*handler.BaseHandler
logger *log.Logger
}
func NewShareCaptchaHandler(
baseHandler *handler.BaseHandler,
echo *echo.Echo,
logger *log.Logger,
) *ShareCaptchaHandler {
h := &ShareCaptchaHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.captcha"),
}
group := echo.Group("share/v1/captcha")
group.POST("/challenge", h.CreateCaptcha)
group.POST("/redeem", h.RedeemCaptcha)
return h
}
// CreateCaptcha
//
// @Summary CreateCaptcha
// @Description CreateCaptcha
// @Tags share_captcha
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Success 200 {object} gocap.ChallengeData
// @Router /share/v1/captcha/challenge [post]
func (h *ShareCaptchaHandler) CreateCaptcha(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
data, err := h.Captcha.CreateChallenge(c.Request().Context())
if err != nil {
return h.NewResponseWithError(c, "create captcha failed", err)
}
return c.JSON(http.StatusCreated, data)
}
// RedeemCaptcha
//
// @Summary RedeemCaptcha
// @Description RedeemCaptcha
// @Tags share_captcha
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Param body body consts.RedeemCaptchaReq true "request"
// @Success 200 {object} gocap.VerificationResult
// @Router /share/v1/captcha/redeem [post]
func (h *ShareCaptchaHandler) RedeemCaptcha(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
var req consts.RedeemCaptchaReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request is invalid", err)
}
data, err := h.Captcha.RedeemChallenge(c.Request().Context(), req.Token, req.Solutions)
if err != nil {
sentry.CaptureException(err)
return c.JSON(http.StatusInternalServerError, gocap.VerificationResult{
Success: false,
Message: err.Error(),
})
}
return c.JSON(http.StatusCreated, gocap.VerificationResult{
Success: true,
TokenData: data,
})
}

View File

@@ -0,0 +1,550 @@
package share
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareChatHandler struct {
*handler.BaseHandler
logger *log.Logger
appUsecase *usecase.AppUsecase
chatUsecase *usecase.ChatUsecase
authUsecase *usecase.AuthUsecase
conversationUsecase *usecase.ConversationUsecase
modelUsecase *usecase.ModelUsecase
}
func NewShareChatHandler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
appUsecase *usecase.AppUsecase,
chatUsecase *usecase.ChatUsecase,
authUsecase *usecase.AuthUsecase,
conversationUsecase *usecase.ConversationUsecase,
modelUsecase *usecase.ModelUsecase,
) *ShareChatHandler {
h := &ShareChatHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.chat"),
appUsecase: appUsecase,
chatUsecase: chatUsecase,
authUsecase: authUsecase,
conversationUsecase: conversationUsecase,
modelUsecase: modelUsecase,
}
share := e.Group("share/v1/chat",
func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type, Origin, Accept")
if c.Request().Method == "OPTIONS" {
return c.NoContent(http.StatusOK)
}
return next(c)
}
})
share.POST("/message", h.ChatMessage, h.ShareAuthMiddleware.Authorize)
share.POST("/search", h.ChatSearch, h.ShareAuthMiddleware.Authorize)
share.POST("/completions", h.ChatCompletions)
share.POST("/widget", h.ChatWidget)
share.POST("/widget/search", h.WidgetSearch)
share.POST("/feedback", h.FeedBack)
return h
}
// ChatMessage chat message
//
// @Summary ChatMessage
// @Description ChatMessage
// @Tags share_chat
// @Accept json
// @Produce json
// @Param app_type query string true "app type"
// @Param request body domain.ChatRequest true "request"
// @Success 200 {object} domain.Response
// @Router /share/v1/chat/message [post]
func (h *ShareChatHandler) ChatMessage(c echo.Context) error {
var req domain.ChatRequest
if err := c.Bind(&req); err != nil {
h.logger.Error("parse request failed", log.Error(err))
return h.sendErrMsg(c, "parse request failed")
}
req.KBID = c.Request().Header.Get("X-KB-ID") // get from caddy header
if err := c.Validate(&req); err != nil {
h.logger.Error("validate request failed", log.Error(err))
return h.sendErrMsg(c, "validate request failed")
}
for _, path := range req.ImagePaths {
if !strings.HasPrefix(path, "/static-file/") {
return h.sendErrMsg(c, "invalid image path")
}
}
if req.Message == "" && len(req.ImagePaths) == 0 {
return h.sendErrMsg(c, "message is empty")
}
if req.AppType != domain.AppTypeWeb {
return h.sendErrMsg(c, "invalid app type")
}
ctx := c.Request().Context()
// validate captcha token
if !h.Captcha.ValidateToken(ctx, req.CaptchaToken) {
return h.sendErrMsg(c, "failed to validate captcha")
}
req.RemoteIP = c.RealIP()
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().Header().Set("Transfer-Encoding", "chunked")
// get user info --> no enterprise is nil
userID := c.Get("user_id")
h.logger.Debug("userid:", userID)
if userID != nil { // find userinfo from auth
userIDValue := userID.(uint)
req.Info.UserInfo.AuthUserID = userIDValue
}
eventCh, err := h.chatUsecase.Chat(ctx, &req)
if err != nil {
return h.sendErrMsg(c, err.Error())
}
for event := range eventCh {
if err := h.writeSSEEvent(c, event); err != nil {
return err
}
if event.Type == "done" || event.Type == "error" {
break
}
}
return nil
}
// ChatWidget chat widget
//
// @Summary ChatWidget
// @Description ChatWidget
// @Tags Widget
// @Accept json
// @Produce json
// @Param app_type query string true "app type"
// @Param request body domain.ChatRequest true "request"
// @Success 200 {object} domain.Response
// @Router /share/v1/chat/widget [post]
func (h *ShareChatHandler) ChatWidget(c echo.Context) error {
var req domain.ChatRequest
if err := c.Bind(&req); err != nil {
h.logger.Error("parse request failed", log.Error(err))
return h.sendErrMsg(c, "parse request failed")
}
req.KBID = c.Request().Header.Get("X-KB-ID") // get from caddy header
if err := c.Validate(&req); err != nil {
h.logger.Error("validate request failed", log.Error(err))
return h.sendErrMsg(c, "validate request failed")
}
if req.AppType != domain.AppTypeWidget {
return h.sendErrMsg(c, "invalid app type")
}
if req.Message == "" && len(req.ImagePaths) == 0 {
return h.sendErrMsg(c, "message is empty")
}
for _, path := range req.ImagePaths {
if !strings.HasPrefix(path, "/static-file/") {
return h.sendErrMsg(c, "invalid image path")
}
}
// get widget app info
widgetAppInfo, err := h.appUsecase.GetWidgetAppInfo(c.Request().Context(), req.KBID)
if err != nil {
h.logger.Error("get widget app info failed", log.Error(err))
return h.sendErrMsg(c, "get app info error")
}
if !widgetAppInfo.Settings.WidgetBotSettings.IsOpen {
return h.sendErrMsg(c, "widget is not open")
}
req.RemoteIP = c.RealIP()
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().Header().Set("Transfer-Encoding", "chunked")
eventCh, err := h.chatUsecase.Chat(c.Request().Context(), &req)
if err != nil {
return h.sendErrMsg(c, err.Error())
}
for event := range eventCh {
if err := h.writeSSEEvent(c, event); err != nil {
return err
}
if event.Type == "done" || event.Type == "error" {
break
}
}
return nil
}
func (h *ShareChatHandler) sendErrMsg(c echo.Context, errMsg string) error {
return h.writeSSEEvent(c, domain.SSEEvent{Type: "error", Content: errMsg})
}
func (h *ShareChatHandler) writeSSEEvent(c echo.Context, data any) error {
jsonContent, err := json.Marshal(data)
if err != nil {
return err
}
sseMessage := fmt.Sprintf("data: %s\n\n", string(jsonContent))
if _, err := c.Response().Write([]byte(sseMessage)); err != nil {
return err
}
c.Response().Flush()
return nil
}
// FeedBack handle chat feedback
//
// @Summary Handle chat feedback
// @Description Process user feedback for chat conversations
// @Tags share_chat
// @Accept json
// @Produce json
// @Param request body domain.FeedbackRequest true "feedback request"
// @Success 200 {object} domain.Response
// @Router /share/v1/chat/feedback [post]
func (h *ShareChatHandler) FeedBack(c echo.Context) error {
// 前端传入对应的conversationId和feedback内容后端处理并返回反馈结果
var feedbackReq domain.FeedbackRequest
if err := c.Bind(&feedbackReq); err != nil {
return h.NewResponseWithError(c, "bind feedback request failed", err)
}
if err := c.Validate(&feedbackReq); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
h.logger.Debug("receive feedback request:", log.Any("feedback_request", feedbackReq))
if err := h.conversationUsecase.FeedBack(c.Request().Context(), &feedbackReq); err != nil {
return h.NewResponseWithError(c, "handle feedback failed", err)
}
return h.NewResponseWithData(c, "success")
}
// ChatCompletions OpenAI API compatible chat completions
//
// @Summary ChatCompletions
// @Description OpenAI API compatible chat completions endpoint
// @Tags share_chat
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "Knowledge Base ID"
// @Param request body domain.OpenAICompletionsRequest true "OpenAI API request"
// @Success 200 {object} domain.OpenAICompletionsResponse
// @Failure 400 {object} domain.OpenAIErrorResponse
// @Router /share/v1/chat/completions [post]
func (h *ShareChatHandler) ChatCompletions(c echo.Context) error {
var req domain.OpenAICompletionsRequest
if err := c.Bind(&req); err != nil {
h.logger.Error("parse OpenAI request failed", log.Error(err))
return h.sendOpenAIError(c, "parse request failed", "invalid_request_error")
}
// get kb id from header
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.sendOpenAIError(c, "X-KB-ID header is required", "invalid_request_error")
}
if err := c.Validate(&req); err != nil {
h.logger.Error("validate OpenAI request failed", log.Error(err))
return h.sendOpenAIError(c, "validate request failed", "invalid_request_error")
}
// validate messages
if len(req.Messages) == 0 {
return h.sendOpenAIError(c, "messages cannot be empty", "invalid_request_error")
}
// use last user message as message
var lastUserMessage string
for i := len(req.Messages) - 1; i >= 0; i-- {
if req.Messages[i].Role == "user" {
if req.Messages[i].Content != nil {
lastUserMessage = req.Messages[i].Content.String()
}
break
}
}
if lastUserMessage == "" {
return h.sendOpenAIError(c, "no user message found", "invalid_request_error")
}
// validate api bot settings
appBot, err := h.appUsecase.GetOpenAIAPIAppInfo(c.Request().Context(), kbID)
if err != nil {
return h.sendOpenAIError(c, err.Error(), "internal_error")
}
if !appBot.Settings.OpenAIAPIBotSettings.IsEnabled {
return h.sendOpenAIError(c, "API Bot is not enabled", "forbidden")
}
secretKeyHeader := c.Request().Header.Get("Authorization")
if secretKeyHeader == "" {
return h.sendOpenAIError(c, "Authorization header is required", "invalid_request_error")
}
if secretKey, found := strings.CutPrefix(secretKeyHeader, "Bearer "); !found {
return h.sendOpenAIError(c, "Invalid Authorization key format", "invalid_request_error")
} else {
if appBot.Settings.OpenAIAPIBotSettings.SecretKey != secretKey {
return h.sendOpenAIError(c, "Invalid Authorization key", "unauthorized")
}
}
chatReq := &domain.ChatRequest{
Message: lastUserMessage,
KBID: kbID,
AppType: domain.AppTypeOpenAIAPI,
RemoteIP: c.RealIP(),
}
// set stream response header
if req.Stream {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().Header().Set("Transfer-Encoding", "chunked")
}
eventCh, err := h.chatUsecase.Chat(c.Request().Context(), chatReq)
if err != nil {
return h.sendOpenAIError(c, err.Error(), "internal_error")
}
// handle stream response
if req.Stream {
return h.handleOpenAIStreamResponse(c, eventCh, req.Model)
} else {
return h.handleOpenAINonStreamResponse(c, eventCh, req.Model)
}
}
func (h *ShareChatHandler) handleOpenAIStreamResponse(c echo.Context, eventCh <-chan domain.SSEEvent, model string) error {
responseID := "chatcmpl-" + generateID()
created := time.Now().Unix()
for event := range eventCh {
switch event.Type {
case "error":
return h.sendOpenAIError(c, event.Content, "internal_error")
case "data":
// send stream response
streamResp := domain.OpenAIStreamResponse{
ID: responseID,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []domain.OpenAIStreamChoice{
{
Index: 0,
Delta: domain.OpenAIMessage{
Role: "assistant",
Content: domain.NewStringContent(event.Content),
},
},
},
}
if err := h.writeOpenAIStreamEvent(c, streamResp); err != nil {
return err
}
case "done":
// send done event
streamResp := domain.OpenAIStreamResponse{
ID: responseID,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []domain.OpenAIStreamChoice{
{
Index: 0,
Delta: domain.OpenAIMessage{},
FinishReason: stringPtr("stop"),
},
},
}
return h.writeOpenAIStreamEvent(c, streamResp)
}
}
return nil
}
func (h *ShareChatHandler) handleOpenAINonStreamResponse(c echo.Context, eventCh <-chan domain.SSEEvent, model string) error {
responseID := "chatcmpl-" + generateID()
created := time.Now().Unix()
var content string
for event := range eventCh {
switch event.Type {
case "error":
return h.sendOpenAIError(c, event.Content, "internal_error")
case "data":
content += event.Content
case "done":
// send complete response
resp := domain.OpenAICompletionsResponse{
ID: responseID,
Object: "chat.completion",
Created: created,
Model: model,
Choices: []domain.OpenAIChoice{
{
Index: 0,
Message: domain.OpenAIMessage{
Role: "assistant",
Content: domain.NewStringContent(content),
},
FinishReason: "stop",
},
},
}
return c.JSON(http.StatusOK, resp)
}
}
return nil
}
func (h *ShareChatHandler) sendOpenAIError(c echo.Context, message, errorType string) error {
errResp := domain.OpenAIErrorResponse{
Error: domain.OpenAIError{
Message: message,
Type: errorType,
},
}
return c.JSON(http.StatusBadRequest, errResp)
}
func (h *ShareChatHandler) writeOpenAIStreamEvent(c echo.Context, data domain.OpenAIStreamResponse) error {
jsonContent, err := json.Marshal(data)
if err != nil {
return err
}
sseMessage := fmt.Sprintf("data: %s\n\n", string(jsonContent))
if _, err := c.Response().Write([]byte(sseMessage)); err != nil {
return err
}
c.Response().Flush()
return nil
}
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
func stringPtr(s string) *string {
return &s
}
// ChatSearch searches chat messages in shared knowledge base
//
// @Summary ChatSearch
// @Description ChatSearch
// @Tags share_chat_search
// @Accept json
// @Produce json
// @Param request body domain.ChatSearchReq true "request"
// @Success 200 {object} domain.Response{data=domain.ChatSearchResp}
// @Router /share/v1/chat/search [post]
func (h *ShareChatHandler) ChatSearch(c echo.Context) error {
var req domain.ChatSearchReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "parse request failed", err)
}
req.KBID = c.Request().Header.Get("X-KB-ID") // get from caddy header
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
// validate captcha token
if !h.Captcha.ValidateToken(ctx, req.CaptchaToken) {
return h.NewResponseWithError(c, "invalid captcha token", nil)
}
req.RemoteIP = c.RealIP()
// get user info --> no enterprise is nil
userID := c.Get("user_id")
if userID != nil {
if userIDValue, ok := userID.(uint); ok {
req.AuthUserID = userIDValue
} else {
return h.NewResponseWithError(c, "invalid user id type", nil)
}
}
resp, err := h.chatUsecase.Search(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "failed to search docs", err)
}
return h.NewResponseWithData(c, resp)
}
// WidgetSearch
//
// @Summary WidgetSearch
// @Description WidgetSearch
// @Tags Widget
// @Accept json
// @Produce json
// @Param request body domain.ChatSearchReq true "Comment"
// @Success 200 {object} domain.Response{data=domain.ChatSearchResp}
// @Router /share/v1/chat/widget/search [post]
func (h *ShareChatHandler) WidgetSearch(c echo.Context) error {
var req domain.ChatSearchReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "parse request failed", err)
}
req.KBID = c.Request().Header.Get("X-KB-ID")
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
// validate widget info
widgetAppInfo, err := h.appUsecase.GetWidgetAppInfo(c.Request().Context(), req.KBID)
if err != nil {
h.logger.Error("get widget app info failed", log.Error(err))
return h.sendErrMsg(c, "get app info error")
}
if !widgetAppInfo.Settings.WidgetBotSettings.IsOpen {
return h.sendErrMsg(c, "widget is not open")
}
req.RemoteIP = c.RealIP()
resp, err := h.chatUsecase.Search(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "failed to search docs", err)
}
return h.NewResponseWithData(c, resp)
}

View File

@@ -0,0 +1,165 @@
package share
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareCommentHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.CommentUsecase
app *usecase.AppUsecase
}
func NewShareCommentHandler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
usecase *usecase.CommentUsecase,
app *usecase.AppUsecase,
) *ShareCommentHandler {
h := &ShareCommentHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.comment"),
usecase: usecase,
app: app,
}
share := e.Group("share/v1/comment",
func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type, Origin, Accept")
if c.Request().Method == "OPTIONS" {
return c.NoContent(http.StatusOK)
}
return next(c)
}
}, h.ShareAuthMiddleware.Authorize)
share.POST("", h.CreateComment)
share.GET("/list", h.GetCommentList)
return h
}
// CreateComment
//
// @Summary CreateComment
// @Description CreateComment
// @Tags share_comment
// @Accept json
// @Produce json
// @Param comment body domain.CommentReq true "Comment"
// @Success 200 {object} domain.PWResponse{data=string} "CommentID"
// @Router /share/v1/comment [post]
func (h *ShareCommentHandler) CreateComment(c echo.Context) error {
ctx := c.Request().Context()
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
var req domain.CommentReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "bind comment request failed", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate req failed", err)
}
// 校验是否开启了评论
appInfo, err := h.app.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppType(domain.AppTypeWeb))
if err != nil {
return h.NewResponseWithError(c, "app info is not found", err)
}
if !appInfo.Settings.WebAppCommentSettings.IsEnable {
return h.NewResponseWithError(c, "please check comment is open", nil)
}
// validate captcha token
if !h.Captcha.ValidateToken(ctx, req.CaptchaToken) {
return h.NewResponseWithError(c, "failed to validate captcha token", nil)
}
for _, url := range req.PicUrls {
if !strings.HasPrefix(url, "/static-file/") {
return h.NewResponseWithError(c, "validate param pic_urls failed", err)
}
}
remoteIP := c.RealIP()
// get user info --> no enterprise is nil
var userIDValue uint
userID := c.Get("user_id")
if userID != nil { // can find userinfo from auth
userIDValue = userID.(uint)
}
var status = 1 // no moderate
// 判断user is moderate comment ---> 默认false
if appInfo.Settings.WebAppCommentSettings.ModerationEnable {
status = 0
}
commentStatus := domain.CommentStatus(status)
// 插入到数据库中
commentID, err := h.usecase.CreateComment(ctx, &req, kbID, remoteIP, commentStatus, userIDValue)
if err != nil {
return h.NewResponseWithError(c, "create comment failed", err)
}
return h.NewResponseWithData(c, commentID)
}
type ShareCommentLists = *domain.PaginatedResult[[]*domain.ShareCommentListItem]
// GetCommentList
//
// @Summary GetCommentList
// @Description GetCommentList
// @Tags share_comment
// @Accept json
// @Produce json
// @Param id query string true "nodeID"
// @Success 200 {object} domain.PWResponse{data=ShareCommentLists} "CommentList
// @Router /share/v1/comment/list [get]
func (h *ShareCommentHandler) GetCommentList(c echo.Context) error {
ctx := c.Request().Context()
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
// 拿到node_id即可
nodeID := c.QueryParam("id")
if nodeID == "" {
return h.NewResponseWithError(c, "node id is required", nil)
}
// 校验是否开启了评论
appInfo, err := h.app.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppType(domain.AppTypeWeb))
if err != nil {
return h.NewResponseWithError(c, "app info is not found", err)
}
if !appInfo.Settings.WebAppCommentSettings.IsEnable {
return h.NewResponseWithError(c, "please check comment is open", nil)
}
// 查询数据库获取所有评论-->0 所有, 12 为需要审核的评论
commentsList, err := h.usecase.GetCommentListByNodeID(ctx, nodeID)
if err != nil {
return h.NewResponseWithError(c, "failed to get comment list", err)
}
return h.NewResponseWithData(c, commentsList)
}

View File

@@ -0,0 +1,157 @@
package share
import (
"fmt"
"net/http"
"net/url"
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/share/v1"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
"github.com/chaitin/panda-wiki/utils"
)
type ShareCommonHandler struct {
*handler.BaseHandler
logger *log.Logger
fileUsecase *usecase.FileUsecase
}
func NewShareCommonHandler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
fileUsecase *usecase.FileUsecase,
) *ShareCommonHandler {
h := &ShareCommonHandler{
BaseHandler: baseHandler,
logger: logger,
fileUsecase: fileUsecase,
}
share := e.Group("share/v1/common",
func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type, Origin, Accept")
if c.Request().Method == "OPTIONS" {
return c.NoContent(http.StatusOK)
}
return next(c)
}
})
share.POST("/file/upload", h.FileUpload, h.ShareAuthMiddleware.Authorize)
share.POST("/file/upload/url", h.FileUploadByUrl, h.ShareAuthMiddleware.Authorize)
return h
}
// FileUpload 文件上传
//
// @Tags ShareFile
// @Summary 文件上传
// @Description 前台用户上传文件,目前只支持图片文件上传
// @ID share-FileUpload
// @Accept multipart/form-data
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Param file formData file true "File"
// @Param captcha_token formData string true "captcha_token"
// @Success 200 {object} domain.Response{data=v1.FileUploadResp}
// @Router /share/v1/common/file/upload [post]
func (h *ShareCommonHandler) FileUpload(c echo.Context) error {
ctx := c.Request().Context()
var req v1.ShareFileUploadReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
req.KbId = kbID
file, err := c.FormFile("file")
if err != nil {
return h.NewResponseWithError(c, "failed to get file", err)
}
if !utils.IsImageFile(file.Filename) {
return h.NewResponseWithError(c, "只支持图片文件上传", fmt.Errorf("unsupported file type: %s", file.Filename))
}
// validate captcha token
if !h.Captcha.ValidateToken(ctx, req.CaptchaToken) {
return h.NewResponseWithError(c, "failed to validate captcha token", nil)
}
key, err := h.fileUsecase.UploadFile(ctx, req.KbId, file)
if err != nil {
return h.NewResponseWithError(c, "upload failed", err)
}
return h.NewResponseWithData(c, v1.FileUploadResp{
Key: key,
})
}
// FileUploadByUrl 通过url上传文件
//
// @Tags ShareFile
// @Summary 文件上传
// @Description 前台用户上传文件,目前只支持图片文件上传
// @ID share-FileUploadByUrl
// @Accept json
// @Produce json
// @Param body body v1.ShareFileUploadUrlReq true "body"
// @Success 200 {object} domain.Response{data=v1.ShareFileUploadUrlResp}
// @Router /share/v1/common/file/upload/url [post]
func (h *ShareCommonHandler) FileUploadByUrl(c echo.Context) error {
ctx := c.Request().Context()
var req v1.ShareFileUploadUrlReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
req.KbId = kbID
parsedURL, err := url.Parse(req.Url)
if err != nil {
return h.NewResponseWithError(c, "invalid URL format", err)
}
if !utils.IsImageFile(parsedURL.Path) {
return h.NewResponseWithError(c, "只支持图片文件上传", fmt.Errorf("unsupported file type: %s", req.Url))
}
// validate captcha token
if !h.Captcha.ValidateToken(ctx, req.CaptchaToken) {
return h.NewResponseWithError(c, "failed to validate captcha token", nil)
}
key, err := h.fileUsecase.UploadFileByUrl(ctx, req.KbId, req.Url)
if err != nil {
return h.NewResponseWithError(c, "upload failed", err)
}
return h.NewResponseWithData(c, v1.ShareFileUploadUrlResp{
Key: key,
})
}

View File

@@ -0,0 +1,63 @@
package share
import (
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareConversationHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.ConversationUsecase
}
func NewShareConversationHandler(
baseHandler *handler.BaseHandler,
echo *echo.Echo,
usecase *usecase.ConversationUsecase,
logger *log.Logger,
) *ShareConversationHandler {
h := &ShareConversationHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.conversation"),
usecase: usecase,
}
group := echo.Group("share/v1/conversation",
h.ShareAuthMiddleware.Authorize,
)
group.GET("/detail", h.GetConversationDetail)
return h
}
// GetConversationDetail
//
// @Summary GetConversationDetail
// @Description GetConversationDetail
// @Tags share_conversation
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Param id query string true "conversation id"
// @Success 200 {object} domain.PWResponse{data=domain.ShareConversationDetailResp}
// @Router /share/v1/conversation/detail [get]
func (h *ShareConversationHandler) GetConversationDetail(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
id := c.QueryParam("id")
if id == "" {
return h.NewResponseWithError(c, "id is required", nil)
}
node, err := h.usecase.GetShareConversationDetail(c.Request().Context(), kbID, id)
if err != nil {
return h.NewResponseWithError(c, "failed to get node detail", err)
}
return h.NewResponseWithData(c, node)
}

View File

@@ -0,0 +1,67 @@
package share
import (
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/share/v1"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareNavHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.NavUsecase
}
func NewShareNavHandler(
baseHandler *handler.BaseHandler,
echo *echo.Echo,
usecase *usecase.NavUsecase,
logger *log.Logger,
) *ShareNavHandler {
h := &ShareNavHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.nav"),
usecase: usecase,
}
group := echo.Group("share/v1/nav",
h.ShareAuthMiddleware.Authorize,
)
group.GET("/list", h.ShareNavList)
return h
}
// ShareNavList
//
// @Summary 前台获取栏目列表
// @Description ShareNavList
// @Tags share_nav
// @Accept json
// @Produce json
// @Param param query v1.ShareNavListReq true "para"
// @Success 200 {object} domain.Response
// @Router /share/v1/nav/list [get]
func (h *ShareNavHandler) ShareNavList(c echo.Context) error {
var req v1.ShareNavListReq
if err := c.Bind(&req); err != nil {
h.logger.Error("parse request failed", log.Error(err))
return h.NewResponseWithError(c, "parse request failed", err)
}
if err := c.Validate(&req); err != nil {
h.logger.Error("validate request failed", log.Error(err))
return h.NewResponseWithError(c, "validate request failed", err)
}
navs, err := h.usecase.GetReleaseList(c.Request().Context(), req.KbId)
if err != nil {
return h.NewResponseWithError(c, "failed to get nav list", err)
}
return h.NewResponseWithData(c, navs)
}

View File

@@ -0,0 +1,106 @@
package share
import (
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareNodeHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.NodeUsecase
}
func NewShareNodeHandler(
baseHandler *handler.BaseHandler,
echo *echo.Echo,
usecase *usecase.NodeUsecase,
logger *log.Logger,
) *ShareNodeHandler {
h := &ShareNodeHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.node"),
usecase: usecase,
}
group := echo.Group("share/v1/node",
h.ShareAuthMiddleware.Authorize,
)
group.GET("/list", h.ShareNodeList)
group.GET("/detail", h.GetNodeDetail)
return h
}
// ShareNodeList
//
// @Summary ShareNodeList
// @Description ShareNodeList
// @Tags share_node
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Success 200 {object} domain.Response
// @Router /share/v1/node/list [get]
func (h *ShareNodeHandler) ShareNodeList(c echo.Context) error {
kbId := c.Request().Header.Get("X-KB-ID")
if kbId == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
nodes, err := h.usecase.GetShareNodeList(c.Request().Context(), kbId, domain.GetAuthID(c))
if err != nil {
return h.NewResponseWithError(c, "failed to get node list", err)
}
return h.NewResponseWithData(c, nodes)
}
// GetNodeDetail
//
// @Summary GetNodeDetail
// @Description GetNodeDetail
// @Tags share_node
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Param id query string true "node id"
// @Param format query string true "format"
// @Success 200 {object} domain.Response{data=v1.ShareNodeDetailResp}
// @Router /share/v1/node/detail [get]
func (h *ShareNodeHandler) GetNodeDetail(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
id := c.QueryParam("id")
if id == "" {
return h.NewResponseWithError(c, "id is required", nil)
}
errCode := h.usecase.ValidateNodePerm(c.Request().Context(), kbID, id, domain.GetAuthID(c))
if errCode != nil {
return h.NewResponseWithErrCode(c, *errCode)
}
node, err := h.usecase.GetNodeReleaseDetailByKBIDAndID(c.Request().Context(), kbID, id, c.QueryParam("format"))
if err != nil {
return h.NewResponseWithError(c, "failed to get node detail", err)
}
// If the node is a folder, return the list of child nodes
if node.Type == domain.NodeTypeFolder {
childNodes, err := h.usecase.GetNodeReleaseListByParentID(c.Request().Context(), kbID, id, domain.GetAuthID(c))
if err != nil {
return h.NewResponseWithError(c, "failed to get child nodes", err)
}
node.List = childNodes
}
return h.NewResponseWithData(c, node)
}

View File

@@ -0,0 +1,157 @@
package share
import (
"context"
"errors"
"io"
"net/http"
"github.com/labstack/echo/v4"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
v1 "github.com/chaitin/panda-wiki/api/share/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type OpenapiV1Handler struct {
*handler.BaseHandler
logger *log.Logger
authUseCase *usecase.AuthUsecase
appCase *usecase.AppUsecase
}
func NewOpenapiV1Handler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
authUseCase *usecase.AuthUsecase,
appCase *usecase.AppUsecase,
) *OpenapiV1Handler {
h := &OpenapiV1Handler{
BaseHandler: baseHandler,
logger: logger,
authUseCase: authUseCase,
appCase: appCase,
}
OpenapiGroup := e.Group("/share/v1/openapi")
OpenapiGroup.Any("/github/callback", h.GitHubCallback)
// lark机器人
OpenapiGroup.POST("/lark/bot/:kb_id", h.LarkBot)
return h
}
// GitHubCallback GitHub回调
//
// @Tags ShareOpenapi
// @Summary GitHub回调
// @Description GitHub回调
// @ID v1-GitHubCallback
// @Accept json
// @Produce json
// @Param param query v1.GitHubCallbackReq true "para"
// @Success 200 {object} domain.PWResponse{data=v1.GitHubCallbackResp}
// @Router /share/v1/openapi/github/callback [get]
func (h *OpenapiV1Handler) GitHubCallback(c echo.Context) error {
ctx := context.WithValue(c.Request().Context(), consts.ContextKeyEdition, consts.GetLicenseEdition(c))
var req v1.GitHubCallbackReq
if err := c.Bind(&req); err != nil {
return err
}
if req.Code == "" {
return h.NewResponseWithError(c, "code is required", nil)
}
auth, redirectUrl, err := h.authUseCase.GitHubCallback(ctx, req)
if err != nil {
return h.NewResponseWithError(c, "handle callback failed", err)
}
if err := h.authUseCase.SaveNewSession(c, auth); err != nil {
return h.NewResponseWithError(c, "save session failed", err)
}
return c.Redirect(http.StatusFound, redirectUrl)
}
// LarkBot Lark机器人请求
//
// @Tags ShareOpenapi
// @Summary Lark机器人请求
// @Description Lark机器人请求
// @ID v1-LarkBot
// @Accept json
// @Produce json
// @Param kb_id path string true "知识库ID"
// @Success 200 {object} domain.PWResponse
// @Router /share/v1/openapi/lark/bot/{kb_id} [post]
func (h *OpenapiV1Handler) LarkBot(c echo.Context) error {
ctx := c.Request().Context()
kbID := c.Param("kb_id")
if kbID == "" {
h.logger.Error("kb_id is required")
return h.NewResponseWithError(c, "kb_id is required", nil)
}
// 获取应用配置
appInfo, err := h.appCase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeLarkBot)
if err != nil {
h.logger.Error("failed to get app detail", log.Error(err), log.String("kb_id", kbID))
return h.NewResponseWithError(c, "failed to get app detail", err)
}
if appInfo.Settings.LarkBotSettings.IsEnabled == nil || !*appInfo.Settings.LarkBotSettings.IsEnabled {
h.logger.Error("lark bot is not enabled")
return h.NewResponseWithError(c, "lark bot is not enabled", err)
}
var eventHandler *dispatcher.EventDispatcher
client, ok := h.appCase.GetLarkBotClient(appInfo.ID)
if ok {
eventHandler = client.GetEventHandler()
}
if eventHandler == nil {
eventHandler = dispatcher.NewEventDispatcher(
appInfo.Settings.LarkBotSettings.VerifyToken,
appInfo.Settings.LarkBotSettings.EncryptKey,
)
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
h.logger.Error("failed to read request body", log.Error(err))
return h.NewResponseWithError(c, "failed to read request body", err)
}
defer c.Request().Body.Close()
eventReq := &larkevent.EventReq{
Header: c.Request().Header,
Body: body,
RequestURI: c.Request().RequestURI,
}
eventResp := eventHandler.Handle(ctx, eventReq)
if eventResp == nil {
h.logger.Error("failed to handle lark event: nil response")
return h.NewResponseWithError(c, "failed to handle lark event", errors.New("nil response"))
}
for key, values := range eventResp.Header {
for _, value := range values {
c.Response().Header().Add(key, value)
}
}
return c.JSONBlob(eventResp.StatusCode, eventResp.Body)
}

View File

@@ -0,0 +1,43 @@
package share
import (
"github.com/google/wire"
"github.com/chaitin/panda-wiki/pkg/captcha"
)
type ShareHandler struct {
ShareNodeHandler *ShareNodeHandler
ShareNavHandler *ShareNavHandler
ShareAppHandler *ShareAppHandler
ShareChatHandler *ShareChatHandler
ShareSitemapHandler *ShareSitemapHandler
ShareStatHandler *ShareStatHandler
ShareCommentHandler *ShareCommentHandler
ShareAuthHandler *ShareAuthHandler
ShareConversationHandler *ShareConversationHandler
ShareWechatHandler *ShareWechatHandler
ShareCaptchaHandler *ShareCaptchaHandler
OpenapiV1Handler *OpenapiV1Handler
ShareCommonHandler *ShareCommonHandler
}
var ProviderSet = wire.NewSet(
captcha.NewCaptcha,
NewShareNodeHandler,
NewShareNavHandler,
NewShareAppHandler,
NewShareChatHandler,
NewShareSitemapHandler,
NewShareStatHandler,
NewShareCommentHandler,
NewShareAuthHandler,
NewShareConversationHandler,
NewShareWechatHandler,
NewShareCaptchaHandler,
NewShareCommonHandler,
NewOpenapiV1Handler,
wire.Struct(new(ShareHandler), "*"),
)

View File

@@ -0,0 +1,46 @@
package share
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareSitemapHandler struct {
*handler.BaseHandler
sitemapUsecase *usecase.SitemapUsecase
appUsecase *usecase.AppUsecase
logger *log.Logger
}
func NewShareSitemapHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, sitemapUsecase *usecase.SitemapUsecase, appUsecase *usecase.AppUsecase, logger *log.Logger) *ShareSitemapHandler {
h := &ShareSitemapHandler{
BaseHandler: baseHandler,
sitemapUsecase: sitemapUsecase,
appUsecase: appUsecase,
logger: logger.WithModule("handler.share.sitemap"),
}
group := echo.Group("/sitemap.xml")
group.GET("", h.GetSitemap)
return h
}
func (h *ShareSitemapHandler) GetSitemap(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
xml, err := h.sitemapUsecase.GetSitemap(c.Request().Context(), kbID)
if err != nil {
return h.NewResponseWithError(c, "failed to generate sitemap", err)
}
return c.Blob(http.StatusOK, echo.MIMEApplicationXMLCharsetUTF8, []byte(xml))
}

View File

@@ -0,0 +1,102 @@
package share
import (
"net/url"
"time"
"github.com/labstack/echo/v4"
"github.com/mileusna/useragent"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareStatHandler struct {
*handler.BaseHandler
useCase *usecase.StatUseCase
logger *log.Logger
}
func NewShareStatHandler(baseHandler *handler.BaseHandler, echo *echo.Echo, useCase *usecase.StatUseCase, logger *log.Logger) *ShareStatHandler {
h := &ShareStatHandler{
BaseHandler: baseHandler,
useCase: useCase,
logger: logger.WithModule("handler.share.stat"),
}
group := echo.Group("/share/v1/stat")
group.POST("/page", h.RecordPage, h.ShareAuthMiddleware.Authorize)
return h
}
// RecordPage record page
//
// @Summary RecordPage
// @Description RecordPage
// @Tags share_stat
// @Accept json
// @Produce json
// @Param request body domain.StatPageReq true "request"
// @Success 200 {object} domain.Response
// @Router /share/v1/stat/page [post]
func (h *ShareStatHandler) RecordPage(c echo.Context) error {
req := &domain.StatPageReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "bind request body failed", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
kbID := c.Request().Header.Get("X-KB-ID")
// get user info --> no enterprise is nil
var userIDValue uint
userID := c.Get("user_id")
if userID != nil { // can find userinfo from auth
userIDValue = userID.(uint)
}
ua := c.Request().UserAgent()
userAgent := useragent.Parse(ua)
browserName := userAgent.Name
browserOS := userAgent.OS
referer := c.Request().Referer()
refererHost := ""
if referer != "" {
refererURL, err := url.Parse(referer)
if err == nil {
refererHost = refererURL.Host
}
}
sessionID := ""
sessionIDCookie, err := c.Request().Cookie("x-pw-session-id")
if err != nil {
sessionID = c.Request().Header.Get("x-pw-session-id")
} else {
sessionID = sessionIDCookie.Value
}
if sessionID == "" {
return h.NewResponseWithError(c, "session id not found", err)
}
ip := c.RealIP()
stat := &domain.StatPage{
KBID: kbID,
UserID: userIDValue,
NodeID: req.NodeID,
Scene: req.Scene,
SessionID: sessionID,
IP: ip,
UA: ua,
BrowserName: browserName,
BrowserOS: browserOS,
Referer: referer,
RefererHost: refererHost,
CreatedAt: time.Now(),
}
if err := h.useCase.RecordPage(c.Request().Context(), stat); err != nil {
return h.NewResponseWithError(c, "record page failed", err)
}
return h.NewResponseWithData(c, nil)
}

View File

@@ -0,0 +1,489 @@
package share
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/sbzhu/weworkapi_golang/wxbizmsgcrypt"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/pkg/bot/wechat"
"github.com/chaitin/panda-wiki/pkg/bot/wechat_service"
"github.com/chaitin/panda-wiki/usecase"
)
type ShareWechatHandler struct {
*handler.BaseHandler
logger *log.Logger
appCase *usecase.AppUsecase
conversationCase *usecase.ConversationUsecase
wechatUsecase *usecase.WechatServiceUsecase
wecomUsecase *usecase.WecomUsecase
wechatAppUsecase *usecase.WechatAppUsecase
}
func NewShareWechatHandler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
appCase *usecase.AppUsecase,
conversationCase *usecase.ConversationUsecase,
wechatUsecase *usecase.WechatServiceUsecase,
wecomUsecase *usecase.WecomUsecase,
wechatAppUsecase *usecase.WechatAppUsecase,
) *ShareWechatHandler {
h := &ShareWechatHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.share.wechat"),
appCase: appCase,
conversationCase: conversationCase,
wechatUsecase: wechatUsecase,
wecomUsecase: wecomUsecase,
wechatAppUsecase: wechatAppUsecase,
}
share := e.Group("share/v1/app",
func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type, Origin, Accept")
if c.Request().Method == "OPTIONS" {
return c.NoContent(http.StatusOK)
}
return next(c)
}
})
// 微信客服
share.GET("/wechat/service", h.VerifyUrlWechatService)
share.POST("/wechat/service", h.WechatHandlerService)
share.GET("/wechat/service/answer", h.GetWechatAnswer)
//企业微信
share.GET("/wechat/app", h.VerifyUrlWechatApp)
share.POST("/wechat/app", h.WechatHandlerApp)
// 企业微信智能机器人
share.GET("/wecom/ai_bot", h.WecomAIBotVerify)
share.POST("/wecom/ai_bot", h.WecomAIBotHandle)
return h
}
// GetWechatAnswer
//
// @Summary GetWechatAnswer
// @Description GetWechatAnswer
// @Tags Wechat
// @Accept json
// @Produce json
// @Param id query string true "conversation id"
// @Success 200 {object} domain.Response
//
// @Router /share/v1/app/wechat/service/answer [get]
func (h *ShareWechatHandler) GetWechatAnswer(c echo.Context) error {
conversationID := c.QueryParam("id")
if conversationID == "" {
return h.NewResponseWithError(c, "conversation_id is required", nil)
}
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().Header().Set("Transfer-Encoding", "chunked")
// checkout if the conversation exists in map
val, ok := domain.ConversationManager.Load(conversationID)
if !ok { // not exist check db
conversation, err := h.conversationCase.GetConversationDetail(c.Request().Context(), "", conversationID)
if err != nil {
return h.sendErrMsg(c, err.Error())
}
// send answer and question
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "question", Content: conversation.Messages[0].Content}); err != nil {
return err
}
//2.answer
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "feedback_score", Content: strconv.Itoa(int(conversation.Messages[1].Info.Score))}); err != nil {
return err
}
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "message_id", Content: conversation.Messages[1].ID}); err != nil {
return err
}
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "answer", Content: conversation.Messages[1].Content}); err != nil {
return err
}
//3.
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "done", Content: ""}); err != nil {
return err
}
return nil
}
// exit --> get message
state := val.(*domain.ConversationState)
// 1. send question
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "question", Content: state.Question}); err != nil {
return err
}
//2. send answer
state.Mutex.Lock()
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "answer", Content: state.Buffer.String()}); err != nil {
return err
}
state.IsVisited = true
state.Mutex.Unlock()
defer func() {
state.Mutex.Lock()
state.IsVisited = false
state.Mutex.Unlock()
}()
for answer := range state.NotificationChan { // listen if has new data
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "answer", Content: answer}); err != nil {
return err
} // catch err
}
return h.writeSSEEvent(c, domain.SSEEvent{Type: "done", Content: ""})
}
func (h *ShareWechatHandler) sendErrMsg(c echo.Context, errMsg string) error {
return h.writeSSEEvent(c, domain.SSEEvent{Type: "error", Content: errMsg})
}
func (h *ShareWechatHandler) writeSSEEvent(c echo.Context, data any) error {
jsonContent, err := json.Marshal(data)
if err != nil {
return err
}
sseMessage := fmt.Sprintf("data: %s\n\n", string(jsonContent))
if _, err := c.Response().Write([]byte(sseMessage)); err != nil {
return err
}
c.Response().Flush()
return nil
}
// callback wechat verify
func (h *ShareWechatHandler) VerifyUrlWechatService(c echo.Context) error {
signature := c.QueryParam("msg_signature")
timestamp := c.QueryParam("timestamp")
nonce := c.QueryParam("nonce")
echoStr := c.QueryParam("echostr")
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
if signature == "" || timestamp == "" || nonce == "" || echoStr == "" {
return h.NewResponseWithError(
c, "verify wechat service params failed", nil,
)
}
ctx := c.Request().Context()
appInfo, err := h.appCase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWechatServiceBot)
if err != nil {
h.logger.Error("find app detail failed", log.Error(err))
return err
}
if appInfo.Settings.WeChatServiceIsEnabled != nil && !*appInfo.Settings.WeChatServiceIsEnabled {
h.logger.Error("wechat service bot is not enabled", log.Error(err))
return errors.New("wechat service bot is not enabled")
}
WechatServiceConf, err := h.wechatUsecase.NewWechatServiceConfig(ctx, kbID, appInfo)
if err != nil {
h.logger.Error("failed to create WechatServiceConfig", log.Error(err))
return err
}
req, err := h.wechatUsecase.VerifyUrlWechatService(ctx, signature, timestamp, nonce, echoStr, WechatServiceConf)
if err != nil {
h.logger.Error("VerifyURL_Service failed", log.Error(err))
return err
}
// success
return c.String(http.StatusOK, string(req))
}
// handler user request and sent info to wechat
func (h *ShareWechatHandler) WechatHandlerService(c echo.Context) error {
signature := c.QueryParam("msg_signature")
timestamp := c.QueryParam("timestamp")
nonce := c.QueryParam("nonce")
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
h.logger.Error("get request failed", log.Error(err))
return err
}
defer c.Request().Body.Close()
ctx := c.Request().Context()
appInfo, err := h.appCase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWechatServiceBot)
if err != nil {
h.logger.Error("GetAppDetailByKBIDAndAppType failed", log.Error(err))
return err
}
if appInfo.Settings.WeChatServiceIsEnabled != nil && !*appInfo.Settings.WeChatServiceIsEnabled {
h.logger.Info("wechat service bot is not enabled")
return nil
}
// 创建一个wechat service对象
wechatServiceConf, err := h.wechatUsecase.NewWechatServiceConfig(context.Background(), kbID, appInfo)
h.logger.Info("wechat service config", log.Any("wechat service config", wechatServiceConf))
if err != nil {
return err
}
// 解密消息
wxCrypt := wxbizmsgcrypt.NewWXBizMsgCrypt(wechatServiceConf.Token, wechatServiceConf.EncodingAESKey, wechatServiceConf.CorpID, wxbizmsgcrypt.XmlType)
decryptMsg, errCode := wxCrypt.DecryptMsg(signature, timestamp, nonce, body)
if errCode != nil {
h.logger.Error("DecryptMsg failed", log.Any("decryptMsg err", errCode))
return nil
}
// 反序列化
msg, err := wechatServiceConf.UnmarshalMsg(decryptMsg)
if err != nil {
h.logger.Error("UnmarshalMsg failed", log.Error(err))
return err
}
go func(WechatServiceConf *wechat_service.WechatServiceConfig, msg *wechat_service.WeixinUserAskMsg, kbID string) {
ctx := context.Background()
err := h.wechatUsecase.WechatService(ctx, msg, kbID, WechatServiceConf)
if err != nil {
h.logger.Error("wechat async failed", log.Any("Wechat_Service", err))
}
}(wechatServiceConf, msg, kbID)
// 先响应
return c.JSON(http.StatusOK, "success")
}
func (h *ShareWechatHandler) VerifyUrlWechatApp(c echo.Context) error {
signature := c.QueryParam("msg_signature")
timestamp := c.QueryParam("timestamp")
nonce := c.QueryParam("nonce")
echoStr := c.QueryParam("echostr")
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
if signature == "" || timestamp == "" || nonce == "" || echoStr == "" {
return h.NewResponseWithError(
c, "verify wechat params failed", nil,
)
}
ctx := c.Request().Context()
//1. get wechat app bot info
appInfo, err := h.appCase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWechatBot)
if err != nil {
h.logger.Error("get app detail failed", log.Error(err))
return err
}
if appInfo.Settings.WeChatAppIsEnabled != nil && !*appInfo.Settings.WeChatAppIsEnabled {
h.logger.Info("wechat service bot is not enabled")
return nil
}
h.logger.Debug("wechat app info", log.Any("info", appInfo))
WechatConf, err := h.wechatAppUsecase.NewWechatConfig(ctx, appInfo, kbID)
if err != nil {
h.logger.Error("failed to create WechatConfig", log.Error(err))
return err
}
req, err := h.wechatAppUsecase.VerifyUrlWechatAPP(ctx, signature, timestamp, nonce, echoStr, kbID, WechatConf)
if err != nil {
return h.NewResponseWithError(c, "VerifyURL failed", err)
}
// success
return c.String(http.StatusOK, string(req))
}
// WechatHandlerApp /share/v1/app/wechat/app
func (h *ShareWechatHandler) WechatHandlerApp(c echo.Context) error {
signature := c.QueryParam("msg_signature")
timestamp := c.QueryParam("timestamp")
nonce := c.QueryParam("nonce")
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
h.logger.Error("get request failed", log.Error(err))
return h.NewResponseWithError(c, "Internal Server Error", err)
}
defer c.Request().Body.Close()
ctx := c.Request().Context()
// get appinfo and init wechatConfig
// 查找数据库找到对应的app配置
appInfo, err := h.appCase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWechatBot)
if err != nil {
return h.NewResponseWithError(c, "GetAppDetailByKBIDAndAppType failed", err)
}
if appInfo.Settings.WeChatAppIsEnabled != nil && !*appInfo.Settings.WeChatAppIsEnabled {
return h.NewResponseWithError(c, "wechat app bot is not enabled", nil)
}
wechatConfig, err := h.wechatAppUsecase.NewWechatConfig(context.Background(), appInfo, kbID)
if err != nil {
return h.NewResponseWithError(c, "wechat app config error", err)
}
// 解密消息
wxCrypt := wxbizmsgcrypt.NewWXBizMsgCrypt(wechatConfig.Token, wechatConfig.EncodingAESKey, wechatConfig.CorpID, wxbizmsgcrypt.XmlType)
decryptMsg, errCode := wxCrypt.DecryptMsg(signature, timestamp, nonce, body)
if errCode != nil {
return h.NewResponseWithError(c, "DecryptMsg failed", nil)
}
msg, err := wechatConfig.UnmarshalMsg(decryptMsg)
if err != nil {
return h.NewResponseWithError(c, "UnmarshalMsg failed", err)
}
h.logger.Info("wechat app msg", log.Any("user msg", msg))
if msg.MsgType != "text" { // 用户进入会话,或者其他非提问类型的事件
return c.String(http.StatusOK, "")
}
var immediateResponse []byte
if domain.GetBaseEditionLimitation(ctx).AllowAdvancedBot && appInfo.Settings.WeChatAppAdvancedSetting.TextResponseEnable {
immediateResponse, err = wechatConfig.SendResponse(*msg, "正在思考您的问题,请稍候...")
if err != nil {
return h.NewResponseWithError(c, "Failed to send immediate response", err)
}
}
go func(ctx context.Context, msg *wechat.ReceivedMessage, wechatConfig *wechat.WechatConfig, kbId string, appInfo *domain.AppDetailResp) {
err := h.wechatAppUsecase.Wechat(ctx, msg, wechatConfig, kbId, &appInfo.Settings.WeChatAppAdvancedSetting)
if err != nil {
h.logger.Error("wechat async failed")
}
}(ctx, msg, wechatConfig, kbID, appInfo)
return c.XMLBlob(http.StatusOK, immediateResponse)
}
func (h *ShareWechatHandler) WecomAIBotVerify(c echo.Context) error {
signature := c.QueryParam("msg_signature")
timestamp := c.QueryParam("timestamp")
nonce := c.QueryParam("nonce")
echoStr := c.QueryParam("echostr")
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
if signature == "" || timestamp == "" || nonce == "" || echoStr == "" {
return h.NewResponseWithError(
c, "verify wecom ai params failed", nil,
)
}
ctx := c.Request().Context()
appInfo, err := h.appCase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWecomAIBot)
if err != nil {
h.logger.Error("find app detail failed", log.Error(err))
return err
}
if !appInfo.Settings.WecomAIBotSettings.IsEnabled {
h.logger.Error("wecom ai bot is not enabled", log.Error(err))
return errors.New("wecom ai bot is not enabled")
}
resp, err := h.wecomUsecase.VerifyUrlService(ctx, signature, timestamp, nonce, echoStr, appInfo)
if err != nil {
h.logger.Error("wecom ai bot verify failed", log.Error(err))
return err
}
return c.String(http.StatusOK, resp)
}
func (h *ShareWechatHandler) WecomAIBotHandle(c echo.Context) error {
signature := c.QueryParam("msg_signature")
timestamp := c.QueryParam("timestamp")
nonce := c.QueryParam("nonce")
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
h.logger.Error("get request failed", log.Error(err))
return h.NewResponseWithError(c, "Internal Server Error", err)
}
defer c.Request().Body.Close()
ctx := c.Request().Context()
appInfo, err := h.appCase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppTypeWecomAIBot)
if err != nil {
return h.NewResponseWithError(c, "GetAppDetailByKBIDAndAppType failed", err)
}
if !appInfo.Settings.WecomAIBotSettings.IsEnabled {
return h.NewResponseWithError(c, "wecom app bot is not enabled", nil)
}
h.logger.Info("msg:", log.String("body", string(body)))
resp, err := h.wecomUsecase.HandleMsg(ctx, kbID, signature, timestamp, nonce, string(body), appInfo)
if err != nil {
h.logger.Error("wecom ai bot handle msg failed", log.Error(err))
return err
}
return c.String(http.StatusOK, resp)
}

145
backend/handler/v1/app.go Normal file
View File

@@ -0,0 +1,145 @@
package v1
import (
"strconv"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type AppHandler struct {
*handler.BaseHandler
logger *log.Logger
auth middleware.AuthMiddleware
usecase *usecase.AppUsecase
modelUsecase *usecase.ModelUsecase
conversationUsecase *usecase.ConversationUsecase
config *config.Config
}
func NewAppHandler(e *echo.Echo, baseHandler *handler.BaseHandler, logger *log.Logger, auth middleware.AuthMiddleware, usecase *usecase.AppUsecase, modelUsecase *usecase.ModelUsecase, conversationUsecase *usecase.ConversationUsecase, config *config.Config) *AppHandler {
h := &AppHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.app"),
auth: auth,
usecase: usecase,
modelUsecase: modelUsecase,
conversationUsecase: conversationUsecase,
config: config,
}
group := e.Group("/api/v1/app", h.auth.Authorize)
group.GET("/detail", h.GetAppDetail, h.auth.ValidateKBUserPerm(consts.UserKBPermissionDocManage))
group.PUT("", h.UpdateApp, h.auth.ValidateKBUserPerm(consts.UserKBPermissionFullControl))
group.DELETE("", h.DeleteApp, h.auth.ValidateKBUserPerm(consts.UserKBPermissionFullControl))
return h
}
// GetAppDetail get app detail
//
// @Summary Get app detail
// @Description Get app detail
// @Tags app
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param kb_id query string true "kb id"
// @Param type query string true "app type"
// @Success 200 {object} domain.PWResponse{data=domain.AppDetailResp}
// @Router /api/v1/app/detail [get]
func (h *AppHandler) GetAppDetail(c echo.Context) error {
kbID := c.QueryParam("kb_id")
if kbID == "" {
return h.NewResponseWithError(c, "kb id is required", nil)
}
appType := c.QueryParam("type")
if appType == "" {
return h.NewResponseWithError(c, "type is required", nil)
}
appTypeInt, err := strconv.ParseInt(appType, 10, 64)
if err != nil {
return h.NewResponseWithError(c, "invalid app type", err)
}
ctx := c.Request().Context()
app, err := h.usecase.GetAppDetailByKBIDAndAppType(ctx, kbID, domain.AppType(appTypeInt))
if err != nil {
return h.NewResponseWithError(c, "get app detail failed", err)
}
if authInfo := domain.GetAuthInfoFromCtx(ctx); authInfo != nil && authInfo.Permission == consts.UserKBPermissionDocManage {
app = h.usecase.SanitizeAppDetailForDocManage(app)
}
return h.NewResponseWithData(c, app)
}
// UpdateApp update app
//
// @Summary Update app
// @Description Update app
// @Tags app
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param id query string true "id"
// @Param app body domain.UpdateAppReq true "app"
// @Success 200 {object} domain.Response
// @Router /api/v1/app [put]
func (h *AppHandler) UpdateApp(c echo.Context) error {
id := c.QueryParam("id")
if id == "" {
return h.NewResponseWithError(c, "id is required", nil)
}
appRequest := domain.UpdateAppReq{}
if err := c.Bind(&appRequest); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
ctx := c.Request().Context()
if err := h.usecase.ValidateUpdateApp(ctx, id, &appRequest); err != nil {
h.logger.Error("UpdateApp", log.Any("req:", appRequest.Settings), log.Any("err:", err))
return h.NewResponseWithErrCode(c, domain.ErrCodePermissionDenied)
}
if err := h.usecase.UpdateApp(ctx, id, &appRequest); err != nil {
return h.NewResponseWithError(c, "update app failed", err)
}
return h.NewResponseWithData(c, nil)
}
// DeleteApp delete app
//
// @Summary Delete app
// @Description Delete app
// @Tags app
// @Accept json
// @Security bearerAuth
// @Param kb_id query string true "kb id"
// @Param id query string true "app id"
// @Success 200 {object} domain.Response
// @Router /api/v1/app [delete]
func (h *AppHandler) DeleteApp(c echo.Context) error {
id := c.QueryParam("id")
if id == "" {
return h.NewResponseWithError(c, "id is required", nil)
}
kbID := c.QueryParam("kb_id")
if kbID == "" {
return h.NewResponseWithError(c, "kb id is required", nil)
}
if err := h.usecase.DeleteApp(c.Request().Context(), id, kbID); err != nil {
return h.NewResponseWithError(c, "delete app failed", err)
}
return h.NewResponseWithData(c, nil)
}

132
backend/handler/v1/auth.go Normal file
View File

@@ -0,0 +1,132 @@
package v1
import (
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/auth/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type AuthV1Handler struct {
*handler.BaseHandler
logger *log.Logger
authUseCase *usecase.AuthUsecase
}
func NewAuthV1Handler(
e *echo.Echo,
baseHandler *handler.BaseHandler,
logger *log.Logger,
authUseCase *usecase.AuthUsecase,
) *AuthV1Handler {
h := &AuthV1Handler{
BaseHandler: baseHandler,
logger: logger,
authUseCase: authUseCase,
}
AuthGroup := e.Group(
"/api/v1/auth",
h.V1Auth.Authorize,
h.V1Auth.ValidateKBUserPerm(consts.UserKBPermissionFullControl),
)
AuthGroup.GET("/get", h.OpenAuthGet)
AuthGroup.POST("/set", h.OpenAuthSet)
AuthGroup.DELETE("/delete", h.OpenAuthDelete)
return h
}
// OpenAuthGet 获取授权信息
//
// @Tags Auth
// @Summary 获取授权信息
// @Description 获取授权信息
// @ID v1-OpenAuthGet
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param query v1.AuthGetReq true "para"
// @Success 200 {object} domain.PWResponse{data=v1.AuthGetResp}
// @Router /api/v1/auth/get [get]
func (h *AuthV1Handler) OpenAuthGet(c echo.Context) error {
var req v1.AuthGetReq
if err := c.Bind(&req); err != nil {
return err
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
resp, err := h.authUseCase.GetAuth(c.Request().Context(), req.KBID, req.SourceType)
if err != nil {
return h.NewResponseWithError(c, "failed to get Auth", err)
}
return h.NewResponseWithData(c, resp)
}
// OpenAuthSet 获取授权信息
//
// @Tags Auth
// @Summary 设置授权信息
// @Description 设置授权信息
// @ID v1-OpenAuthSet
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param body v1.AuthSetReq true "para"
// @Success 200 {object} domain.Response
// @Router /api/v1/auth/set [post]
func (h *AuthV1Handler) OpenAuthSet(c echo.Context) error {
var req v1.AuthSetReq
if err := c.Bind(&req); err != nil {
return err
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.authUseCase.SetAuth(c.Request().Context(), req); err != nil {
return h.NewResponseWithError(c, "failed to set Auth", err)
}
return h.NewResponseWithData(c, nil)
}
// OpenAuthDelete 删除授权信息
//
// @Tags Auth
// @Summary 删除授权信息
// @Description 删除授权信息
// @ID v1-OpenAuthDelete
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param query v1.AuthDeleteReq true "para"
// @Success 200 {object} domain.Response
// @Router /api/v1/auth/delete [delete]
func (h *AuthV1Handler) OpenAuthDelete(c echo.Context) error {
var req v1.AuthDeleteReq
if err := c.Bind(&req); err != nil {
return err
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.authUseCase.DeleteAuth(c.Request().Context(), req); err != nil {
return h.NewResponseWithError(c, "failed to delete Auth", err)
}
return h.NewResponseWithData(c, nil)
}

View File

@@ -0,0 +1,92 @@
package v1
import (
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type CommentHandler struct {
*handler.BaseHandler
logger *log.Logger
auth middleware.AuthMiddleware
usecase *usecase.CommentUsecase
}
func NewCommentHandler(e *echo.Echo, baseHandler *handler.BaseHandler, logger *log.Logger, auth middleware.AuthMiddleware,
usecase *usecase.CommentUsecase) *CommentHandler {
h := &CommentHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.comment"),
auth: auth,
usecase: usecase,
}
group := e.Group("/api/v1/comment", h.auth.Authorize, h.auth.ValidateKBUserPerm(consts.UserKBPermissionDataOperate))
group.GET("", h.GetCommentModeratedList)
group.DELETE("/list", h.DeleteCommentList)
return h
}
type CommentLists = domain.PaginatedResult[[]*domain.CommentListItem]
// GetCommentModeratedList
//
// @Summary GetCommentModeratedList
// @Description GetCommentModeratedList
// @Tags comment
// @Accept json
// @Produce json
// @Param req query domain.CommentListReq true "CommentListReq"
// @Success 200 {object} domain.PWResponse{data=CommentLists} "conversationList"
// @Router /api/v1/comment [get]
func (h *CommentHandler) GetCommentModeratedList(c echo.Context) error {
var req domain.CommentListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "bind request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
ctx := c.Request().Context()
commentList, err := h.usecase.GetCommentListByKbID(ctx, &req, consts.GetLicenseEdition(c))
if err != nil {
return h.NewResponseWithError(c, "failed to get comment list KBID", err)
}
return h.NewResponseWithData(c, commentList)
}
// DeleteCommentList
//
// @Summary DeleteCommentList
// @Description DeleteCommentList
// @Tags comment
// @Accept json
// @Produce json
// @Param req query domain.DeleteCommentListReq true "DeleteCommentListReq"
// @Success 200 {object} domain.Response "total"
// @Router /api/v1/comment/list [delete]
func (h *CommentHandler) DeleteCommentList(c echo.Context) error {
var req domain.DeleteCommentListReq
ids := c.QueryParams()["ids[]"]
if len(ids) == 0 {
return h.NewResponseWithError(c, "len comment id is zero", nil)
}
req.IDS = ids
ctx := c.Request().Context()
err := h.usecase.DeleteCommentList(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "failed to delete comment list", err)
}
// success
return h.NewResponseWithData(c, nil)
}

View File

@@ -0,0 +1,144 @@
package v1
import (
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/conversation/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type ConversationHandler struct {
*handler.BaseHandler
logger *log.Logger
auth middleware.AuthMiddleware
usecase *usecase.ConversationUsecase
}
func NewConversationHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *log.Logger, auth middleware.AuthMiddleware, usecase *usecase.ConversationUsecase) *ConversationHandler {
handler := &ConversationHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler_conversation"),
auth: auth,
usecase: usecase,
}
group := echo.Group("/api/v1/conversation", handler.auth.Authorize, handler.auth.ValidateKBUserPerm(consts.UserKBPermissionDataOperate))
group.GET("", handler.GetConversationList)
group.GET("/detail", handler.GetConversationDetail)
group.GET("/message/list", handler.GetMessageFeedBackList)
group.GET("/message/detail", handler.GetMessageDetail)
return handler
}
type ConversationListItems = domain.PaginatedResult[[]domain.ConversationListItem]
// GetConversationList
//
// @Summary get conversation list
// @Description get conversation list
// @Tags conversation
// @Accept json
// @Produce json
// @Param req query domain.ConversationListReq true "conversation list request"
// @Success 200 {object} domain.PWResponse{data=ConversationListItems}
// @Router /api/v1/conversation [get]
func (h *ConversationHandler) GetConversationList(c echo.Context) error {
var request domain.ConversationListReq
if err := c.Bind(&request); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
ctx := c.Request().Context()
conversationList, err := h.usecase.GetConversationList(ctx, &request)
if err != nil {
return h.NewResponseWithError(c, "failed to get conversation list", err)
}
return h.NewResponseWithData(c, conversationList)
}
// GetConversationDetail
//
// @Summary get conversation detail
// @Description get conversation detail
// @Tags conversation
// @Accept json
// @Produce json
// @Param param query v1.GetConversationDetailReq true "conversation id"
// @Success 200 {object} domain.PWResponse{data=domain.ConversationDetailResp}
// @Router /api/v1/conversation/detail [get]
func (h *ConversationHandler) GetConversationDetail(c echo.Context) error {
var req v1.GetConversationDetailReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
conversation, err := h.usecase.GetConversationDetail(c.Request().Context(), req.KbId, req.ID)
if err != nil {
return h.NewResponseWithError(c, "failed to get conversation detail", err)
}
return h.NewResponseWithData(c, conversation)
}
// GetMessageFeedBackList
//
// @Summary GetMessageFeedBackList
// @Description GetMessageFeedBackList
// @Tags Message
// @Accept json
// @Produce json
// @Param req query domain.MessageListReq true "message list request"
//
// @Success 200 {object} domain.PWResponse{data=domain.PaginatedResult[[]domain.ConversationMessageListItem]} "MessageList"
// @Router /api/v1/conversation/message/list [get]
func (h *ConversationHandler) GetMessageFeedBackList(c echo.Context) error {
var request domain.MessageListReq
if err := c.Bind(&request); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
h.logger.Info("GetMessageFeedBackList request", log.Any("request", request))
ctx := c.Request().Context()
messages, err := h.usecase.GetMessageList(ctx, &request)
if err != nil {
return h.NewResponseWithError(c, "failed to get message list", err)
}
return h.NewResponseWithData(c, messages)
}
// GetMessageDetail
//
// @Summary Get message detail
// @Description Get message detail
// @Tags Message
// @Accept json
// @Produce json
// @Param id query v1.GetMessageDetailReq true "message id"
// @Success 200 {object} domain.PWResponse{data=domain.ConversationMessage}
// @Router /api/v1/conversation/message/detail [get]
func (h *ConversationHandler) GetMessageDetail(c echo.Context) error {
var req v1.GetMessageDetailReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
message, err := h.usecase.GetMessageDetail(c.Request().Context(), req.KbId, req.ID)
if err != nil {
return h.NewResponseWithError(c, "failed to get message detail", err)
}
return h.NewResponseWithData(c, message)
}

View File

@@ -0,0 +1,164 @@
package v1
import (
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/crawler/v1"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type CrawlerHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.CrawlerUsecase
config *config.Config
fileUsecase *usecase.FileUsecase
}
func NewCrawlerHandler(echo *echo.Echo,
baseHandler *handler.BaseHandler,
auth middleware.AuthMiddleware,
logger *log.Logger,
config *config.Config,
usecase *usecase.CrawlerUsecase,
fileUsecase *usecase.FileUsecase,
) *CrawlerHandler {
h := &CrawlerHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.crawler"),
config: config,
usecase: usecase,
fileUsecase: fileUsecase,
}
group := echo.Group("/api/v1/crawler", auth.Authorize)
group.POST("/parse", h.CrawlerParse)
group.POST("/export", h.CrawlerExport)
group.GET("/result", h.CrawlerResult)
group.POST("/results", h.CrawlerResults)
return h
}
// CrawlerParse 解析文档树
//
// @Summary 解析文档树
// @Description 解析文档树
// @Tags crawler
// @Accept json
// @Produce json
// @Param body body v1.CrawlerParseReq true "Scrape"
// @Success 200 {object} domain.PWResponse{data=v1.CrawlerParseResp}
// @Router /api/v1/crawler/parse [post]
func (h *CrawlerHandler) CrawlerParse(c echo.Context) error {
var req v1.CrawlerParseReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
switch req.CrawlerSource {
case consts.CrawlerSourceFeishu:
if req.FeishuSetting.AppID == "" || req.FeishuSetting.AppSecret == "" || req.FeishuSetting.UserAccessToken == "" {
return h.NewResponseWithError(c, "validate request param feishu failed", nil)
}
case consts.CrawlerSourceDingtalk:
if req.DingtalkSetting.AppID == "" || req.DingtalkSetting.AppSecret == "" || (req.DingtalkSetting.UnionID == "" && req.DingtalkSetting.Phone == "") {
return h.NewResponseWithError(c, "validate request param dingtalk failed", nil)
}
default:
if req.Key == "" {
return h.NewResponseWithError(c, "validate request param key failed", nil)
}
}
resp, err := h.usecase.ParseUrl(c.Request().Context(), &req)
if err != nil {
h.logger.Error("scrape url failed", log.Error(err))
return h.NewResponseWithError(c, "scrape url failed", err)
}
return h.NewResponseWithData(c, resp)
}
// CrawlerExport
//
// @Summary CrawlerExport
// @Description CrawlerExport
// @Tags crawler
// @Accept json
// @Produce json
// @Param body body v1.CrawlerExportReq true "Scrape"
// @Success 200 {object} domain.PWResponse{data=v1.CrawlerExportResp}
// @Router /api/v1/crawler/export [post]
func (h *CrawlerHandler) CrawlerExport(c echo.Context) error {
var req v1.CrawlerExportReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
resp, err := h.usecase.ExportDoc(c.Request().Context(), &req)
if err != nil {
return h.NewResponseWithError(c, "scrape url failed", err)
}
return h.NewResponseWithData(c, resp)
}
// CrawlerResult
//
// @Summary Get Crawler Result
// @Description Retrieve the result of a previously started scraping task
// @Tags crawler
// @Accept json
// @Produce json
// @Param body body v1.CrawlerResultReq true "Crawler Result Request"
// @Success 200 {object} domain.PWResponse{data=v1.CrawlerResultResp}
// @Router /api/v1/crawler/result [get]
func (h *CrawlerHandler) CrawlerResult(c echo.Context) error {
var req v1.CrawlerResultReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
resp, err := h.usecase.ScrapeGetResult(c.Request().Context(), req.TaskId)
if err != nil {
h.logger.Error("get scrape result failed", log.Error(err))
return h.NewResponseWithError(c, "get scrape result failed", err)
}
return h.NewResponseWithData(c, resp)
}
// CrawlerResults
//
// @Summary Get Crawler Results
// @Description Retrieve the results of a previously started scraping task
// @Tags crawler
// @Accept json
// @Produce json
// @Param param body v1.CrawlerResultsReq true "Crawler Results Request"
// @Success 200 {object} domain.PWResponse{data=v1.CrawlerResultsResp}
// @Router /api/v1/crawler/results [post]
func (h *CrawlerHandler) CrawlerResults(c echo.Context) error {
var req v1.CrawlerResultsReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
resp, err := h.usecase.ScrapeGetResults(c.Request().Context(), req.TaskIds)
if err != nil {
h.logger.Error("get scrape results failed", log.Error(err))
return h.NewResponseWithError(c, "get scrape results failed", err)
}
return h.NewResponseWithData(c, resp)
}

View File

@@ -0,0 +1,103 @@
package v1
import (
"context"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
)
type CreationHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.CreationUsecase
}
func NewCreationHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *log.Logger, usecase *usecase.CreationUsecase) *CreationHandler {
h := &CreationHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.creation"),
usecase: usecase,
}
api := echo.Group("/api/v1/creation", h.V1Auth.Authorize)
api.POST("/text", h.Text)
api.POST("/tab-complete", h.TabComplete)
return h
}
// Text text creation
//
// @Summary Text creation
// @Description Text creation
// @Tags creation
// @Accept json
// @Produce json
// @Param body body domain.TextReq true "text creation request"
// @Success 200 {string} string "success"
// @Router /api/v1/creation/text [post]
func (h *CreationHandler) Text(c echo.Context) error {
var req domain.TextReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().Header().Set("Transfer-Encoding", "chunked")
onChunk := func(ctx context.Context, dataType, chunk string) error {
if _, err := c.Response().Write([]byte(chunk)); err != nil {
return err
}
c.Response().Flush()
return nil
}
err := h.usecase.TextCreation(c.Request().Context(), &req, onChunk)
if err != nil {
h.logger.Error("text creation failed", log.Error(err))
return h.NewResponseWithError(c, "text creation failed", err)
}
return nil
}
// TabComplete handles tab-based document completion similar to AI coding's FIM (Fill in Middle)
//
// @Summary Tab-based document completion
// @Description Tab-based document completion similar to AI coding's FIM (Fill in Middle)
// @Tags creation
// @Accept json
// @Produce json
// @Param body body domain.CompleteReq true "tab completion request"
// @Success 200 {string} string "success"
// @Router /api/v1/creation/tab-complete [post]
func (h *CreationHandler) TabComplete(c echo.Context) error {
var req domain.CompleteReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
// For FIM-style completion, we don't need streaming
result, err := h.usecase.TabComplete(c.Request().Context(), &req)
if err != nil {
h.logger.Error("tab completion failed", log.Error(err))
return h.NewResponseWithError(c, "tab completion failed", err)
}
return c.JSON(200, map[string]interface{}{
"success": true,
"data": result,
})
}

159
backend/handler/v1/file.go Normal file
View File

@@ -0,0 +1,159 @@
package v1
import (
"fmt"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/store/s3"
"github.com/chaitin/panda-wiki/usecase"
"github.com/chaitin/panda-wiki/utils"
)
type FileHandler struct {
*handler.BaseHandler
logger *log.Logger
auth middleware.AuthMiddleware
config *config.Config
fileUsecase *usecase.FileUsecase
}
func NewFileHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *log.Logger, auth middleware.AuthMiddleware, minioClient *s3.MinioClient, config *config.Config, fileUsecase *usecase.FileUsecase) *FileHandler {
h := &FileHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.file"),
auth: auth,
config: config,
fileUsecase: fileUsecase,
}
group := echo.Group("/api/v1/file")
group.POST("/upload", h.Upload, h.auth.Authorize)
group.POST("/upload/url", h.UploadByUrl, h.auth.Authorize)
group.POST("/upload/anydoc", h.UploadAnydoc)
return h
}
// Upload
//
// @Summary Upload File
// @Description Upload File
// @Tags file
// @Accept multipart/form-data
// @Param file formData file true "File"
// @Param kb_id formData string false "Knowledge Base ID"
// @Success 200 {object} domain.ObjectUploadResp
// @Router /api/v1/file/upload [post]
func (h *FileHandler) Upload(c echo.Context) error {
cxt := c.Request().Context()
kbID := c.FormValue("kb_id")
if kbID == "" {
kbID = uuid.New().String()
}
file, err := c.FormFile("file")
if err != nil {
return h.NewResponseWithError(c, "failed to get file", err)
}
key, err := h.fileUsecase.UploadFile(cxt, kbID, file)
if err != nil {
return h.NewResponseWithError(c, "upload failed", err)
}
return h.NewResponseWithData(c, domain.ObjectUploadResp{
Key: key,
Filename: file.Filename,
})
}
// UploadByUrl
//
// @Summary Upload File By Url
// @Description Upload File By Url
// @Tags file
// @Accept json
// @Produce json
// @Param body body domain.UploadByUrlReq true "Request Body"
// @Success 200 {object} domain.Response{data=domain.ObjectUploadResp}
// @Router /api/v1/file/upload/url [post]
func (h *FileHandler) UploadByUrl(c echo.Context) error {
ctx := c.Request().Context()
var req domain.UploadByUrlReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
kbID := req.KbId
if kbID == "" {
kbID = uuid.New().String()
}
key, err := h.fileUsecase.UploadFileByUrl(ctx, kbID, req.Url)
if err != nil {
return h.NewResponseWithError(c, "upload failed", err)
}
return h.NewResponseWithData(c, domain.ObjectUploadResp{
Key: key,
})
}
// UploadAnydoc
//
// @Summary Upload Anydoc File
// @Description Upload Anydoc File
// @Tags file
// @Accept multipart/form-data
// @Param file formData file true "File"
// @Param path formData string true "File Path"
// @Success 200 {object} domain.AnydocUploadResp
// @Router /api/v1/file/upload/anydoc [post]
func (h *FileHandler) UploadAnydoc(c echo.Context) error {
clientIP := fmt.Sprintf("%s.17", h.config.SubnetPrefix)
if utils.GetClientIPFromRemoteAddr(c) != clientIP {
return c.JSON(http.StatusUnauthorized, domain.AnydocUploadResp{
Code: 1,
Err: "invalid required",
})
}
file, err := c.FormFile("file")
if err != nil {
return c.JSON(http.StatusBadRequest, domain.AnydocUploadResp{
Code: 1,
Err: "invalid required",
})
}
path := c.FormValue("path")
if path == "" {
return c.JSON(http.StatusBadRequest, domain.AnydocUploadResp{
Code: 1,
Err: "invalid required",
})
}
h.logger.Debug("AnydocUpload file", "path", path)
_, err = h.fileUsecase.AnyDocUploadFile(c.Request().Context(), file, path)
if err != nil {
return h.NewResponseWithError(c, "upload failed", err)
}
url := fmt.Sprintf("/static-file/%s", strings.TrimPrefix(path, "/"))
h.logger.Debug("AnydocUpload file", "path", url)
return c.JSON(http.StatusOK, domain.AnydocUploadResp{
Code: 0,
Data: url,
})
}

View File

@@ -0,0 +1,130 @@
package v1
import (
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/kb/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
)
// KBUserList
//
// @Summary KBUserList
// @Description KBUserList
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param kb_id query string true "Knowledge Base ID"
// @Success 200 {object} domain.PWResponse{data=[]v1.KBUserListItemResp}
// @Router /api/v1/knowledge_base/user/list [get]
func (h *KnowledgeBaseHandler) KBUserList(c echo.Context) error {
var req v1.KBUserListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
resp, err := h.usecase.GetKBUserList(c.Request().Context(), req)
if err != nil {
return h.NewResponseWithError(c, "get kb user list failed", err)
}
return h.NewResponseWithData(c, resp)
}
// KBUserInvite
//
// @Summary KBUserInvite
// @Description Invite user to knowledge base
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param body v1.KBUserInviteReq true "Invite User Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base/user/invite [post]
func (h *KnowledgeBaseHandler) KBUserInvite(c echo.Context) error {
var req v1.KBUserInviteReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
if !domain.GetBaseEditionLimitation(c.Request().Context()).AllowAdminPerm && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "当前版本不支持管理员分权控制", nil)
}
err := h.usecase.KBUserInvite(c.Request().Context(), req)
if err != nil {
return h.NewResponseWithError(c, "invite user to kb failed", err)
}
return h.NewResponseWithData(c, nil)
}
// KBUserUpdate
//
// @Summary KBUserUpdate
// @Description Update user permission in knowledge base
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param body v1.KBUserUpdateReq true "Update User Permission Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base/user/update [patch]
func (h *KnowledgeBaseHandler) KBUserUpdate(c echo.Context) error {
var req v1.KBUserUpdateReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
if !domain.GetBaseEditionLimitation(c.Request().Context()).AllowAdminPerm && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "当前版本不支持管理员分权控制", nil)
}
err := h.usecase.UpdateUserKB(c.Request().Context(), req)
if err != nil {
return h.NewResponseWithError(c, "update user kb permission failed", err)
}
return h.NewResponseWithData(c, nil)
}
// KBUserDelete
//
// @Summary KBUserDelete
// @Description Remove user from knowledge base
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param query v1.KBUserDeleteReq true "Remove User Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base/user/delete [delete]
func (h *KnowledgeBaseHandler) KBUserDelete(c echo.Context) error {
var req v1.KBUserDeleteReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
err := h.usecase.KBUserDelete(c.Request().Context(), req)
if err != nil {
return h.NewResponseWithError(c, "remove user from kb failed", err)
}
return h.NewResponseWithData(c, nil)
}

View File

@@ -0,0 +1,292 @@
package v1
import (
"errors"
"github.com/labstack/echo/v4"
"github.com/samber/lo"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type KnowledgeBaseHandler struct {
*handler.BaseHandler
usecase *usecase.KnowledgeBaseUsecase
llmUsecase *usecase.LLMUsecase
logger *log.Logger
auth middleware.AuthMiddleware
}
func NewKnowledgeBaseHandler(
baseHandler *handler.BaseHandler,
echo *echo.Echo,
usecase *usecase.KnowledgeBaseUsecase,
llmUsecase *usecase.LLMUsecase,
auth middleware.AuthMiddleware,
logger *log.Logger,
) *KnowledgeBaseHandler {
h := &KnowledgeBaseHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.knowledge_base"),
usecase: usecase,
llmUsecase: llmUsecase,
auth: auth,
}
group := echo.Group("/api/v1/knowledge_base", h.auth.Authorize)
group.POST("", h.CreateKnowledgeBase, h.auth.ValidateUserRole(consts.UserRoleAdmin))
group.GET("/list", h.GetKnowledgeBaseList)
group.GET("/detail", h.GetKnowledgeBaseDetail, h.auth.ValidateKBUserPerm(consts.UserKBPermissionNotNull))
group.PUT("/detail", h.UpdateKnowledgeBase, h.auth.ValidateKBUserPerm(consts.UserKBPermissionFullControl))
group.DELETE("/detail", h.DeleteKnowledgeBase, h.auth.ValidateUserRole(consts.UserRoleAdmin))
// user management
userGroup := group.Group("/user", h.auth.ValidateKBUserPerm(consts.UserKBPermissionFullControl))
userGroup.GET("/list", h.KBUserList)
userGroup.POST("/invite", h.KBUserInvite)
userGroup.PATCH("/update", h.KBUserUpdate)
userGroup.DELETE("/delete", h.KBUserDelete)
// release
releaseGroup := group.Group("/release", h.auth.ValidateKBUserPerm(consts.UserKBPermissionDocManage))
releaseGroup.POST("", h.CreateKBRelease)
releaseGroup.GET("/list", h.GetKBReleaseList)
return h
}
// CreateKnowledgeBase
//
// @Summary CreateKnowledgeBase
// @Description CreateKnowledgeBase
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Param body body domain.CreateKnowledgeBaseReq true "CreateKnowledgeBase Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base [post]
func (h *KnowledgeBaseHandler) CreateKnowledgeBase(c echo.Context) error {
var req domain.CreateKnowledgeBaseReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
req.Hosts = lo.Uniq(req.Hosts)
req.Ports = lo.Uniq(req.Ports)
req.SSLPorts = lo.Uniq(req.SSLPorts)
if len(req.Hosts) == 0 {
return h.NewResponseWithError(c, "hosts is required", nil)
}
if len(req.Ports)+len(req.SSLPorts) == 0 {
return h.NewResponseWithError(c, "ports is required", nil)
}
req.MaxKB = domain.GetBaseEditionLimitation(c.Request().Context()).MaxKb
did, err := h.usecase.CreateKnowledgeBase(c.Request().Context(), &req)
if err != nil {
if errors.Is(err, domain.ErrPortHostAlreadyExists) {
return h.NewResponseWithError(c, "端口或域名已被其他知识库占用", nil)
}
if errors.Is(err, domain.ErrSyncCaddyConfigFailed) {
return h.NewResponseWithError(c, "保存配置失败,请检查端口或证书配置", nil)
}
return h.NewResponseWithError(c, "failed to create knowledge base", err)
}
return h.NewResponseWithData(c, map[string]string{
"id": did,
})
}
// GetKnowledgeBaseList
//
// @Summary GetKnowledgeBaseList
// @Description GetKnowledgeBaseList
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Success 200 {object} domain.PWResponse{data=[]domain.KnowledgeBaseListItem}
// @Router /api/v1/knowledge_base/list [get]
func (h *KnowledgeBaseHandler) GetKnowledgeBaseList(c echo.Context) error {
knowledgeBases, err := h.usecase.GetKnowledgeBaseListByUserId(c.Request().Context())
if err != nil {
return h.NewResponseWithError(c, "failed to get knowledge base list", err)
}
return h.NewResponseWithData(c, knowledgeBases)
}
// UpdateKnowledgeBase
//
// @Summary UpdateKnowledgeBase
// @Description UpdateKnowledgeBase
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Param body body domain.UpdateKnowledgeBaseReq true "UpdateKnowledgeBase Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base/detail [put]
func (h *KnowledgeBaseHandler) UpdateKnowledgeBase(c echo.Context) error {
var req domain.UpdateKnowledgeBaseReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
err := h.usecase.UpdateKnowledgeBase(c.Request().Context(), &req)
if err != nil {
if errors.Is(err, domain.ErrPortHostAlreadyExists) {
return h.NewResponseWithError(c, "端口或域名已被其他知识库占用", nil)
}
if errors.Is(err, domain.ErrSyncCaddyConfigFailed) {
return h.NewResponseWithError(c, "保存配置失败,请检查端口或证书配置", nil)
}
return h.NewResponseWithError(c, "failed to update knowledge base", err)
}
return h.NewResponseWithData(c, nil)
}
// GetKnowledgeBaseDetail
//
// @Summary GetKnowledgeBaseDetail
// @Description GetKnowledgeBaseDetail
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param id query string true "Knowledge Base ID"
// @Success 200 {object} domain.PWResponse{data=domain.KnowledgeBaseDetail}
// @Router /api/v1/knowledge_base/detail [get]
func (h *KnowledgeBaseHandler) GetKnowledgeBaseDetail(c echo.Context) error {
kbID := c.QueryParam("id")
if kbID == "" {
return h.NewResponseWithError(c, "kb id is required", nil)
}
kb, err := h.usecase.GetKnowledgeBase(c.Request().Context(), kbID)
if err != nil {
return h.NewResponseWithError(c, "failed to get knowledge base detail", err)
}
perm, err := h.usecase.GetKnowledgeBasePerm(c.Request().Context(), kbID)
if err != nil {
return h.NewResponseWithError(c, "failed to get knowledge base permission", err)
}
if perm != consts.UserKBPermissionFullControl {
kb.AccessSettings.PrivateKey = ""
kb.AccessSettings.PublicKey = ""
}
return h.NewResponseWithData(c, &domain.KnowledgeBaseDetail{
ID: kb.ID,
Name: kb.Name,
DatasetID: kb.DatasetID,
Perm: perm,
AccessSettings: kb.AccessSettings,
CreatedAt: kb.CreatedAt,
UpdatedAt: kb.UpdatedAt,
})
}
// DeleteKnowledgeBase
//
// @Summary DeleteKnowledgeBase
// @Description DeleteKnowledgeBase
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Param id query string true "Knowledge Base ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base/detail [delete]
func (h *KnowledgeBaseHandler) DeleteKnowledgeBase(c echo.Context) error {
kbID := c.QueryParam("id")
if kbID == "" {
return h.NewResponseWithError(c, "kb id is required", nil)
}
err := h.usecase.DeleteKnowledgeBase(c.Request().Context(), kbID)
if err != nil {
return h.NewResponseWithError(c, "failed to delete knowledge base", err)
}
return h.NewResponseWithData(c, nil)
}
// CreateKBRelease
//
// @Summary CreateKBRelease
// @Description CreateKBRelease
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Param body body domain.CreateKBReleaseReq true "CreateKBRelease Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base/release [post]
func (h *KnowledgeBaseHandler) CreateKBRelease(c echo.Context) error {
ctx := c.Request().Context()
authInfo := domain.GetAuthInfoFromCtx(ctx)
if authInfo == nil {
return h.NewResponseWithError(c, "authInfo not found in context", nil)
}
req := &domain.CreateKBReleaseReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
id, err := h.usecase.CreateKBRelease(ctx, req, authInfo.UserId)
if err != nil {
return h.NewResponseWithError(c, "create kb release failed", err)
}
return h.NewResponseWithData(c, map[string]any{
"id": id,
})
}
// GetKBReleaseList
//
// @Summary GetKBReleaseList
// @Description GetKBReleaseList
// @Tags knowledge_base
// @Accept json
// @Produce json
// @Param kb_id query string true "Knowledge Base ID"
// @Success 200 {object} domain.PWResponse{data=domain.GetKBReleaseListResp}
// @Router /api/v1/knowledge_base/release/list [get]
func (h *KnowledgeBaseHandler) GetKBReleaseList(c echo.Context) error {
var req domain.GetKBReleaseListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
resp, err := h.usecase.GetKBReleaseList(c.Request().Context(), &req)
if err != nil {
return h.NewResponseWithError(c, "get kb release list failed", err)
}
return h.NewResponseWithData(c, resp)
}

269
backend/handler/v1/model.go Normal file
View File

@@ -0,0 +1,269 @@
package v1
import (
"github.com/google/uuid"
"github.com/labstack/echo/v4"
modelkitDomain "github.com/chaitin/ModelKit/v2/domain"
modelkit "github.com/chaitin/ModelKit/v2/usecase"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type ModelHandler struct {
*handler.BaseHandler
logger *log.Logger
auth middleware.AuthMiddleware
usecase *usecase.ModelUsecase
llmUsecase *usecase.LLMUsecase
modelkit *modelkit.ModelKit
}
func NewModelHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *log.Logger, auth middleware.AuthMiddleware, usecase *usecase.ModelUsecase, llmUsecase *usecase.LLMUsecase) *ModelHandler {
modelkit := modelkit.NewModelKit(logger.Logger)
handler := &ModelHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.model"),
auth: auth,
usecase: usecase,
llmUsecase: llmUsecase,
modelkit: modelkit,
}
group := echo.Group("/api/v1/model", handler.auth.Authorize, handler.auth.ValidateUserRole(consts.UserRoleAdmin))
group.GET("/list", handler.GetModelList)
group.POST("", handler.CreateModel)
group.POST("/check", handler.CheckModel)
group.POST("/provider/supported", handler.GetProviderSupportedModelList)
group.PUT("", handler.UpdateModel)
group.POST("/switch-mode", handler.SwitchMode)
group.GET("/mode-setting", handler.GetModelModeSetting)
return handler
}
// GetModelList
//
// @Summary get model list
// @Description get model list
// @Tags model
// @Accept json
// @Produce json
// @Success 200 {object} domain.PWResponse{data=domain.ModelListItem}
// @Router /api/v1/model/list [get]
func (h *ModelHandler) GetModelList(c echo.Context) error {
ctx := c.Request().Context()
models, err := h.usecase.GetList(ctx)
if err != nil {
return h.NewResponseWithError(c, "get model list failed", err)
}
return h.NewResponseWithData(c, models)
}
// CreateModel
//
// @Summary create model
// @Description create model
// @Tags model
// @Accept json
// @Produce json
// @Param model body domain.CreateModelReq true "create model request"
// @Success 200 {object} domain.Response
// @Router /api/v1/model [post]
func (h *ModelHandler) CreateModel(c echo.Context) error {
var req domain.CreateModelReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
ctx := c.Request().Context()
param := domain.ModelParam{}
if req.Parameters != nil {
param = *req.Parameters
}
model := &domain.Model{
ID: uuid.New().String(),
Provider: req.Provider,
Model: req.Model,
APIKey: req.APIKey,
APIHeader: req.APIHeader,
BaseURL: req.BaseURL,
APIVersion: req.APIVersion,
Type: req.Type,
IsActive: true,
Parameters: param,
}
if err := h.usecase.Create(ctx, model); err != nil {
return h.NewResponseWithError(c, "create model failed", err)
}
return h.NewResponseWithData(c, model)
}
// UpdateModel
//
// @Description update model
// @Tags model
// @Accept json
// @Produce json
// @Param model body domain.UpdateModelReq true "update model request"
// @Success 200 {object} domain.Response
// @Router /api/v1/model [put]
func (h *ModelHandler) UpdateModel(c echo.Context) error {
var req domain.UpdateModelReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
// 不支持修改非视觉模型的启用状态
if req.IsActive != nil && req.Type != domain.ModelTypeAnalysisVL {
return h.NewResponseWithError(c, "仅支持修改视觉模型的启用状态", nil)
}
ctx := c.Request().Context()
if err := h.usecase.Update(ctx, &req); err != nil {
return h.NewResponseWithError(c, "update model failed", err)
}
return h.NewResponseWithData(c, nil)
}
// CheckModel
//
// @Summary check model
// @Description check model
// @Tags model
// @Accept json
// @Produce json
// @Param model body domain.CheckModelReq true "check model request"
// @Success 200 {object} domain.Response{data=domain.CheckModelResp}
// @Router /api/v1/model/check [post]
func (h *ModelHandler) CheckModel(c echo.Context) error {
var req domain.CheckModelReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
ctx := c.Request().Context()
modelType := req.Type
switch req.Type {
case domain.ModelTypeAnalysis, domain.ModelTypeAnalysisVL: // for rag analysis
modelType = domain.ModelTypeChat
default:
}
model, err := h.modelkit.CheckModel(ctx, &modelkitDomain.CheckModelReq{
Provider: string(req.Provider),
Model: req.Model,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
APIHeader: req.APIHeader,
APIVersion: req.APIVersion,
Type: string(modelType),
Param: (*modelkitDomain.ModelParam)(req.Parameters),
})
if err != nil {
return h.NewResponseWithError(c, "get model failed", err)
}
return h.NewResponseWithData(c, model)
}
// GetProviderSupportedModelList
//
// @Summary get provider supported model list
// @Description get provider supported model list
// @Tags model
// @Accept json
// @Produce json
// @Param params body domain.GetProviderModelListReq true "get supported model list request"
// @Success 200 {object} domain.PWResponse{data=domain.GetProviderModelListResp}
// @Router /api/v1/model/provider/supported [post]
func (h *ModelHandler) GetProviderSupportedModelList(c echo.Context) error {
var req domain.GetProviderModelListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
models, err := h.modelkit.ModelList(ctx, &modelkitDomain.ModelListReq{
Provider: req.Provider,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
APIHeader: req.APIHeader,
Type: string(req.Type),
})
if err != nil {
return h.NewResponseWithError(c, "get user model list failed", err)
}
return h.NewResponseWithData(c, models)
}
// SwitchMode
//
// @Summary switch mode
// @Description switch model mode between manual and auto
// @Tags model
// @Accept json
// @Produce json
// @Param request body domain.SwitchModeReq true "switch mode request"
// @Success 200 {object} domain.Response{data=domain.SwitchModeResp}
// @Router /api/v1/model/switch-mode [post]
func (h *ModelHandler) SwitchMode(c echo.Context) error {
var req domain.SwitchModeReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "bind request failed", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
if err := h.usecase.SwitchMode(ctx, &req); err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
resp := &domain.SwitchModeResp{
Message: "模式切换成功",
}
return h.NewResponseWithData(c, resp)
}
// GetModelModeSetting
//
// @Summary get model mode setting
// @Description get current model mode setting including mode, API key and chat model
// @Tags model
// @Accept json
// @Produce json
// @Success 200 {object} domain.Response{data=domain.ModelModeSetting}
// @Router /api/v1/model/mode-setting [get]
func (h *ModelHandler) GetModelModeSetting(c echo.Context) error {
ctx := c.Request().Context()
setting, err := h.usecase.GetModelModeSetting(ctx)
if err != nil {
// 如果获取失败,返回默认值(手动模式)
h.logger.Warn("failed to get model mode setting, return default", log.Error(err))
defaultSetting := domain.ModelModeSetting{
Mode: consts.ModelSettingModeManual,
AutoModeAPIKey: "",
ChatModel: "",
}
return h.NewResponseWithData(c, defaultSetting)
}
return h.NewResponseWithData(c, setting)
}

182
backend/handler/v1/nav.go Normal file
View File

@@ -0,0 +1,182 @@
package v1
import (
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/nav/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type NavHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.NavUsecase
auth middleware.AuthMiddleware
}
func NewNavHandler(
baseHandler *handler.BaseHandler,
echo *echo.Echo,
usecase *usecase.NavUsecase,
auth middleware.AuthMiddleware,
logger *log.Logger,
) *NavHandler {
h := &NavHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.nav"),
usecase: usecase,
auth: auth,
}
group := echo.Group("/api/v1/nav", h.auth.Authorize, h.auth.ValidateKBUserPerm(consts.UserKBPermissionDocManage))
group.GET("/list", h.NavList)
group.POST("/add", h.NavAdd)
group.DELETE("/delete", h.NavDelete)
group.PATCH("/update", h.NavUpdate)
group.POST("/move", h.NavMove)
return h
}
// NavList
//
// @Summary 获取分栏列表
// @Description Get Nav List
// @Tags Nav
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param params query v1.NavListReq true "Params"
// @Success 200 {object} domain.PWResponse{data=[]v1.NavListResp}
// @Router /api/v1/nav/list [get]
func (h *NavHandler) NavList(c echo.Context) error {
ctx := c.Request().Context()
var req v1.NavListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
nodes, err := h.usecase.GetList(ctx, req.KbId)
if err != nil {
return h.NewResponseWithError(c, "get nav list failed", err)
}
return h.NewResponseWithData(c, nodes)
}
// NavAdd
//
// @Summary 添加分栏
// @Description Add Nav
// @Tags Nav
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body v1.NavAddReq true "Params"
// @Success 200 {object} domain.PWResponse
// @Router /api/v1/nav/add [post]
func (h *NavHandler) NavAdd(c echo.Context) error {
ctx := c.Request().Context()
var req v1.NavAddReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.usecase.Add(ctx, &req); err != nil {
return h.NewResponseWithError(c, "add nav failed", err)
}
return h.NewResponseWithData(c, nil)
}
// NavDelete
//
// @Summary 删除栏目
// @Description DeleteNav Nav
// @Tags Nav
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param query query v1.NavDeleteReq true "Params"
// @Success 200 {object} domain.PWResponse
// @Router /api/v1/nav/delete [delete]
func (h *NavHandler) NavDelete(c echo.Context) error {
ctx := c.Request().Context()
var req v1.NavDeleteReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.usecase.Delete(ctx, &req); err != nil {
return h.NewResponseWithError(c, "delete nav failed", err)
}
return h.NewResponseWithData(c, nil)
}
// NavMove
//
// @Summary 移动栏目
// @Description Move Nav
// @Tags Nav
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body v1.NavMoveReq true "Params"
// @Success 200 {object} domain.PWResponse
// @Router /api/v1/nav/move [post]
func (h *NavHandler) NavMove(c echo.Context) error {
ctx := c.Request().Context()
var req v1.NavMoveReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.usecase.Move(ctx, &req); err != nil {
return h.NewResponseWithError(c, "move nav failed", err)
}
return h.NewResponseWithData(c, nil)
}
// NavUpdate
//
// @Summary 更新栏目信息
// @Description Update Nav
// @Tags Nav
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body v1.NavUpdateReq true "Params"
// @Success 200 {object} domain.PWResponse
// @Router /api/v1/nav/update [patch]
func (h *NavHandler) NavUpdate(c echo.Context) error {
ctx := c.Request().Context()
var req v1.NavUpdateReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.usecase.Update(ctx, &req); err != nil {
return h.NewResponseWithError(c, "update nav failed", err)
}
return h.NewResponseWithData(c, nil)
}

565
backend/handler/v1/node.go Normal file
View File

@@ -0,0 +1,565 @@
package v1
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/node/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type NodeHandler struct {
*handler.BaseHandler
logger *log.Logger
usecase *usecase.NodeUsecase
auth middleware.AuthMiddleware
}
func NewNodeHandler(
baseHandler *handler.BaseHandler,
echo *echo.Echo,
usecase *usecase.NodeUsecase,
auth middleware.AuthMiddleware,
logger *log.Logger,
) *NodeHandler {
h := &NodeHandler{
BaseHandler: baseHandler,
logger: logger.WithModule("handler.v1.node"),
usecase: usecase,
auth: auth,
}
group := echo.Group("/api/v1/node", h.auth.Authorize, h.auth.ValidateKBUserPerm(consts.UserKBPermissionDocManage))
group.GET("/list", h.GetNodeList)
group.GET("/list/group/nav", h.NodeListGroupNav)
group.GET("/stats", h.NodeStats)
group.POST("", h.CreateNode)
group.GET("/detail", h.GetNodeDetail)
group.PUT("/detail", h.UpdateNodeDetail)
group.POST("/summary", h.SummaryNode)
group.POST("/summary/stream", h.SummaryNodeStream)
group.POST("/action", h.NodeAction)
group.POST("/move", h.MoveNode)
group.POST("/move/nav", h.NodeMoveNav)
group.POST("/batch_move", h.BatchMoveNode)
group.GET("/recommend_nodes", h.RecommendNodes)
group.POST("/restudy", h.NodeRestudy)
// node permission
group.GET("/permission", h.NodePermission)
group.PATCH("/permission/edit", h.NodePermissionEdit)
return h
}
// CreateNode
//
// @Summary Create Node
// @Description Create Node
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body domain.CreateNodeReq true "Node"
// @Success 200 {object} domain.PWResponse{data=map[string]string}
// @Router /api/v1/node [post]
func (h *NodeHandler) CreateNode(c echo.Context) error {
ctx := c.Request().Context()
authInfo := domain.GetAuthInfoFromCtx(ctx)
if authInfo == nil {
return h.NewResponseWithError(c, "authInfo not found in context", nil)
}
req := &domain.CreateNodeReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
req.MaxNode = domain.GetBaseEditionLimitation(ctx).MaxNode
id, err := h.usecase.Create(c.Request().Context(), req, authInfo.UserId)
if err != nil {
if errors.Is(err, domain.ErrMaxNodeLimitReached) {
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到更高版本", nil)
}
return h.NewResponseWithError(c, "create node failed", err)
}
return h.NewResponseWithData(c, map[string]any{
"id": id,
})
}
// NodeStats
//
// @Summary Get Node Statistics
// @Description Get Node Statistics
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param kb_id query v1.NodeStatsReq true "Knowledge Base ID"
// @Success 200 {object} domain.PWResponse{data=v1.NodeStatsResp}
// @Router /api/v1/node/stats [get]
func (h *NodeHandler) NodeStats(c echo.Context) error {
var req v1.NodeStatsReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
ctx := c.Request().Context()
stats, err := h.usecase.GetNodeStats(ctx, req.KbId)
if err != nil {
return h.NewResponseWithError(c, "get node stats failed", err)
}
return h.NewResponseWithData(c, stats)
}
// GetNodeList
//
// @Summary Get Node List
// @Description Get Node List
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param params query domain.GetNodeListReq true "Params"
// @Success 200 {object} domain.PWResponse{data=[]domain.NodeListItemResp}
// @Router /api/v1/node/list [get]
func (h *NodeHandler) GetNodeList(c echo.Context) error {
var req domain.GetNodeListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
ctx := c.Request().Context()
nodes, err := h.usecase.GetList(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "get node list failed", err)
}
return h.NewResponseWithData(c, nodes)
}
// NodeListGroupNav
//
// @Summary Get Node List Grouped by Nav
// @Description Get unpublished or unstudied document list grouped by nav
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param params query v1.NodeListGroupNavReq true "Params"
// @Success 200 {object} domain.PWResponse{data=[]v1.NodeListGroupNavResp}
// @Router /api/v1/node/list/group/nav [get]
func (h *NodeHandler) NodeListGroupNav(c echo.Context) error {
var req v1.NodeListGroupNavReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
ctx := c.Request().Context()
result, err := h.usecase.GetNodeListGroupByNav(ctx, req)
if err != nil {
return h.NewResponseWithError(c, "get node list group by nav failed", err)
}
return h.NewResponseWithData(c, result)
}
// GetNodeDetail
//
// @Summary Get Node Detail
// @Description Get Node Detail
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param query v1.GetNodeDetailReq true "conversation id"
// @Success 200 {object} domain.PWResponse{data=v1.NodeDetailResp}
// @Router /api/v1/node/detail [get]
func (h *NodeHandler) GetNodeDetail(c echo.Context) error {
var req v1.GetNodeDetailReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
node, err := h.usecase.GetNodeByKBID(c.Request().Context(), req.ID, req.KbId, req.Format)
if err != nil {
h.logger.Error("get node by kb id failed", log.Error(err))
return h.NewResponseWithError(c, "get node detail failed", err)
}
return h.NewResponseWithData(c, node)
}
// NodeAction
//
// @Summary Node Action
// @Description Node Action
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param action body domain.NodeActionReq true "Action"
// @Success 200 {object} domain.PWResponse{data=map[string]string}
// @Router /api/v1/node/action [post]
func (h *NodeHandler) NodeAction(c echo.Context) error {
req := &domain.NodeActionReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
ctx := c.Request().Context()
if err := h.usecase.NodeAction(ctx, req); err != nil {
return h.NewResponseWithError(c, "node action failed", err)
}
return h.NewResponseWithData(c, nil)
}
// UpdateNodeDetail
//
// @Summary Update Node Detail
// @Description Update Node Detail
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body domain.UpdateNodeReq true "Node"
// @Success 200 {object} domain.Response
// @Router /api/v1/node/detail [put]
func (h *NodeHandler) UpdateNodeDetail(c echo.Context) error {
ctx := c.Request().Context()
authInfo := domain.GetAuthInfoFromCtx(ctx)
if authInfo == nil {
return h.NewResponseWithError(c, "authInfo not found in context", nil)
}
req := &domain.UpdateNodeReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
if err := h.usecase.Update(ctx, req, authInfo.UserId); err != nil {
return h.NewResponseWithError(c, "update node detail failed", err)
}
return h.NewResponseWithData(c, nil)
}
// MoveNode
//
// @Summary Move Node
// @Description Move Node
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body domain.MoveNodeReq true "Move Node"
// @Success 200 {object} domain.Response
// @Router /api/v1/node/move [post]
func (h *NodeHandler) MoveNode(c echo.Context) error {
req := &domain.MoveNodeReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
ctx := c.Request().Context()
if err := h.usecase.MoveNode(ctx, req); err != nil {
return h.NewResponseWithError(c, "move node failed", err)
}
return h.NewResponseWithData(c, nil)
}
// NodeMoveNav
//
// @Summary Move Node to Nav
// @Description Move node (and all its descendants if folder) to a different nav
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body v1.NodeMoveNavReq true "Move Node Nav"
// @Success 200 {object} domain.Response
// @Router /api/v1/node/move/nav [post]
func (h *NodeHandler) NodeMoveNav(c echo.Context) error {
req := &v1.NodeMoveNavReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
ctx := c.Request().Context()
if err := h.usecase.MoveNodeNav(ctx, req); err != nil {
return h.NewResponseWithError(c, "move node nav failed", err)
}
return h.NewResponseWithData(c, nil)
}
// SummaryNode
//
// @Summary Summary Node 异步后台生成
// @Description Summary Node
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body domain.NodeSummaryReq true "Summary Node"
// @Success 200 {object} domain.Response
// @Router /api/v1/node/summary [post]
func (h *NodeHandler) SummaryNode(c echo.Context) error {
req := &domain.NodeSummaryReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
ctx := c.Request().Context()
err := h.usecase.SummaryNode(ctx, req)
if err != nil {
if errors.Is(err, domain.ErrModelNotConfigured) {
return h.NewResponseWithError(c, "请前往管理后台,点击右上角的“系统设置”配置推理大模型。", err)
}
return h.NewResponseWithError(c, "summary node failed", err)
}
return h.NewResponseWithData(c, nil)
}
// SummaryNodeStream
//
// @Summary Stream Summary Node
// @Description Stream Summary Node for single document
// @Tags node
// @Accept json
// @Produce text/event-stream
// @Security bearerAuth
// @Param body body domain.NodeSummaryReq true "Summary Node"
// @Success 200 {string} string "SSE stream"
// @Router /api/v1/node/summary/stream [post]
func (h *NodeHandler) SummaryNodeStream(c echo.Context) error {
req := &domain.NodeSummaryReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
if len(req.IDs) != 1 {
return h.NewResponseWithError(c, "stream summary only supports single node", nil)
}
ctx := c.Request().Context()
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().Header().Set("Transfer-Encoding", "chunked")
err := h.usecase.StreamSummaryNode(ctx, req, func(ctx context.Context, dataType, chunk string) error {
return h.writeSSEEvent(c, domain.SSEEvent{Type: dataType, Content: chunk})
})
if err != nil {
msg := "summary node failed"
if errors.Is(err, domain.ErrModelNotConfigured) {
msg = "请前往管理后台,点击右上角的“系统设置”配置推理大模型。"
}
if writeErr := h.writeSSEEvent(c, domain.SSEEvent{Type: "error", Content: msg, Error: err.Error()}); writeErr != nil {
return writeErr
}
return nil
}
return h.writeSSEEvent(c, domain.SSEEvent{Type: "done"})
}
func (h *NodeHandler) writeSSEEvent(c echo.Context, data any) error {
jsonContent, err := json.Marshal(data)
if err != nil {
return err
}
sseMessage := fmt.Sprintf("data: %s\n\n", string(jsonContent))
if _, err := c.Response().Write([]byte(sseMessage)); err != nil {
return err
}
c.Response().Flush()
return nil
}
// RecommendNodes
//
// @Summary Recommend Nodes
// @Description Recommend Nodes
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param query query domain.GetRecommendNodeListReq true "Recommend Nodes"
// @Success 200 {object} domain.PWResponse{data=[]domain.RecommendNodeListResp}
// @Router /api/v1/node/recommend_nodes [get]
func (h *NodeHandler) RecommendNodes(c echo.Context) error {
var req domain.GetRecommendNodeListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if len(req.NodeIDs) == 0 && len(req.NavIds) == 0 {
return h.NewResponseWithError(c, "node_ids or nav_ids is required", nil)
}
ctx := c.Request().Context()
nodes, err := h.usecase.GetRecommendNodeList(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "get recommend nodes failed", err)
}
return h.NewResponseWithData(c, nodes)
}
// BatchMoveNode
//
// @Summary Batch Move Node
// @Description Batch Move Node
// @Tags node
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param body body domain.BatchMoveReq true "Batch Move Node"
// @Success 200 {object} domain.Response
// @Router /api/v1/node/batch_move [post]
func (h *NodeHandler) BatchMoveNode(c echo.Context) error {
req := &domain.BatchMoveReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
ctx := c.Request().Context()
if err := h.usecase.BatchMoveNode(ctx, req); err != nil {
return h.NewResponseWithError(c, "batch move node failed", err)
}
return h.NewResponseWithData(c, nil)
}
// NodePermission 文档授权信息获取
//
// @Tags NodePermission
// @Summary 文档授权信息获取
// @Description 文档授权信息获取
// @ID v1-NodePermission
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param query v1.NodePermissionReq true "para"
// @Success 200 {object} domain.Response{data=v1.NodePermissionResp}
// @Router /api/v1/node/permission [get]
func (h *NodeHandler) NodePermission(c echo.Context) error {
var req v1.NodePermissionReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
ctx := c.Request().Context()
release, err := h.usecase.GetNodePermissionsByID(ctx, req.ID, req.KbId)
if err != nil {
return h.NewResponseWithError(c, "get node permission detail failed", err)
}
return h.NewResponseWithData(c, release)
}
// NodePermissionEdit 文档授权信息更新
//
// @Tags NodePermission
// @Summary 文档授权信息更新
// @Description 文档授权信息更新
// @ID v1-NodePermissionEdit
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param body v1.NodePermissionEditReq true "para"
// @Success 200 {object} domain.Response{data=v1.NodePermissionEditResp}
// @Router /api/v1/node/permission/edit [patch]
func (h *NodeHandler) NodePermissionEdit(c echo.Context) error {
var req v1.NodePermissionEditReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.usecase.ValidateNodePermissionsEdit(req, consts.GetLicenseEdition(c)); err != nil {
return h.NewResponseWithError(c, "validate node permission failed", err)
}
ctx := c.Request().Context()
err := h.usecase.NodePermissionsEdit(ctx, req)
if err != nil {
return h.NewResponseWithError(c, "update node permission failed", err)
}
return h.NewResponseWithData(c, nil)
}
// NodeRestudy 文档重新学习
//
// @Tags Node
// @Summary 文档重新学习
// @Description 文档重新学习
// @ID v1-NodeRestudy
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param body v1.NodeRestudyReq true "para"
// @Success 200 {object} domain.Response{data=v1.NodeRestudyResp}
// @Router /api/v1/node/restudy [post]
func (h *NodeHandler) NodeRestudy(c echo.Context) error {
var req v1.NodeRestudyReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.usecase.NodeRestudy(c.Request().Context(), &req); err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
return h.NewResponseWithData(c, nil)
}

View File

@@ -0,0 +1,47 @@
package v1
import (
"github.com/google/wire"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type APIHandlers struct {
UserHandler *UserHandler
KnowledgeBaseHandler *KnowledgeBaseHandler
NodeHandler *NodeHandler
AppHandler *AppHandler
FileHandler *FileHandler
ModelHandler *ModelHandler
ConversationHandler *ConversationHandler
CrawlerHandler *CrawlerHandler
CreationHandler *CreationHandler
StatHandler *StatHandler
CommentHandler *CommentHandler
AuthV1Handler *AuthV1Handler
NavHandler *NavHandler
}
var ProviderSet = wire.NewSet(
middleware.ProviderSet,
usecase.ProviderSet,
handler.NewBaseHandler,
NewNodeHandler,
NewAppHandler,
NewConversationHandler,
NewUserHandler,
NewFileHandler,
NewModelHandler,
NewKnowledgeBaseHandler,
NewCrawlerHandler,
NewCreationHandler,
NewStatHandler,
NewCommentHandler,
NewAuthV1Handler,
NewNavHandler,
wire.Struct(new(APIHandlers), "*"),
)

295
backend/handler/v1/stat.go Normal file
View File

@@ -0,0 +1,295 @@
package v1
import (
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/stat/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/usecase"
)
type StatHandler struct {
*handler.BaseHandler
usecase *usecase.StatUseCase
auth middleware.AuthMiddleware
logger *log.Logger
}
func NewStatHandler(baseHandler *handler.BaseHandler, echo *echo.Echo, usecase *usecase.StatUseCase, logger *log.Logger, auth middleware.AuthMiddleware) *StatHandler {
h := &StatHandler{
BaseHandler: baseHandler,
usecase: usecase,
auth: auth,
logger: logger.WithModule("handler.v1.stat"),
}
group := echo.Group("/api/v1/stat", h.auth.Authorize, auth.ValidateKBUserPerm(consts.UserKBPermissionDataOperate))
// 实时
group.GET("/instant_count", h.GetInstantCount) // instant count (30min, every 1min)
group.GET("/instant_pages", h.GetInstantPages) // instant pages (latest 10 pages)
// 周期统计
group.GET("/count", h.StatCount)
group.GET("/geo_count", h.StatGeoCountReq) // geo (24h)
group.GET("/conversation_distribution", h.StatConversationDistribution) // conversation (24h)
group.GET("/hot_pages", h.StatHotPages)
group.GET("/referer_hosts", h.StatRefererHosts)
group.GET("/browsers", h.StatBrowsers)
return h
}
// StatCount 全局统计
//
// @Summary 全局统计
// @Description 全局统计
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatCountReq true "para"
// @Success 200 {object} domain.PWResponse{data=v1.StatCountResp}
// @Router /api/v1/stat/count [get]
func (h *StatHandler) StatCount(c echo.Context) error {
var req v1.StatCountReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
if err := h.usecase.ValidateStatDay(req.Day, consts.GetLicenseEdition(c)); err != nil {
h.logger.Error("validate stat day failed")
return h.NewResponseWithErrCode(c, domain.ErrCodePermissionDenied)
}
count, err := h.usecase.GetStatCount(c.Request().Context(), req.KbID, req.Day)
if err != nil {
return h.NewResponseWithError(c, "get count failed", err)
}
return h.NewResponseWithData(c, count)
}
// StatGeoCountReq 用户地理分布
//
// @Summary 用户地理分布
// @Description 用户地理分布
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatGeoCountReq true "para"
// @Success 200 {object} domain.Response
// @Router /api/v1/stat/geo_count [get]
func (h *StatHandler) StatGeoCountReq(c echo.Context) error {
var req v1.StatGeoCountReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
if err := h.usecase.ValidateStatDay(req.Day, consts.GetLicenseEdition(c)); err != nil {
h.logger.Error("validate stat day failed")
return h.NewResponseWithErrCode(c, domain.ErrCodePermissionDenied)
}
geoCount, err := h.usecase.GetGeoCount(c.Request().Context(), req.KbID, req.Day)
if err != nil {
return h.NewResponseWithError(c, "get geo count failed", err)
}
return h.NewResponseWithData(c, geoCount)
}
// StatConversationDistribution
//
// @Summary 问答来源
// @Description 问答来源
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatConversationDistributionReq true "para"
// @Success 200 {object} domain.Response{data=[]v1.StatConversationDistributionResp}
// @Router /api/v1/stat/conversation_distribution [get]
func (h *StatHandler) StatConversationDistribution(c echo.Context) error {
var req v1.StatConversationDistributionReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
if err := h.usecase.ValidateStatDay(req.Day, consts.GetLicenseEdition(c)); err != nil {
h.logger.Error("validate stat day failed")
return h.NewResponseWithErrCode(c, domain.ErrCodePermissionDenied)
}
distribution, err := h.usecase.GetConversationDistribution(c.Request().Context(), req.KbID, req.Day)
if err != nil {
return h.NewResponseWithError(c, "get conversation distribution failed", err)
}
return h.NewResponseWithData(c, distribution)
}
// StatHotPages 热门文档
//
// @Summary 热门文档
// @Description 热门文档
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatHotPagesReq true "para"
// @Success 200 {object} domain.Response{data=[]domain.HotPage}
// @Router /api/v1/stat/hot_pages [get]
func (h *StatHandler) StatHotPages(c echo.Context) error {
var req v1.StatHotPagesReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
if err := h.usecase.ValidateStatDay(req.Day, consts.GetLicenseEdition(c)); err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
hotPages, err := h.usecase.GetHotPages(c.Request().Context(), req.KbID, req.Day)
if err != nil {
return h.NewResponseWithError(c, "get hot pages failed", err)
}
return h.NewResponseWithData(c, hotPages)
}
// StatRefererHosts 来源域名
//
// @Summary 来源域名
// @Description 来源域名
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatRefererHostsReq true "para"
// @Success 200 {object} domain.Response{data=[]domain.HotRefererHost}
// @Router /api/v1/stat/referer_hosts [get]
func (h *StatHandler) StatRefererHosts(c echo.Context) error {
var req v1.StatRefererHostsReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
if err := h.usecase.ValidateStatDay(req.Day, consts.GetLicenseEdition(c)); err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
refererHosts, err := h.usecase.GetHotRefererHosts(c.Request().Context(), req.KbID, req.Day)
if err != nil {
return h.NewResponseWithError(c, "get hot referer hosts failed", err)
}
return h.NewResponseWithData(c, refererHosts)
}
// StatBrowsers 客户端统计
//
// @Summary 客户端统计
// @Description 客户端统计
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatBrowsersReq true "para"
// @Success 200 {object} domain.Response{data=domain.HotBrowser}
// @Router /api/v1/stat/browsers [get]
func (h *StatHandler) StatBrowsers(c echo.Context) error {
var req v1.StatBrowsersReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
if err := h.usecase.ValidateStatDay(req.Day, consts.GetLicenseEdition(c)); err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
hotBrowsers, err := h.usecase.GetHotBrowsers(c.Request().Context(), req.KbID, req.Day)
if err != nil {
return h.NewResponseWithError(c, "get hot browsers failed", err)
}
return h.NewResponseWithData(c, hotBrowsers)
}
// GetInstantCount get instant count
//
// @Summary GetInstantCount
// @Description GetInstantCount
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatInstantCountReq true "para"
// @Success 200 {object} domain.Response{data=[]domain.InstantCountResp}
// @Router /api/v1/stat/instant_count [get]
func (h *StatHandler) GetInstantCount(c echo.Context) error {
var req v1.StatInstantCountReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
count, err := h.usecase.GetInstantCount(c.Request().Context(), req.KbID)
if err != nil {
return h.NewResponseWithError(c, "get instant count failed", err)
}
return h.NewResponseWithData(c, count)
}
// GetInstantPages get instant pages
//
// @Summary GetInstantPages
// @Description GetInstantPages
// @Tags stat
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param para query v1.StatInstantPagesReq true "para"
// @Success 200 {object} domain.Response{data=[]domain.InstantPageResp}
// @Router /api/v1/stat/instant_pages [get]
func (h *StatHandler) GetInstantPages(c echo.Context) error {
var req v1.StatInstantPagesReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request parameters", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validation failed", err)
}
pages, err := h.usecase.GetInstantPages(c.Request().Context(), req.KbID)
if err != nil {
return h.NewResponseWithError(c, "get instant pages failed", err)
}
return h.NewResponseWithData(c, pages)
}

302
backend/handler/v1/user.go Normal file
View File

@@ -0,0 +1,302 @@
package v1
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
v1 "github.com/chaitin/panda-wiki/api/user/v1"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/middleware"
"github.com/chaitin/panda-wiki/pkg/ratelimit"
"github.com/chaitin/panda-wiki/store/cache"
"github.com/chaitin/panda-wiki/usecase"
)
type UserHandler struct {
*handler.BaseHandler
usecase *usecase.UserUsecase
logger *log.Logger
config *config.Config
auth middleware.AuthMiddleware
rateLimiter *ratelimit.RateLimiter
}
func NewUserHandler(e *echo.Echo, baseHandler *handler.BaseHandler, logger *log.Logger, usecase *usecase.UserUsecase, auth middleware.AuthMiddleware, config *config.Config, cache *cache.Cache) *UserHandler {
handlerLogger := logger.WithModule("handler.v1.user")
h := &UserHandler{
BaseHandler: baseHandler,
logger: handlerLogger,
usecase: usecase,
auth: auth,
config: config,
rateLimiter: ratelimit.NewRateLimiter(handlerLogger, cache),
}
group := e.Group("/api/v1/user")
group.POST("/login", h.Login)
group.GET("", h.GetUserInfo, h.auth.Authorize)
group.GET("/list", h.ListUsers, h.auth.Authorize)
group.POST("/create", h.CreateUser, h.auth.Authorize, h.auth.ValidateUserRole(consts.UserRoleAdmin))
group.PUT("/reset_password", h.ResetPassword, h.auth.Authorize, h.auth.ValidateUserRole(consts.UserRoleAdmin))
group.DELETE("/delete", h.DeleteUser, h.auth.Authorize, h.auth.ValidateUserRole(consts.UserRoleAdmin))
return h
}
// CreateUser
//
// @Summary CreateUser
// @Description CreateUser
// @Tags user
// @Accept json
// @Produce json
// @Param body body v1.CreateUserReq true "CreateUser Request"
// @Success 200 {object} domain.Response{data=v1.CreateUserResp}
// @Router /api/v1/user/create [post]
func (h *UserHandler) CreateUser(c echo.Context) error {
var req v1.CreateUserReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
uid := uuid.New().String()
err := h.usecase.CreateUser(c.Request().Context(), &domain.User{
ID: uid,
Account: req.Account,
Password: req.Password,
Role: req.Role,
}, consts.GetLicenseEdition(c))
if err != nil {
return h.NewResponseWithError(c, "failed to create user", err)
}
return h.NewResponseWithData(c, v1.CreateUserResp{ID: uid})
}
// Login
//
// @Summary Login
// @Description Login
// @Tags user
// @Accept json
// @Produce json
// @Param body body v1.LoginReq true "Login Request"
// @Success 200 {object} v1.LoginResp
// @Router /api/v1/user/login [post]
func (h *UserHandler) Login(c echo.Context) error {
var req v1.LoginReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
ctx := c.Request().Context()
ip := c.RealIP()
locked, remaining := h.rateLimiter.CheckIPLocked(ctx, ip)
if locked {
h.logger.Warn("IP is locked", "ip", ip, "remaining", remaining)
return h.NewResponseWithError(c, fmt.Sprintf("账号已被锁定,请 %s 后重试", remaining.String()), nil)
}
token, err := h.usecase.VerifyUserAndGenerateToken(ctx, req)
if err != nil {
h.rateLimiter.LockAttempt(ctx, ip)
return h.NewResponseWithError(c, "用户名或密码错误", err)
}
go func() {
if err := h.rateLimiter.ResetLoginAttempts(context.Background(), ip); err != nil {
h.logger.Error("failed to reset login attempts", "error", err, "ip", ip)
}
}()
return h.NewResponseWithData(c, v1.LoginResp{Token: token})
}
// GetUserInfo
//
// @Summary GetUser
// @Description GetUser
// @Tags user
// @Accept json
// @Success 200 {object} v1.UserInfoResp
// @Router /api/v1/user [get]
func (h *UserHandler) GetUserInfo(c echo.Context) error {
ctx := c.Request().Context()
authInfo := domain.GetAuthInfoFromCtx(ctx)
if authInfo == nil {
return h.NewResponseWithError(c, "authInfo not found in context", nil)
}
user, err := h.usecase.GetUser(c.Request().Context(), authInfo.UserId)
if err != nil {
return h.NewResponseWithError(c, "failed to get user", err)
}
userInfo := &v1.UserInfoResp{
ID: user.ID,
Account: user.Account,
Role: user.Role,
IsToken: authInfo.IsToken,
LastAccess: &user.LastAccess,
CreatedAt: user.CreatedAt,
}
return h.NewResponseWithData(c, userInfo)
}
// ListUsers
//
// @Summary ListUsers
// @Description ListUsers
// @Tags user
// @Accept json
// @Produce json
// @Success 200 {object} domain.PWResponse{data=v1.UserListResp}
// @Router /api/v1/user/list [get]
func (h *UserHandler) ListUsers(c echo.Context) error {
var req v1.UserListReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
users, err := h.usecase.ListUsers(c.Request().Context())
if err != nil {
return h.NewResponseWithError(c, "failed to list users", err)
}
return h.NewResponseWithData(c, users)
}
// ResetPassword
//
// @Summary ResetPassword
// @Description ResetPassword
// @Tags user
// @Accept json
// @Produce json
// @Param body body v1.ResetPasswordReq true "ResetPassword Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/user/reset_password [put]
func (h *UserHandler) ResetPassword(c echo.Context) error {
ctx := c.Request().Context()
var req v1.ResetPasswordReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
authInfo := domain.GetAuthInfoFromCtx(ctx)
if authInfo == nil {
return h.NewResponseWithError(c, "authInfo not found in context", nil)
}
if authInfo.IsToken {
return h.NewResponseWithError(c, "this api not support token call", nil)
}
user, err := h.usecase.GetUser(ctx, authInfo.UserId)
if err != nil {
return h.NewResponseWithError(c, "failed to get user", err)
}
if user.Account == "admin" {
// admin 改不了自己的密码
if authInfo.UserId == req.ID {
return h.NewResponseWithError(c, "请修改安装目录下 .env 文件中的 ADMIN_PASSWORD并重启 panda-wiki-api 容器使更改生效。", nil)
}
} else {
targetUser, err := h.usecase.GetUser(ctx, req.ID)
if err != nil {
return h.NewResponseWithError(c, "failed to get target user", err)
}
// 超级管理员不能改其他超级管理员密码
if targetUser.Role == consts.UserRoleAdmin && targetUser.ID != authInfo.UserId {
return h.NewResponseWithError(c, "无法修改其他超级管理员密码", nil)
}
}
err = h.usecase.ResetPassword(c.Request().Context(), &req)
if err != nil {
return h.NewResponseWithError(c, "failed to reset password", err)
}
return h.NewResponseWithData(c, nil)
}
// DeleteUser
//
// @Summary DeleteUser
// @Description DeleteUser
// @Tags user
// @Accept json
// @Produce json
// @Param params query v1.DeleteUserReq true "DeleteUser Request"
// @Success 200 {object} domain.Response
// @Router /api/v1/user/delete [delete]
func (h *UserHandler) DeleteUser(c echo.Context) error {
ctx := c.Request().Context()
var req v1.DeleteUserReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "invalid request", err)
}
authInfo := domain.GetAuthInfoFromCtx(ctx)
if authInfo == nil {
return h.NewResponseWithError(c, "authInfo not found in context", nil)
}
if authInfo.IsToken {
return h.NewResponseWithError(c, "this api not support token call", nil)
}
if authInfo.UserId == req.UserID {
return h.NewResponseWithError(c, "cannot delete yourself", nil)
}
user, err := h.usecase.GetUser(ctx, authInfo.UserId)
if err != nil {
return h.NewResponseWithError(c, "failed to get user", err)
}
targetUser, err := h.usecase.GetUser(ctx, req.UserID)
if err != nil {
return h.NewResponseWithError(c, "failed to get target user", err)
}
if targetUser.Account == "admin" {
return h.NewResponseWithError(c, "cannot delete admin user", nil)
}
// 非admin账号的管理员只能删除普通用户的账户
if user.Account != "admin" {
if targetUser.Role != consts.UserRoleUser {
return h.NewResponseWithError(c, "cannot delete other admin users", nil)
}
}
err = h.usecase.DeleteUser(ctx, req.UserID)
if err != nil {
return h.NewResponseWithError(c, "failed to delete user", err)
}
return h.NewResponseWithData(c, nil)
}