avatar 菜单添加反馈渠道, 存储于数据库表 UserFeedback, 相同用户每四小时只能反馈一次

This commit is contained in:
Gaofeng 2025-12-07 18:18:50 +08:00
parent 0389737154
commit 1d925ce46a
7 changed files with 402 additions and 0 deletions

View file

@ -0,0 +1,42 @@
"""Add user_feedback table for user suggestions
Revision ID: 1c2d3e4f5a6b
Revises: f8c9d0e4a3b2
Create Date: 2025-12-07 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "1c2d3e4f5a6b"
down_revision = "f8c9d0e4a3b2"
branch_labels = None
depends_on = None
def upgrade():
# 新增 user_feedback 表:用于用户主动提交建议/反馈
# 字段说明:
# - id: 主键
# - user_id: 提交用户
# - content: 反馈正文
# - contact: 联系方式(可选)
# - status: 处理状态pending/resolved
# - created_at/updated_at: 秒级时间戳
op.create_table(
"user_feedback",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False, index=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("contact", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="pending"),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
# SQLite does not support server_default on existing rows; ensure default is set for new rows only.
def downgrade():
# 回滚时直接删除表
op.drop_table("user_feedback")

View file

@ -0,0 +1,26 @@
"""Merge heads for user_feedback branch and legacy head
Revision ID: 2b3c4d5e6f7g
Revises: 1c2d3e4f5a6b, b2c3d4e5f6a7
Create Date: 2025-12-07 12:10:00.000000
此迁移用于合并分叉的两个 headuser_feedback 分支与 b2c3d4e5f6a7不做实际数据变更
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "2b3c4d5e6f7g"
down_revision = ("1c2d3e4f5a6b", "b2c3d4e5f6a7")
branch_labels = None
depends_on = None
def upgrade():
# 纯合并迁移,无实际操作
pass
def downgrade():
# 回滚时仅拆分分叉
pass

View file

@ -0,0 +1,130 @@
import time
import uuid
from typing import Optional, List
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, BigInteger
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
import logging
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
class UserFeedback(Base):
__tablename__ = "user_feedback"
# 用户主动反馈建议,带 4 小时限流。仅存最基本的信息,方便前端直接插入查看。
id = Column(Text, primary_key=True)
user_id = Column(Text, index=True, nullable=False)
content = Column(Text, nullable=False) # 反馈正文
contact = Column(Text, nullable=True) # 可选联系方式
status = Column(Text, default="pending") # pending/resolved
created_at = Column(BigInteger, nullable=False) # 秒级时间戳
updated_at = Column(BigInteger, nullable=False)
class UserFeedbackModel(BaseModel):
id: str
user_id: str
content: str
contact: Optional[str] = None
status: str
created_at: int
updated_at: int
model_config = ConfigDict(from_attributes=True)
class UserFeedbackForm(BaseModel):
content: str
contact: Optional[str] = None
class UserFeedbacksTable:
def create(
self, user_id: str, content: str, contact: Optional[str] = None
) -> Optional[UserFeedbackModel]:
"""创建用户反馈,默认状态 pending。"""
now_ts = int(time.time())
record = UserFeedbackModel(
id=str(uuid.uuid4()),
user_id=user_id,
content=content,
contact=contact,
status="pending",
created_at=now_ts,
updated_at=now_ts,
)
try:
with get_db() as db:
db_item = UserFeedback(**record.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return UserFeedbackModel.model_validate(db_item)
except Exception as e:
log.exception(f"Error creating user feedback: {e}")
return None
def get_recent_within(self, user_id: str, seconds: int) -> Optional[UserFeedbackModel]:
"""查询指定时间窗口内最新一条反馈,用于冷却期检查。"""
try:
with get_db() as db:
cutoff = int(time.time()) - seconds
item = (
db.query(UserFeedback)
.filter(UserFeedback.user_id == user_id, UserFeedback.created_at >= cutoff)
.order_by(UserFeedback.created_at.desc())
.first()
)
if not item:
return None
return UserFeedbackModel.model_validate(item)
except Exception as e:
log.exception(f"Error reading recent user feedback: {e}")
return None
def list_by_user(self, user_id: str) -> List[UserFeedbackModel]:
"""按用户列出反馈,按时间倒序。"""
with get_db() as db:
items = (
db.query(UserFeedback)
.filter(UserFeedback.user_id == user_id)
.order_by(UserFeedback.created_at.desc())
.all()
)
return [UserFeedbackModel.model_validate(x) for x in items]
def list_all(self) -> List[UserFeedbackModel]:
"""管理员查看全部反馈。"""
with get_db() as db:
items = db.query(UserFeedback).order_by(UserFeedback.created_at.desc()).all()
return [UserFeedbackModel.model_validate(x) for x in items]
def update_status(self, id: str, status: str) -> Optional[UserFeedbackModel]:
"""更新状态(例如管理员处理后标记 resolved"""
with get_db() as db:
item = db.query(UserFeedback).filter_by(id=id).first()
if not item:
return None
item.status = status
item.updated_at = int(time.time())
db.commit()
db.refresh(item)
return UserFeedbackModel.model_validate(item)
def delete_by_id(self, id: str) -> bool:
"""删除单条反馈。"""
with get_db() as db:
item = db.query(UserFeedback).filter_by(id=id).first()
if not item:
return False
db.delete(item)
db.commit()
return True
UserFeedbacks = UserFeedbacksTable()

View file

@ -9,6 +9,11 @@ from open_webui.models.feedbacks import (
FeedbackForm,
Feedbacks,
)
from open_webui.models.user_feedback import (
UserFeedbackModel,
UserFeedbackForm,
UserFeedbacks,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
@ -71,6 +76,11 @@ class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserResponse] = None
class UserSuggestionForm(BaseModel):
content: str
contact: Optional[str] = None
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
@ -127,6 +137,56 @@ async def create_feedback(
return feedback
@router.post("/feedback/suggestion", response_model=UserFeedbackModel)
async def create_suggestion_feedback(
form_data: UserSuggestionForm, user=Depends(get_verified_user)
):
"""用户主动反馈入口:带 4 小时冷却期,命中则返回 429。"""
content = (form_data.content or "").strip()
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="反馈内容不能为空",
)
# 4 小时限流
recent = UserFeedbacks.get_recent_within(user.id, seconds=4 * 60 * 60)
if recent:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="您最近反馈过,请再等等一天",
)
feedback = UserFeedbacks.create(
user_id=user.id,
content=content,
contact=(form_data.contact or "").strip() or None,
)
if not feedback:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
return feedback
@router.get("/feedbacks/suggestion", response_model=list[UserFeedbackModel])
async def list_user_suggestions(user=Depends(get_admin_user)):
"""管理员查看所有用户反馈。"""
return UserFeedbacks.list_all()
@router.delete("/feedback/suggestion/{id}")
async def delete_user_suggestion(id: str, user=Depends(get_admin_user)):
"""管理员删除单条用户反馈。"""
success = UserFeedbacks.delete_by_id(id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
return True
@router.get("/feedback/{id}", response_model=FeedbackModel)
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
if user.role == "admin":

View file

@ -155,6 +155,35 @@ export const createNewFeedback = async (token: string, feedback: object) => {
return res;
};
export const createUserSuggestionFeedback = async (token: string, body: { content: string; contact?: string }) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedback/suggestion`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail ?? err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFeedbackById = async (token: string, feedbackId: string) => {
let error = null;

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Modal from '$lib/components/common/Modal.svelte';
import { createUserSuggestionFeedback } from '$lib/apis/evaluations';
const i18n = getContext('i18n');
export let show = false;
export let onClose: () => void = () => {};
let content = '';
let contact = '';
let submitting = false;
const reset = () => {
content = '';
contact = '';
};
const handleClose = () => {
reset();
onClose();
};
const submit = async () => {
if (submitting) return;
const trimmed = content.trim();
if (!trimmed) {
toast.error('请填写反馈内容');
return;
}
submitting = true;
try {
await createUserSuggestionFeedback(localStorage.token, {
content: trimmed,
contact: contact.trim() || undefined
});
toast.success('反馈已提交,感谢您的建议');
handleClose();
} catch (err) {
const msg = typeof err === 'string' ? err : err?.detail ?? '提交失败,请稍后重试';
toast.error(msg);
} finally {
submitting = false;
}
};
</script>
<Modal bind:show className="bg-white dark:bg-gray-900 rounded-2xl" size="sm">
<div class="p-4 sm:p-6 space-y-4">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{$i18n.t('反馈') ?? '反馈'}
</div>
<div class="space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-300">{$i18n.t('反馈内容') ?? '反馈内容'}</label>
<textarea
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-850 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
rows="5"
placeholder={$i18n.t('请描述您遇到的问题或建议') ?? '请描述您遇到的问题或建议'}
bind:value={content}
/>
</div>
<div class="space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-300">{$i18n.t('联系方式(可选)') ?? '联系方式(可选)'}</label>
<input
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-850 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={$i18n.t('邮箱/手机号/微信等') ?? '邮箱/手机号/微信等'}
bind:value={contact}
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
class="px-4 py-2 rounded-xl text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
on:click={handleClose}
disabled={submitting}
>
{$i18n.t('取消') ?? '取消'}
</button>
<button
class="px-4 py-2 rounded-xl text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-60"
on:click={submit}
disabled={submitting}
>
{submitting ? $i18n.t('提交中...') ?? '提交中...' : $i18n.t('提交') ?? '提交'}
</button>
</div>
</div>
</Modal>

View file

@ -23,6 +23,7 @@
import UserGroup from '$lib/components/icons/UserGroup.svelte';
import SignOut from '$lib/components/icons/SignOut.svelte';
import BalanceDisplay from '$lib/components/billing/BalanceDisplay.svelte';
import UserSuggestionModal from '$lib/components/common/UserSuggestionModal.svelte';
const i18n = getContext('i18n');
@ -34,6 +35,7 @@
const dispatch = createEventDispatcher();
let usage = null;
let showSuggestionModal = false;
const getUsageInfo = async () => {
const res = await getUsage(localStorage.token).catch((error) => {
console.error('Error fetching usage info:', error);
@ -288,6 +290,19 @@
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer"
on:click={() => {
show = false;
showSuggestionModal = true;
}}
>
<div class=" self-center mr-3">
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-[11px] font-semibold text-white"></span>
</div>
<div class=" self-center truncate">{$i18n.t('反馈')}</div>
</DropdownMenu.Item>
<hr class=" border-gray-50 dark:border-gray-800 my-1 p-0" />
<DropdownMenu.Item
@ -350,3 +365,10 @@
</DropdownMenu.Content>
</slot>
</DropdownMenu.Root>
<UserSuggestionModal
bind:show={showSuggestionModal}
onClose={() => {
showSuggestionModal = false;
}}
/>