Merge remote-tracking branch 'origin/fix/2.0.1/chat' into test

This commit is contained in:
LinkinStars
2026-02-09 23:36:02 +08:00
10 changed files with 236 additions and 17 deletions
+15
View File
@@ -184,7 +184,22 @@ func (am *AuthUserMiddleware) AdminAuth() gin.HandlerFunc {
return
}
if userInfo != nil {
if userInfo.EmailStatus == entity.EmailStatusToBeVerified {
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive})
ctx.Abort()
return
}
if userInfo.UserStatus == entity.UserStatusSuspended {
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
handler.HandleResponse(ctx, errors.Forbidden(reason.UserSuspended),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeUserSuspended})
ctx.Abort()
return
}
if userInfo.UserStatus == entity.UserStatusDeleted {
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
@@ -58,6 +58,7 @@ func (ac *ActivityController) GetObjectTimeline(ctx *gin.Context) {
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
if userInfo := middleware.GetUserInfoFromContext(ctx); userInfo != nil {
req.IsAdmin = userInfo.RoleID == role.RoleAdminID
}
@@ -81,6 +82,7 @@ func (ac *ActivityController) GetObjectTimelineDetail(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
resp, err := ac.activityService.GetObjectTimelineDetail(ctx, req)
handler.HandleResponse(ctx, err, resp)
+9 -7
View File
@@ -34,10 +34,11 @@ type ActivityMsg struct {
// GetObjectTimelineReq get object timeline request
type GetObjectTimelineReq struct {
ObjectID string `validate:"omitempty,gt=0,lte=100" form:"object_id"`
ShowVote bool `validate:"omitempty" form:"show_vote"`
UserID string `json:"-"`
IsAdmin bool `json:"-"`
ObjectID string `validate:"omitempty,gt=0,lte=100" form:"object_id"`
ShowVote bool `validate:"omitempty" form:"show_vote"`
UserID string `json:"-"`
IsAdmin bool `json:"-"`
IsAdminModerator bool `json:"-"`
}
// GetObjectTimelineResp get object timeline response
@@ -73,9 +74,10 @@ type ActObjectInfo struct {
// GetObjectTimelineDetailReq get object timeline detail request
type GetObjectTimelineDetailReq struct {
NewRevisionID string `validate:"required,gt=0,lte=100" form:"new_revision_id"`
OldRevisionID string `validate:"required,gt=0,lte=100" form:"old_revision_id"`
UserID string `json:"-"`
NewRevisionID string `validate:"required,gt=0,lte=100" form:"new_revision_id"`
OldRevisionID string `validate:"required,gt=0,lte=100" form:"old_revision_id"`
UserID string `json:"-"`
IsAdminModerator bool `json:"-"`
}
// GetObjectTimelineDetailResp get object timeline detail response
@@ -30,6 +30,7 @@ type SimpleObjectInfo struct {
ObjectCreatorUserID string `json:"object_creator_user_id"`
QuestionID string `json:"question_id"`
QuestionStatus int `json:"question_status"`
QuestionShow int `json:"question_show"`
AnswerID string `json:"answer_id"`
AnswerStatus int `json:"answer_status"`
CommentID string `json:"comment_id"`
+108
View File
@@ -30,6 +30,7 @@ import (
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/comment_common"
@@ -41,6 +42,7 @@ import (
"github.com/apache/answer/pkg/converter"
"github.com/apache/answer/pkg/obj"
"github.com/apache/answer/pkg/uid"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
)
@@ -90,6 +92,10 @@ func NewActivityService(
// GetObjectTimeline get object timeline
func (as *ActivityService) GetObjectTimeline(ctx context.Context, req *schema.GetObjectTimelineReq) (
resp *schema.GetObjectTimelineResp, err error) {
if err = as.ensureTimelineObjectVisible(ctx, req.ObjectID, req.UserID, req.IsAdminModerator); err != nil {
return nil, err
}
resp = &schema.GetObjectTimelineResp{
ObjectInfo: &schema.ActObjectInfo{},
Timeline: make([]*schema.ActObjectTimeline, 0),
@@ -254,12 +260,114 @@ func (as *ActivityService) formatTimelineUserInfo(ctx context.Context, timeline
// GetObjectTimelineDetail get object timeline
func (as *ActivityService) GetObjectTimelineDetail(ctx context.Context, req *schema.GetObjectTimelineDetailReq) (
resp *schema.GetObjectTimelineDetailResp, err error) {
if err = as.ensureTimelineRevisionVisible(ctx, req.NewRevisionID, req.UserID, req.IsAdminModerator); err != nil {
return nil, err
}
if err = as.ensureTimelineRevisionVisible(ctx, req.OldRevisionID, req.UserID, req.IsAdminModerator); err != nil {
return nil, err
}
resp = &schema.GetObjectTimelineDetailResp{}
resp.OldRevision, _ = as.getOneObjectDetail(ctx, req.OldRevisionID)
resp.NewRevision, _ = as.getOneObjectDetail(ctx, req.NewRevisionID)
return resp, nil
}
func (as *ActivityService) ensureTimelineRevisionVisible(ctx context.Context, revisionID, userID string,
isAdminModerator bool) error {
if revisionID == "0" {
return nil
}
revisionInfo, err := as.revisionService.GetRevision(ctx, revisionID)
if err != nil {
return err
}
return as.ensureTimelineObjectVisible(ctx, revisionInfo.ObjectID, userID, isAdminModerator)
}
func (as *ActivityService) ensureTimelineObjectVisible(ctx context.Context, objectID, userID string,
isAdminModerator bool) error {
objInfo, err := as.objectInfoService.GetInfo(ctx, objectID)
if err != nil {
return err
}
var parentQuestionInfo *schema.SimpleObjectInfo
if objInfo.ObjectType != constant.QuestionObjectType && len(objInfo.QuestionID) > 0 && objInfo.QuestionID != "0" {
parentQuestionInfo, err = as.objectInfoService.GetInfo(ctx, objInfo.QuestionID)
if err != nil {
return err
}
}
return validateTimelineObjectVisibility(objInfo, parentQuestionInfo, userID, isAdminModerator)
}
func validateTimelineObjectVisibility(objInfo, parentQuestionInfo *schema.SimpleObjectInfo,
userID string, isAdminModerator bool) error {
if objInfo == nil {
return errors.NotFound(reason.ObjectNotFound)
}
if isTimelineObjectRestricted(objInfo) &&
!canViewRestrictedTimelineObject(objInfo.ObjectType, objInfo.ObjectCreatorUserID, userID, isAdminModerator) {
return errors.NotFound(timelineNotFoundReasonByObjectType(objInfo.ObjectType))
}
if parentQuestionInfo != nil && isTimelineQuestionRestricted(parentQuestionInfo) &&
!canViewRestrictedTimelineObject(parentQuestionInfo.ObjectType, parentQuestionInfo.ObjectCreatorUserID,
userID, isAdminModerator) {
return errors.NotFound(reason.QuestionNotFound)
}
return nil
}
func canViewRestrictedTimelineObject(objectType, creatorUserID, userID string, isAdminModerator bool) bool {
if isAdminModerator {
return true
}
switch objectType {
case constant.QuestionObjectType, constant.AnswerObjectType, constant.CommentObjectType:
return creatorUserID == userID
default:
return false
}
}
func isTimelineObjectRestricted(objInfo *schema.SimpleObjectInfo) bool {
switch objInfo.ObjectType {
case constant.QuestionObjectType:
return isTimelineQuestionRestricted(objInfo)
case constant.AnswerObjectType:
return objInfo.AnswerStatus == entity.AnswerStatusDeleted || objInfo.AnswerStatus == entity.AnswerStatusPending
case constant.CommentObjectType:
return objInfo.CommentStatus == entity.CommentStatusDeleted || objInfo.CommentStatus == entity.CommentStatusPending
case constant.TagObjectType:
return objInfo.TagStatus == entity.TagStatusDeleted
default:
return false
}
}
func isTimelineQuestionRestricted(questionInfo *schema.SimpleObjectInfo) bool {
return questionInfo.QuestionStatus == entity.QuestionStatusDeleted ||
questionInfo.QuestionStatus == entity.QuestionStatusPending ||
questionInfo.QuestionShow == entity.QuestionHide
}
func timelineNotFoundReasonByObjectType(objectType string) string {
switch objectType {
case constant.QuestionObjectType:
return reason.QuestionNotFound
case constant.AnswerObjectType:
return reason.AnswerNotFound
case constant.CommentObjectType:
return reason.CommentNotFound
case constant.TagObjectType:
return reason.TagNotFound
default:
return reason.ObjectNotFound
}
}
// getOneObjectDetail get object detail
func (as *ActivityService) getOneObjectDetail(ctx context.Context, revisionID string) (
resp *schema.ObjectTimelineDetail, err error) {
+33 -1
View File
@@ -145,7 +145,39 @@ func (as *AuthService) RemoveTokensExceptCurrentUser(ctx context.Context, userID
// Admin
func (as *AuthService) GetAdminUserCacheInfo(ctx context.Context, accessToken string) (userInfo *entity.UserCacheInfo, err error) {
return as.authRepo.GetAdminUserCacheInfo(ctx, accessToken)
adminCacheInfo, err := as.authRepo.GetAdminUserCacheInfo(ctx, accessToken)
if err != nil {
return nil, err
}
if adminCacheInfo == nil {
return nil, nil
}
// Keep admin authorization aligned with user-token lifecycle and status refresh.
refreshedUserCacheInfo, err := as.GetUserCacheInfo(ctx, accessToken)
if err != nil {
return nil, err
}
if refreshedUserCacheInfo == nil {
if err = as.authRepo.RemoveAdminUserCacheInfo(ctx, accessToken); err != nil {
return nil, err
}
return nil, nil
}
adminCacheInfo.UserStatus = refreshedUserCacheInfo.UserStatus
adminCacheInfo.EmailStatus = refreshedUserCacheInfo.EmailStatus
if refreshedUserCacheInfo.RoleID > 0 {
adminCacheInfo.RoleID = refreshedUserCacheInfo.RoleID
}
if len(refreshedUserCacheInfo.ExternalID) > 0 {
adminCacheInfo.ExternalID = refreshedUserCacheInfo.ExternalID
}
if err = as.authRepo.SetAdminUserCacheInfo(ctx, accessToken, adminCacheInfo); err != nil {
return nil, err
}
return adminCacheInfo, nil
}
func (as *AuthService) SetAdminUserCacheInfo(ctx context.Context, accessToken string, userInfo *entity.UserCacheInfo) (err error) {
@@ -204,6 +204,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc
ObjectCreatorUserID: questionInfo.UserID,
QuestionID: questionInfo.ID,
QuestionStatus: questionInfo.Status,
QuestionShow: questionInfo.Show,
ObjectType: objectType,
Title: questionInfo.Title,
Content: questionInfo.ParsedText, // todo trim
@@ -228,6 +229,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc
ObjectCreatorUserID: answerInfo.UserID,
QuestionID: answerInfo.QuestionID,
QuestionStatus: questionInfo.Status,
QuestionShow: questionInfo.Show,
AnswerStatus: answerInfo.Status,
AnswerID: answerInfo.ID,
ObjectType: objectType,
@@ -258,6 +260,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc
if exist {
objInfo.QuestionID = questionInfo.ID
objInfo.QuestionStatus = questionInfo.Status
objInfo.QuestionShow = questionInfo.Show
objInfo.Title = questionInfo.Title
}
answerInfo, exist, err := os.answerRepo.GetAnswer(ctx, commentInfo.ObjectID)
+61 -5
View File
@@ -21,10 +21,9 @@ import { FC, useEffect, useState, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import copy from 'copy-to-clipboard';
import { voteConversation } from '@/services';
import { markdownToHtml, voteConversation } from '@/services';
import { Icon, htmlRender } from '@/components';
interface IProps {
@@ -40,6 +39,17 @@ interface IProps {
};
}
const escapeHtml = (text: string) =>
text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const renderPlainTextAsHtml = (text: string) =>
escapeHtml(text).replace(/\r?\n/g, '<br />');
const BubbleAi: FC<IProps> = ({
canType = false,
isLast,
@@ -55,6 +65,7 @@ const BubbleAi: FC<IProps> = ({
const [isHelpful, setIsHelpful] = useState(false);
const [isUnhelpful, setIsUnhelpful] = useState(false);
const [canShowAction, setCanShowAction] = useState(false);
const [safeHtml, setSafeHtml] = useState('');
const typewriterRef = useRef<{
timer: NodeJS.Timeout | null;
index: number;
@@ -64,6 +75,8 @@ const BubbleAi: FC<IProps> = ({
index: 0,
isTyping: false,
});
const renderTimerRef = useRef<NodeJS.Timeout | null>(null);
const renderTaskRef = useRef(0);
const fmtContainer = useRef<HTMLDivElement>(null);
// add ref for ScrollIntoView
const containerRef = useRef<HTMLDivElement>(null);
@@ -194,13 +207,56 @@ const BubbleAi: FC<IProps> = ({
};
}, [content, isCompleted]);
useEffect(() => {
if (renderTimerRef.current) {
clearTimeout(renderTimerRef.current);
renderTimerRef.current = null;
}
renderTaskRef.current += 1;
const currentRenderTask = renderTaskRef.current;
if (!displayContent) {
setSafeHtml('');
return undefined;
}
// During streaming, render escaped plain text to avoid executing unsanitized HTML.
if (!isCompleted) {
setSafeHtml(renderPlainTextAsHtml(displayContent));
return undefined;
}
renderTimerRef.current = setTimeout(() => {
markdownToHtml(displayContent)
.then((resp) => {
if (renderTaskRef.current !== currentRenderTask) {
return;
}
setSafeHtml(resp || renderPlainTextAsHtml(displayContent));
})
.catch(() => {
if (renderTaskRef.current !== currentRenderTask) {
return;
}
setSafeHtml(renderPlainTextAsHtml(displayContent));
});
}, 0);
return () => {
if (renderTimerRef.current) {
clearTimeout(renderTimerRef.current);
renderTimerRef.current = null;
}
};
}, [displayContent, isCompleted]);
useEffect(() => {
setIsHelpful(actionData.helpful > 0);
setIsUnhelpful(actionData.unhelpful > 0);
}, [actionData]);
useEffect(() => {
if (fmtContainer.current && isCompleted) {
if (fmtContainer.current && isCompleted && safeHtml) {
htmlRender(fmtContainer.current, {
copySuccessText: t('copied', { keyPrefix: 'messages' }),
copyText: t('copy', { keyPrefix: 'messages' }),
@@ -211,7 +267,7 @@ const BubbleAi: FC<IProps> = ({
});
setCanShowAction(true);
}
}, [isCompleted, fmtContainer.current]);
}, [isCompleted, safeHtml, t]);
return (
<div
@@ -223,7 +279,7 @@ const BubbleAi: FC<IProps> = ({
className="fmt text-break text-wrap"
ref={fmtContainer}
style={{ transition: 'all 0.2s ease' }}
dangerouslySetInnerHTML={{ __html: marked.parse(displayContent) }}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
{canShowAction && (
+1 -1
View File
@@ -328,7 +328,7 @@ const Index = () => {
canType={isGenerate && isLastMessage}
chatId={item.chat_completion_id}
isLast={isLastMessage}
isCompleted={!isGenerate}
isCompleted={!isGenerate || !isLastMessage}
content={item.content}
actionData={{
helpful: item.helpful,
@@ -149,10 +149,10 @@ const Index = () => {
<BubbleUser content={item.content} />
) : (
<BubbleAi
canType
canType={isGenerate && isLastMessage}
chatId={item.chat_completion_id}
isLast
isCompleted={!isGenerate}
isLast={isLastMessage}
isCompleted={!isGenerate || !isLastMessage}
content={item.content}
actionData={{
helpful: item.helpful,