feat: 添加每日总结独立页面与后端接口

1.  新增每日总结前端页面,支持企微授权登录、客户对话总结展示
2.  后端新增查询全量客户总结接口,优化去重与排序逻辑
3.  配置Nginx路由与Vite多入口打包,适配Java版本降级
This commit is contained in:
jqb 2026-06-18 17:49:18 +08:00
parent d55ae221c2
commit 4ba2109f8d
8 changed files with 457 additions and 8 deletions

View File

@ -37,4 +37,18 @@ public class DailySummaryController {
List<DailySummaryVO> list = dailySummaryService.queryDailySummary(customerId, date); List<DailySummaryVO> list = dailySummaryService.queryDailySummary(customerId, date);
return Result.success(list); return Result.success(list);
} }
/**
* 查询客户所有日期的消息总结,按时间倒序
*
* @param customerId 客户 ID(企微外部联系人 userid),不传则查该销售所有客户的总结
* @param staffId 销售老师企微 userId
*/
@GetMapping("/daily-summary/all")
public Result<List<DailySummaryVO>> allDailySummary(
@RequestParam(required = false) String customerId,
@RequestParam String staffId) {
List<DailySummaryVO> list = dailySummaryService.queryAllDailySummary(customerId, staffId);
return Result.success(list);
}
} }

View File

@ -22,6 +22,7 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -74,7 +75,44 @@ public class DailySummaryService {
escape(customerId), escape(customerId), escape(queryDate) escape(customerId), escape(customerId), escape(queryDate)
); );
log.info("Query Milvus daily summary, customerId={}, date={}, expr={}", customerId, queryDate, expr); return queryByExpr(customerId, expr, true);
}
/**
* 查询客户所有日期的消息总结
*
* @param customerId 客户企微外部联系人 userid
* @param staffId 销售老师企微 userId,传入则只返回该销售相关的总结
* @return 总结列表,按 created_at 倒序
*/
public List<DailySummaryVO> queryAllDailySummary(String customerId, String staffId) {
if (staffId == null || staffId.trim().isEmpty()) {
throw new IllegalArgumentException("staffId 不能为空");
}
String s = escape(staffId);
String expr;
if (customerId == null || customerId.trim().isEmpty()) {
// 只按员工查:from_user 是员工,或 to_user 包含员工(可能为 JSON 数组)
expr = String.format("from_user == '%s' || to_user like '%%%s%%'", s, s);
} else {
String c = escape(customerId);
// 双向匹配:
// 1) 员工发、客户收(to_user 可能为单值或 JSON 数组)
// 2) 客户发、员工收(to_user 可能为单值或 JSON 数组)
expr = String.format(
"(from_user == '%s' && (to_user == '%s' || to_user like '%%%s%%')) || " +
"(from_user == '%s' && (to_user == '%s' || to_user like '%%%s%%'))",
s, c, c,
c, s, s
);
}
return queryByExpr(customerId, expr, false);
}
private List<DailySummaryVO> queryByExpr(String customerId, String expr, boolean asc) {
log.info("Query Milvus daily summary, customerId={}, expr={}", customerId, expr);
QueryParam queryParam = QueryParam.newBuilder() QueryParam queryParam = QueryParam.newBuilder()
.withCollectionName(milvusProperties.getCollection()) .withCollectionName(milvusProperties.getCollection())
@ -91,12 +129,14 @@ public class DailySummaryService {
} }
List<DailySummaryVO> result = convert(response.getData()); List<DailySummaryVO> result = convert(response.getData());
result = deduplicateByMsgTime(result);
fillStaffInfo(result, customerId); fillStaffInfo(result, customerId);
// 统一按 msg_time 倒序排列
result.sort((a, b) -> { result.sort((a, b) -> {
long ca = a.getCreatedAt() != null ? a.getCreatedAt() : 0L; String ta = a.getMsgTime() != null ? a.getMsgTime() : "";
long cb = b.getCreatedAt() != null ? b.getCreatedAt() : 0L; String tb = b.getMsgTime() != null ? b.getMsgTime() : "";
return Long.compare(ca, cb); return tb.compareTo(ta);
}); });
log.info("Milvus daily summary found {} records", result.size()); log.info("Milvus daily summary found {} records", result.size());
@ -177,6 +217,9 @@ public class DailySummaryService {
* 从 from_user / to_user 中找出销售老师 ID * 从 from_user / to_user 中找出销售老师 ID
*/ */
private String resolveStaffId(DailySummaryVO vo, String customerId) { private String resolveStaffId(DailySummaryVO vo, String customerId) {
if (customerId == null || customerId.trim().isEmpty()) {
return vo.getFromUser();
}
if (customerId.equals(vo.getFromUser())) { if (customerId.equals(vo.getFromUser())) {
return vo.getToUser(); return vo.getToUser();
} }
@ -208,6 +251,33 @@ public class DailySummaryService {
} }
} }
/**
* 按 msgTime 去重,同一日期保留 createdAt 最新的一条
*/
private List<DailySummaryVO> deduplicateByMsgTime(List<DailySummaryVO> list) {
if (list == null || list.isEmpty()) {
return list;
}
Map<String, DailySummaryVO> map = new LinkedHashMap<>();
for (DailySummaryVO vo : list) {
String key = vo.getMsgTime();
if (key == null) {
key = "";
}
DailySummaryVO exist = map.get(key);
if (exist == null || newer(vo, exist)) {
map.put(key, vo);
}
}
return new ArrayList<>(map.values());
}
private boolean newer(DailySummaryVO a, DailySummaryVO b) {
if (a.getCreatedAt() == null) return false;
if (b.getCreatedAt() == null) return true;
return a.getCreatedAt() > b.getCreatedAt();
}
private <T> T getOrDefault(List<T> list, int index) { private <T> T getOrDefault(List<T> list, int index) {
if (list == null || index < 0 || index >= list.size()) { if (list == null || index < 0 || index >= list.size()) {
return null; return null;

View File

@ -27,9 +27,9 @@
</modules> </modules>
<properties> <properties>
<java.version>1.8</java.version> <java.version>17</java.version>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>2.7.18</spring-boot.version> <spring-boot.version>2.7.18</spring-boot.version>

View File

@ -12,6 +12,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# 每日总结独立页面
location /summary {
try_files /summary.html =404;
}
# 静态资源 # 静态资源
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@ -0,0 +1,327 @@
import { useEffect, useState } from 'react'
import { SpinLoading, Empty, Toast } from 'antd-mobile'
const CORPID = 'wwd483c2fba24ae30a'
const AGENTID = '1000021'
const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary.html')
interface SummaryItem {
id?: number
msgTime?: string
msgType?: number
fromUser?: string
toUser?: string
roomId?: string
messageContext?: string
summary?: string
createdAt?: number
staffId?: string
staffName?: string
}
function SummaryApp() {
const [loading, setLoading] = useState(true)
const [list, setList] = useState<SummaryItem[]>([])
const [error, setError] = useState('')
const [needLogin, setNeedLogin] = useState(false)
useEffect(() => {
const init = async () => {
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
// 1. URL 中有 code → 先登录
if (code) {
const cleanUrl = window.location.pathname + window.location.hash
window.history.replaceState({}, document.title, cleanUrl)
const ok = await tryCodeLogin(code)
if (!ok) {
setLoading(false)
setNeedLogin(true)
setError('登录失败,请重试')
return
}
}
// 2. 检查员工登录态
const staffId = urlParams.get('staffId') || localStorage.getItem('staff_user_id') || ''
if (!staffId) {
setLoading(false)
setNeedLogin(true)
setError('未获取到员工ID,请先登录')
return
}
// 3. 加载每日总结
await loadSummary(staffId)
}
init()
}, [])
const noCacheFetch = async (url: string, options?: RequestInit) => {
const sep = url.includes('?') ? '&' : '?'
const nocacheUrl = `${url}${sep}_t=${Date.now()}`
return fetch(nocacheUrl, {
...options,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
...options?.headers
}
})
}
const tryCodeLogin = async (code: string): Promise<boolean> => {
try {
const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0 && data.data) {
const info = data.data
localStorage.setItem('staff_token', info.token)
localStorage.setItem('staff_user_id', info.userId)
localStorage.setItem('staff_user_name', info.userName || '')
localStorage.setItem('staff_avatar', info.avatar || '')
localStorage.setItem('staff_role', info.role || 'SEAT')
console.log('OAuth 登录成功:', info.userId, info.userName)
return true
}
console.error('OAuth login failed:', data.message)
} catch (e) {
console.error('Code login error:', e)
}
return false
}
const redirectToWeComOAuth = () => {
const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${CORPID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=snsapi_base&agentid=${AGENTID}&state=summary#wechat_redirect`
console.log('跳转企微 OAuth:', oauthUrl)
window.location.href = oauthUrl
}
const initWxConfigAndGetCustomerId = (): Promise<string> => {
return new Promise((resolve) => {
const wx = (window as any).wx
if (!wx || !wx.config) {
console.log('非企微环境,跳过 JS-SDK')
resolve('')
return
}
const currentUrl = window.location.href.split('#')[0]
noCacheFetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`)
.then(res => res.json())
.then(corpData => {
if (corpData.code !== 0 || !corpData.data) {
resolve('')
return
}
const cfg = corpData.data
wx.config({
beta: true,
debug: false,
appId: cfg.corpId,
timestamp: cfg.timestamp,
nonceStr: cfg.nonceStr,
signature: cfg.signature,
jsApiList: ['agentConfig', 'getContext', 'getCurExternalContact']
})
wx.ready(() => {
noCacheFetch(`/api/v1/auth/signature?type=agent&agentId=${AGENTID}&url=${encodeURIComponent(currentUrl)}`)
.then(res => res.json())
.then(agentData => {
if (agentData.code !== 0 || !agentData.data) {
resolve('')
return
}
const ac = agentData.data
wx.agentConfig({
corpid: ac.corpId,
agentid: ac.agentId,
timestamp: ac.timestamp,
nonceStr: ac.nonceStr,
signature: ac.signature,
jsApiList: ['getContext', 'getCurExternalContact'],
success: () => {
wx.invoke('getCurExternalContact', {}, (res: any) => {
if (res.err_msg === 'getCurExternalContact:ok' && res.userId) {
console.log('JS-SDK 获取客户ID:', res.userId)
resolve(res.userId)
} else {
console.warn('getCurExternalContact failed:', res)
resolve('')
}
})
},
fail: (err: any) => {
console.warn('agentConfig fail:', err)
resolve('')
}
})
})
.catch((e: any) => {
console.error('agentConfig fetch error:', e)
resolve('')
})
})
wx.error((err: any) => {
console.error('wx.config error:', err)
resolve('')
})
})
.catch((e: any) => {
console.error('initWxConfig error:', e)
resolve('')
})
})
}
const loadSummary = async (staffId: string) => {
const urlParams = new URLSearchParams(window.location.search)
let customerId = urlParams.get('customerId') || ''
// 优先通过企微 JS-SDK 获取当前客户ID
const sdkCustomerId = await initWxConfigAndGetCustomerId()
if (sdkCustomerId) {
customerId = sdkCustomerId
localStorage.setItem('current_customer_id', sdkCustomerId)
} else {
customerId = customerId || localStorage.getItem('current_customer_id') || ''
}
if (!customerId) {
setLoading(false)
setError('缺少客户ID参数')
Toast.show({ content: '缺少客户ID参数', icon: 'fail' })
return
}
try {
const res = await fetch(
`/api/v1/archive/daily-summary/all?customerId=${encodeURIComponent(customerId)}&staffId=${encodeURIComponent(staffId)}&_t=${Date.now()}`,
{
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
}
)
const data = await res.json()
if (data.code === 0 && Array.isArray(data.data)) {
setList(data.data)
} else {
const msg = data.message || '加载失败'
setError(msg)
Toast.show({ content: msg, icon: 'fail' })
}
} catch (e) {
console.error('加载每日总结失败:', e)
setError('网络错误')
Toast.show({ content: '网络错误', icon: 'fail' })
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div style={{ padding: 60, textAlign: 'center' }}>
<SpinLoading style={{ '--size': '32px' } as any} />
<div style={{ marginTop: 16, color: '#999', fontSize: 14 }}>加载每日总结中...</div>
</div>
)
}
if (needLogin) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 20 }}>
<div style={{ fontSize: 14, color: '#999', marginBottom: 24 }}>{error || '未获取到员工ID,请先登录'}</div>
<button
onClick={redirectToWeComOAuth}
style={{
padding: '10px 32px',
background: '#1677ff',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: 15,
cursor: 'pointer',
fontWeight: 500
}}
>
企微一键登录
</button>
</div>
)
}
if (error) {
return (
<div style={{ padding: 60, textAlign: 'center' }}>
<div style={{ color: '#999', fontSize: 14 }}>{error}</div>
</div>
)
}
if (list.length === 0) {
return (
<div style={{ padding: 60 }}>
<Empty description="暂无每日总结" />
</div>
)
}
return (
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
<div style={{
background: '#fff',
borderRadius: 12,
padding: '12px 16px',
marginBottom: 12,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<div style={{ fontSize: 16, fontWeight: 500, color: '#333' }}>
📋 每日总结
</div>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
共 {list.length} 条 · 按时间倒序
</div>
</div>
{list.map((item, index) => (
<div key={item.id || index} style={{
background: '#fff',
borderRadius: 12,
padding: '14px 16px',
marginBottom: 12,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div style={{ fontSize: 13, color: '#1677ff', fontWeight: 500 }}>
{item.msgTime || formatDate(item.createdAt)}
</div>
<div style={{ fontSize: 12, color: '#999' }}>
{item.staffName || item.staffId || '未知老师'}
</div>
</div>
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{item.summary || item.messageContext || '无内容'}
</div>
</div>
))}
</div>
)
}
function formatDate(timestamp?: number) {
if (!timestamp) return '-'
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
export default SummaryApp

View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import SummaryApp from './SummaryApp'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<SummaryApp />
</React.StrictMode>,
)

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>每日总结</title>
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/summary.tsx"></script>
</body>
</html>

View File

@ -1,5 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
base: '/sidebar/', base: '/sidebar/',
@ -15,6 +16,12 @@ export default defineConfig({
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: true sourcemap: true,
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
summary: resolve(__dirname, 'summary.html')
}
}
} }
}) })