18 Commits

Author SHA1 Message Date
wong
2939b63998 1 2025-12-01 16:47:57 +08:00
wong
ff7097f2d9 1 2025-12-01 16:46:23 +08:00
wong
33cd4de3c5 Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into wong-dev 2025-12-01 16:44:38 +08:00
wong
0bc6c3a22a 代码优化 2025-12-01 16:41:40 +08:00
wong
44edfe7a81 消息同步优化 2025-12-01 16:25:15 +08:00
wong
d3fae3bbd0 队列优化 2025-12-01 15:42:54 +08:00
wong
31fdabaa15 Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into wong-dev
# Conflicts:
#	Server/composer.json
2025-12-01 10:27:35 +08:00
wong
43e9930b45 Merge tag 'v1.1.1-a' into develop 2025-12-01 10:17:25 +08:00
wong
a77192ebc2 Merge branch 'release/v1.1.1-a' 2025-12-01 10:17:18 +08:00
wong
181a792d0a Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into develop
# Conflicts:
#	Cunkebao/src/pages/mobile/mine/wechat-accounts/detail/index.tsx
2025-12-01 10:15:45 +08:00
wong
43f6a061e1 解决代码冲突问题 2025-12-01 10:05:26 +08:00
超级老白兔
05bba1a461 Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into develop 2025-11-28 23:00:44 +08:00
超级老白兔
97fc9959bb Refactor cache clearing functionality in settings page to improve user feedback and error handling. Update WeChat API functions to support debounce options for contact and group list retrieval. Enhance message rendering logic to include red packet messages and improve user identification in chat records. 2025-11-28 23:00:40 +08:00
wong
ad2d1c27ab 优化代码 2025-11-28 17:03:05 +08:00
wong
97144e4ebe Merge tag 'v1.1.1' into develop 2025-11-28 16:55:27 +08:00
wong
4baa82449c Merge branch 'develop' of https://gitee.com/cunkebao/cunkebao_v3 into wong-dev
# Conflicts:
#	Server/composer.json
2025-11-20 16:15:30 +08:00
wong
5a5160b92d 代码优化 2025-11-20 11:48:31 +08:00
wong
3e78122659 composer依赖提交 2025-11-20 11:45:53 +08:00
20 changed files with 6657 additions and 235 deletions

View File

@@ -8,7 +8,6 @@ import {
LogoutOutlined,
SettingOutlined,
LockOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import { useUserStore } from "@/store/module/user";
@@ -16,7 +15,7 @@ import { useSettingsStore } from "@/store/module/settings";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
import { sendMessageToParent, TYPE_EMUE } from "@/utils/postApp";
import { updateChecker } from "@/utils/updateChecker";
import { clearApplicationCache } from "@/utils/cacheCleaner";
interface SettingItem {
id: string;
@@ -58,13 +57,35 @@ const Setting: React.FC = () => {
const handleClearCache = () => {
Dialog.confirm({
content: "确定要清除缓存吗?这将清除所有本地数据。",
onConfirm: () => {
sendMessageToParent(
{
action: "clearCache",
},
TYPE_EMUE.FUNCTION,
);
onConfirm: async () => {
const handler = Toast.show({
icon: "loading",
content: "正在清理缓存...",
duration: 0,
});
try {
await clearApplicationCache();
sendMessageToParent(
{
action: "clearCache",
},
TYPE_EMUE.FUNCTION,
);
handler.close();
Toast.show({
icon: "success",
content: "缓存清理完成",
position: "top",
});
} catch (error) {
console.error("clear cache failed", error);
handler.close();
Toast.show({
icon: "fail",
content: "缓存清理失败,请稍后再试",
position: "top",
});
}
},
});
};

View File

@@ -1050,6 +1050,101 @@
height: 500px;
overflow-y: auto;
// 健康分评估区域
.health-score-section {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.health-score-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.health-score-info {
.health-score-status {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
.status-tag {
background: #ffebeb;
color: #ff4d4f;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.status-time {
font-size: 12px;
color: #999;
}
}
.health-score-display {
display: flex;
align-items: center;
.score-circle-wrapper {
width: 100px;
height: 100px;
margin-right: 24px;
position: relative;
.score-circle {
width: 100%;
height: 100%;
border-radius: 50%;
background: #fff;
border: 8px solid #ff4d4f;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.score-number {
font-size: 28px;
font-weight: 700;
color: #ff4d4f;
line-height: 1;
}
.score-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
.health-score-stats {
flex: 1;
.stats-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.stats-label {
font-size: 14px;
color: #666;
}
.stats-value {
font-size: 14px;
color: #333;
font-weight: 500;
}
}
}
}
}
}
.health-score-card {
background: #ffffff;
border-radius: 12px;

View File

@@ -11,6 +11,7 @@ import {
Avatar,
Tag,
Switch,
DatePicker,
} from "antd-mobile";
import { Input, Pagination } from "antd";
import NavCommon from "@/components/NavCommon";
@@ -25,11 +26,14 @@ import {
getWechatAccountDetail,
getWechatFriends,
transferWechatFriends,
getWechatAccountOverview,
getWechatMoments,
exportWechatMoments,
} from "./api";
import DeviceSelection from "@/components/DeviceSelection";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { WechatAccountSummary, Friend } from "./data";
import { WechatAccountSummary, Friend, MomentItem } from "./data";
const WechatAccountDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -38,11 +42,10 @@ const WechatAccountDetail: React.FC = () => {
const [accountSummary, setAccountSummary] =
useState<WechatAccountSummary | null>(null);
const [accountInfo, setAccountInfo] = useState<any>(null);
const [overviewData, setOverviewData] = useState<any>(null);
const [showRestrictions, setShowRestrictions] = useState(false);
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
[],
);
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
const [inheritInfo, setInheritInfo] = useState(true);
const [transferLoading, setTransferLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -56,6 +59,22 @@ const WechatAccountDetail: React.FC = () => {
const [isFetchingFriends, setIsFetchingFriends] = useState(false);
const [hasFriendLoadError, setHasFriendLoadError] = useState(false);
const [isFriendsEmpty, setIsFriendsEmpty] = useState(false);
const [moments, setMoments] = useState<MomentItem[]>([]);
const [momentsPage, setMomentsPage] = useState(1);
const [momentsTotal, setMomentsTotal] = useState(0);
const [isFetchingMoments, setIsFetchingMoments] = useState(false);
const [momentsError, setMomentsError] = useState<string | null>(null);
const MOMENTS_LIMIT = 10;
// 导出相关状态
const [showExportPopup, setShowExportPopup] = useState(false);
const [exportKeyword, setExportKeyword] = useState("");
const [exportType, setExportType] = useState<number | undefined>(undefined);
const [exportStartTime, setExportStartTime] = useState<Date | null>(null);
const [exportEndTime, setExportEndTime] = useState<Date | null>(null);
const [showStartTimePicker, setShowStartTimePicker] = useState(false);
const [showEndTimePicker, setShowEndTimePicker] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
// 获取基础信息
const fetchAccountInfo = useCallback(async () => {
@@ -86,6 +105,19 @@ const WechatAccountDetail: React.FC = () => {
}
}, [id]);
// 获取概览数据
const fetchOverviewData = useCallback(async () => {
if (!id) return;
try {
const response = await getWechatAccountOverview(id);
if (response) {
setOverviewData(response);
}
} catch (e) {
console.error("获取概览数据失败:", e);
}
}, [id]);
// 获取好友列表 - 封装为独立函数
const fetchFriendsList = useCallback(
async (page: number = 1, keyword: string = "") => {
@@ -102,26 +134,44 @@ const WechatAccountDetail: React.FC = () => {
keyword: keyword,
});
const newFriends = response.list.map((friend: any) => ({
id: friend.id.toString(),
avatar: friend.avatar || "/placeholder.svg",
nickname: friend.nickname || "未知用户",
wechatId: friend.wechatId || "",
remark: friend.memo || "",
addTime: friend.createTime || new Date().toISOString().split("T")[0],
lastInteraction:
friend.lastInteraction || new Date().toISOString().split("T")[0],
tags: friend.tags
? friend.tags.map((tag: string, index: number) => ({
id: `tag-${index}`,
name: tag,
color: getRandomTagColor(),
}))
: [],
region: friend.region || "未知",
source: friend.source || "未知",
notes: friend.notes || "",
}));
const newFriends = response.list.map((friend: any) => {
const memoTags = Array.isArray(friend.memo)
? friend.memo
: friend.memo
? String(friend.memo)
.split(/[,\s、]+/)
.filter(Boolean)
: [];
const tagList = Array.isArray(friend.tags)
? friend.tags
: friend.tags
? [friend.tags]
: [];
return {
id: friend.id.toString(),
avatar: friend.avatar || "/placeholder.svg",
nickname: friend.nickname || "未知用户",
wechatId: friend.wechatId || "",
remark: friend.notes || "",
addTime:
friend.createTime || new Date().toISOString().split("T")[0],
lastInteraction:
friend.lastInteraction || new Date().toISOString().split("T")[0],
tags: memoTags.map((tag: string, index: number) => ({
id: `tag-${index}`,
name: tag,
color: getRandomTagColor(),
})),
statusTags: tagList,
region: friend.region || "未知",
source: friend.source || "未知",
notes: friend.notes || "",
value: friend.value,
valueFormatted: friend.valueFormatted,
};
});
setFriends(newFriends);
setFriendsTotal(response.total);
@@ -143,6 +193,46 @@ const WechatAccountDetail: React.FC = () => {
[id],
);
const fetchMomentsList = useCallback(
async (page: number = 1, append: boolean = false) => {
if (!id) return;
setIsFetchingMoments(true);
setMomentsError(null);
try {
const response = await getWechatMoments({
wechatId: id,
page,
limit: MOMENTS_LIMIT,
});
const list: MomentItem[] = (response.list || []).map((moment: any) => ({
id: moment.id?.toString() || Math.random().toString(),
snsId: moment.snsId,
type: moment.type,
content: moment.content || "",
resUrls: moment.resUrls || [],
commentList: moment.commentList || [],
likeList: moment.likeList || [],
createTime: moment.createTime || "",
momentEntity: moment.momentEntity || {},
}));
setMoments(prev => (append ? [...prev, ...list] : list));
setMomentsTotal(response.total || list.length);
setMomentsPage(page);
} catch (error) {
console.error("获取朋友圈数据失败:", error);
setMomentsError("获取朋友圈数据失败");
if (!append) {
setMoments([]);
}
} finally {
setIsFetchingMoments(false);
}
},
[id],
);
// 搜索好友
const handleSearch = useCallback(() => {
setFriendsPage(1);
@@ -167,8 +257,9 @@ const WechatAccountDetail: React.FC = () => {
useEffect(() => {
if (id) {
fetchAccountInfo();
fetchOverviewData();
}
}, [id, fetchAccountInfo]);
}, [id, fetchAccountInfo, fetchOverviewData]);
// 监听标签切换 - 只在切换到好友列表时请求一次
useEffect(() => {
@@ -179,6 +270,14 @@ const WechatAccountDetail: React.FC = () => {
}
}, [activeTab, id, fetchFriendsList, searchQuery]);
useEffect(() => {
if (activeTab === "moments" && id) {
if (moments.length === 0) {
fetchMomentsList(1, false);
}
}
}, [activeTab, id, fetchMomentsList, moments.length]);
// 工具函数
const getRandomTagColor = (): string => {
const colors = [
@@ -222,7 +321,7 @@ const WechatAccountDetail: React.FC = () => {
await transferWechatFriends({
wechatId: id,
devices: selectedDevices.map(device => device.id),
inherit: inheritInfo,
inherit: inheritInfo
});
Toast.show({
@@ -277,6 +376,85 @@ const WechatAccountDetail: React.FC = () => {
navigate(`/mine/traffic-pool/detail/${friend.wechatId}/${friend.id}`);
};
const handleLoadMoreMoments = () => {
if (isFetchingMoments) return;
if (moments.length >= momentsTotal) return;
fetchMomentsList(momentsPage + 1, true);
};
// 处理朋友圈导出
const handleExportMoments = useCallback(async () => {
if (!id) {
Toast.show({ content: "微信ID不存在", position: "top" });
return;
}
setExportLoading(true);
try {
// 格式化时间
const formatDate = (date: Date | null): string | undefined => {
if (!date) return undefined;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
await exportWechatMoments({
wechatId: id,
keyword: exportKeyword || undefined,
type: exportType,
startTime: formatDate(exportStartTime),
endTime: formatDate(exportEndTime),
});
Toast.show({ content: "导出成功", position: "top" });
setShowExportPopup(false);
// 重置筛选条件
setExportKeyword("");
setExportType(undefined);
setExportStartTime(null);
setExportEndTime(null);
} catch (error: any) {
console.error("导出失败:", error);
Toast.show({
content: error.message || "导出失败,请重试",
position: "top",
});
} finally {
setExportLoading(false);
}
}, [id, exportKeyword, exportType, exportStartTime, exportEndTime]);
const formatMomentDateParts = (dateString: string) => {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return { day: "--", month: "--" };
}
const day = date.getDate().toString().padStart(2, "0");
const month = `${date.getMonth() + 1}`;
return { day, month };
};
const formatMomentTimeAgo = (dateString: string) => {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return dateString || "--";
}
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}天前`;
return date.toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
});
};
return (
<Layout header={<NavCommon title="微信号详情" />} loading={loadingInfo}>
<div className={style["wechat-account-detail-page"]}>
@@ -319,73 +497,223 @@ const WechatAccountDetail: React.FC = () => {
onChange={handleTabChange}
className={style["tabs"]}
>
<Tabs.Tab title="账号概览" key="overview">
<Tabs.Tab title="概览" key="overview">
<div className={style["overview-content"]}>
<div className={style["summary-grid"]}>
<div className={style["summary-item"]}>
<div className={style["summary-value"]}>
{accountInfo?.friendShip?.totalFriend ?? "-"}
{/* 健康分评估区域 */}
<div className={style["health-score-section"]}>
<div className={style["health-score-title"]}></div>
<div className={style["health-score-info"]}>
<div className={style["health-score-status"]}>
<span className={style["status-tag"]}>{overviewData?.healthScoreAssessment?.statusTag || "已添加加人"}</span>
<span className={style["status-time"]}>: {overviewData?.healthScoreAssessment?.lastAddTime || "18:44:14"}</span>
</div>
<div className={style["summary-label"]}></div>
</div>
<div className={style["summary-item"]}>
<div className={style["summary-value-green"]}>
+{accountSummary?.statistics.todayAdded ?? "-"}
<div className={style["health-score-display"]}>
<div className={style["score-circle-wrapper"]}>
<div className={style["score-circle"]}>
<div className={style["score-number"]}>
{overviewData?.healthScoreAssessment?.score || 67}
</div>
<div className={style["score-label"]}>SCORE</div>
</div>
</div>
<div className={style["health-score-stats"]}>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.dailyLimit || 0} </div>
</div>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.todayAdded || 0} </div>
</div>
</div>
</div>
<div className={style["summary-label"]}></div>
</div>
</div>
<div className={style["summary-progress-row"]}>
<span>:</span>
<span className={style["summary-progress-text"]}>
{accountSummary?.statistics.todayAdded ?? 0}/
{accountSummary?.statistics.addLimit ?? 0}
</span>
</div>
<div className={style["summary-progress-bar"]}>
<div className={style["progress-bg"]}>
<div
className={style["progress-fill"]}
style={{
width: `${Math.min(((accountSummary?.statistics.todayAdded ?? 0) / (accountSummary?.statistics.addLimit || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<div className={style["summary-grid"]}>
<div className={style["summary-item"]}>
<div className={style["summary-value-blue"]}>
{accountInfo?.friendShip?.groupNumber ?? "-"}
{/* 账号价值和好友数量区域 */}
<div className={style["account-stats-grid"]}>
{/* 账号价值 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-up"]}></div>
</div>
<div className={style["summary-label"]}></div>
</div>
<div className={style["summary-item"]}>
<div className={style["summary-value-green"]}>
{accountInfo?.activity?.yesterdayMsgCount ?? "-"}
<div className={style["stat-value"]}>
{overviewData?.accountValue?.formatted || `¥${overviewData?.accountValue?.value || "29,800"}`}
</div>
</div>
{/* 今日价值变化 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-plus"]}></div>
</div>
<div className={style["stat-value-positive"]}>
{overviewData?.todayValueChange?.formatted || `+${overviewData?.todayValueChange?.change || "500"}`}
</div>
<div className={style["summary-label"]}></div>
</div>
</div>
<div className={style["device-card"]}>
<div className={style["device-title"]}></div>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceName ?? "-"}</span>
{/* 好友数量和今日新增好友区域 */}
<div className={style["account-stats-grid"]}>
{/* 好友总数 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-people"]}></div>
</div>
<div className={style["stat-value"]}>
{overviewData?.totalFriends || accountInfo?.friendShip?.totalFriend || "0"}
</div>
</div>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceType ?? "-"}</span>
{/* 今日新增好友 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-plus"]}></div>
</div>
<div className={style["stat-value-positive"]}>
+{overviewData?.todayNewFriends || accountSummary?.statistics.todayAdded || "0"}
</div>
</div>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceVersion ?? "-"}</span>
</div>
{/* 高价群聊区域 */}
<div className={style["account-stats-grid"]}>
{/* 高价群聊 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-chat"]}></div>
</div>
<div className={style["stat-value"]}>
{overviewData?.highValueChatrooms || accountInfo?.friendShip?.groupNumber || "0"}
</div>
</div>
{/* 今日新增群聊 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-plus"]}></div>
</div>
<div className={style["stat-value-positive"]}>
+{overviewData?.todayNewChatrooms || "0"}
</div>
</div>
</div>
</div>
</Tabs.Tab>
<Tabs.Tab title="健康分" key="health">
<div className={style["health-content"]}>
{/* 健康分评估区域 */}
<div className={style["health-score-section"]}>
<div className={style["health-score-title"]}></div>
<div className={style["health-score-info"]}>
<div className={style["health-score-status"]}>
<span className={style["status-tag"]}>{overviewData?.healthScoreAssessment?.statusTag || "已添加加人"}</span>
<span className={style["status-time"]}>: {overviewData?.healthScoreAssessment?.lastAddTime || "18:44:14"}</span>
</div>
<div className={style["health-score-display"]}>
<div className={style["score-circle-wrapper"]}>
<div className={style["score-circle"]}>
<div className={style["score-number"]}>
{overviewData?.healthScoreAssessment?.score || 67}
</div>
<div className={style["score-label"]}>SCORE</div>
</div>
</div>
<div className={style["health-score-stats"]}>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.dailyLimit || 0} </div>
</div>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.todayAdded || 0} </div>
</div>
</div>
</div>
</div>
</div>
{/* 基础构成 */}
<div className={style["health-section"]}>
<div className={style["health-section-title"]}></div>
{(overviewData?.healthScoreAssessment?.baseComposition &&
overviewData.healthScoreAssessment.baseComposition.length > 0
? overviewData.healthScoreAssessment.baseComposition
: [
{ name: "账号基础分", formatted: "+60" },
{ name: "已修改微信号", formatted: "+10" },
{ name: "好友数量加成", formatted: "+12", friendCount: 5595 },
]
).map((item, index) => (
<div className={style["health-item"]} key={`${item.name}-${index}`}>
<div className={style["health-item-label"]}>
{item.name}
{item.friendCount ? ` (${item.friendCount})` : ""}
</div>
<div
className={
(item.score ?? 0) >= 0
? style["health-item-value-positive"]
: style["health-item-value-negative"]
}
>
{item.formatted || `${item.score ?? 0}`}
</div>
</div>
))}
</div>
{/* 动态记录 */}
<div className={style["health-section"]}>
<div className={style["health-section-title"]}></div>
{overviewData?.healthScoreAssessment?.dynamicRecords &&
overviewData.healthScoreAssessment.dynamicRecords.length > 0 ? (
overviewData.healthScoreAssessment.dynamicRecords.map(
(record, index) => (
<div className={style["health-item"]} key={`record-${index}`}>
<div className={style["health-item-label"]}>
<span className={style["health-item-icon-warning"]}></span>
{record.title || record.description || "记录"}
{record.statusTag && (
<span className={style["health-item-tag"]}>
{record.statusTag}
</span>
)}
</div>
<div
className={
(record.score ?? 0) >= 0
? style["health-item-value-positive"]
: style["health-item-value-negative"]
}
>
{record.formatted ||
(record.score && record.score > 0
? `+${record.score}`
: record.score || "-")}
</div>
</div>
),
)
) : (
<div className={style["health-empty"]}></div>
)}
</div>
</div>
</Tabs.Tab>
<Tabs.Tab
title={`好友列表${activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ""}`}
title={`好友${activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ""}`}
key="friends"
>
<div className={style["friends-content"]}>
@@ -412,6 +740,23 @@ const WechatAccountDetail: React.FC = () => {
</Button>
</div>
{/* 好友概要 */}
<div className={style["friends-summary"]}>
<div className={style["summary-item"]}>
<div className={style["summary-label"]}></div>
<div className={style["summary-value"]}>
{friendsTotal || overviewData?.totalFriends || 0}
</div>
</div>
<div className={style["summary-divider"]} />
<div className={style["summary-item"]}>
<div className={style["summary-label"]}></div>
<div className={style["summary-value-highlight"]}>
{overviewData?.accountValue?.formatted || "¥1,500,000"}
</div>
</div>
</div>
{/* 好友列表 */}
<div className={style["friends-list"]}>
{isFetchingFriends && friends.length === 0 ? (
@@ -437,36 +782,44 @@ const WechatAccountDetail: React.FC = () => {
{friends.map(friend => (
<div
key={friend.id}
className={style["friend-item"]}
className={style["friend-card"]}
onClick={() => handleFriendClick(friend)}
>
<Avatar
src={friend.avatar}
className={style["friend-avatar"]}
/>
<div className={style["friend-info"]}>
<div className={style["friend-header"]}>
<div className={style["friend-avatar"]}>
<Avatar src={friend.avatar} />
</div>
<div className={style["friend-main"]}>
<div className={style["friend-name-row"]}>
<div className={style["friend-name"]}>
{friend.nickname}
{friend.remark && (
<span className={style["friend-remark"]}>
({friend.remark})
</span>
)}
{friend.nickname || "未知好友"}
</div>
</div>
<div className={style["friend-wechat-id"]}>
{friend.wechatId}
<div className={style["friend-id-row"]}>
ID: {friend.wechatId || "-"}
</div>
<div className={style["friend-tags"]}>
{friend.tags?.map((tag, index) => (
<Tag
key={index}
className={style["friend-tag"]}
<div className={style["friend-status-row"]}>
{friend.statusTags?.map((tag, idx) => (
<span
key={idx}
className={style["friend-status-chip"]}
>
{typeof tag === "string" ? tag : tag.name}
</Tag>
{tag}
</span>
))}
{friend.remark && (
<span className={style["friend-status-chip"]}>
{friend.remark}
</span>
)}
</div>
</div>
<div className={style["friend-value"]}>
<div className={style["value-amount"]}>
{friend.valueFormatted
|| (typeof friend.value === "number"
? `¥${friend.value.toLocaleString()}`
: "估值 -")}
</div>
</div>
</div>
@@ -490,45 +843,118 @@ const WechatAccountDetail: React.FC = () => {
</div>
</Tabs.Tab>
<Tabs.Tab title="风险评估" key="risk">
<div className={style["risk-content"]}>
{accountSummary?.restrictions &&
accountSummary.restrictions.length > 0 ? (
<div className={style["restrictions-list"]}>
{accountSummary.restrictions.map(restriction => (
<div
key={restriction.id}
className={style["restriction-item"]}
>
<div className={style["restriction-info"]}>
<div className={style["restriction-reason"]}>
{restriction.reason}
</div>
<div className={style["restriction-date"]}>
{restriction.date
? formatDateTime(restriction.date)
: "暂无时间"}
</div>
</div>
<div className={style["restriction-level"]}>
<span
className={`${style["level-badge"]} ${style[`level-${restriction.level}`]}`}
>
{restriction.level === 1
? "低风险"
: restriction.level === 2
? "中风险"
: "高风险"}
</span>
</div>
</div>
))}
<Tabs.Tab title="朋友圈" key="moments">
<div className={style["moments-content"]}>
{/* 功能按钮栏 */}
<div className={style["moments-action-bar"]}>
<div className={style["action-button"]}>
<span className={style["action-icon-text"]}></span>
<span className={style["action-text"]}></span>
</div>
<div className={style["action-button"]}>
<span className={style["action-icon-image"]}></span>
<span className={style["action-text"]}></span>
</div>
<div className={style["action-button"]}>
<span className={style["action-icon-video"]}></span>
<span className={style["action-text"]}></span>
</div>
<div
className={style["action-button-dark"]}
onClick={() => setShowExportPopup(true)}
>
<span className={style["action-icon-export"]}></span>
<span className={style["action-text-light"]}></span>
</div>
</div>
{/* 朋友圈列表 */}
<div className={style["moments-list"]}>
{isFetchingMoments && moments.length === 0 ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : momentsError ? (
<div className={style["error"]}>{momentsError}</div>
) : moments.length === 0 ? (
<div className={style["empty"]}></div>
) : (
moments.map(moment => {
const { day, month } = formatMomentDateParts(
moment.createTime,
);
const timeAgo = formatMomentTimeAgo(moment.createTime);
const imageCount = moment.resUrls?.length || 0;
// 根据图片数量选择对应的grid类参考素材管理的实现
let gridClass = "";
if (imageCount === 1) gridClass = style["single"];
else if (imageCount === 2) gridClass = style["double"];
else if (imageCount === 3) gridClass = style["triple"];
else if (imageCount === 4) gridClass = style["quad"];
else if (imageCount > 4) gridClass = style["grid"];
return (
<div className={style["moment-item"]} key={moment.id}>
<div className={style["moment-date"]}>
<div className={style["date-day"]}>{day}</div>
<div className={style["date-month"]}>{month}</div>
</div>
<div className={style["moment-content"]}>
{moment.content && (
<div className={style["moment-text"]}>
{moment.content}
</div>
)}
{imageCount > 0 && (
<div className={style["moment-images"]}>
<div
className={`${style["image-grid"]} ${gridClass}`}
>
{moment.resUrls
.slice(0, 9)
.map((url, index) => (
<img
key={`${moment.id}-img-${index}`}
src={url}
alt="朋友圈图片"
/>
))}
{imageCount > 9 && (
<div className={style["image-more"]}>
+{imageCount - 9}
</div>
)}
</div>
</div>
)}
<div className={style["moment-footer"]}>
<span className={style["moment-time"]}>
{timeAgo}
</span>
</div>
</div>
</div>
);
})
)}
</div>
{moments.length < momentsTotal && (
<div className={style["moments-load-more"]}>
<Button
size="small"
onClick={handleLoadMoreMoments}
loading={isFetchingMoments}
disabled={isFetchingMoments}
>
</Button>
</div>
) : (
<div className={style["empty"]}></div>
)}
</div>
</Tabs.Tab>
</Tabs>
</Card>
</div>
@@ -614,7 +1040,10 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["form-item"]}>
<div className={style["form-label"]}></div>
<div className={style["form-control-switch"]}>
<Switch checked={inheritInfo} onChange={setInheritInfo} />
<Switch
checked={inheritInfo}
onChange={setInheritInfo}
/>
<span className={style["switch-label"]}>
{inheritInfo ? "是" : "否"}
</span>
@@ -647,6 +1076,153 @@ const WechatAccountDetail: React.FC = () => {
</div>
</Popup>
{/* 朋友圈导出弹窗 */}
<Popup
visible={showExportPopup}
onMaskClick={() => setShowExportPopup(false)}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
<div className={style["popup-content"]}>
<div className={style["popup-header"]}>
<h3></h3>
<Button
size="small"
fill="outline"
onClick={() => setShowExportPopup(false)}
>
</Button>
</div>
<div className={style["export-form"]}>
{/* 关键词搜索 */}
<div className={style["form-item"]}>
<label></label>
<Input
placeholder="请输入关键词"
value={exportKeyword}
onChange={e => setExportKeyword(e.target.value)}
allowClear
/>
</div>
{/* 类型筛选 */}
<div className={style["form-item"]}>
<label></label>
<div className={style["type-selector"]}>
<div
className={`${style["type-option"]} ${
exportType === undefined ? style["active"] : ""
}`}
onClick={() => setExportType(undefined)}
>
</div>
<div
className={`${style["type-option"]} ${
exportType === 4 ? style["active"] : ""
}`}
onClick={() => setExportType(4)}
>
</div>
<div
className={`${style["type-option"]} ${
exportType === 1 ? style["active"] : ""
}`}
onClick={() => setExportType(1)}
>
</div>
<div
className={`${style["type-option"]} ${
exportType === 3 ? style["active"] : ""
}`}
onClick={() => setExportType(3)}
>
</div>
</div>
</div>
{/* 开始时间 */}
<div className={style["form-item"]}>
<label></label>
<Input
readOnly
placeholder="请选择开始时间"
value={
exportStartTime
? exportStartTime.toLocaleDateString("zh-CN")
: ""
}
onClick={() => setShowStartTimePicker(true)}
/>
<DatePicker
visible={showStartTimePicker}
title="开始时间"
value={exportStartTime}
onClose={() => setShowStartTimePicker(false)}
onConfirm={val => {
setExportStartTime(val);
setShowStartTimePicker(false);
}}
/>
</div>
{/* 结束时间 */}
<div className={style["form-item"]}>
<label></label>
<Input
readOnly
placeholder="请选择结束时间"
value={
exportEndTime ? exportEndTime.toLocaleDateString("zh-CN") : ""
}
onClick={() => setShowEndTimePicker(true)}
/>
<DatePicker
visible={showEndTimePicker}
title="结束时间"
value={exportEndTime}
onClose={() => setShowEndTimePicker(false)}
onConfirm={val => {
setExportEndTime(val);
setShowEndTimePicker(false);
}}
/>
</div>
</div>
<div className={style["popup-footer"]}>
<Button
block
color="primary"
onClick={handleExportMoments}
loading={exportLoading}
disabled={exportLoading}
>
{exportLoading ? "导出中..." : "确认导出"}
</Button>
<Button
block
color="danger"
fill="outline"
onClick={() => {
setShowExportPopup(false);
setExportKeyword("");
setExportType(undefined);
setExportStartTime(null);
setExportEndTime(null);
}}
style={{ marginTop: 12 }}
>
</Button>
</div>
</div>
</Popup>
{/* 好友详情弹窗 */}
{/* Removed */}
</Layout>

View File

@@ -2,13 +2,11 @@
export * from "./module/user";
export * from "./module/app";
export * from "./module/settings";
export * from "./module/websocket/websocket";
// 导入store实例
import { useUserStore } from "./module/user";
import { useAppStore } from "./module/app";
import { useSettingsStore } from "./module/settings";
import { useWebSocketStore } from "./module/websocket/websocket";
// 导出持久化store创建函数
export {
@@ -34,7 +32,6 @@ export interface StoreState {
user: ReturnType<typeof useUserStore.getState>;
app: ReturnType<typeof useAppStore.getState>;
settings: ReturnType<typeof useSettingsStore.getState>;
websocket: ReturnType<typeof useWebSocketStore.getState>;
}
// 便利的store访问函数
@@ -42,14 +39,12 @@ export const getStores = (): StoreState => ({
user: useUserStore.getState(),
app: useAppStore.getState(),
settings: useSettingsStore.getState(),
websocket: useWebSocketStore.getState(),
});
// 获取特定store状态
export const getUserStore = () => useUserStore.getState();
export const getAppStore = () => useAppStore.getState();
export const getSettingsStore = () => useSettingsStore.getState();
export const getWebSocketStore = () => useWebSocketStore.getState();
// 清除所有持久化数据(使用工具函数)
export const clearAllPersistedData = clearAllData;
@@ -61,7 +56,6 @@ export const getPersistKeys = () => Object.values(PERSIST_KEYS);
export const subscribeToUserStore = useUserStore.subscribe;
export const subscribeToAppStore = useAppStore.subscribe;
export const subscribeToSettingsStore = useSettingsStore.subscribe;
export const subscribeToWebSocketStore = useWebSocketStore.subscribe;
// 组合订阅函数
export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
@@ -74,14 +68,10 @@ export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
const unsubscribeSettings = useSettingsStore.subscribe(() => {
callback(getStores());
});
const unsubscribeWebSocket = useWebSocketStore.subscribe(() => {
callback(getStores());
});
return () => {
unsubscribeUser();
unsubscribeApp();
unsubscribeSettings();
unsubscribeWebSocket();
};
};

View File

@@ -0,0 +1,70 @@
// 全局缓存清理工具:浏览器存储 + IndexedDB + Zustand store
import { clearAllPersistedData } from "@/store";
import { useUserStore } from "@/store/module/user";
import { useAppStore } from "@/store/module/app";
import { useSettingsStore } from "@/store/module/settings";
const isBrowser = typeof window !== "undefined";
const safeStorageClear = (storage?: Storage) => {
if (!storage) return;
try {
storage.clear();
} catch (error) {
console.warn("清理存储失败:", error);
}
};
export const clearBrowserStorage = () => {
if (!isBrowser) return;
safeStorageClear(window.localStorage);
safeStorageClear(window.sessionStorage);
try {
clearAllPersistedData();
} catch (error) {
console.warn("清理持久化 store 失败:", error);
}
};
export const clearAllIndexedDB = async (): Promise<void> => {
if (!isBrowser || !window.indexedDB || !indexedDB.databases) return;
const databases = await indexedDB.databases();
const deleteJobs = databases
.map(db => db.name)
.filter((name): name is string => Boolean(name))
.map(
name =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error(`删除数据库 ${name} 失败`));
request.onblocked = () => {
setTimeout(() => {
const retry = indexedDB.deleteDatabase(name);
retry.onsuccess = () => resolve();
retry.onerror = () =>
reject(new Error(`删除数据库 ${name} 失败`));
}, 100);
};
}),
);
await Promise.allSettled(deleteJobs);
};
export const resetAllStores = () => {
const userStore = useUserStore.getState();
const appStore = useAppStore.getState();
const settingsStore = useSettingsStore.getState();
userStore?.clearUser?.();
appStore?.resetAppState?.();
settingsStore?.resetSettings?.();
};
export const clearApplicationCache = async () => {
clearBrowserStorage();
await clearAllIndexedDB();
resetAllStores();
};

View File

@@ -0,0 +1,73 @@
// 缓存清理工具,统一处理浏览器存储与 Zustand store
import { clearAllPersistedData } from "@/store";
import { useUserStore } from "@/store/module/user";
import { useAppStore } from "@/store/module/app";
import { useSettingsStore } from "@/store/module/settings";
const isBrowser = typeof window !== "undefined";
const safeStorageClear = (storage?: Storage) => {
if (!storage) return;
try {
storage.clear();
} catch (error) {
console.warn("清理存储失败:", error);
}
};
export const clearBrowserStorage = () => {
if (!isBrowser) return;
safeStorageClear(window.localStorage);
safeStorageClear(window.sessionStorage);
// 清理自定义持久化数据
try {
clearAllPersistedData();
} catch (error) {
console.warn("清理持久化 store 失败:", error);
}
};
export const clearAllIndexedDB = async (): Promise<void> => {
if (!isBrowser || !window.indexedDB || !indexedDB.databases) return;
const databases = await indexedDB.databases();
const deleteJobs = databases
.map(db => db.name)
.filter((name): name is string => Boolean(name))
.map(
name =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () =>
reject(new Error(`删除数据库 ${name} 失败`));
request.onblocked = () => {
setTimeout(() => {
const retry = indexedDB.deleteDatabase(name);
retry.onsuccess = () => resolve();
retry.onerror = () =>
reject(new Error(`删除数据库 ${name} 失败`));
}, 100);
};
}),
);
await Promise.allSettled(deleteJobs);
};
export const resetAllStores = () => {
const userStore = useUserStore.getState();
const appStore = useAppStore.getState();
const settingsStore = useSettingsStore.getState();
userStore?.clearUser?.();
appStore?.resetAppState?.();
settingsStore?.resetSettings?.();
};
export const clearApplicationCache = async () => {
clearBrowserStorage();
await clearAllIndexedDB();
resetAllStores();
};

254
Server/README_scheduler.md Normal file
View File

@@ -0,0 +1,254 @@
# 统一任务调度器使用说明
## 概述
统一任务调度器TaskSchedulerCommand是一个集中管理所有定时任务的调度系统支持
- ✅ 单条 crontab 配置管理所有任务
- ✅ 多进程并发执行任务
- ✅ 自动根据 cron 表达式判断任务执行时间
- ✅ 任务锁机制,防止重复执行
- ✅ 完善的日志记录
## 安装配置
### 1. 配置文件
任务配置位于 `config/task_scheduler.php`,格式如下:
```php
'任务标识' => [
'command' => '命令名称', // 必填:执行的命令
'schedule' => 'cron表达式', // 必填cron表达式
'options' => ['--option=value'], // 可选:命令参数
'enabled' => true, // 可选:是否启用
'max_concurrent' => 1, // 可选:最大并发数
'timeout' => 3600, // 可选:超时时间(秒)
'log_file' => 'custom.log', // 可选:自定义日志文件
]
```
### 2. Cron 表达式格式
标准 cron 格式:`分钟 小时 日 月 星期`
示例:
- `*/1 * * * *` - 每分钟执行
- `*/5 * * * *` - 每5分钟执行
- `*/30 * * * *` - 每30分钟执行
- `0 2 * * *` - 每天凌晨2点执行
- `0 3 */3 * *` - 每3天的3点执行
### 3. Crontab 配置
**只需要在 crontab 中添加一条任务:**
```bash
# 每分钟执行一次调度器(调度器内部会根据 cron 表达式判断哪些任务需要执行)
* * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think scheduler:run >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/scheduler.log 2>&1
```
### 4. 系统要求
- PHP >= 5.6.0
- 推荐启用 `pcntl` 扩展以支持多进程并发(非必需,未启用时使用单进程顺序执行)
检查 pcntl 扩展:
```bash
php -m | grep pcntl
```
## 使用方法
### 手动执行调度器
```bash
# 执行调度器(会自动判断当前时间需要执行的任务)
php think scheduler:run
```
### 查看任务配置
```bash
# 查看所有已注册的命令
php think list
```
### 启用/禁用任务
编辑 `config/task_scheduler.php`,设置 `'enabled' => false` 即可禁用任务。
## 功能特性
### 1. 多进程并发执行
- 默认最大并发数10 个进程
- 自动管理进程池
- 自动清理僵尸进程
### 2. 任务锁机制
- 每个任务在执行时会设置锁5分钟内不重复执行
- 防止任务重复执行
- 锁存储在缓存中,自动过期
### 3. 日志记录
- 调度器日志:`runtime/log/scheduler.log`
- 每个任务的日志:`runtime/log/{log_file}`
- 任务执行开始和结束都有标记
### 4. 超时控制
- 默认超时时间3600 秒1小时
- 可在配置中为每个任务单独设置超时时间
- 超时后自动终止任务
## 配置示例
### 高频任务(每分钟)
```php
'wechat_friends_active' => [
'command' => 'wechatFriends:list',
'schedule' => '*/1 * * * *',
'options' => ['--isDel=0'],
'enabled' => true,
],
```
### 中频任务每5分钟
```php
'device_active' => [
'command' => 'device:list',
'schedule' => '*/5 * * * *',
'options' => ['--isDel=0'],
'enabled' => true,
],
```
### 每日任务
```php
'wechat_calculate_score' => [
'command' => 'wechat:calculate-score',
'schedule' => '0 2 * * *', // 每天凌晨2点
'options' => [],
'enabled' => true,
],
```
### 定期任务每3天
```php
'sync_all_friends' => [
'command' => 'sync:allFriends',
'schedule' => '0 3 */3 * *', // 每3天的3点
'options' => [],
'enabled' => true,
],
```
## 从旧配置迁移
### 旧配置(多条 crontab
```bash
*/5 * * * * cd /path && php think device:list --isDel=0 >> log1.log 2>&1
*/1 * * * * cd /path && php think wechatFriends:list >> log2.log 2>&1
```
### 新配置(单条 crontab + 配置文件)
**Crontab**
```bash
* * * * * cd /path && php think scheduler:run >> scheduler.log 2>&1
```
**config/task_scheduler.php**
```php
'device_active' => [
'command' => 'device:list',
'schedule' => '*/5 * * * *',
'options' => ['--isDel=0'],
'log_file' => 'log1.log',
],
'wechat_friends' => [
'command' => 'wechatFriends:list',
'schedule' => '*/1 * * * *',
'log_file' => 'log2.log',
],
```
## 监控和调试
### 查看调度器日志
```bash
tail -f runtime/log/scheduler.log
```
### 查看任务执行日志
```bash
tail -f runtime/log/crontab_device_active.log
```
### 检查任务是否在执行
```bash
# 查看进程
ps aux | grep "php think"
```
### 手动测试任务
```bash
# 直接执行某个任务
php think device:list --isDel=0
```
## 注意事项
1. **时间同步**:确保服务器时间准确,调度器依赖系统时间判断任务执行时间
2. **资源限制**:根据服务器性能调整 `maxConcurrent` 参数
3. **日志清理**:定期清理日志文件,避免占用过多磁盘空间
4. **任务冲突**:如果任务执行时间较长,建议调整执行频率或增加并发数
5. **缓存依赖**:任务锁使用缓存,确保缓存服务正常运行
## 故障排查
### 任务未执行
1. 检查任务是否启用:`'enabled' => true`
2. 检查 cron 表达式是否正确
3. 检查调度器是否正常运行:查看 `scheduler.log`
4. 检查任务锁任务可能在5分钟内重复执行被跳过
### 任务执行失败
1. 查看任务日志:`runtime/log/{log_file}`
2. 检查命令是否正确:手动执行命令测试
3. 检查权限:确保有执行权限和日志写入权限
### 多进程不工作
1. 检查 pcntl 扩展:`php -m | grep pcntl`
2. 检查系统限制:`ulimit -u` 查看最大进程数
3. 查看调度器日志中的错误信息
## 性能优化建议
1. **合理设置并发数**:根据服务器 CPU 核心数和内存大小调整
2. **错开高频任务**:避免所有任务在同一分钟执行
3. **优化任务执行时间**:减少任务执行时长
4. **使用队列**:对于耗时任务,建议使用队列异步处理
## 更新日志
### v1.0.0 (2024-01-XX)
- 初始版本
- 支持多进程并发执行
- 支持 cron 表达式调度
- 支持任务锁机制

View File

@@ -61,10 +61,8 @@ class MessageController extends BaseController
// 发送请求获取好友列表
$result = requestCurl($this->baseUrl . 'api/WechatFriend/listWechatFriendForMsgPagination', $params, 'POST', $header, 'json');
$response = handleApiResponse($result);
// 获取同步消息标志
$syncMessages = $this->request->param('syncMessages', true);
// 如果需要同步消息,则获取每个好友的消息
if ($syncMessages && !empty($response['results'])) {
$from = strtotime($fromTime) * 1000;
@@ -90,7 +88,6 @@ class MessageController extends BaseController
// 调用获取消息的接口
$messageResult = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $messageParams, 'GET', $header, 'json');
$messageResponse = handleApiResponse($messageResult);
// 保存消息到数据库
if (!empty($messageResponse)) {
foreach ($messageResponse as $item) {
@@ -353,12 +350,6 @@ class MessageController extends BaseController
{
// 检查消息是否已存在
$exists = WechatMessageModel::where('id', $item['id']) ->find();
// 如果消息已存在,直接返回
if ($exists) {
return;
}
// 将毫秒时间戳转换为秒级时间戳
$createTime = isset($item['createTime']) ? strtotime($item['createTime']) : null;
$deleteTime = !empty($item['isDeleted']) ? strtotime($item['deleteTime']) : null;
@@ -387,7 +378,8 @@ class MessageController extends BaseController
'wechatTime' => $wechatTime
];
//已被删除
//已被删除
if ($item['msgType'] == 10000 && strpos($item['content'],'开启了朋友验证') !== false) {
Db::table('s2_wechat_friend')->where('id',$item['wechatFriendId'])->update(['isDeleted'=> 1,'deleteTime' => $wechatTime]);
}else{
@@ -425,8 +417,19 @@ class MessageController extends BaseController
}
}
}
// 创建新记录
$res = WechatMessageModel::create($data);
$id = '';
if (empty($exists)){
// 创建新记录
$res = WechatMessageModel::create($data);
$id= $res['id'];
}else{
$id = $data['id'];
unset($data['id']);
$res = $exists->save($data);
}
// 1 文字 3图片 47动态图片 34语言 43视频 42名片 40/20链接 49文件
if (!empty($res) && empty($item['isSend']) && in_array($item['msgType'],[1,3,20,34,40,42,43,47,49])){
@@ -439,13 +442,14 @@ class MessageController extends BaseController
'companyId' => $friend['companyId'],
'trafficPoolId' => $trafficPoolId,
'source' => 0,
'uniqueId' => $res['id'],
'uniqueId' => $id,
'sourceData' => json_encode([]),
'remark' => '用户发送了消息',
'createTime' => time(),
'updateTime' => time()
];
Db::name('user_portrait')->insert($data);
Db::name('user_portrait')->insert($data);
}
}
}
@@ -461,11 +465,7 @@ class MessageController extends BaseController
{
// 检查消息是否已存在
$exists = WechatMessageModel::where('id', $item['id'])->find();
// 如果消息已存在,直接返回
if ($exists) {
return true;
}
// 处理发送者信息
$sender = $item['sender'] ?? [];
@@ -515,7 +515,12 @@ class MessageController extends BaseController
// 创建新记录
try {
WechatMessageModel::create($data);
if(empty($exists)){
WechatMessageModel::create($data);
}else{
unset($data['id']);
$exists->save($data);
}
return true;
} catch (\Exception $e) {
return false;

View File

@@ -42,4 +42,7 @@ return [
'wechat:calculate-score' => 'app\command\CalculateWechatAccountScoreCommand', // 统一计算微信账号健康分
'wechat:update-score' => 'app\command\UpdateWechatAccountScoreCommand', // 更新微信账号评分记录
// 统一任务调度器
'scheduler:run' => 'app\command\TaskSchedulerCommand', // 统一任务调度器,支持多进程并发执行
];

View File

@@ -0,0 +1,478 @@
<?php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Config;
use think\facade\Log;
use think\facade\Cache;
/**
* 统一任务调度器
* 支持多进程并发执行任务
*
* 使用方法:
* php think scheduler:run
*
* 在 crontab 中配置:
* * * * * * cd /path/to/project && php think scheduler:run >> /path/to/log/scheduler.log 2>&1
*/
class TaskSchedulerCommand extends Command
{
/**
* 任务配置
*/
protected $tasks = [];
/**
* 最大并发进程数
*/
protected $maxConcurrent = 10;
/**
* 当前运行的进程数
*/
protected $runningProcesses = [];
/**
* 日志目录
*/
protected $logDir = '';
protected function configure()
{
$this->setName('scheduler:run')
->setDescription('统一任务调度器,支持多进程并发执行所有定时任务');
}
protected function execute(Input $input, Output $output)
{
$output->writeln('==========================================');
$output->writeln('任务调度器启动');
$output->writeln('时间: ' . date('Y-m-d H:i:s'));
$output->writeln('==========================================');
// 检查是否支持 pcntl 扩展
if (!function_exists('pcntl_fork')) {
$output->writeln('<error>错误:系统不支持 pcntl 扩展,无法使用多进程功能</error>');
$output->writeln('<info>提示:将使用单进程顺序执行任务</info>');
$this->maxConcurrent = 1;
}
// 加载任务配置(优先使用框架配置,其次直接引入配置文件,避免加载失败)
$this->tasks = Config::get('task_scheduler', []);
// 如果通过 Config 没有读到,再尝试直接 include 配置文件
if (empty($this->tasks)) {
// 以项目根目录为基准查找 config/task_scheduler.php
$configFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php';
if (is_file($configFile)) {
$config = include $configFile;
if (is_array($config) && !empty($config)) {
$this->tasks = $config;
}
}
}
if (empty($this->tasks)) {
$output->writeln('<error>错误未找到任务配置task_scheduler请检查 config/task_scheduler.php 是否存在且返回数组</error>');
return false;
}
// 设置日志目录ThinkPHP5 中无 runtime_path 辅助函数,直接使用 ROOT_PATH/runtime/log
if (!defined('ROOT_PATH')) {
// CLI 下正常情况下 ROOT_PATH 已在入口脚本 define这里兜底一次
define('ROOT_PATH', dirname(__DIR__, 2));
}
$this->logDir = ROOT_PATH . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR;
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0755, true);
}
// 获取当前时间
$currentTime = time();
$currentMinute = date('i', $currentTime);
$currentHour = date('H', $currentTime);
$currentDay = date('d', $currentTime);
$currentMonth = date('m', $currentTime);
$currentWeekday = date('w', $currentTime); // 0=Sunday, 6=Saturday
$output->writeln("当前时间: {$currentHour}:{$currentMinute}");
$output->writeln("已加载 " . count($this->tasks) . " 个任务配置");
// 筛选需要执行的任务
$tasksToRun = [];
foreach ($this->tasks as $taskId => $task) {
if (!isset($task['enabled']) || !$task['enabled']) {
continue;
}
if ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) {
$tasksToRun[$taskId] = $task;
}
}
if (empty($tasksToRun)) {
$output->writeln('<info>当前时间没有需要执行的任务</info>');
return true;
}
$output->writeln("找到 " . count($tasksToRun) . " 个需要执行的任务");
// 执行任务
if ($this->maxConcurrent > 1 && function_exists('pcntl_fork')) {
$this->executeConcurrent($tasksToRun, $output);
} else {
$this->executeSequential($tasksToRun, $output);
}
// 清理僵尸进程
$this->cleanupZombieProcesses();
$output->writeln('==========================================');
$output->writeln('任务调度器执行完成');
$output->writeln('==========================================');
return true;
}
/**
* 判断任务是否应该执行
*
* @param string $schedule cron表达式格式分钟 小时 日 月 星期
* @param int $minute 当前分钟
* @param int $hour 当前小时
* @param int $day 当前日期
* @param int $month 当前月份
* @param int $weekday 当前星期
* @return bool
*/
protected function shouldRun($schedule, $minute, $hour, $day, $month, $weekday)
{
$parts = preg_split('/\s+/', trim($schedule));
if (count($parts) < 5) {
return false;
}
list($scheduleMinute, $scheduleHour, $scheduleDay, $scheduleMonth, $scheduleWeekday) = $parts;
// 解析分钟
if (!$this->matchCronField($scheduleMinute, $minute)) {
return false;
}
// 解析小时
if (!$this->matchCronField($scheduleHour, $hour)) {
return false;
}
// 解析日期
if (!$this->matchCronField($scheduleDay, $day)) {
return false;
}
// 解析月份
if (!$this->matchCronField($scheduleMonth, $month)) {
return false;
}
// 解析星期注意cron中0和7都表示星期日
if ($scheduleWeekday !== '*') {
$scheduleWeekday = str_replace('7', '0', $scheduleWeekday);
if (!$this->matchCronField($scheduleWeekday, $weekday)) {
return false;
}
}
return true;
}
/**
* 匹配cron字段
*
* @param string $field cron字段表达式
* @param int $value 当前值
* @return bool
*/
protected function matchCronField($field, $value)
{
// 通配符
if ($field === '*') {
return true;
}
// 列表(逗号分隔)
if (strpos($field, ',') !== false) {
$values = explode(',', $field);
foreach ($values as $v) {
if ($this->matchCronField(trim($v), $value)) {
return true;
}
}
return false;
}
// 范围(如 1-5
if (strpos($field, '-') !== false) {
list($start, $end) = explode('-', $field);
return $value >= (int)$start && $value <= (int)$end;
}
// 步长(如 */5 或 0-59/5
if (strpos($field, '/') !== false) {
$parts = explode('/', $field);
$base = $parts[0];
$step = (int)$parts[1];
if ($base === '*') {
return $value % $step === 0;
} else {
// 处理范围步长,如 0-59/5
if (strpos($base, '-') !== false) {
list($start, $end) = explode('-', $base);
if ($value >= (int)$start && $value <= (int)$end) {
return ($value - (int)$start) % $step === 0;
}
return false;
} else {
return $value % $step === 0;
}
}
}
// 精确匹配
return (int)$field === $value;
}
/**
* 并发执行任务(多进程)
*
* @param array $tasks 任务列表
* @param Output $output 输出对象
*/
protected function executeConcurrent($tasks, Output $output)
{
$output->writeln('<info>使用多进程并发执行任务(最大并发数:' . $this->maxConcurrent . '</info>');
foreach ($tasks as $taskId => $task) {
// 等待可用进程槽
while (count($this->runningProcesses) >= $this->maxConcurrent) {
$this->waitForProcesses();
usleep(100000); // 等待100ms
}
// 检查任务是否已经在运行(防止重复执行)
$lockKey = "scheduler_task_lock:{$taskId}";
$lockTime = Cache::get($lockKey);
if ($lockTime && (time() - $lockTime) < 300) { // 5分钟内不重复执行
$output->writeln("<comment>任务 {$taskId} 正在运行中,跳过</comment>");
continue;
}
// 创建子进程
$pid = pcntl_fork();
if ($pid == -1) {
// 创建进程失败
$output->writeln("<error>创建子进程失败:{$taskId}</error>");
Log::error("任务调度器:创建子进程失败", ['task' => $taskId]);
continue;
} elseif ($pid == 0) {
// 子进程:执行任务
$this->runTask($taskId, $task);
exit(0);
} else {
// 父进程记录子进程PID
$this->runningProcesses[$pid] = [
'task_id' => $taskId,
'start_time' => time(),
];
$output->writeln("<info>启动任务:{$taskId} (PID: {$pid})</info>");
// 设置任务锁
Cache::set($lockKey, time(), 600); // 10分钟过期
}
}
// 等待所有子进程完成
while (!empty($this->runningProcesses)) {
$this->waitForProcesses();
usleep(500000); // 等待500ms
}
}
/**
* 顺序执行任务(单进程)
*
* @param array $tasks 任务列表
* @param Output $output 输出对象
*/
protected function executeSequential($tasks, Output $output)
{
$output->writeln('<info>使用单进程顺序执行任务</info>');
foreach ($tasks as $taskId => $task) {
$output->writeln("<info>执行任务:{$taskId}</info>");
$this->runTask($taskId, $task);
}
}
/**
* 执行单个任务
*
* @param string $taskId 任务ID
* @param array $task 任务配置
*/
protected function runTask($taskId, $task)
{
$startTime = microtime(true);
$logFile = $this->logDir . ($task['log_file'] ?? "scheduler_{$taskId}.log");
// 确保日志目录存在
$logDir = dirname($logFile);
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
// 构建命令
// 使用项目根目录下的 think 脚本(同命令行 php think
if (!defined('ROOT_PATH')) {
define('ROOT_PATH', dirname(__DIR__, 2));
}
$thinkPath = ROOT_PATH . DIRECTORY_SEPARATOR . 'think';
$command = "php {$thinkPath} {$task['command']}";
if (!empty($task['options'])) {
foreach ($task['options'] as $option) {
$command .= ' ' . escapeshellarg($option);
}
}
// 添加日志重定向
$command .= " >> " . escapeshellarg($logFile) . " 2>&1";
// 记录任务开始
$logMessage = "\n" . str_repeat('=', 60) . "\n";
$logMessage .= "任务开始执行: {$taskId}\n";
$logMessage .= "执行时间: " . date('Y-m-d H:i:s') . "\n";
$logMessage .= "命令: {$command}\n";
$logMessage .= str_repeat('=', 60) . "\n";
file_put_contents($logFile, $logMessage, FILE_APPEND);
// 执行命令
$descriptorspec = [
0 => ['file', (PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'), 'r'], // stdin
1 => ['file', $logFile, 'a'], // stdout
2 => ['file', $logFile, 'a'], // stderr
];
$process = @proc_open($command, $descriptorspec, $pipes, ROOT_PATH);
if (is_resource($process)) {
// 关闭管道
if (isset($pipes[0])) @fclose($pipes[0]);
if (isset($pipes[1])) @fclose($pipes[1]);
if (isset($pipes[2])) @fclose($pipes[2]);
// 设置超时
$timeout = $task['timeout'] ?? 3600;
$startWaitTime = time();
// 等待进程完成或超时
while (true) {
$status = proc_get_status($process);
if (!$status['running']) {
break;
}
// 检查超时
if ((time() - $startWaitTime) > $timeout) {
if (function_exists('proc_terminate')) {
proc_terminate($process, SIGTERM);
// 等待进程终止
sleep(2);
$status = proc_get_status($process);
if ($status['running']) {
// 强制终止
proc_terminate($process, SIGKILL);
}
}
Log::warning("任务执行超时", [
'task' => $taskId,
'timeout' => $timeout,
]);
break;
}
usleep(500000); // 等待500ms
}
// 关闭进程
proc_close($process);
} else {
// 如果 proc_open 失败,尝试直接执行(后台执行)
if (PHP_OS_FAMILY === 'Windows') {
pclose(popen("start /B " . $command, "r"));
} else {
exec($command . ' > /dev/null 2>&1 &');
}
}
$endTime = microtime(true);
$duration = round($endTime - $startTime, 2);
// 记录任务完成
$logMessage = "\n" . str_repeat('=', 60) . "\n";
$logMessage .= "任务执行完成: {$taskId}\n";
$logMessage .= "完成时间: " . date('Y-m-d H:i:s') . "\n";
$logMessage .= "执行时长: {$duration}\n";
$logMessage .= str_repeat('=', 60) . "\n";
file_put_contents($logFile, $logMessage, FILE_APPEND);
Log::info("任务执行完成", [
'task' => $taskId,
'duration' => $duration,
]);
}
/**
* 等待进程完成
*/
protected function waitForProcesses()
{
foreach ($this->runningProcesses as $pid => $info) {
$status = 0;
$result = pcntl_waitpid($pid, $status, WNOHANG);
if ($result == $pid || $result == -1) {
// 进程已结束
unset($this->runningProcesses[$pid]);
$duration = time() - $info['start_time'];
Log::info("子进程执行完成", [
'pid' => $pid,
'task' => $info['task_id'],
'duration' => $duration,
]);
}
}
}
/**
* 清理僵尸进程
*/
protected function cleanupZombieProcesses()
{
if (!function_exists('pcntl_waitpid')) {
return;
}
$status = 0;
while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
// 清理僵尸进程
}
}
}

View File

@@ -74,7 +74,7 @@ class PostTransferFriends extends BaseController
$taskId = Db::name('customer_acquisition_task')->insertGetId([
'name' => '迁移好友('. $wechat['nickname'] .'',
'sceneId' => 1,
'sceneId' => 10,
'sceneConf' => json_encode($sceneConf),
'reqConf' => json_encode($reqConf),
'tagConf' => json_encode([]),

View File

@@ -47,26 +47,74 @@
"topthink/think-migration": "^2.0",
"phpunit/phpunit": "^5.0|^6.0"
},
"autoload": {
"psr-4": {
"app\\": "application",
"Eison\\": "extend/Eison"
},
"files": [
"application/common.php"
],
"classmap": []
},
"extra": {
"think-path": "thinkphp"
},
"config": {
"preferred-install": "dist",
"allow-plugins": {
"topthink/think-installer": true,
"easywechat-composer/easywechat-composer": true
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
"autoload": {
"psr-4": {
"app\\": "application",
"Eison\\": "extend/Eison"
},
"files": [
"application/common.php"
],
"homepage": "http://thinkphp.cn/",
"license": "Apache-2.0",
"authors": [
{
"name": "liu21st",
"email": "liu21st@gmail.com"
},
{
"name": "yunwuxin",
"email": "448901948@qq.com"
}
],
"require": {
"php": ">=5.6.0",
"topthink/framework": "5.1.41",
"topthink/think-installer": "~1.0",
"topthink/think-captcha": "^2.0",
"topthink/think-helper": "^3.0",
"topthink/think-image": "^1.0",
"topthink/think-queue": "^2.0",
"topthink/think-worker": "^2.0",
"textalk/websocket": "^1.2",
"aliyuncs/oss-sdk-php": "^2.3",
"monolog/monolog": "^1.24",
"guzzlehttp/guzzle": "^6.3",
"overtrue/wechat": "~4.0",
"endroid/qr-code": "^3.5",
"phpoffice/phpspreadsheet": "^1.8",
"workerman/workerman": "^3.5",
"workerman/gateway-worker": "^3.0",
"hashids/hashids": "^2.0",
"khanamiryan/qrcode-detector-decoder": "^1.0",
"lizhichao/word": "^2.0",
"adbario/php-dot-notation": "^2.2"
},
"require-dev": {
"symfony/var-dumper": "^3.4",
"topthink/think-migration": "^2.0"
},
"autoload": {
"psr-4": {
"app\\": "application",
"Eison\\": "extend/Eison"
},
"files": [
"application/common.php"
],
"classmap": []
},
"extra": {
"think-path": "thinkphp"
},
"config": {
"preferred-install": "dist",
"allow-plugins": {
"topthink/think-installer": true,
"easywechat-composer/easywechat-composer": true
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
}

4299
Server/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,301 @@
<?php
// +----------------------------------------------------------------------
// | 任务调度器配置文件
// +----------------------------------------------------------------------
// | 定义所有需要定时执行的任务及其执行频率
// +----------------------------------------------------------------------
return [
// 任务配置格式:
// '任务标识' => [
// 'command' => '命令名称', // 必填:执行的 ThinkPHP 命令(见 application/command.php
// 'schedule' => 'cron表达式', // 必填cron 表达式,如 '*/5 * * * *' 表示每5分钟
// 'options' => ['--option=value'], // 可选:命令参数(原来 crontab 里的 --xxx=yyy
// 'enabled' => true, // 可选:是否启用,默认 true
// 'max_concurrent'=> 1, // 可选:单任务最大并发数(目前由调度器统一控制,可预留)
// 'timeout' => 3600, // 可选:超时时间(秒),默认 3600
// 'log_file' => 'custom.log', // 可选:日志文件名,默认使用任务标识
// ]
// ===========================
// 高频任务(每分钟或更频繁)
// ===========================
// 同步微信好友列表(未删除好友),用于保持系统中好友数据实时更新
'wechat_friends_active' => [
'command' => 'wechatFriends:list',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => ['--isDel=0'],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_wechatFriends_active.log',
],
// 拉取“添加好友任务”列表,驱动自动加好友的任务队列
'friend_task' => [
'command' => 'friendTask:list',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_friendTask.log',
],
// 同步微信好友私聊消息列表,写入消息表,供客服工作台使用
'message_friends' => [
'command' => 'message:friendsList',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_messageFriends.log',
],
// 同步微信群聊消息列表,写入消息表,供群聊记录与风控分析
'message_chatroom' => [
'command' => 'message:chatroomList',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_messageChatroom.log',
],
// 客服端消息提醒任务,负责给在线客服推送新消息通知
'kf_notice' => [
'command' => 'kf:notice',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'kf_notice.log',
],
// ===========================
// 中频任务(每 2-5 分钟)
// ===========================
// 同步微信设备列表(未删除设备),用于设备管理与监控
'device_active' => [
'command' => 'device:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => ['--isDel=0'],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_device_active.log',
],
// 同步微信群聊列表(未删除群),用于群管理与后续任务分配
'wechat_chatroom_active' => [
'command' => 'wechatChatroom:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => ['--isDel=0'],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_wechatChatroom_active.log',
],
// 同步微信群成员列表(群好友),维持群成员明细数据
'group_friends' => [
'command' => 'groupFriends:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_groupFriends.log',
],
// 同步“微信客服列表”,获取绑定到公司的微信号,用于工作台与分配规则
'wechat_list' => [
'command' => 'wechatList:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_wechatList.log',
],
// 同步公司账号列表(企业/租户账号),供后台管理与统计
'account_list' => [
'command' => 'account:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_account.log',
],
// 内容采集任务,将外部或设备内容同步到系统内容库
'content_collect' => [
'command' => 'content:collect',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_contentCollect.log',
],
// 工作台:自动点赞好友/客户朋友圈,提高账号活跃度
'workbench_auto_like' => [
'command' => 'workbench:autoLike',
'schedule' => '*/6 * * * *', // 每6分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_workbench_autoLike.log',
],
// 工作台:自动建群任务,按规则批量创建微信群
'workbench_group_create' => [
'command' => 'workbench:groupCreate',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'workbench_groupCreate.log',
],
// 工作台:自动导入通讯录到系统,生成加粉/建群等任务
'workbench_import_contact' => [
'command' => 'workbench:import-contact',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'import_contact.log',
],
// ===========================
// 低频任务(每 2 分钟)
// ===========================
// 清洗并同步微信原始数据到存客宝业务表(数据治理任务)
'sync_wechat_data' => [
'command' => 'sync:wechatData',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'sync_wechat_data.log',
],
// 工作台:流量分发任务,把流量池中的线索按规则分配给微信号或员工
'workbench_traffic_distribute' => [
'command' => 'workbench:trafficDistribute',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'traffic_distribute.log',
],
// 工作台:朋友圈同步任务,拉取并落库朋友圈内容
'workbench_moments' => [
'command' => 'workbench:moments',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'workbench_moments.log',
],
// 预防性切换好友任务,监控频繁/风控风险,自动切换加人对象,保护微信号
'switch_friends' => [
'command' => 'switch:friends',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'switch_friends.log',
],
// ===========================
// 低频任务(每 30 分钟)
// ===========================
// 拉取设备通话记录(语音/电话),用于质检、统计或标签打分
'call_recording' => [
'command' => 'call-recording:list',
'schedule' => '*/30 * * * *', // 每30分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'call_recording.log',
],
// ===========================
// 每日 / 每几天任务
// ===========================
// 每日 1:00 同步“已删除设备”列表,补齐历史状态
'device_deleted' => [
'command' => 'device:list',
'schedule' => '0 1 * * *', // 每天1点
'options' => ['--isDel=1'],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_device_deleted.log',
],
// 每日 1:10 同步“已停用设备”列表,更新停用状态
'device_stopped' => [
'command' => 'device:list',
'schedule' => '10 1 * * *', // 每天1:10
'options' => ['--isDel=2'],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_device_stopped.log',
],
// 每日 1:30 同步“已删除微信好友”,用于历史恢复与报表
'wechat_friends_deleted' => [
'command' => 'wechatFriends:list',
'schedule' => '30 1 * * *', // 每天1:30
'options' => ['--isDel=1'],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_wechatFriends_deleted.log',
],
// 每日 1:30 同步“已删除微信群聊”,用于统计与留痕
'wechat_chatroom_deleted' => [
'command' => 'wechatChatroom:list',
'schedule' => '30 1 * * *', // 每天1:30
'options' => ['--isDel=1'],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_wechatChatroom_deleted.log',
],
// 每日 2:00 统一计算所有微信账号健康分(基础分 + 动态分)
'wechat_calculate_score' => [
'command' => 'wechat:calculate-score',
'schedule' => '0 2 * * *', // 每天2点
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'calculate_score.log',
],
// 每 3 天执行的全量任务
// 每 3 天 3:00 全量同步所有在线好友,做一次大规模校准
'sync_all_friends' => [
'command' => 'sync:allFriends',
'schedule' => '0 3 */3 * *', // 每3天的3点
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'all_friends.log',
],
// 已禁用的任务(注释掉的任务)
// 'workbench_group_push' => [
// 'command' => 'workbench:groupPush',
// 'schedule' => '*/2 * * * *',
// 'options' => [],
// 'enabled' => false,
// 'log_file' => 'workbench_groupPush.log',
// ],
];

View File

@@ -28,14 +28,30 @@ export function getTrafficPoolList() {
"GET",
);
}
type ListRequestOptions = {
debounceGap?: number;
};
// 好友列表
export function getContactList(params) {
return request("/v1/kefu/wechatFriend/list", params, "GET");
export function getContactList(params, options?: ListRequestOptions) {
return request(
"/v1/kefu/wechatFriend/list",
params,
"GET",
undefined,
options?.debounceGap,
);
}
// 群列表
export function getGroupList(params) {
return request("/v1/kefu/wechatChatroom/list", params, "GET");
export function getGroupList(params, options?: ListRequestOptions) {
return request(
"/v1/kefu/wechatChatroom/list",
params,
"GET",
undefined,
options?.debounceGap,
);
}
// 分组列表
export function getLabelsListByGroup(params) {

View File

@@ -0,0 +1,160 @@
// 红包消息样式
.redPacketMessage {
background: transparent;
box-shadow: none;
max-width: 300px;
}
.redPacketCard {
position: relative;
display: flex;
flex-direction: column;
padding: 16px 20px;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
overflow: hidden;
// 红包装饰背景
&::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(255, 215, 0, 0.15) 0%,
transparent 70%
);
animation: shimmer 3s ease-in-out infinite;
}
// 金色装饰边框
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px solid rgba(255, 215, 0, 0.4);
border-radius: 8px;
pointer-events: none;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(255, 107, 107, 0.4);
background: linear-gradient(135deg, #ff7b7b 0%, #ff6b7f 100%);
}
&:active {
transform: translateY(0);
}
}
@keyframes shimmer {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
}
.redPacketHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
position: relative;
z-index: 1;
}
.redPacketIcon {
font-size: 32px;
line-height: 1;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.redPacketTitle {
flex: 1;
font-size: 16px;
font-weight: 600;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
letter-spacing: 0.5px;
line-height: 1.4;
word-break: break-word;
}
.redPacketFooter {
display: flex;
align-items: center;
justify-content: flex-end;
position: relative;
z-index: 1;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.3);
}
.redPacketLabel {
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
&::before {
content: "💰";
margin-right: 4px;
font-size: 14px;
}
}
// 消息文本样式(用于错误提示)
.messageText {
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
color: #8c8c8c;
font-size: 13px;
}
// 响应式设计
@media (max-width: 768px) {
.redPacketMessage {
max-width: 200px;
}
.redPacketCard {
padding: 12px 16px;
}
.redPacketIcon {
font-size: 28px;
}
.redPacketTitle {
font-size: 14px;
}
.redPacketLabel {
font-size: 11px;
}
}

View File

@@ -0,0 +1,62 @@
import React from "react";
import styles from "./RedPacketMessage.module.scss";
interface RedPacketData {
nativeurl?: string;
paymsgid?: string;
sendertitle?: string;
[key: string]: any;
}
interface RedPacketMessageProps {
content: string;
}
const RedPacketMessage: React.FC<RedPacketMessageProps> = ({ content }) => {
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[红包消息 - 无效内容]");
}
try {
const trimmedContent = content.trim();
const jsonData: RedPacketData = JSON.parse(trimmedContent);
// 验证是否为红包消息
const isRedPacket =
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
);
if (!isRedPacket) {
return renderErrorMessage("[红包消息 - 格式错误]");
}
const title = jsonData.sendertitle || "恭喜发财,大吉大利";
const paymsgid = jsonData.paymsgid || "";
return (
<div className={styles.redPacketMessage}>
<div className={styles.redPacketCard}>
<div className={styles.redPacketHeader}>
<div className={styles.redPacketIcon}>🧧</div>
<div className={styles.redPacketTitle}>{title}</div>
</div>
<div className={styles.redPacketFooter}>
<span className={styles.redPacketLabel}></span>
</div>
</div>
</div>
);
} catch (e) {
console.warn("红包消息解析失败:", e);
return renderErrorMessage("[红包消息 - 解析失败]");
}
};
export default RedPacketMessage;

View File

@@ -7,6 +7,7 @@ import VideoMessage from "./components/VideoMessage";
import ClickMenu from "./components/ClickMeau";
import LocationMessage from "./components/LocationMessage";
import SystemRecommendRemarkMessage from "./components/SystemRecommendRemarkMessage/index";
import RedPacketMessage from "./components/RedPacketMessage";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
@@ -254,6 +255,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
msg?: ChatRecord,
contract?: ContractData | weChatGroup,
) => {
console.log("红包");
if (isLegacyEmojiContent(trimmedContent)) {
return renderEmojiContent(rawContent);
}
@@ -261,6 +263,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const jsonData = tryParseContentJson(trimmedContent);
if (jsonData && typeof jsonData === "object") {
// 判断是否为红包消息
if (
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
)
) {
return <RedPacketMessage content={rawContent} />;
}
if (jsonData.type === "file" && msg && contract) {
return (
<SmallProgramMessage
@@ -378,13 +391,15 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
if (!msg) {
return { avatar: "", nickname: "" };
}
const member =
groupRender.find(user => user?.identifier === msg?.sender?.wechatId) ||
groupRender.find(user => user?.wechatId === msg?.sender?.wechatId);
const member = groupRender.find(
user => user?.identifier === msg?.senderWechatId,
);
console.log(member, "member");
return {
avatar: member?.avatar || msg?.sender?.avatar || "",
nickname: member?.nickname || msg?.sender?.nickname || "",
avatar: member?.avatar || msg?.avatar,
nickname: member?.nickname || msg?.senderNickname,
};
};
@@ -615,7 +630,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const isOwn = msg?.isSend;
const isGroup = !!contract.chatroomId;
const groupUser = isGroup ? renderGroupUser(msg) : null;
return (
<div
key={msg.id || `msg-${Date.now()}`}
@@ -667,14 +682,14 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
)}
<Avatar
size={32}
src={groupUser?.avatar}
src={renderGroupUser(msg)?.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{groupUser?.nickname}
{renderGroupUser(msg)?.nickname}
</div>
)}
<>

View File

@@ -18,7 +18,7 @@ export const getAllFriends = async () => {
let hasMore = true;
while (hasMore) {
const result = await getContactList({ page, limit });
const result = await getContactList({ page, limit }, { debounceGap: 0 });
const friendList = result?.list || [];
if (
@@ -56,7 +56,7 @@ export const getAllGroups = async () => {
let hasMore = true;
while (hasMore) {
const result = await getGroupList({ page, limit });
const result = await getGroupList({ page, limit }, { debounceGap: 0 });
const groupList = result?.list || [];
if (!groupList || !Array.isArray(groupList) || groupList.length === 0) {

View File

@@ -659,7 +659,7 @@ export class MessageManager {
updatedSession.sortKey = this.generateSortKey(updatedSession);
await chatSessionService.update(serverId, updatedSession);
console.log(`会话时间已更新: ${serverId} -> ${newTime}`);
await this.triggerCallbacks(userId);
}
} catch (error) {
console.error("更新会话时间失败:", error);
@@ -830,7 +830,7 @@ export class MessageManager {
};
await chatSessionService.create(sessionWithSortKey);
console.log(`创建新会话: ${session.nickname || session.wechatId}`);
await this.triggerCallbacks(userId);
} catch (error) {
console.error("创建会话失败:", error);
throw error;