This commit is contained in:
2026-06-10 07:55:16 +08:00
parent 821eca9f0c
commit aaac7e9d98
31 changed files with 548 additions and 149 deletions
+3 -2
View File
@@ -137,6 +137,7 @@ type Message struct {
PromptTokens *int
CompletionTokens *int
ThinkingTokens *int
Attachments []Attachment // 用户可读路径填充;composeHistory 不依赖此字段
CreatedAt, UpdatedAt time.Time
}
@@ -173,7 +174,7 @@ type ConversationReader interface {
type ConversationService interface {
Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error)
List(ctx context.Context, userID uuid.UUID, filter ConversationFilter) ([]Conversation, error)
Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error)
Get(ctx context.Context, userID, id uuid.UUID, limit int) (*Conversation, []Message, error)
UpdateTitle(ctx context.Context, userID, id uuid.UUID, title string) error
SoftDelete(ctx context.Context, userID, id uuid.UUID) error
}
@@ -203,7 +204,7 @@ type MessageService interface {
Send(ctx context.Context, userID, conversationID uuid.UUID, content string, attachmentIDs []uuid.UUID) (userMsgID, assistantMsgID int64, err error)
Stream(ctx context.Context, userID, conversationID uuid.UUID, sinceMessageID int64) (<-chan SSEEvent, error)
Cancel(ctx context.Context, userID uuid.UUID, messageID int64) error
Retry(ctx context.Context, userID uuid.UUID, messageID int64) (newAssistantMsgID int64, err error)
Retry(ctx context.Context, userID uuid.UUID, messageID int64) (newAssistantMsgID int64, conversationID uuid.UUID, err error)
ListMessages(ctx context.Context, userID, conversationID uuid.UUID, beforeID *int64, limit int) ([]Message, error)
}
+41 -15
View File
@@ -105,18 +105,19 @@ type ConversationDTO struct {
// MessageDTO is the JSON representation of a Message.
type MessageDTO struct {
ID int64 `json:"id"`
ConversationID uuid.UUID `json:"conversation_id"`
Role string `json:"role"`
Content string `json:"content"`
Thinking *string `json:"thinking,omitempty"`
Status string `json:"status"`
ErrorMessage *string `json:"error_message,omitempty"`
PromptTokens *int `json:"prompt_tokens,omitempty"`
CompletionTokens *int `json:"completion_tokens,omitempty"`
ThinkingTokens *int `json:"thinking_tokens,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
ConversationID uuid.UUID `json:"conversation_id"`
Role string `json:"role"`
Content string `json:"content"`
Thinking *string `json:"thinking,omitempty"`
Status string `json:"status"`
ErrorMessage *string `json:"error_message,omitempty"`
PromptTokens *int `json:"prompt_tokens,omitempty"`
CompletionTokens *int `json:"completion_tokens,omitempty"`
ThinkingTokens *int `json:"thinking_tokens,omitempty"`
Attachments []AttachmentDTO `json:"attachments"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AttachmentDTO is the JSON representation of an Attachment.
@@ -205,11 +206,18 @@ type ModelUsageDTO struct {
CostUSD float64 `json:"cost_usd"`
}
// AdminUsageItem is a single aggregated usage row keyed by user/model/day.
type AdminUsageItem struct {
Key string `json:"key"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
ThinkingTokens int `json:"thinking_tokens"`
CostUSD float64 `json:"cost_usd"`
}
// AdminUsageResp is the response body for GET /admin/usage.
type AdminUsageResp struct {
ByUser []UserUsageDTO `json:"by_user"`
ByModel []ModelUsageDTO `json:"by_model"`
ByDay []DayUsageDTO `json:"by_day"`
Items []AdminUsageItem `json:"items"`
}
// ===== Conversion helpers =====
@@ -241,11 +249,29 @@ func toMessageDTO(m *Message) MessageDTO {
PromptTokens: m.PromptTokens,
CompletionTokens: m.CompletionTokens,
ThinkingTokens: m.ThinkingTokens,
Attachments: toAttachmentDTOs(m.Attachments),
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
// toAttachmentDTOs maps a slice of Attachment to DTOs, always returning a
// non-nil (possibly empty) slice so the JSON field serializes as [].
func toAttachmentDTOs(atts []Attachment) []AttachmentDTO {
out := make([]AttachmentDTO, 0, len(atts))
for _, a := range atts {
out = append(out, AttachmentDTO{
ID: a.ID,
Filename: a.Filename,
MimeType: a.MimeType,
SizeBytes: a.SizeBytes,
SHA256: a.SHA256,
CreatedAt: a.CreatedAt,
})
}
return out
}
func toTemplateDTO(t *PromptTemplate) TemplateDTO {
return TemplateDTO{
ID: t.ID,
+96 -62
View File
@@ -99,6 +99,9 @@ func (h *Handler) Mount(r chi.Router) {
// User usage.
r.Get("/users/me/usage", h.userDailyUsage)
// Public (non-admin) models list for chat composers.
r.Get("/models", h.listModelsPublic)
// Admin-only routes.
r.Group(func(r chi.Router) {
r.Use(h.adminGuard)
@@ -246,7 +249,13 @@ func (h *Handler) getConversation(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
return
}
conv, msgs, err := h.convSvc.Get(r.Context(), uid, id)
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, e := strconv.Atoi(v); e == nil && n > 0 {
limit = n
}
}
conv, msgs, err := h.convSvc.Get(r.Context(), uid, id, limit)
if err != nil {
writeErr(w, r, err)
return
@@ -398,12 +407,15 @@ func (h *Handler) retryMessage(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id"))
return
}
newID, err := h.msgSvc.Retry(r.Context(), uid, msgID)
newID, convID, err := h.msgSvc.Retry(r.Context(), uid, msgID)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]int64{"assistant_message_id": newID})
writeJSON(w, http.StatusOK, map[string]any{
"assistant_message_id": newID,
"stream_url": fmt.Sprintf("/api/v1/conversations/%s/stream", convID),
})
}
// ===== Template handlers =====
@@ -641,6 +653,22 @@ func (h *Handler) listModels(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// listModelsPublic mirrors listModels but is reachable by any authenticated
// user (non-admin), so chat composers can list available models.
func (h *Handler) listModelsPublic(w http.ResponseWriter, r *http.Request) {
onlyEnabled := r.URL.Query().Get("only_enabled") == "true"
models, err := h.epSvc.ListModels(r.Context(), onlyEnabled)
if err != nil {
writeErr(w, r, err)
return
}
out := make([]ModelDTO, 0, len(models))
for i := range models {
out = append(out, toModelDTO(&models[i]))
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) {
var req CreateModelReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -648,18 +676,18 @@ func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) {
return
}
m, err := h.epSvc.CreateModel(r.Context(), CreateModelInput{
EndpointID: req.EndpointID,
ModelID: req.ModelID,
DisplayName: req.DisplayName,
Capabilities: req.Capabilities,
ContextWindow: req.ContextWindow,
MaxOutputTokens: req.MaxOutputTokens,
PromptPrice: req.PromptPricePerMillionUSD,
CompletionPrice: req.CompletionPricePerMillionUSD,
ThinkingPrice: req.ThinkingPricePerMillionUSD,
EndpointID: req.EndpointID,
ModelID: req.ModelID,
DisplayName: req.DisplayName,
Capabilities: req.Capabilities,
ContextWindow: req.ContextWindow,
MaxOutputTokens: req.MaxOutputTokens,
PromptPrice: req.PromptPricePerMillionUSD,
CompletionPrice: req.CompletionPricePerMillionUSD,
ThinkingPrice: req.ThinkingPricePerMillionUSD,
IsTitleGenerator: req.IsTitleGenerator,
Enabled: req.Enabled,
SortOrder: req.SortOrder,
Enabled: req.Enabled,
SortOrder: req.SortOrder,
})
if err != nil {
writeErr(w, r, err)
@@ -727,56 +755,62 @@ func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) {
}
}
byUser, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
if err != nil {
writeErr(w, r, err)
return
groupBy := q.Get("group_by")
if groupBy == "" {
groupBy = "day"
}
byModel, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
if err != nil {
writeErr(w, r, err)
return
}
byDay, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
if err != nil {
writeErr(w, r, err)
items := make([]AdminUsageItem, 0)
switch groupBy {
case "user":
rows, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
if err != nil {
writeErr(w, r, err)
return
}
for _, row := range rows {
items = append(items, AdminUsageItem{
Key: row.UserID.String(),
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: row.CostUSD,
})
}
case "model":
rows, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
if err != nil {
writeErr(w, r, err)
return
}
for _, row := range rows {
items = append(items, AdminUsageItem{
Key: row.ModelID.String(),
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: row.CostUSD,
})
}
case "day":
rows, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
if err != nil {
writeErr(w, r, err)
return
}
for _, row := range rows {
items = append(items, AdminUsageItem{
Key: row.Day.Format(time.RFC3339),
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: row.CostUSD,
})
}
default:
writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group_by"))
return
}
userDTOs := make([]UserUsageDTO, 0, len(byUser))
for _, row := range byUser {
userDTOs = append(userDTOs, UserUsageDTO{
UserID: row.UserID,
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: row.CostUSD,
})
}
modelDTOs := make([]ModelUsageDTO, 0, len(byModel))
for _, row := range byModel {
modelDTOs = append(modelDTOs, ModelUsageDTO{
ModelID: row.ModelID,
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: row.CostUSD,
})
}
dayDTOs := make([]DayUsageDTO, 0, len(byDay))
for _, row := range byDay {
dayDTOs = append(dayDTOs, DayUsageDTO{
Day: row.Day,
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: row.CostUSD,
})
}
writeJSON(w, http.StatusOK, AdminUsageResp{
ByUser: userDTOs,
ByModel: modelDTOs,
ByDay: dayDTOs,
})
writeJSON(w, http.StatusOK, AdminUsageResp{Items: items})
}
+4 -4
View File
@@ -67,7 +67,7 @@ func (f *fakeConvSvc) List(ctx context.Context, userID uuid.UUID, filter Convers
}
return nil, nil
}
func (f *fakeConvSvc) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) {
func (f *fakeConvSvc) Get(ctx context.Context, userID, id uuid.UUID, _ int) (*Conversation, []Message, error) {
if f.getFunc != nil {
return f.getFunc(ctx, userID, id)
}
@@ -95,11 +95,11 @@ func (f *fakeMsgSvc) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ int64
return ch, nil
}
func (f *fakeMsgSvc) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil }
func (f *fakeMsgSvc) Retry(_ context.Context, _ uuid.UUID, msgID int64) (int64, error) {
func (f *fakeMsgSvc) Retry(_ context.Context, _ uuid.UUID, msgID int64) (int64, uuid.UUID, error) {
if f.retryErr != nil {
return 0, f.retryErr
return 0, uuid.Nil, f.retryErr
}
return msgID + 100, nil
return msgID + 100, uuid.Nil, nil
}
func (f *fakeMsgSvc) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
return nil, nil
+14 -2
View File
@@ -84,7 +84,8 @@ func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in C
}
// Get returns a conversation and its most recent messages, enforcing ownership.
func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) {
// limit caps the number of returned messages (defaults to 50 when non-positive).
func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID, limit int) (*Conversation, []Message, error) {
c, err := s.repo.GetConversation(ctx, id)
if err != nil {
return nil, nil, err
@@ -92,10 +93,21 @@ func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID) (*C
if c.UserID != userID {
return nil, nil, errs.New(errs.CodeForbidden, "not conversation owner")
}
msgs, err := s.repo.ListMessagesByConversation(ctx, id, nil, 50)
if limit <= 0 {
limit = 50
}
msgs, err := s.repo.ListMessagesByConversation(ctx, id, nil, limit)
if err != nil {
return nil, nil, err
}
// Populate each message's attachments for the user-facing read path.
for i := range msgs {
atts, err := s.repo.ListAttachmentsForMessage(ctx, msgs[i].ID)
if err != nil {
return nil, nil, err
}
msgs[i].Attachments = atts
}
return c, msgs, nil
}
+6 -2
View File
@@ -118,6 +118,10 @@ func (r *convFakeRepo) SoftDeleteConversation(_ context.Context, id uuid.UUID) e
return nil
}
func (r *convFakeRepo) ListAttachmentsForMessage(_ context.Context, _ int64) ([]Attachment, error) {
return nil, nil
}
func (r *convFakeRepo) ListMessagesByConversation(_ context.Context, convID uuid.UUID, _ *int64, limit int) ([]Message, error) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -335,7 +339,7 @@ func TestConversationService_Get_HappyPath(t *testing.T) {
{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hello"},
}
got, msgs, err := svc.Get(context.Background(), userID, conv.ID)
got, msgs, err := svc.Get(context.Background(), userID, conv.ID, 50)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, conv.ID, got.ID)
@@ -350,7 +354,7 @@ func TestConversationService_Get_NotOwner(t *testing.T) {
callerID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, ownerID)
_, _, err := svc.Get(context.Background(), callerID, conv.ID)
_, _, err := svc.Get(context.Background(), callerID, conv.ID, 50)
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
+29 -17
View File
@@ -161,8 +161,8 @@ func (s *messageService) Send(
TargetType: "message",
TargetID: int64ToString(userMsg.ID),
Metadata: map[string]any{
"conversation_id": conversationID.String(),
"attachment_count": len(atts),
"conversation_id": conversationID.String(),
"attachment_count": len(atts),
},
})
@@ -301,25 +301,25 @@ func (s *messageService) Cancel(ctx context.Context, userID uuid.UUID, messageID
// Retry deletes the failed/cancelled assistant message, inserts a fresh pending
// one, and restarts the streamer.
func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID int64) (int64, error) {
func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID int64) (int64, uuid.UUID, error) {
old, err := s.repo.GetMessage(ctx, messageID)
if err != nil {
return 0, err
return 0, uuid.Nil, err
}
if old.Status != StatusError && old.Status != StatusCancelled {
return 0, errs.New(errs.CodeChatMessageNotPending, "message is not retriable")
return 0, uuid.Nil, errs.New(errs.CodeChatMessageNotPending, "message is not retriable")
}
conv, err := s.repo.GetConversation(ctx, old.ConversationID)
if err != nil {
return 0, err
return 0, uuid.Nil, err
}
if conv.UserID != userID {
return 0, errs.New(errs.CodeForbidden, "not conversation owner")
return 0, uuid.Nil, errs.New(errs.CodeForbidden, "not conversation owner")
}
repo, tx, err := s.repo.WithTx(ctx)
if err != nil {
return 0, err
return 0, uuid.Nil, err
}
committed := false
defer func() {
@@ -329,15 +329,15 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
}()
if err := repo.DeleteMessage(ctx, messageID); err != nil {
return 0, err
return 0, uuid.Nil, err
}
asstMsg, err := repo.InsertMessage(ctx, conv.ID, RoleAssistant, "", StatusPending)
if err != nil {
return 0, err
return 0, uuid.Nil, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
return 0, uuid.Nil, err
}
committed = true
@@ -345,20 +345,20 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, err
return asstMsg.ID, conv.ID, err
}
endpoint, err := s.repo.GetLLMEndpoint(ctx, model.EndpointID)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, err
return asstMsg.ID, conv.ID, err
}
req, err := s.composeHistory(ctx, conv, model)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, err
return asstMsg.ID, conv.ID, err
}
params := StreamParams{
@@ -373,7 +373,7 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
if err := s.hub.Start(ctx, params); err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
return asstMsg.ID, err
return asstMsg.ID, conv.ID, err
}
// Audit after successful hub start.
@@ -388,7 +388,7 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
},
})
return asstMsg.ID, nil
return asstMsg.ID, conv.ID, nil
}
// ListMessages returns paginated messages for a conversation, enforcing ownership.
@@ -408,7 +408,19 @@ func (s *messageService) ListMessages(
if limit == 0 || limit > 200 {
limit = 50
}
return s.repo.ListMessagesByConversation(ctx, conversationID, beforeID, limit)
msgs, err := s.repo.ListMessagesByConversation(ctx, conversationID, beforeID, limit)
if err != nil {
return nil, err
}
// Populate each message's attachments for the user-facing read path.
for i := range msgs {
atts, err := s.repo.ListAttachmentsForMessage(ctx, msgs[i].ID)
if err != nil {
return nil, err
}
msgs[i].Attachments = atts
}
return msgs, nil
}
// composeHistory loads all OK/Pending messages for the conversation, fetches
+7 -5
View File
@@ -408,8 +408,8 @@ func TestMessageService_Send_AttachmentTooLarge_Total(t *testing.T) {
// Use config with smaller maxMsg to make the test reliable.
cfg := defaultMsgConfig()
cfg.MaxFileBytes = 50 * 1024 * 1024 // 50 MB per file — both pass individually
cfg.MaxMsgBytes = 40 * 1024 * 1024 // 40 MB total — 60 MB fails
cfg.MaxFileBytes = 50 * 1024 * 1024 // 50 MB per file — both pass individually
cfg.MaxMsgBytes = 40 * 1024 * 1024 // 40 MB total — 60 MB fails
svc := buildMessageService(repo, hub, cfg)
_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a1.ID, a2.ID})
@@ -621,7 +621,7 @@ func TestMessageService_Retry_NotRetriable(t *testing.T) {
repo.addMessage(m)
svc := buildMessageService(repo, hub, defaultMsgConfig())
_, err := svc.Retry(context.Background(), userID, 55)
_, _, err := svc.Retry(context.Background(), userID, 55)
require.Error(t, err)
var ae *errs.AppError
@@ -641,10 +641,11 @@ func TestMessageService_Retry_HappyPath(t *testing.T) {
repo.addMessage(old)
svc := buildMessageService(repo, hub, defaultMsgConfig())
newID, err := svc.Retry(context.Background(), userID, 10)
newID, convID, err := svc.Retry(context.Background(), userID, 10)
require.NoError(t, err)
require.Greater(t, newID, int64(0))
require.Equal(t, conv.ID, convID)
// Old message deleted.
repo.mu.Lock()
@@ -681,10 +682,11 @@ func TestMessageService_Retry_Cancelled_HappyPath(t *testing.T) {
repo.addMessage(old)
svc := buildMessageService(repo, hub, defaultMsgConfig())
newID, err := svc.Retry(context.Background(), userID, 11)
newID, convID, err := svc.Retry(context.Background(), userID, 11)
require.NoError(t, err)
require.Greater(t, newID, int64(0))
require.Equal(t, conv.ID, convID)
}
// ===== Stream tests =====
+2 -2
View File
@@ -53,8 +53,8 @@ func (s *sseMessageService) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID,
return s.streamCh, nil
}
func (s *sseMessageService) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil }
func (s *sseMessageService) Retry(_ context.Context, _ uuid.UUID, _ int64) (int64, error) {
return 0, nil
func (s *sseMessageService) Retry(_ context.Context, _ uuid.UUID, _ int64) (int64, uuid.UUID, error) {
return 0, uuid.Nil, nil
}
func (s *sseMessageService) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
return nil, nil