Merge pull request #1560 from coltea/feat-pv

feat: pv 支持浏览量展示
This commit is contained in:
Coltea 2025-11-26 14:56:01 +08:00 committed by GitHub
commit 2d7c20c799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 396 additions and 16 deletions

View File

@ -30,6 +30,7 @@ type NodeDetailResp struct {
CreatorAccount string `json:"creator_account"`
EditorAccount string `json:"editor_account"`
PublisherAccount string `json:"publisher_account" gorm:"-"`
PV int64 `json:"pv" gorm:"-"`
}
type NodePermissionReq struct {

View File

@ -25,4 +25,5 @@ type ShareNodeDetailResp struct {
EditorAccount string `json:"editor_account"`
PublisherAccount string `json:"publisher_account"`
List []*domain.ShareNodeDetailItem `json:"list" gorm:"-"`
PV int64 `json:"pv" gorm:"-"`
}

View File

@ -4655,6 +4655,9 @@ const docTemplate = `{
"search_placeholder": {
"type": "string"
},
"stats_setting": {
"$ref": "#/definitions/domain.StatsSetting"
},
"theme_and_style": {
"$ref": "#/definitions/domain.ThemeAndStyle"
},
@ -4933,6 +4936,9 @@ const docTemplate = `{
"search_placeholder": {
"type": "string"
},
"stats_setting": {
"$ref": "#/definitions/domain.StatsSetting"
},
"theme_and_style": {
"$ref": "#/definitions/domain.ThemeAndStyle"
},
@ -7377,6 +7383,14 @@ const docTemplate = `{
"StatPageSceneLogin"
]
},
"domain.StatsSetting": {
"type": "object",
"properties": {
"pv_enable": {
"type": "boolean"
}
}
},
"domain.SwitchModeReq": {
"type": "object",
"required": [
@ -8573,6 +8587,9 @@ const docTemplate = `{
"publisher_id": {
"type": "string"
},
"pv": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/domain.NodeStatus"
},
@ -8750,6 +8767,9 @@ const docTemplate = `{
"publisher_id": {
"type": "string"
},
"pv": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/domain.NodeStatus"
},

View File

@ -4648,6 +4648,9 @@
"search_placeholder": {
"type": "string"
},
"stats_setting": {
"$ref": "#/definitions/domain.StatsSetting"
},
"theme_and_style": {
"$ref": "#/definitions/domain.ThemeAndStyle"
},
@ -4926,6 +4929,9 @@
"search_placeholder": {
"type": "string"
},
"stats_setting": {
"$ref": "#/definitions/domain.StatsSetting"
},
"theme_and_style": {
"$ref": "#/definitions/domain.ThemeAndStyle"
},
@ -7370,6 +7376,14 @@
"StatPageSceneLogin"
]
},
"domain.StatsSetting": {
"type": "object",
"properties": {
"pv_enable": {
"type": "boolean"
}
}
},
"domain.SwitchModeReq": {
"type": "object",
"required": [
@ -8566,6 +8580,9 @@
"publisher_id": {
"type": "string"
},
"pv": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/domain.NodeStatus"
},
@ -8743,6 +8760,9 @@
"publisher_id": {
"type": "string"
},
"pv": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/domain.NodeStatus"
},

View File

@ -506,6 +506,8 @@ definitions:
type: array
search_placeholder:
type: string
stats_setting:
$ref: '#/definitions/domain.StatsSetting'
theme_and_style:
$ref: '#/definitions/domain.ThemeAndStyle'
theme_mode:
@ -680,6 +682,8 @@ definitions:
type: array
search_placeholder:
type: string
stats_setting:
$ref: '#/definitions/domain.StatsSetting'
theme_and_style:
$ref: '#/definitions/domain.ThemeAndStyle'
theme_mode:
@ -2277,6 +2281,11 @@ definitions:
- StatPageSceneNodeDetail
- StatPageSceneChat
- StatPageSceneLogin
domain.StatsSetting:
properties:
pv_enable:
type: boolean
type: object
domain.SwitchModeReq:
properties:
auto_mode_api_key:
@ -3064,6 +3073,8 @@ definitions:
type: string
publisher_id:
type: string
pv:
type: integer
status:
$ref: '#/definitions/domain.NodeStatus'
type:
@ -3184,6 +3195,8 @@ definitions:
type: string
publisher_id:
type: string
pv:
type: integer
status:
$ref: '#/definitions/domain.NodeStatus'
type:

View File

@ -66,8 +66,6 @@ func (t AppType) ToSourceType() consts.SourceType {
return consts.SourceTypeOpenAIAPI
case AppTypeLarkBot:
return consts.SourceTypeLarkBot
case AppTypeMcpServer:
return consts.SourceTypeMcpServer
default:
return ""
}
@ -172,6 +170,11 @@ type AppSettings struct {
ConversationSetting ConversationSetting `json:"conversation_setting"`
// MCP Server Settings
MCPServerSettings MCPServerSettings `json:"mcp_server_settings,omitempty"`
StatsSetting StatsSetting `json:"stats_setting"`
}
type StatsSetting struct {
PVEnable bool `json:"pv_enable"`
}
type ConversationSetting struct {
@ -561,6 +564,7 @@ type AppSettingsResp struct {
ConversationSetting ConversationSetting `json:"conversation_setting"`
// MCP Server Settings
MCPServerSettings MCPServerSettings `json:"mcp_server_settings,omitempty"`
StatsSetting StatsSetting `json:"stats_setting"`
}
type WebAppLandingConfigResp struct {

View File

@ -20,6 +20,7 @@ type BaseEditionLimitation struct {
AllowCopyProtection bool `json:"allow_copy_protection"` // 支持内容复制保护
AllowOpenAIBotSettings bool `json:"allow_open_ai_bot_settings"` // 支持问答机器人
AllowMCPServer bool `json:"allow_mcp_server"` // 支持创建MCP Server
AllowNodeStats bool `json:"allow_node_stats"` // 支持文档统计
}
var baseEditionLimitationDefault = BaseEditionLimitation{

View File

@ -105,3 +105,14 @@ type StatPageHour struct {
func (StatPageHour) TableName() string {
return "stat_page_hours"
}
// NodeStats node表统计数据
type NodeStats struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
NodeID string `json:"node_id" gorm:"uniqueIndex"`
PV int64 `json:"pv"`
}
func (NodeStats) TableName() string {
return "node_stats"
}

View File

@ -2,6 +2,7 @@ package mq
import (
"context"
"time"
"github.com/robfig/cron/v3"
@ -66,6 +67,16 @@ func NewStatCronHandler(logger *log.Logger, statRepo *pg.StatRepository, statUse
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))

View File

@ -146,6 +146,7 @@ func (h *NodeHandler) GetNodeDetail(c echo.Context) error {
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)

@ -1 +1 @@
Subproject commit 7771d31053770721d75e194d1c6e7a4b60fb58aa
Subproject commit 93a303e5d55e5fece36675283ba11b81bb79d8db

View File

@ -0,0 +1,39 @@
package pg
import (
"context"
"errors"
"gorm.io/gorm"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/utils"
)
func (r *NodeRepository) GetNodeStatsByNodeId(ctx context.Context, nodeId string) (*domain.NodeStats, error) {
var nodeStats *domain.NodeStats
if err := r.db.WithContext(ctx).
Model(&domain.NodeStats{}).
Where("node_id = ?", nodeId).
First(&nodeStats).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
nodeStats = &domain.NodeStats{
ID: 0,
NodeID: nodeId,
PV: 0,
}
} else {
return nil, err
}
}
var todayStats int64
if err := r.db.WithContext(ctx).Model(&domain.StatPage{}).
Where("created_at >= ?", utils.GetTimeHourOffset(-24)).
Where("node_id = ?", nodeId).Count(&todayStats).Error; err != nil {
return nil, err
}
nodeStats.PV += todayStats
return nodeStats, nil
}

View File

@ -3,6 +3,9 @@ package pg
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
v1 "github.com/chaitin/panda-wiki/api/stat/v1"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/store/cache"
@ -156,3 +159,46 @@ func (r *StatRepository) RemoveOldData(ctx context.Context) error {
}
return nil
}
// GetYesterdayPVByNode 获取昨天的PV数据按node_id分组
func (r *StatRepository) GetYesterdayPVByNode(ctx context.Context) (map[string]int64, error) {
type PVResult struct {
NodeID string
Count int64
}
var results []PVResult
if err := r.db.WithContext(ctx).Model(&domain.StatPage{}).
Where("created_at < ?", utils.GetTimeHourOffset(0)).
Where("created_at >= ?", utils.GetTimeHourOffset(-24)).
Where("node_id != ?", "").
Group("node_id").
Select("node_id, COUNT(*) as count").
Find(&results).Error; err != nil {
return nil, err
}
pvMap := make(map[string]int64)
for _, result := range results {
pvMap[result.NodeID] = result.Count
}
return pvMap, nil
}
// UpsertNodeStats 插入或更新node_stats表
func (r *StatRepository) UpsertNodeStats(ctx context.Context, nodeID string, pvCount int64) error {
nodeStats := &domain.NodeStats{
NodeID: nodeID,
PV: pvCount,
}
// 使用GORM的Clauses进行upsert操作
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "node_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"pv": gorm.Expr("node_stats.pv + ?", pvCount),
}),
}).
Create(nodeStats).Error
}

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS node_stats;

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS node_stats (
id BIGSERIAL PRIMARY KEY,
node_id TEXT NOT NULL UNIQUE,
pv BIGINT NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT NOW()
);

View File

@ -534,6 +534,7 @@ func (u *AppUsecase) GetAppDetailByKBIDAndAppType(ctx context.Context, kbID stri
WecomAIBotSettings: app.Settings.WecomAIBotSettings,
MCPServerSettings: app.Settings.MCPServerSettings,
StatsSetting: app.Settings.StatsSetting,
}
if !domain.GetBaseEditionLimitation(ctx).AllowCustomCopyright {
@ -651,6 +652,7 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
ContributeSettings: app.Settings.ContributeSettings,
HomePageSetting: app.Settings.HomePageSetting,
ConversationSetting: app.Settings.ConversationSetting,
StatsSetting: app.Settings.StatsSetting,
},
}
// init ai feedback string

View File

@ -119,6 +119,12 @@ func (u *NodeUsecase) GetNodeByKBID(ctx context.Context, id, kbId, format string
node.PublisherAccount = nodeRelease.PublisherAccount
}
nodeStat, err := u.nodeRepo.GetNodeStatsByNodeId(ctx, node.ID)
if err != nil {
return nil, err
}
node.PV = nodeStat.PV
if node.Meta.ContentType == domain.ContentTypeMD {
return node, nil
}
@ -221,6 +227,21 @@ func (u *NodeUsecase) GetNodeReleaseDetailByKBIDAndID(ctx context.Context, kbID,
node.PublisherAccount = account
}
if domain.GetBaseEditionLimitation(ctx).AllowNodeStats {
webApp, err := u.appRepo.GetOrCreateAppByKBIDAndType(ctx, kbID, domain.AppTypeWeb)
if err != nil {
return nil, err
}
if webApp.Settings.StatsSetting.PVEnable {
nodeStat, err := u.nodeRepo.GetNodeStatsByNodeId(ctx, nodeId)
if err != nil {
return nil, err
}
node.PV = nodeStat.PV
}
}
if node.Meta.ContentType == domain.ContentTypeMD {
return node, nil
}

View File

@ -472,3 +472,28 @@ func (u *StatUseCase) AggregateHourlyStats(ctx context.Context) error {
func (u *StatUseCase) CleanupOldHourlyStats(ctx context.Context) error {
return u.repo.CleanupOldHourlyStats(ctx)
}
// MigrateYesterdayPVToNodeStats 将昨天的PV数据从stat_page迁移到node_stats
func (u *StatUseCase) MigrateYesterdayPVToNodeStats(ctx context.Context) error {
// 获取昨天的PV数据按node_id分组
pvMap, err := u.repo.GetYesterdayPVByNode(ctx)
if err != nil {
u.logger.Error("failed to get yesterday PV data", log.Error(err))
return err
}
// 遍历并插入/更新到node_stats表
for nodeID, pvCount := range pvMap {
if err := u.repo.UpsertNodeStats(ctx, nodeID, pvCount); err != nil {
u.logger.Error("failed to upsert node stats",
log.Error(err),
log.String("node_id", nodeID),
log.Int64("pv_count", pvCount))
return err
}
}
u.logger.Info("successfully migrated yesterday PV data to node_stats",
log.Int("node_count", len(pvMap)))
return nil
}

View File

@ -36,6 +36,7 @@ import Header from './Header';
import Summary from './Summary';
import Toc from './Toc';
import Toolbar from './Toolbar';
import IconPageview1 from '@panda-wiki/icons/IconPageview1';
interface WrapProps {
detail: V1NodeDetailResp;
@ -434,6 +435,15 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
<IconZiti sx={{ fontSize: 12 }} />
{characterCount}
</Stack>
<Stack
direction={'row'}
alignItems={'center'}
gap={0.5}
sx={{ fontSize: 12, color: 'text.tertiary' }}
>
<IconPageview1 sx={{ fontSize: 12 }} />
{nodeDetail?.pv}
</Stack>
</Stack>
<Box
sx={{
@ -681,19 +691,7 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
</Box>
<Box
sx={{ ...(fixedToc && { display: 'flex' }) }}
onKeyDown={event => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
return;
}
if (
isMarkdown &&
(event.ctrlKey || event.metaKey) &&
event.key === 'b'
) {
return;
}
event.stopPropagation();
}}
onKeyDown={event => event.stopPropagation()}
>
{isMarkdown ? (
<Box

View File

@ -15,6 +15,7 @@ import CardStyle from './CardStyle';
import CardWebCustomCode from './CardWebCustomCode';
import CardWebSEO from './CardWebSEO';
import CardQaCopyright from './CardQaCopyright';
import CardWebStats from './CardWebStats';
interface CardWebProps {
kb: DomainKnowledgeBaseDetail;
@ -134,6 +135,22 @@ const CardWeb = ({ kb, refresh }: CardWebProps) => {
});
}}
/>
<CardWebStats
id={info.id}
data={info}
refresh={value => {
setInfo({
...info,
settings: {
...info.settings,
stats_setting: {
...info.settings?.stats_setting,
...value,
},
},
});
}}
/>
</Box>
);
};

View File

@ -0,0 +1,85 @@
import { message } from '@ctzhian/ui';
import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { DomainAppDetailResp } from '@/request/types';
import { SettingCardItem, FormItem } from './Common';
import { useAppSelector } from '@/store';
import { putApiV1App } from '@/request/App';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version.ts';
import VersionMask from '@/components/VersionMask';
interface CardWebStatsProps {
id: string;
data: DomainAppDetailResp;
refresh: (value: { pv_enable?: boolean }) => void;
}
interface StatsFormData {
pv_enable: 1 | 2;
}
const CardWebStats = ({ data, id, refresh }: CardWebStatsProps) => {
const [isEdit, setIsEdit] = useState(false);
const { kb_id } = useAppSelector(state => state.config);
const { handleSubmit, control, setValue } = useForm<StatsFormData>({
defaultValues: {
pv_enable: 2,
},
});
const onSubmit = handleSubmit((value: StatsFormData) => {
const submitValue = {
pv_enable: value.pv_enable === 1,
};
putApiV1App(
{ id },
{ kb_id, settings: { ...data.settings, stats_setting: submitValue } },
).then(() => {
message.success('保存成功');
refresh(submitValue);
setIsEdit(false);
});
});
useEffect(() => {
const pvEnable = data.settings?.stats_setting?.pv_enable;
setValue('pv_enable', pvEnable === true ? 1 : 2);
}, [data]);
return (
<SettingCardItem title='统计分析' isEdit={isEdit} onSubmit={onSubmit}>
<VersionMask permission={PROFESSION_VERSION_PERMISSION}>
<FormItem label='文档浏览量'>
<Controller
control={control}
name='pv_enable'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(+e.target.value as 1 | 2);
setIsEdit(true);
}}
>
<FormControlLabel
value={1}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value={2}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)}
/>
</FormItem>
</VersionMask>
</SettingCardItem>
);
};
export default CardWebStats;

View File

@ -341,6 +341,7 @@ export interface DomainAppSettings {
recommend_node_ids?: string[];
recommend_questions?: string[];
search_placeholder?: string;
stats_setting?: DomainStatsSetting;
theme_and_style?: DomainThemeAndStyle;
/** theme */
theme_mode?: string;
@ -428,6 +429,7 @@ export interface DomainAppSettingsResp {
recommend_node_ids?: string[];
recommend_questions?: string[];
search_placeholder?: string;
stats_setting?: DomainStatsSetting;
theme_and_style?: DomainThemeAndStyle;
/** theme */
theme_mode?: string;
@ -1256,6 +1258,10 @@ export interface DomainStatPageReq {
scene: 1 | 2 | 3 | 4;
}
export interface DomainStatsSetting {
pv_enable?: boolean;
}
export interface DomainSwitchModeReq {
/** 百智云 API Key */
auto_mode_api_key?: string;
@ -1675,6 +1681,7 @@ export interface V1NodeDetailResp {
permissions?: DomainNodePermissions;
publisher_account?: string;
publisher_id?: string;
pv?: number;
status?: DomainNodeStatus;
type?: DomainNodeType;
updated_at?: string;
@ -1735,6 +1742,7 @@ export interface V1ShareNodeDetailResp {
permissions?: DomainNodePermissions;
publisher_account?: string;
publisher_id?: string;
pv?: number;
status?: DomainNodeStatus;
type?: DomainNodeType;
updated_at?: string;

View File

@ -341,6 +341,7 @@ export interface DomainAppSettings {
recommend_node_ids?: string[];
recommend_questions?: string[];
search_placeholder?: string;
stats_setting?: DomainStatsSetting;
theme_and_style?: DomainThemeAndStyle;
/** theme */
theme_mode?: string;
@ -428,6 +429,7 @@ export interface DomainAppSettingsResp {
recommend_node_ids?: string[];
recommend_questions?: string[];
search_placeholder?: string;
stats_setting?: DomainStatsSetting;
theme_and_style?: DomainThemeAndStyle;
/** theme */
theme_mode?: string;
@ -1256,6 +1258,10 @@ export interface DomainStatPageReq {
scene: 1 | 2 | 3 | 4;
}
export interface DomainStatsSetting {
pv_enable?: boolean;
}
export interface DomainSwitchModeReq {
/** 百智云 API Key */
auto_mode_api_key?: string;
@ -1675,6 +1681,7 @@ export interface V1NodeDetailResp {
permissions?: DomainNodePermissions;
publisher_account?: string;
publisher_id?: string;
pv?: number;
status?: DomainNodeStatus;
type?: DomainNodeType;
updated_at?: string;
@ -1735,6 +1742,7 @@ export interface V1ShareNodeDetailResp {
permissions?: DomainNodePermissions;
publisher_account?: string;
publisher_id?: string;
pv?: number;
status?: DomainNodeStatus;
type?: DomainNodeType;
updated_at?: string;

View File

@ -22,6 +22,7 @@ import ConfirmModal from './ConfirmModal';
import Header from './Header';
import Toc from './Toc';
import Toolbar from './Toolbar';
import IconPageview1 from '@panda-wiki/icons/IconPageview1';
interface WrapProps {
detail: V1NodeDetailResp;
@ -295,6 +296,15 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
<IconZiti />
{characterCount}
</Stack>
<Stack
direction={'row'}
alignItems={'center'}
gap={0.5}
sx={{ fontSize: 12, color: 'text.tertiary' }}
>
<IconPageview1 sx={{ fontSize: 12 }} />
{nodeDetail?.pv}
</Stack>
</Stack>
{editorRef.editor && (
<Box sx={{ ...(fixedToc && { display: 'flex' }) }}>

View File

@ -236,6 +236,12 @@ const DocContent = ({
<Box>{characterCount} </Box>
</>
)}
{(info.pv ?? 0) > 0 && (
<>
<Box>·</Box>
<Box> {info.pv}</Box>
</>
)}
</Stack>
{info?.meta?.summary && (
<Box

View File

@ -0,0 +1,23 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconPageview1 = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path
d='M512 192c206.752 0 419.872 254.016 446.496 320-26.56 66.016-239.744 320-446.496 320-207.456 0-420.416-254.176-446.496-320 26.24-65.792 239.2-320 446.496-320zM512 128c-255.808 0-512 319.808-512 384 0 64.064 255.104 384 512 384 256.32 0 512-320.384 512-384 0-64-256.864-384-512-384l0 0z'
fill='#444444'
></path>
<path
d='M512 352c-88.416 0-160 71.648-160 160 0 88.384 71.584 160 160 160 88.448 0 160-71.616 160-160 0-88.352-71.552-160-160-160zM512 608c-52.992 0-96-43.008-96-96 0-53.024 43.008-96 96-96s96 42.976 96 96c0 52.992-42.944 96-96 96z'
fill='#444444'
></path>
</SvgIcon>
);
IconPageview1.displayName = 'icon-pageview1';
export default IconPageview1;

View File

@ -153,6 +153,7 @@ export { default as IconOllama } from './IconOllama';
export { default as IconOpenrouter } from './IconOpenrouter';
export { default as IconPCduan } from './IconPCduan';
export { default as IconPDF } from './IconPDF';
export { default as IconPageview1 } from './IconPageview1';
export { default as IconPaperFull } from './IconPaperFull';
export { default as IconPaperPlaneFill } from './IconPaperPlaneFill';
export { default as IconPeizhi } from './IconPeizhi';