|
|
const cloud = require("wx-server-sdk");
|
|
|
cloud.init({
|
|
|
env: cloud.DYNAMIC_CURRENT_ENV,
|
|
|
});
|
|
|
|
|
|
const axios = require('axios');
|
|
|
const crypto = require('crypto');
|
|
|
|
|
|
const db = cloud.database();
|
|
|
const _ = db.command; // 数据库命令引用
|
|
|
// 获取openid
|
|
|
const getOpenId = async () => {
|
|
|
// 获取基础信息
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
return {
|
|
|
openid: wxContext.OPENID,
|
|
|
appid: wxContext.APPID,
|
|
|
unionid: wxContext.UNIONID,
|
|
|
};
|
|
|
};
|
|
|
|
|
|
const sendChatMessage = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const fromOpenId = wxContext.OPENID;
|
|
|
const toUserIdParam = String(event?.toUserId || '').trim();
|
|
|
const toOpenIdParam = String(event?.toOpenId || '').trim();
|
|
|
const contentType = String(event?.contentType || '').trim();
|
|
|
const content = String(event?.content || '').trim();
|
|
|
const imageUrl = String(event?.imageUrl || '');
|
|
|
const productId = String(event?.productId || '');
|
|
|
if (!contentType) return { success: false, error: 'contentType required' };
|
|
|
if (contentType === 'text' && !content) return { success: false, error: 'content required' };
|
|
|
if (contentType === 'image' && !imageUrl) return { success: false, error: 'imageUrl required' };
|
|
|
let toUserId = '';
|
|
|
let toOpenId = '';
|
|
|
if (toUserIdParam) {
|
|
|
const uById = await db.collection('T_user').doc(toUserIdParam).get();
|
|
|
const uDoc = uById.data || null;
|
|
|
toUserId = uDoc ? (uDoc._id || toUserIdParam) : toUserIdParam;
|
|
|
toOpenId = uDoc ? (uDoc._openid || '') : toOpenIdParam;
|
|
|
} else if (toOpenIdParam) {
|
|
|
const uByOpen = await db.collection('T_user').where({ _openid: toOpenIdParam }).limit(1).get();
|
|
|
const uDoc = (uByOpen.data && uByOpen.data[0]) || null;
|
|
|
toOpenId = toOpenIdParam;
|
|
|
toUserId = uDoc ? (uDoc._id || '') : '';
|
|
|
}
|
|
|
if (!toOpenId && !toUserId) return { success: false, error: 'toUserId or toOpenId required' };
|
|
|
if (!toOpenId && toUserId) {
|
|
|
const uById2 = await db.collection('T_user').doc(toUserId).get();
|
|
|
const uDoc2 = uById2.data || null;
|
|
|
toOpenId = uDoc2 ? (uDoc2._openid || '') : '';
|
|
|
}
|
|
|
if (!toOpenId || toOpenId === fromOpenId) return { success: false, error: 'invalid recipient' };
|
|
|
const meRes = await db.collection('T_user').where({ _openid: fromOpenId }).limit(1).get();
|
|
|
const meDoc = (meRes.data && meRes.data[0]) || null;
|
|
|
const fromUserId = meDoc ? (meDoc._id || '') : '';
|
|
|
const sessionKey = [fromOpenId, toOpenId].sort().join('|');
|
|
|
const sessionKeyUser = (fromUserId && toUserId) ? [fromUserId, toUserId].sort().join('|') : '';
|
|
|
const nowTs = Date.now();
|
|
|
const isSystem = Boolean(event?.isSystem);
|
|
|
const orderId = String(event?.orderId || '');
|
|
|
const chatDoc = {
|
|
|
sessionKey,
|
|
|
sessionKeyUser,
|
|
|
productId,
|
|
|
fromOpenId,
|
|
|
toOpenId,
|
|
|
fromUserId,
|
|
|
toUserId,
|
|
|
contentType,
|
|
|
content,
|
|
|
imageUrl,
|
|
|
isSystem,
|
|
|
orderId,
|
|
|
timestamp: nowTs,
|
|
|
createTime: new Date()
|
|
|
};
|
|
|
const addRes = await db.collection('T_chat').add({ data: chatDoc });
|
|
|
return { success: true, id: addRes._id || '', data: { ...chatDoc, _id: addRes._id || '' } };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const getChatMessages = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const myOpenId = wxContext.OPENID;
|
|
|
const toUserIdParam = String(event?.toUserId || '').trim();
|
|
|
const toOpenIdParam = String(event?.toOpenId || '').trim();
|
|
|
const limit = Math.max(1, Math.min(200, Number(event?.limit) || 100));
|
|
|
const before = Number(event?.before) || 0;
|
|
|
let toOpenId = '';
|
|
|
let toUserId = '';
|
|
|
if (toUserIdParam) {
|
|
|
const uById = await db.collection('T_user').doc(toUserIdParam).get();
|
|
|
const uDoc = uById.data || null;
|
|
|
toUserId = uDoc ? (uDoc._id || toUserIdParam) : toUserIdParam;
|
|
|
toOpenId = uDoc ? (uDoc._openid || '') : toOpenIdParam;
|
|
|
} else if (toOpenIdParam) {
|
|
|
const uByOpen = await db.collection('T_user').where({ _openid: toOpenIdParam }).limit(1).get();
|
|
|
const uDoc = (uByOpen.data && uByOpen.data[0]) || null;
|
|
|
toOpenId = toOpenIdParam;
|
|
|
toUserId = uDoc ? (uDoc._id || '') : '';
|
|
|
}
|
|
|
if (!toOpenId && !toUserId) return { success: false, error: 'toUserId or toOpenId required' };
|
|
|
if (!toOpenId && toUserId) {
|
|
|
const uById2 = await db.collection('T_user').doc(toUserId).get();
|
|
|
const uDoc2 = uById2.data || null;
|
|
|
toOpenId = uDoc2 ? (uDoc2._openid || '') : '';
|
|
|
}
|
|
|
const sessionKey = [myOpenId, toOpenId].sort().join('|');
|
|
|
let query = null;
|
|
|
if (toUserId) {
|
|
|
const meRes = await db.collection('T_user').where({ _openid: myOpenId }).limit(1).get();
|
|
|
const meDoc = (meRes.data && meRes.data[0]) || null;
|
|
|
const myUserId = meDoc ? (meDoc._id || '') : '';
|
|
|
const sessionKeyUser = (myUserId && toUserId) ? [myUserId, toUserId].sort().join('|') : '';
|
|
|
query = sessionKeyUser ? db.collection('T_chat').where({ sessionKeyUser }) : db.collection('T_chat').where({ sessionKey });
|
|
|
} else {
|
|
|
query = db.collection('T_chat').where({ sessionKey });
|
|
|
}
|
|
|
if (before > 0) {
|
|
|
query = query.where({ timestamp: _.lt(before) });
|
|
|
}
|
|
|
const ret = await query.orderBy('timestamp', 'asc').limit(limit).get();
|
|
|
const docs = (ret.data || []).filter(d => d && (d.fromOpenId === myOpenId || d.toOpenId === myOpenId));
|
|
|
return { success: true, data: docs };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const revokeChatMessage = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const myOpenId = wxContext.OPENID;
|
|
|
const id = String(event?.id || '').trim();
|
|
|
if (!id) return { success: false, error: 'id required' };
|
|
|
const doc = await db.collection('T_chat').doc(id).get();
|
|
|
const m = doc.data || null;
|
|
|
if (!m) return { success: false, error: 'not found' };
|
|
|
if (m.fromOpenId !== myOpenId) return { success: false, error: 'not sender' };
|
|
|
const now = Date.now();
|
|
|
if ((now - (m.timestamp || 0)) > 2 * 60 * 1000) return { success: false, error: 'timeout' };
|
|
|
await db.collection('T_chat').doc(id).update({ data: { isRevoked: true, contentType: 'revoke', content: '' } });
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const listChatSessions = async () => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const myOpenId = wxContext.OPENID;
|
|
|
const uRes = await db.collection('T_user').where({ _openid: myOpenId }).limit(1).get();
|
|
|
const me = (uRes.data && uRes.data[0]) || null;
|
|
|
const myUserId = me ? (me._id || '') : '';
|
|
|
|
|
|
let msgs = [];
|
|
|
if (myUserId) {
|
|
|
const retUser = await db.collection('T_chat')
|
|
|
.where(_.or([{ fromUserId: myUserId }, { toUserId: myUserId }]))
|
|
|
.orderBy('timestamp', 'desc')
|
|
|
.limit(200)
|
|
|
.get();
|
|
|
msgs = retUser.data || [];
|
|
|
}
|
|
|
if (!msgs.length) {
|
|
|
const retOpen = await db.collection('T_chat')
|
|
|
.where(_.or([{ fromOpenId: myOpenId }, { toOpenId: myOpenId }]))
|
|
|
.orderBy('timestamp', 'desc')
|
|
|
.limit(200)
|
|
|
.get();
|
|
|
msgs = retOpen.data || [];
|
|
|
}
|
|
|
|
|
|
const bySession = {};
|
|
|
msgs.forEach(m => {
|
|
|
const sk = m.sessionKeyUser || (m.sessionKey || [m.fromOpenId, m.toOpenId].sort().join('|'));
|
|
|
const ts = m.timestamp || 0;
|
|
|
if (!bySession[sk] || ts > (bySession[sk].timestamp || 0)) bySession[sk] = m;
|
|
|
});
|
|
|
let sessions = Object.values(bySession).map(m => {
|
|
|
const peerUserId = (m.fromUserId === myUserId ? m.toUserId : m.fromUserId) || '';
|
|
|
const peerOpenId = (m.fromOpenId === myOpenId ? m.toOpenId : m.fromOpenId) || '';
|
|
|
return {
|
|
|
sessionKey: m.sessionKey || [m.fromOpenId, m.toOpenId].sort().join('|'),
|
|
|
sessionKeyUser: m.sessionKeyUser || ((myUserId && peerUserId) ? [myUserId, peerUserId].sort().join('|') : ''),
|
|
|
peerUserId,
|
|
|
peerOpenId,
|
|
|
lastContentType: m.contentType,
|
|
|
lastContent: m.contentType === 'text' ? m.content : '[图片]',
|
|
|
lastTime: m.timestamp || Date.now(),
|
|
|
peerName: '',
|
|
|
peerAvatar: '',
|
|
|
unreadCount: 0
|
|
|
};
|
|
|
});
|
|
|
|
|
|
const peerUserIds = sessions.map(s => s.peerUserId).filter(Boolean);
|
|
|
if (peerUserIds.length) {
|
|
|
const uresById = await db.collection('T_user').where({ _id: _.in(peerUserIds) }).get();
|
|
|
const umap = {};
|
|
|
(uresById.data || []).forEach(u => { umap[u._id] = u; });
|
|
|
sessions = sessions.map(s => {
|
|
|
const u = umap[s.peerUserId] || {};
|
|
|
return { ...s, peerName: u.sname || u.nickName || '用户', peerAvatar: u.avatar || '' };
|
|
|
});
|
|
|
} else {
|
|
|
const peerOpenIds = sessions.map(s => s.peerOpenId).filter(Boolean);
|
|
|
if (peerOpenIds.length) {
|
|
|
const uresByOpen = await db.collection('T_user').where({ _openid: _.in(peerOpenIds) }).get();
|
|
|
const umap2 = {};
|
|
|
(uresByOpen.data || []).forEach(u => { umap2[u._openid] = u; });
|
|
|
sessions = sessions.map(s => {
|
|
|
const u = umap2[s.peerOpenId] || {};
|
|
|
return { ...s, peerName: u.sname || u.nickName || '用户', peerAvatar: u.avatar || '' };
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
sessions.sort((a, b) => b.lastTime - a.lastTime);
|
|
|
// 计算未读数
|
|
|
const statesRes = await db.collection('T_chat_read_state').where({ myOpenId }).get();
|
|
|
const states = statesRes.data || [];
|
|
|
const stateMap = new Map();
|
|
|
states.forEach(s => {
|
|
|
const key = s.sessionKeyUser || s.sessionKey;
|
|
|
stateMap.set(key, s.lastReadTime || 0);
|
|
|
});
|
|
|
for (let i = 0; i < sessions.length; i++) {
|
|
|
const s = sessions[i];
|
|
|
const key = s.sessionKeyUser || s.sessionKey;
|
|
|
const lastRead = stateMap.get(key) || 0;
|
|
|
let q = null;
|
|
|
if (s.sessionKeyUser) {
|
|
|
q = db.collection('T_chat').where({ sessionKeyUser: s.sessionKeyUser, toOpenId: myOpenId, timestamp: _.gt(lastRead) });
|
|
|
} else {
|
|
|
q = db.collection('T_chat').where({ sessionKey: s.sessionKey, toOpenId: myOpenId, timestamp: _.gt(lastRead) });
|
|
|
}
|
|
|
try {
|
|
|
const cnt = await q.count();
|
|
|
s.unreadCount = cnt.total || 0;
|
|
|
} catch (e) {
|
|
|
s.unreadCount = 0;
|
|
|
}
|
|
|
}
|
|
|
return { success: true, data: sessions };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const updateChatReadState = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const myOpenId = wxContext.OPENID;
|
|
|
const toUserIdParam = String(event?.toUserId || '').trim();
|
|
|
const toOpenIdParam = String(event?.toOpenId || '').trim();
|
|
|
let toOpenId = '';
|
|
|
let toUserId = '';
|
|
|
if (toUserIdParam) {
|
|
|
const uById = await db.collection('T_user').doc(toUserIdParam).get();
|
|
|
const uDoc = uById.data || null;
|
|
|
toUserId = uDoc ? (uDoc._id || toUserIdParam) : toUserIdParam;
|
|
|
toOpenId = uDoc ? (uDoc._openid || '') : toOpenIdParam;
|
|
|
} else if (toOpenIdParam) {
|
|
|
const uByOpen = await db.collection('T_user').where({ _openid: toOpenIdParam }).limit(1).get();
|
|
|
const uDoc = (uByOpen.data && uByOpen.data[0]) || null;
|
|
|
toOpenId = toOpenIdParam;
|
|
|
toUserId = uDoc ? (uDoc._id || '') : '';
|
|
|
}
|
|
|
if (!toOpenId && toUserId) {
|
|
|
const uById2 = await db.collection('T_user').doc(toUserId).get();
|
|
|
const uDoc2 = uById2.data || null;
|
|
|
toOpenId = uDoc2 ? (uDoc2._openid || '') : '';
|
|
|
}
|
|
|
const meRes = await db.collection('T_user').where({ _openid: myOpenId }).limit(1).get();
|
|
|
const meDoc = (meRes.data && meRes.data[0]) || null;
|
|
|
const myUserId = meDoc ? (meDoc._id || '') : '';
|
|
|
const sessionKey = [myOpenId, toOpenId].sort().join('|');
|
|
|
const sessionKeyUser = (myUserId && toUserId) ? [myUserId, toUserId].sort().join('|') : '';
|
|
|
const now = Date.now();
|
|
|
const col = db.collection('T_chat_read_state');
|
|
|
const where = sessionKeyUser ? { sessionKeyUser, myOpenId } : { sessionKey, myOpenId };
|
|
|
const exist = await col.where(where).limit(1).get();
|
|
|
if (exist.data && exist.data[0]) {
|
|
|
await col.doc(exist.data[0]._id).update({ data: { lastReadTime: now } });
|
|
|
} else {
|
|
|
await col.add({ data: { sessionKey, sessionKeyUser, myOpenId, myUserId, lastReadTime: now, createTime: new Date() } });
|
|
|
}
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
// 获取小程序二维码
|
|
|
const getMiniProgramCode = async () => {
|
|
|
// 获取小程序二维码的buffer
|
|
|
const resp = await cloud.openapi.wxacode.get({
|
|
|
path: "pages/index/index",
|
|
|
});
|
|
|
const { buffer } = resp;
|
|
|
// 将图片上传云存储空间
|
|
|
const upload = await cloud.uploadFile({
|
|
|
cloudPath: "code.png",
|
|
|
fileContent: buffer,
|
|
|
});
|
|
|
return upload.fileID;
|
|
|
};
|
|
|
|
|
|
// 创建集合
|
|
|
const createCollection = async () => {
|
|
|
try {
|
|
|
// 创建集合
|
|
|
await db.createCollection("sales");
|
|
|
await db.collection("sales").add({
|
|
|
// data 字段表示需新增的 JSON 数据
|
|
|
data: {
|
|
|
region: "华东",
|
|
|
city: "上海",
|
|
|
sales: 11,
|
|
|
},
|
|
|
});
|
|
|
await db.collection("sales").add({
|
|
|
// data 字段表示需新增的 JSON 数据
|
|
|
data: {
|
|
|
region: "华东",
|
|
|
city: "南京",
|
|
|
sales: 11,
|
|
|
},
|
|
|
});
|
|
|
await db.collection("sales").add({
|
|
|
// data 字段表示需新增的 JSON 数据
|
|
|
data: {
|
|
|
region: "华南",
|
|
|
city: "广州",
|
|
|
sales: 22,
|
|
|
},
|
|
|
});
|
|
|
await db.collection("sales").add({
|
|
|
// data 字段表示需新增的 JSON 数据
|
|
|
data: {
|
|
|
region: "华南",
|
|
|
city: "深圳",
|
|
|
sales: 22,
|
|
|
},
|
|
|
});
|
|
|
return {
|
|
|
success: true,
|
|
|
};
|
|
|
} catch (e) {
|
|
|
// 这里catch到的是该collection已经存在,从业务逻辑上来说是运行成功的,所以catch返回success给前端,避免工具在前端抛出异常
|
|
|
return {
|
|
|
success: true,
|
|
|
data: "create collection success",
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 查询数据
|
|
|
const selectRecord = async () => {
|
|
|
// 返回数据库查询结果
|
|
|
return await db.collection("sales").get();
|
|
|
};
|
|
|
|
|
|
// 更新数据
|
|
|
const updateRecord = async (event) => {
|
|
|
try {
|
|
|
// 遍历修改数据库信息
|
|
|
for (let i = 0; i < event.data.length; i++) {
|
|
|
await db
|
|
|
.collection("sales")
|
|
|
.where({
|
|
|
_id: event.data[i]._id,
|
|
|
})
|
|
|
.update({
|
|
|
data: {
|
|
|
sales: event.data[i].sales,
|
|
|
},
|
|
|
});
|
|
|
}
|
|
|
return {
|
|
|
success: true,
|
|
|
data: event.data,
|
|
|
};
|
|
|
} catch (e) {
|
|
|
return {
|
|
|
success: false,
|
|
|
errMsg: e,
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 新增数据
|
|
|
const insertRecord = async (event) => {
|
|
|
try {
|
|
|
const insertRecord = event.data;
|
|
|
// 插入数据
|
|
|
await db.collection("sales").add({
|
|
|
data: {
|
|
|
region: insertRecord.region,
|
|
|
city: insertRecord.city,
|
|
|
sales: Number(insertRecord.sales),
|
|
|
},
|
|
|
});
|
|
|
return {
|
|
|
success: true,
|
|
|
data: event.data,
|
|
|
};
|
|
|
} catch (e) {
|
|
|
return {
|
|
|
success: false,
|
|
|
errMsg: e,
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 删除数据
|
|
|
const deleteRecord = async (event) => {
|
|
|
try {
|
|
|
await db
|
|
|
.collection("sales")
|
|
|
.where({
|
|
|
_id: event.data._id,
|
|
|
})
|
|
|
.remove();
|
|
|
return {
|
|
|
success: true,
|
|
|
};
|
|
|
} catch (e) {
|
|
|
return {
|
|
|
success: false,
|
|
|
errMsg: e,
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 通过云函数进行腾讯位置服务逆地理编码(更安全,避免前端域名/密钥限制)
|
|
|
const cloudReverseGeocode = async (event) => {
|
|
|
try {
|
|
|
const lat = Number(event.latitude || (event.location && event.location.latitude));
|
|
|
const lng = Number(event.longitude || (event.location && event.location.longitude));
|
|
|
// 组装可用 Keys:优先单 Key,其次多 Key 列表(逗号分隔)
|
|
|
const primaryKey = process.env.QQMAP_KEY || event.key; // 优先使用云环境变量
|
|
|
const multiKeysStr = process.env.QQMAP_KEYS || event.keys || '';
|
|
|
const extraKeys = multiKeysStr.split(',').map(k => k.trim()).filter(Boolean);
|
|
|
const keys = [];
|
|
|
if (primaryKey) keys.push(primaryKey);
|
|
|
for (const k of extraKeys) { if (!keys.includes(k)) keys.push(k); }
|
|
|
if (keys.length === 0) {
|
|
|
return { success: false, error: '缺少QQMAP_KEY,请在云环境变量配置或传入key' };
|
|
|
}
|
|
|
if (!isFinite(lat) || !isFinite(lng)) {
|
|
|
return { success: false, error: '坐标参数无效' };
|
|
|
}
|
|
|
|
|
|
// 规范化 Referer:移除所有非 URL 合法字符(含空格、反引号等),避免校验失败
|
|
|
const refererRaw = process.env.QQMAP_REFERER || event.referer || '';
|
|
|
// 规范化 Referer:显式去除常见引号字符(包含反引号、中文引号),并移除空白
|
|
|
const normalizeReferer = (val) => {
|
|
|
let s = String(val || '')
|
|
|
.replace(/[`"'“”‘’]/g, '')
|
|
|
.replace(/\s+/g, '');
|
|
|
// 二次清理:仅保留 URL 常见合法字符
|
|
|
s = s.replace(/[^a-zA-Z0-9:\/._-]/g, '');
|
|
|
// 若为空或不以 http(s) 开头,兜底为微信域
|
|
|
if (!s || !/^https?:\/\//.test(s)) return 'https://servicewechat.com';
|
|
|
// 若包含 servicewechat.com,则统一为基础域,避免子路径差异
|
|
|
if (s.includes('servicewechat.com')) return 'https://servicewechat.com';
|
|
|
return s;
|
|
|
};
|
|
|
const referer = normalizeReferer(refererRaw);
|
|
|
const sk = process.env.QQMAP_SK || event.sk || '';
|
|
|
const urlBase = 'https://apis.map.qq.com';
|
|
|
const apiPathVariants = ['/ws/geocoder/v1', '/ws/geocoder/v1/'];
|
|
|
let lastError = null;
|
|
|
console.log('[Reverse] using referer:', referer);
|
|
|
for (const k of keys) {
|
|
|
// 仅使用必需参数参与签名,避免含 '=' 的值(如 poi_options)造成歧义
|
|
|
const base = { key: k, location: `${lat},${lng}` };
|
|
|
const keysSorted = Object.keys(base).sort();
|
|
|
const qsPlain = keysSorted.map(p => `${p}=${base[p]}`).join('&');
|
|
|
const qsEncoded = keysSorted.map(p => `${p}=${encodeURIComponent(String(base[p]))}`).join('&');
|
|
|
const qsReqBase = qsEncoded;
|
|
|
|
|
|
for (const apiPath of apiPathVariants) {
|
|
|
let qsReq = qsReqBase;
|
|
|
// 先尝试带签名
|
|
|
if (sk) {
|
|
|
// 同时尝试“未编码”和“已编码”两种签名算法
|
|
|
const sigStrPlain = `${apiPath}?${qsPlain}${sk}`;
|
|
|
const sigPlain = crypto.createHash('md5').update(sigStrPlain).digest('hex');
|
|
|
console.log('[Reverse] try sigPlain', { apiPath, qsPlain, sigPlain });
|
|
|
let tried = false;
|
|
|
// 先尝试 plain 签名
|
|
|
try {
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReq}&sig=${sigPlain}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
tried = true;
|
|
|
if (resp.data && resp.data.status === 0) {
|
|
|
const r = resp.data.result || {};
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
address: r.address || '',
|
|
|
formatted_addresses: r.formatted_addresses || {},
|
|
|
address_component: r.address_component || {},
|
|
|
address_reference: r.address_reference || {},
|
|
|
ad_info: r.ad_info || {},
|
|
|
location: { lat, lng }
|
|
|
}
|
|
|
};
|
|
|
} else {
|
|
|
lastError = new Error(resp.data?.message || '逆地理编码失败');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
lastError = e;
|
|
|
}
|
|
|
|
|
|
// 若失败再尝试 encoded 签名
|
|
|
try {
|
|
|
const sigStrEnc = `${apiPath}?${qsEncoded}${sk}`;
|
|
|
const sigEnc = crypto.createHash('md5').update(sigStrEnc).digest('hex');
|
|
|
console.log('[Reverse] try sigEnc', { apiPath, qsEncoded, sigEnc });
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReq}&sig=${sigEnc}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
if (resp.data && resp.data.status === 0) {
|
|
|
const r = resp.data.result || {};
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
address: r.address || '',
|
|
|
formatted_addresses: r.formatted_addresses || {},
|
|
|
address_component: r.address_component || {},
|
|
|
address_reference: r.address_reference || {},
|
|
|
ad_info: r.ad_info || {},
|
|
|
location: { lat, lng }
|
|
|
}
|
|
|
};
|
|
|
} else {
|
|
|
lastError = new Error(resp.data?.message || '逆地理编码失败');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
lastError = e;
|
|
|
}
|
|
|
// 如果上面两种签名都失败,将在后面尝试无签名请求
|
|
|
}
|
|
|
try {
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReq}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
if (resp.data && resp.data.status === 0) {
|
|
|
const r = resp.data.result || {};
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
address: r.address || '',
|
|
|
formatted_addresses: r.formatted_addresses || {},
|
|
|
address_component: r.address_component || {},
|
|
|
address_reference: r.address_reference || {},
|
|
|
ad_info: r.ad_info || {},
|
|
|
location: { lat, lng }
|
|
|
}
|
|
|
};
|
|
|
} else {
|
|
|
lastError = new Error(resp.data?.message || '逆地理编码失败');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
lastError = e;
|
|
|
}
|
|
|
|
|
|
// 若签名失败且开启了签名校验,尝试不带签名的请求(部分控制台配置允许)
|
|
|
try {
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReqBase}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
if (resp.data && resp.data.status === 0) {
|
|
|
const r = resp.data.result || {};
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
address: r.address || '',
|
|
|
formatted_addresses: r.formatted_addresses || {},
|
|
|
address_component: r.address_component || {},
|
|
|
address_reference: r.address_reference || {},
|
|
|
ad_info: r.ad_info || {},
|
|
|
location: { lat, lng }
|
|
|
}
|
|
|
};
|
|
|
}
|
|
|
} catch (e) {
|
|
|
// 保留最后的错误用于返回
|
|
|
lastError = e;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return { success: false, error: lastError?.message || '逆地理编码失败' };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 正向地理编码:根据地址获取坐标(支持多 Key 与签名)
|
|
|
const cloudGeocode = async (event) => {
|
|
|
try {
|
|
|
const address = String(event.address || '').trim();
|
|
|
if (!address) {
|
|
|
return { success: false, error: '地址为空' };
|
|
|
}
|
|
|
const primaryKey = process.env.QQMAP_KEY || event.key; // 优先环境变量
|
|
|
const multiKeysStr = process.env.QQMAP_KEYS || event.keys || '';
|
|
|
const extraKeys = multiKeysStr.split(',').map(k => k.trim()).filter(Boolean);
|
|
|
const keys = [];
|
|
|
if (primaryKey) keys.push(primaryKey);
|
|
|
for (const k of extraKeys) { if (!keys.includes(k)) keys.push(k); }
|
|
|
if (keys.length === 0) {
|
|
|
return { success: false, error: '缺少QQMAP_KEY,请在云环境变量配置或传入key' };
|
|
|
}
|
|
|
const refererRaw = process.env.QQMAP_REFERER || event.referer || '';
|
|
|
const normalizeReferer = (val) => {
|
|
|
let s = String(val || '')
|
|
|
.replace(/[`"'“”‘’]/g, '')
|
|
|
.replace(/\s+/g, '');
|
|
|
s = s.replace(/[^a-zA-Z0-9:\/._-]/g, '');
|
|
|
if (!s || !/^https?:\/\//.test(s)) return 'https://servicewechat.com';
|
|
|
if (s.includes('servicewechat.com')) return 'https://servicewechat.com';
|
|
|
return s;
|
|
|
};
|
|
|
const referer = normalizeReferer(refererRaw);
|
|
|
const sk = process.env.QQMAP_SK || event.sk || '';
|
|
|
const urlBase = 'https://apis.map.qq.com';
|
|
|
const apiPathVariants = ['/ws/geocoder/v1', '/ws/geocoder/v1/'];
|
|
|
let lastError = null;
|
|
|
console.log('[Forward] using referer:', referer);
|
|
|
for (const k of keys) {
|
|
|
// 仅使用必需参数参与签名
|
|
|
const base = { address, key: k };
|
|
|
const keysSorted = Object.keys(base).sort();
|
|
|
const qsPlain = keysSorted.map(p => `${p}=${base[p]}`).join('&');
|
|
|
const qsEncoded = keysSorted.map(p => `${p}=${encodeURIComponent(String(base[p]))}`).join('&');
|
|
|
const qsReqBase = qsEncoded;
|
|
|
|
|
|
for (const apiPath of apiPathVariants) {
|
|
|
let qsReq = qsReqBase;
|
|
|
// 先尝试带签名
|
|
|
if (sk) {
|
|
|
// 同时尝试“未编码”和“已编码”两种签名算法
|
|
|
const sigStrPlain = `${apiPath}?${qsPlain}${sk}`;
|
|
|
const sigPlain = crypto.createHash('md5').update(sigStrPlain).digest('hex');
|
|
|
console.log('[Forward] try sigPlain', { apiPath, qsPlain, sigPlain });
|
|
|
try {
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReq}&sig=${sigPlain}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
if (resp.data && resp.data.status === 0 && resp.data.result && resp.data.result.location) {
|
|
|
const loc = resp.data.result.location;
|
|
|
return { success: true, data: { latitude: loc.lat, longitude: loc.lng } };
|
|
|
} else {
|
|
|
lastError = new Error(resp.data?.message || '正向地理编码失败');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
lastError = e;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
const sigStrEnc = `${apiPath}?${qsEncoded}${sk}`;
|
|
|
const sigEnc = crypto.createHash('md5').update(sigStrEnc).digest('hex');
|
|
|
console.log('[Forward] try sigEnc', { apiPath, qsEncoded, sigEnc });
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReq}&sig=${sigEnc}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
if (resp.data && resp.data.status === 0 && resp.data.result && resp.data.result.location) {
|
|
|
const loc = resp.data.result.location;
|
|
|
return { success: true, data: { latitude: loc.lat, longitude: loc.lng } };
|
|
|
} else {
|
|
|
lastError = new Error(resp.data?.message || '正向地理编码失败');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
lastError = e;
|
|
|
}
|
|
|
}
|
|
|
try {
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReq}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
if (resp.data && resp.data.status === 0 && resp.data.result && resp.data.result.location) {
|
|
|
const loc = resp.data.result.location;
|
|
|
return { success: true, data: { latitude: loc.lat, longitude: loc.lng } };
|
|
|
} else {
|
|
|
lastError = new Error(resp.data?.message || '正向地理编码失败');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
lastError = e;
|
|
|
}
|
|
|
|
|
|
// 若签名失败,尝试不带签名
|
|
|
try {
|
|
|
const resp = await axios.get(`${urlBase}${apiPath}?${qsReqBase}`, {
|
|
|
timeout: 5000,
|
|
|
headers: referer ? { Referer: referer } : {}
|
|
|
});
|
|
|
if (resp.data && resp.data.status === 0 && resp.data.result && resp.data.result.location) {
|
|
|
const loc = resp.data.result.location;
|
|
|
return { success: true, data: { latitude: loc.lat, longitude: loc.lng } };
|
|
|
}
|
|
|
} catch (e) {
|
|
|
lastError = e;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return { success: false, error: lastError?.message || '正向地理编码失败' };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 创建通知集合 T_notify
|
|
|
const createNotifyCollection = async () => {
|
|
|
try {
|
|
|
await db.createCollection('T_notify');
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
// 集合可能已存在,视为成功
|
|
|
return { success: true, data: 'T_notify exists' };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 创建聊天集合 T_chat,并为 sessionKey/sessionKeyUser + timestamp 建索引(若环境支持)
|
|
|
const createChatCollection = async () => {
|
|
|
try {
|
|
|
await db.createCollection('T_chat');
|
|
|
try {
|
|
|
// 索引创建在部分环境可能不支持,失败不影响主要功能
|
|
|
await db.collection('T_chat').createIndex({
|
|
|
fields: {
|
|
|
sessionKey: 1,
|
|
|
timestamp: -1
|
|
|
},
|
|
|
name: 'idx_session_time'
|
|
|
});
|
|
|
await db.collection('T_chat').createIndex({
|
|
|
fields: {
|
|
|
sessionKeyUser: 1,
|
|
|
timestamp: -1
|
|
|
},
|
|
|
name: 'idx_session_user_time'
|
|
|
});
|
|
|
} catch (e) {
|
|
|
// 忽略索引创建失败
|
|
|
}
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
// 集合可能已存在,视为成功
|
|
|
return { success: true, data: 'T_chat exists' };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const createMessageCollection = async () => {
|
|
|
try {
|
|
|
await db.createCollection('T_message');
|
|
|
try {
|
|
|
await db.collection('T_message').createIndex({
|
|
|
fields: { toUserId: 1, fromUserId: 1 },
|
|
|
name: 'idx_to_from'
|
|
|
});
|
|
|
} catch (e) {}
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
return { success: true, data: 'T_message exists' };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 批量为 T_product 补齐缺失的 sellerUserId(根据 sellerOpenId 关联 T_user)
|
|
|
const backfillSellerUserId = async (event) => {
|
|
|
try {
|
|
|
const batchSize = Math.max(10, Math.min(500, Number(event?.batchSize) || 100));
|
|
|
const dryRun = Boolean(event?.dryRun);
|
|
|
|
|
|
const colProduct = db.collection('T_product');
|
|
|
const colUser = db.collection('T_user');
|
|
|
|
|
|
// 统计总数
|
|
|
const countRes = await colProduct.count();
|
|
|
const total = countRes.total || 0;
|
|
|
|
|
|
let processed = 0;
|
|
|
let corrected = 0;
|
|
|
let skipped = 0;
|
|
|
const openIdToUserId = new Map();
|
|
|
|
|
|
for (let offset = 0; offset < total; offset += batchSize) {
|
|
|
const page = await colProduct.skip(offset).limit(batchSize).get();
|
|
|
const items = page.data || [];
|
|
|
for (const p of items) {
|
|
|
processed += 1;
|
|
|
const hasSellerUserId = !!(p.sellerUserId && String(p.sellerUserId).trim() !== '');
|
|
|
if (hasSellerUserId) {
|
|
|
skipped += 1;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const sellerOpenId = p.sellerOpenId || '';
|
|
|
if (!sellerOpenId) {
|
|
|
skipped += 1;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// 缓存查询,避免重复命中数据库
|
|
|
let userId = openIdToUserId.get(sellerOpenId);
|
|
|
if (!userId) {
|
|
|
const uRes = await colUser.where({ _openid: sellerOpenId }).limit(1).get();
|
|
|
const u = (uRes.data && uRes.data[0]) || null;
|
|
|
userId = u ? u._id : undefined;
|
|
|
if (userId) openIdToUserId.set(sellerOpenId, userId);
|
|
|
}
|
|
|
|
|
|
if (!userId) {
|
|
|
skipped += 1;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
if (!dryRun) {
|
|
|
try {
|
|
|
await colProduct.doc(p._id).update({
|
|
|
data: {
|
|
|
sellerUserId: userId
|
|
|
}
|
|
|
});
|
|
|
corrected += 1;
|
|
|
} catch (e) {
|
|
|
// 单条失败不影响整体流程
|
|
|
console.error('更新商品 sellerUserId 失败:', p._id, e?.message);
|
|
|
}
|
|
|
} else {
|
|
|
corrected += 1; // 试运行只统计可修复数
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: dryRun ? '试运行完成(未实际写库)' : '回填完成',
|
|
|
total,
|
|
|
processed,
|
|
|
corrected,
|
|
|
skipped,
|
|
|
batchSize,
|
|
|
dryRun
|
|
|
};
|
|
|
} catch (e) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e?.message || 'backfillSellerUserId 执行失败'
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 创建用户地址集合 T_address(用于存储默认地址等)
|
|
|
const createAddressCollection = async () => {
|
|
|
try {
|
|
|
await db.createCollection('T_address');
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
// 已存在也视为成功
|
|
|
return { success: true, data: 'T_address exists' };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 便捷初始化:为当前用户写入一个默认地址(校园地址),便于测试“地址附近交易点”
|
|
|
const seedDefaultAddress = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const openid = wxContext.OPENID;
|
|
|
if (!openid) {
|
|
|
return { success: false, error: '未获取openid' };
|
|
|
}
|
|
|
|
|
|
const col = db.collection('T_address');
|
|
|
// 若已有默认地址则直接返回成功
|
|
|
const existing = await col.where({ _openid: openid, isDefault: true }).limit(1).get();
|
|
|
if (existing.data && existing.data.length > 0) {
|
|
|
return { success: true, data: '已有默认地址' };
|
|
|
}
|
|
|
|
|
|
const now = new Date();
|
|
|
await col.add({
|
|
|
data: {
|
|
|
name: event?.name || '默认地址',
|
|
|
phone: event?.phone || '',
|
|
|
province: event?.province || '天津市',
|
|
|
city: event?.city || '东丽区',
|
|
|
district: event?.district || '',
|
|
|
detail: event?.detail || '中国民航大学',
|
|
|
isDefault: true,
|
|
|
createTime: now
|
|
|
}
|
|
|
});
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 批量修复商品缺失坐标:优先地标匹配,其次地址正向地理编码
|
|
|
// 用法:wx.cloud.callFunction({ name: 'quickstartFunctions', data: { type: 'fixProductCoords', limit: 200, dryRun: false } })
|
|
|
const fixProductCoords = async (event) => {
|
|
|
try {
|
|
|
const limit = Number(event?.limit) > 0 ? Math.min(Number(event.limit), 500) : 200;
|
|
|
const dryRun = !!event?.dryRun;
|
|
|
const col = db.collection('T_product');
|
|
|
// QQMap 配置(若云环境变量未设,可通过 event 传入)
|
|
|
const qqKey = process.env.QQMAP_KEY || event?.key || '';
|
|
|
const qqKeys = process.env.QQMAP_KEYS || event?.keys || '';
|
|
|
const qqSk = process.env.QQMAP_SK || event?.sk || '';
|
|
|
const qqReferer = process.env.QQMAP_REFERER || event?.referer || 'https://servicewechat.com';
|
|
|
|
|
|
// 内置校园地标(云端版,可加入更细的别名以提高匹配率)
|
|
|
const campusLandmarksCloud = [
|
|
|
{ name: '图书馆', latitude: 39.1234, longitude: 117.3365 },
|
|
|
{ name: '体育馆', latitude: 39.1216, longitude: 117.3394 },
|
|
|
{ name: '第一食堂', latitude: 39.1242, longitude: 117.3396 },
|
|
|
{ name: '第二食堂', latitude: 39.1211, longitude: 117.3358 },
|
|
|
{ name: '西门快递点', latitude: 39.1209, longitude: 117.3349 },
|
|
|
{ name: '南门', latitude: 39.1198, longitude: 117.3382 },
|
|
|
{ name: '北门', latitude: 39.1255, longitude: 117.3361 },
|
|
|
// 常见别称与扩展(示例)
|
|
|
{ name: '礼堂', latitude: 39.112869, longitude: 117.349220 },
|
|
|
{ name: '中国民航大学北校区礼堂', latitude: 39.112869, longitude: 117.349220 },
|
|
|
{ name: '北校区礼堂', latitude: 39.112869, longitude: 117.349220 }
|
|
|
];
|
|
|
|
|
|
const normalizeNum = (v) => {
|
|
|
const n = Number(v);
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
};
|
|
|
|
|
|
const extractCoordsFromString = (s) => {
|
|
|
if (!s || typeof s !== 'string') return null;
|
|
|
const m = s.match(/(-?\d+\.?\d*)[\s,]+(-?\d+\.?\d*)/);
|
|
|
if (m) {
|
|
|
const lat = normalizeNum(m[1]);
|
|
|
const lng = normalizeNum(m[2]);
|
|
|
if (lat != null && lng != null) return { lat, lng };
|
|
|
}
|
|
|
return null;
|
|
|
};
|
|
|
|
|
|
const simplify = (s) => String(s || '')
|
|
|
.replace(/[()()]/g, '')
|
|
|
.replace(/附近$/g, '')
|
|
|
.trim();
|
|
|
|
|
|
const matchLandmark = (names) => {
|
|
|
const cands = (names || []).filter(Boolean).map(v => simplify(v));
|
|
|
if (cands.length === 0) return null;
|
|
|
for (const candidate of cands) {
|
|
|
const m = campusLandmarksCloud.find(l => candidate.includes(l.name) || l.name.includes(candidate));
|
|
|
if (m && Number.isFinite(m.latitude) && Number.isFinite(m.longitude)) {
|
|
|
return { lat: m.latitude, lng: m.longitude };
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
};
|
|
|
|
|
|
// 分页获取,筛选出缺坐标的记录
|
|
|
const countRes = await col.count();
|
|
|
const total = (countRes && countRes.total) || 0;
|
|
|
const pageSize = 100;
|
|
|
const toFix = [];
|
|
|
for (let skip = 0; skip < Math.max(total, pageSize) && toFix.length < limit; skip += pageSize) {
|
|
|
const page = await col.skip(skip).limit(pageSize).get();
|
|
|
const batch = (page && page.data) || [];
|
|
|
for (const p of batch) {
|
|
|
const lat = normalizeNum(p.tradeLocationLat ?? p.tradeLat ?? p.lat ?? p.latitude ?? p?.tradeLocation?.latitude);
|
|
|
const lng = normalizeNum(p.tradeLocationLng ?? p.tradeLng ?? p.lng ?? p.longitude ?? p?.tradeLocation?.longitude);
|
|
|
if (!(lat != null && lng != null)) {
|
|
|
toFix.push(p);
|
|
|
}
|
|
|
if (toFix.length >= limit) break;
|
|
|
}
|
|
|
if (!page || !page.data || page.data.length < pageSize) break;
|
|
|
}
|
|
|
|
|
|
const results = [];
|
|
|
let fixedCount = 0;
|
|
|
for (const p of toFix) {
|
|
|
let latLng = null;
|
|
|
// 1) 解析字符串坐标
|
|
|
latLng = latLng || extractCoordsFromString(String(p.tradeLocation || ''));
|
|
|
// 2) 地标匹配
|
|
|
latLng = latLng || matchLandmark([p.tradeLandmarkName, p.tradeLocationName, p.tradeAddress]);
|
|
|
// 3) 地址正向地理编码
|
|
|
if (!latLng) {
|
|
|
const addr = simplify(p.tradeAddress || '') || `${simplify(p.tradeLandmarkName || p.tradeLocationName || '')} 中国民航大学 东丽校区`;
|
|
|
if (addr) {
|
|
|
try {
|
|
|
const r = await cloudGeocode({ address: addr, key: qqKey, keys: qqKeys, sk: qqSk, referer: qqReferer });
|
|
|
if (r && r.success && r.data && Number.isFinite(r.data.latitude) && Number.isFinite(r.data.longitude)) {
|
|
|
latLng = { lat: r.data.latitude, lng: r.data.longitude };
|
|
|
}
|
|
|
} catch (e) {
|
|
|
// 忽略该条错误,继续下一个
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (latLng) {
|
|
|
if (!dryRun) {
|
|
|
try {
|
|
|
await col.doc(p._id).update({ data: { tradeLocationLat: latLng.lat, tradeLocationLng: latLng.lng } });
|
|
|
fixedCount += 1;
|
|
|
results.push({ id: p._id, ok: true, lat: latLng.lat, lng: latLng.lng });
|
|
|
} catch (e) {
|
|
|
results.push({ id: p._id, ok: false, error: e.message });
|
|
|
}
|
|
|
} else {
|
|
|
results.push({ id: p._id, ok: true, lat: latLng.lat, lng: latLng.lng, dryRun: true });
|
|
|
}
|
|
|
} else {
|
|
|
results.push({ id: p._id, ok: false, error: '未能解析或编码地址' });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return { success: true, fixedCount, totalCandidates: toFix.length, dryRun, results };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 创建地标集合 T_campus_landmarks(若不存在)
|
|
|
const createCampusLandmarksCollection = async () => {
|
|
|
try {
|
|
|
await db.createCollection('T_campus_landmarks');
|
|
|
return { success: true };
|
|
|
} catch (e) {
|
|
|
// 已存在也视为成功
|
|
|
return { success: true, data: 'T_campus_landmarks exists' };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 规范化/去噪地标名称(用于去重与匹配)
|
|
|
const normalizeLandmarkName = (s) => String(s || '')
|
|
|
.replace(/[()()]/g, '')
|
|
|
.replace(/附近$/g, '')
|
|
|
.replace(/\s+/g, '')
|
|
|
.trim();
|
|
|
|
|
|
// 上报/同步单条地标:按规范化名称去重,存在则更新,不存在则新增
|
|
|
// 用法:wx.cloud.callFunction({ name: 'quickstartFunctions', data: { type: 'upsertCampusLandmark', name, latitude, longitude, address, tags } })
|
|
|
const upsertCampusLandmark = async (event) => {
|
|
|
try {
|
|
|
const name = String(event?.name || '').trim();
|
|
|
const latitude = Number(event?.latitude);
|
|
|
const longitude = Number(event?.longitude);
|
|
|
const address = String(event?.address || '').trim();
|
|
|
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
|
|
const source = event?.source || 'product';
|
|
|
const productId = String(event?.productId || '').trim();
|
|
|
const selling = typeof event?.selling === 'boolean' ? event.selling : undefined;
|
|
|
const productCategory = String(event?.productCategory || '').trim();
|
|
|
const thumbUrl = String(event?.thumbUrl || '').trim();
|
|
|
|
|
|
if (!name) return { success: false, error: 'name不能为空' };
|
|
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
|
return { success: false, error: '坐标无效' };
|
|
|
}
|
|
|
|
|
|
const col = db.collection('T_campus_landmarks');
|
|
|
const normalizedName = normalizeLandmarkName(name);
|
|
|
|
|
|
// 若集合不存在,尝试创建
|
|
|
try { await db.collection('T_campus_landmarks').limit(1).get(); } catch (e) {
|
|
|
const msg = String(e?.message || e?.errMsg || '');
|
|
|
if (/DATABASE_COLLECTION_NOT_EXIST|ResourceNotFound|Db or Table not exist/i.test(msg)) {
|
|
|
await createCampusLandmarksCollection();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const existing = await col.where({ normalizedName }).limit(1).get();
|
|
|
const now = new Date();
|
|
|
if (existing.data && existing.data.length > 0) {
|
|
|
const id = existing.data[0]._id;
|
|
|
const prev = existing.data[0] || {};
|
|
|
const prevIds = Array.isArray(prev.productIds) ? prev.productIds : [];
|
|
|
const nextIds = productId && !prevIds.includes(productId) ? [...prevIds, productId] : prevIds;
|
|
|
const data = {
|
|
|
name,
|
|
|
normalizedName,
|
|
|
latitude,
|
|
|
longitude,
|
|
|
address,
|
|
|
tags,
|
|
|
source,
|
|
|
productIds: nextIds,
|
|
|
selling: selling === undefined ? prev.selling : selling,
|
|
|
productCategory: productCategory ? productCategory : prev.productCategory,
|
|
|
thumbUrl: thumbUrl ? thumbUrl : prev.thumbUrl,
|
|
|
updateTime: now
|
|
|
};
|
|
|
await col.doc(id).update({ data });
|
|
|
return { success: true, updated: 1, id };
|
|
|
} else {
|
|
|
const addRes = await col.add({
|
|
|
data: {
|
|
|
name,
|
|
|
normalizedName,
|
|
|
latitude,
|
|
|
longitude,
|
|
|
address,
|
|
|
tags,
|
|
|
source,
|
|
|
productIds: productId ? [productId] : [],
|
|
|
selling: selling === undefined ? true : selling,
|
|
|
productCategory,
|
|
|
thumbUrl,
|
|
|
createTime: now,
|
|
|
updateTime: now
|
|
|
}
|
|
|
});
|
|
|
return { success: true, created: 1, id: addRes._id };
|
|
|
}
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 批量从商品集合同步可用地标到 T_campus_landmarks(去重,保留最新坐标)
|
|
|
// 支持:旧字段、字符串坐标解析;若无坐标但有 tradeAddress,则云端正向地理编码补齐
|
|
|
// 用法:wx.cloud.callFunction({ name: 'quickstartFunctions', data: { type: 'syncProductLandmarks', limit: 500, dryRun: false, key: '<QQMAP_KEY>', referer: '<Referer>' } })
|
|
|
const syncProductLandmarks = async (event) => {
|
|
|
try {
|
|
|
const limit = Number(event?.limit) > 0 ? Math.min(Number(event.limit), 1000) : 500;
|
|
|
const dryRun = !!event?.dryRun;
|
|
|
const col = db.collection('T_product');
|
|
|
|
|
|
// 工具:数值规范化与字符串坐标解析、名称简化
|
|
|
const normalizeNum = (v) => {
|
|
|
const n = Number(v);
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
};
|
|
|
const parseStringCoords = (s) => {
|
|
|
if (!s || typeof s !== 'string') return null;
|
|
|
const m = s.match(/(-?\d+\.?\d*)[\s,]+(-?\d+\.?\d*)/);
|
|
|
if (!m) return null;
|
|
|
const lat = normalizeNum(m[1]);
|
|
|
const lng = normalizeNum(m[2]);
|
|
|
return lat != null && lng != null ? { lat, lng } : null;
|
|
|
};
|
|
|
const simplify = (s) => String(s || '')
|
|
|
.replace(/[()()]/g, '')
|
|
|
.replace(/附近$/g, '')
|
|
|
.trim();
|
|
|
|
|
|
const countRes = await col.count();
|
|
|
const total = (countRes && countRes.total) || 0;
|
|
|
const pageSize = 100;
|
|
|
const candidates = [];
|
|
|
for (let skip = 0; skip < Math.max(total, pageSize) && candidates.length < limit; skip += pageSize) {
|
|
|
const page = await col.skip(skip).limit(pageSize).get();
|
|
|
const batch = (page && page.data) || [];
|
|
|
for (const p of batch) {
|
|
|
// 解析坐标:优先数值字段,其次字符串坐标
|
|
|
let lat = normalizeNum(p.tradeLocationLat ?? p.tradeLat ?? p.lat ?? p.latitude ?? p?.tradeLocation?.latitude);
|
|
|
let lng = normalizeNum(p.tradeLocationLng ?? p.tradeLng ?? p.lng ?? p.longitude ?? p?.tradeLocation?.longitude);
|
|
|
if (lat == null || lng == null) {
|
|
|
const sc = parseStringCoords(String(p.tradeLocation || p.location || p.coords || ''));
|
|
|
if (sc) { lat = sc.lat; lng = sc.lng; }
|
|
|
}
|
|
|
|
|
|
// 名称:支持嵌套字段与地址,并做简化
|
|
|
const rawName = p.tradeLandmarkName || p.tradeLocationName || p?.tradeLocation?.landmarkName || p.tradeAddress || '';
|
|
|
const name = simplify(rawName);
|
|
|
|
|
|
if (name && lat != null && lng != null) {
|
|
|
candidates.push({ name, lat, lng, address: String(p.tradeAddress || ''), source: 'product', productId: String(p._id || '') });
|
|
|
}
|
|
|
if (candidates.length >= limit) break;
|
|
|
}
|
|
|
if (!page || !page.data || page.data.length < pageSize) break;
|
|
|
}
|
|
|
|
|
|
const results = [];
|
|
|
let created = 0;
|
|
|
let updated = 0;
|
|
|
let geocoded = 0;
|
|
|
|
|
|
// 若存在缺坐标但有地址的潜在项,尝试地理编码补齐
|
|
|
const potentials = [];
|
|
|
{
|
|
|
// 重新扫描一次(避免漏掉仅有地址的商品),但不超过 limit
|
|
|
const countRes2 = await col.count();
|
|
|
const total2 = (countRes2 && countRes2.total) || 0;
|
|
|
const pageSize2 = 100;
|
|
|
for (let skip = 0; skip < Math.max(total2, pageSize2) && potentials.length < limit; skip += pageSize2) {
|
|
|
const page = await col.skip(skip).limit(pageSize2).get();
|
|
|
const batch = (page && page.data) || [];
|
|
|
for (const p of batch) {
|
|
|
// 名称与地址
|
|
|
const rawName = p.tradeLandmarkName || p.tradeLocationName || p?.tradeLocation?.landmarkName || p.tradeAddress || '';
|
|
|
const name = simplify(rawName);
|
|
|
const address = String(p.tradeAddress || '').trim();
|
|
|
// 坐标(含字符串解析)
|
|
|
let lat = normalizeNum(p.tradeLocationLat ?? p.tradeLat ?? p.lat ?? p.latitude ?? p?.tradeLocation?.latitude);
|
|
|
let lng = normalizeNum(p.tradeLocationLng ?? p.tradeLng ?? p.lng ?? p.longitude ?? p?.tradeLocation?.longitude);
|
|
|
if (lat == null || lng == null) {
|
|
|
const sc = parseStringCoords(String(p.tradeLocation || p.location || p.coords || ''));
|
|
|
if (sc) { lat = sc.lat; lng = sc.lng; }
|
|
|
}
|
|
|
if ((name || address) && (lat == null || lng == null) && address) {
|
|
|
potentials.push({ name, address, lat, lng, productId: String(p._id || '') });
|
|
|
}
|
|
|
if (potentials.length >= limit) break;
|
|
|
}
|
|
|
if (!page || !page.data || page.data.length < pageSize2) break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 并发地理编码补齐
|
|
|
if (potentials.length > 0) {
|
|
|
const concurrency = 6;
|
|
|
let idx = 0;
|
|
|
const worker = async () => {
|
|
|
while (idx < potentials.length) {
|
|
|
const item = potentials[idx++];
|
|
|
try {
|
|
|
const r = await cloudGeocode({ address: item.address, key: event?.key, keys: event?.keys, referer: event?.referer, sk: event?.sk });
|
|
|
if (r.success && r.data && typeof r.data.latitude === 'number' && typeof r.data.longitude === 'number') {
|
|
|
item.lat = r.data.latitude;
|
|
|
item.lng = r.data.longitude;
|
|
|
geocoded += 1;
|
|
|
// 若名称有效,直接纳入候选进行 upsert
|
|
|
if (item.name && item.lat != null && item.lng != null) {
|
|
|
candidates.push({ name: item.name, lat: item.lat, lng: item.lng, address: item.address, source: 'product', productId: String(item.productId || '') });
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
// 忽略失败,继续
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
const workers = Array.from({ length: Math.min(concurrency, potentials.length) }, () => worker());
|
|
|
await Promise.allSettled(workers);
|
|
|
}
|
|
|
|
|
|
for (const c of candidates) {
|
|
|
const normalizedName = normalizeLandmarkName(c.name);
|
|
|
if (dryRun) {
|
|
|
results.push({ name: c.name, normalizedName, latitude: c.lat, longitude: c.lng, dryRun: true });
|
|
|
continue;
|
|
|
}
|
|
|
try {
|
|
|
// 直接调用内部 upsert 逻辑
|
|
|
const r = await upsertCampusLandmark({ name: c.name, latitude: c.lat, longitude: c.lng, address: c.address, source: c.source, productId: c.productId });
|
|
|
if (r.success && r.created) created += r.created;
|
|
|
if (r.success && r.updated) updated += r.updated;
|
|
|
results.push({ name: c.name, normalizedName, latitude: c.lat, longitude: c.lng, ok: r.success, id: r.id });
|
|
|
} catch (e) {
|
|
|
results.push({ name: c.name, normalizedName, error: e.message });
|
|
|
}
|
|
|
}
|
|
|
return { success: true, totalCandidates: candidates.length, created, updated, geocoded, dryRun, results };
|
|
|
} catch (e) {
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// const getOpenId = require('./getOpenId/index');
|
|
|
// const getMiniProgramCode = require('./getMiniProgramCode/index');
|
|
|
// const createCollection = require('./createCollection/index');
|
|
|
// const selectRecord = require('./selectRecord/index');
|
|
|
// const updateRecord = require('./updateRecord/index');
|
|
|
// const sumRecord = require('./sumRecord/index');
|
|
|
// const fetchGoodsList = require('./fetchGoodsList/index');
|
|
|
// const genMpQrcode = require('./genMpQrcode/index');
|
|
|
// 查询T_user表数据
|
|
|
const queryTUser = async () => {
|
|
|
try {
|
|
|
const result = await db.collection("T_user").get();
|
|
|
return {
|
|
|
success: true,
|
|
|
data: result.data,
|
|
|
count: result.data.length
|
|
|
};
|
|
|
} catch (e) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 根据openid查询用户信息(用于表关联)
|
|
|
const getUserByOpenId = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const openid = event.openid || wxContext.OPENID;
|
|
|
|
|
|
console.log('查询用户信息,openid:', openid);
|
|
|
|
|
|
if (!openid) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: 'openid不能为空'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 先尝试通过_openid查询(微信云开发自动添加的字段)
|
|
|
let userResult = await db.collection("T_user").where({
|
|
|
_openid: openid
|
|
|
}).get();
|
|
|
|
|
|
// 如果没找到,尝试通过其他方式查询(如果T_user表中有openid字段)
|
|
|
if (userResult.data.length === 0) {
|
|
|
// 这里可以根据实际需求添加其他查询方式
|
|
|
// 例如:如果有存储openid字段,可以查询
|
|
|
// userResult = await db.collection("T_user").where({
|
|
|
// openid: openid
|
|
|
// }).get();
|
|
|
}
|
|
|
|
|
|
if (userResult.data.length > 0) {
|
|
|
const userData = userResult.data[0];
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
userId: userData._id,
|
|
|
sno: userData.sno || '',
|
|
|
sname: userData.sname || '',
|
|
|
phone: userData.phone || '',
|
|
|
major: userData.major || '',
|
|
|
sushe: userData.sushe || '',
|
|
|
grade: userData.年级 || '',
|
|
|
avatar: userData.avatar || '',
|
|
|
openid: openid
|
|
|
}
|
|
|
};
|
|
|
} else {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '未找到用户信息',
|
|
|
openid: openid
|
|
|
};
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('查询用户信息失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 添加测试用户到T_user表
|
|
|
const addTestUser = async (event) => {
|
|
|
try {
|
|
|
const userData = event.userData || {
|
|
|
sno: "230340151",
|
|
|
sname: "测试用户",
|
|
|
phone: "13800138000",
|
|
|
password: "100997@mkg",
|
|
|
major: "计算机科学",
|
|
|
sushe: "1号楼 101",
|
|
|
年级: "大三",
|
|
|
avatar: "https://via.placeholder.com/100x100/4285F4/ffffff?text=T"
|
|
|
};
|
|
|
|
|
|
const result = await db.collection("T_user").add({
|
|
|
data: userData
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: result,
|
|
|
message: "测试用户添加成功"
|
|
|
};
|
|
|
} catch (e) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// AI定价功能 - 调用Coze工作流分析商品图片
|
|
|
const analyzeProductPrice = async (event) => {
|
|
|
const axios = require('axios');
|
|
|
const FormData = require('form-data');
|
|
|
|
|
|
const COZE_API_TOKEN = 'pat_EB0pYMqiuAjIEnGK3i1e12RWwBjF5iZzrzMgdLQE8jNgZWVykJo6ZPGZ2YESYamq';
|
|
|
const COZE_UPLOAD_URL = 'https://api.coze.cn/v1/files/upload';
|
|
|
const COZE_WORKFLOW_URL = 'https://api.coze.cn/v1/workflow/run';
|
|
|
const WORKFLOW_ID = '7567021771821105167';
|
|
|
|
|
|
try {
|
|
|
console.log('========== AI定价功能开始 ==========');
|
|
|
console.log('接收到的参数:', JSON.stringify(event, null, 2));
|
|
|
|
|
|
// 获取参数
|
|
|
const { fileID, originalPrice, imageUrl } = event;
|
|
|
|
|
|
// 确保originalPrice是数字类型
|
|
|
const priceNum = originalPrice ? parseFloat(originalPrice) : null;
|
|
|
if (priceNum && isNaN(priceNum)) {
|
|
|
throw new Error('原价必须是有效的数字');
|
|
|
}
|
|
|
|
|
|
console.log('接收到的参数详情:');
|
|
|
console.log(' fileID:', fileID);
|
|
|
console.log(' originalPrice:', originalPrice, '类型:', typeof originalPrice);
|
|
|
console.log(' imageUrl:', imageUrl);
|
|
|
|
|
|
// 第一步:获取图片文件内容
|
|
|
let imageBuffer = null;
|
|
|
let fileName = 'image.jpg';
|
|
|
|
|
|
if (fileID) {
|
|
|
// 从云存储下载文件
|
|
|
console.log('步骤1: 从云存储下载文件, fileID:', fileID);
|
|
|
try {
|
|
|
if (!fileID || typeof fileID !== 'string') {
|
|
|
throw new Error('fileID参数无效');
|
|
|
}
|
|
|
|
|
|
const result = await cloud.downloadFile({
|
|
|
fileID: fileID
|
|
|
});
|
|
|
|
|
|
if (!result || !result.fileContent) {
|
|
|
throw new Error('下载文件结果为空');
|
|
|
}
|
|
|
|
|
|
imageBuffer = result.fileContent;
|
|
|
fileName = fileID.split('/').pop() || 'image.jpg';
|
|
|
|
|
|
// 确保imageBuffer是Buffer类型
|
|
|
if (!Buffer.isBuffer(imageBuffer)) {
|
|
|
imageBuffer = Buffer.from(imageBuffer);
|
|
|
}
|
|
|
|
|
|
console.log('✅ 文件下载成功,文件名:', fileName, '文件大小:', imageBuffer.length, 'bytes');
|
|
|
} catch (downloadError) {
|
|
|
console.error('❌ 下载云存储文件失败:', downloadError);
|
|
|
console.error('下载错误详情:', JSON.stringify(downloadError, Object.getOwnPropertyNames(downloadError)));
|
|
|
throw new Error('下载图片文件失败: ' + (downloadError.message || downloadError));
|
|
|
}
|
|
|
} else if (imageUrl) {
|
|
|
// 从URL下载图片
|
|
|
console.log('步骤1: 从URL下载图片, imageUrl:', imageUrl);
|
|
|
try {
|
|
|
if (!imageUrl || typeof imageUrl !== 'string') {
|
|
|
throw new Error('imageUrl参数无效');
|
|
|
}
|
|
|
|
|
|
const response = await axios.get(imageUrl, {
|
|
|
responseType: 'arraybuffer',
|
|
|
timeout: 30000 // 30秒超时
|
|
|
});
|
|
|
|
|
|
imageBuffer = Buffer.from(response.data);
|
|
|
fileName = imageUrl.split('/').pop().split('?')[0] || 'image.jpg';
|
|
|
console.log('✅ 图片下载成功,文件名:', fileName, '文件大小:', imageBuffer.length, 'bytes');
|
|
|
} catch (downloadError) {
|
|
|
console.error('❌ 下载图片失败:', downloadError);
|
|
|
console.error('下载错误详情:', downloadError.response?.data || downloadError.message);
|
|
|
throw new Error('下载图片失败: ' + (downloadError.message || downloadError));
|
|
|
}
|
|
|
} else {
|
|
|
throw new Error('缺少必要参数:需要提供fileID或imageUrl');
|
|
|
}
|
|
|
|
|
|
// 验证图片缓冲区
|
|
|
if (!imageBuffer || imageBuffer.length === 0) {
|
|
|
throw new Error('图片数据为空');
|
|
|
}
|
|
|
|
|
|
// 第二步:上传图片到Coze
|
|
|
console.log('步骤2: 上传图片到Coze');
|
|
|
let cozeFileId = null;
|
|
|
let uploadResponse = null; // 在外部声明,确保在返回时能访问
|
|
|
|
|
|
try {
|
|
|
if (!imageBuffer || imageBuffer.length === 0) {
|
|
|
throw new Error('图片数据为空,无法上传');
|
|
|
}
|
|
|
|
|
|
const formData = new FormData();
|
|
|
formData.append('file', imageBuffer, {
|
|
|
filename: fileName,
|
|
|
contentType: 'image/jpeg'
|
|
|
});
|
|
|
|
|
|
console.log('准备上传到Coze,文件大小:', imageBuffer.length, 'bytes');
|
|
|
|
|
|
uploadResponse = await axios.post(COZE_UPLOAD_URL, formData, {
|
|
|
headers: {
|
|
|
'Authorization': `Bearer ${COZE_API_TOKEN}`,
|
|
|
...formData.getHeaders()
|
|
|
},
|
|
|
timeout: 60000, // 60秒超时
|
|
|
maxContentLength: Infinity,
|
|
|
maxBodyLength: Infinity
|
|
|
});
|
|
|
|
|
|
console.log('✅ 图片上传到Coze成功');
|
|
|
console.log('上传响应数据:', JSON.stringify(uploadResponse.data, null, 2));
|
|
|
|
|
|
if (uploadResponse.data.code === 0 && uploadResponse.data.data && uploadResponse.data.data.id) {
|
|
|
cozeFileId = uploadResponse.data.data.id;
|
|
|
console.log('✅ 获取到Coze file_id:', cozeFileId);
|
|
|
} else {
|
|
|
throw new Error('上传响应格式错误: ' + JSON.stringify(uploadResponse.data));
|
|
|
}
|
|
|
} catch (uploadError) {
|
|
|
console.error('❌ 上传图片到Coze失败:', uploadError.response?.data || uploadError.message);
|
|
|
throw new Error('上传图片到Coze失败: ' + (uploadError.response?.data?.msg || uploadError.message));
|
|
|
}
|
|
|
|
|
|
// 第三步:调用Coze工作流
|
|
|
console.log('步骤3: 调用Coze工作流');
|
|
|
console.log('workflow_id:', WORKFLOW_ID);
|
|
|
console.log('file_id:', cozeFileId);
|
|
|
|
|
|
try {
|
|
|
// 根据curl示例,input参数应该是数字类型(原价)
|
|
|
// curl示例: "input": 30
|
|
|
// 不再需要inputText,直接使用priceNum
|
|
|
|
|
|
// 验证file_id
|
|
|
if (!cozeFileId || typeof cozeFileId !== 'string') {
|
|
|
throw new Error('Coze file_id无效: ' + cozeFileId);
|
|
|
}
|
|
|
|
|
|
// 根据API文档,image参数应该是字符串,内容是JSON格式的字符串
|
|
|
// 格式: "{\"file_id\":\"...\"}"
|
|
|
// 注意:需要手动构建JSON字符串,确保格式正确
|
|
|
const imageParam = JSON.stringify({ file_id: String(cozeFileId) });
|
|
|
|
|
|
// 验证image参数格式
|
|
|
try {
|
|
|
const imageObj = JSON.parse(imageParam);
|
|
|
if (!imageObj.file_id || typeof imageObj.file_id !== 'string') {
|
|
|
throw new Error('image参数格式错误: file_id无效');
|
|
|
}
|
|
|
console.log('✅ image参数格式验证通过:', imageObj);
|
|
|
} catch (parseError) {
|
|
|
console.error('❌ image参数格式验证失败:', parseError);
|
|
|
throw new Error('image参数格式错误: ' + parseError.message);
|
|
|
}
|
|
|
|
|
|
// 构建请求对象
|
|
|
const workflowRequest = {
|
|
|
workflow_id: String(WORKFLOW_ID),
|
|
|
parameters: {
|
|
|
image: imageParam, // JSON字符串: "{\"file_id\":\"7567222758887850038\"}"
|
|
|
input: Number(priceNum || 0) // 数字类型(原价),根据curl示例
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 验证最终请求格式
|
|
|
const testSerialize = JSON.stringify(workflowRequest);
|
|
|
console.log('测试序列化结果:', testSerialize);
|
|
|
const testParse = JSON.parse(testSerialize);
|
|
|
console.log('测试解析结果:', JSON.stringify(testParse, null, 2));
|
|
|
|
|
|
// 验证解析后的image参数格式
|
|
|
const parsedImage = JSON.parse(testParse.parameters.image);
|
|
|
console.log('解析后的image对象:', parsedImage);
|
|
|
if (!parsedImage.file_id) {
|
|
|
throw new Error('序列化后image参数格式错误');
|
|
|
}
|
|
|
|
|
|
// 打印最终发送的请求(序列化后的格式)
|
|
|
const requestBody = JSON.stringify(workflowRequest);
|
|
|
console.log('========== 请求参数详情 ==========');
|
|
|
console.log('工作流请求数据(序列化前):', JSON.stringify(workflowRequest, null, 2));
|
|
|
console.log('工作流请求数据(序列化后,将发送给API):', requestBody);
|
|
|
console.log('参数类型检查:');
|
|
|
console.log(' workflow_id类型:', typeof workflowRequest.workflow_id);
|
|
|
console.log(' workflow_id值:', workflowRequest.workflow_id);
|
|
|
console.log(' image类型:', typeof workflowRequest.parameters.image);
|
|
|
console.log(' image值:', workflowRequest.parameters.image);
|
|
|
console.log(' image值长度:', workflowRequest.parameters.image.length);
|
|
|
console.log(' image值解析:', JSON.parse(workflowRequest.parameters.image));
|
|
|
console.log(' input类型:', typeof workflowRequest.parameters.input);
|
|
|
console.log(' input值:', workflowRequest.parameters.input);
|
|
|
console.log('===================================');
|
|
|
|
|
|
// 验证input是数字类型
|
|
|
if (typeof workflowRequest.parameters.input !== 'number') {
|
|
|
throw new Error('input参数必须是数字类型,当前类型: ' + typeof workflowRequest.parameters.input);
|
|
|
}
|
|
|
|
|
|
// 模拟curl命令格式,用于对比
|
|
|
const curlFormat = JSON.stringify({
|
|
|
workflow_id: workflowRequest.workflow_id,
|
|
|
parameters: {
|
|
|
image: workflowRequest.parameters.image,
|
|
|
input: workflowRequest.parameters.input
|
|
|
}
|
|
|
}, null, 2);
|
|
|
console.log('预期格式(类似curl):', curlFormat);
|
|
|
console.log('✅ 参数格式验证通过,符合curl示例格式');
|
|
|
|
|
|
const workflowResponse = await axios.post(COZE_WORKFLOW_URL, workflowRequest, {
|
|
|
headers: {
|
|
|
'Authorization': `Bearer ${COZE_API_TOKEN}`,
|
|
|
'Content-Type': 'application/json'
|
|
|
},
|
|
|
timeout: 120000 // 120秒超时,因为AI分析可能需要较长时间
|
|
|
});
|
|
|
|
|
|
console.log('✅ Coze工作流HTTP请求成功');
|
|
|
console.log('HTTP状态码:', workflowResponse.status);
|
|
|
console.log('工作流响应数据:', JSON.stringify(workflowResponse.data, null, 2));
|
|
|
|
|
|
// 检查响应状态
|
|
|
if (workflowResponse.data.code !== 0) {
|
|
|
const errorData = workflowResponse.data;
|
|
|
console.error('❌ Coze API返回错误');
|
|
|
console.error('错误代码:', errorData.code);
|
|
|
console.error('错误消息:', errorData.msg);
|
|
|
console.error('错误详情:', errorData.detail);
|
|
|
console.error('调试URL:', errorData.debug_url);
|
|
|
|
|
|
// 如果是参数错误,提供更详细的诊断信息
|
|
|
if (errorData.code === 4000) {
|
|
|
console.error('参数错误诊断:');
|
|
|
console.error(' 发送的请求数据:', JSON.stringify(workflowRequest, null, 2));
|
|
|
console.error(' image参数原始值:', imageParam);
|
|
|
console.error(' input参数原始值:', inputText);
|
|
|
}
|
|
|
|
|
|
throw new Error(`Coze API错误 (${errorData.code}): ${errorData.msg || '未知错误'}`);
|
|
|
}
|
|
|
|
|
|
// 解析返回结果
|
|
|
let parsedResult = {};
|
|
|
if (workflowResponse.data.data) {
|
|
|
try {
|
|
|
// data字段是字符串,需要解析
|
|
|
const dataStr = typeof workflowResponse.data.data === 'string'
|
|
|
? workflowResponse.data.data
|
|
|
: JSON.stringify(workflowResponse.data.data);
|
|
|
|
|
|
const parsedData = JSON.parse(dataStr);
|
|
|
|
|
|
// 解析output字段
|
|
|
if (parsedData.output) {
|
|
|
let outputStr = typeof parsedData.output === 'string'
|
|
|
? parsedData.output
|
|
|
: JSON.stringify(parsedData.output);
|
|
|
|
|
|
console.log('步骤3.1: 原始output字符串:', outputStr);
|
|
|
|
|
|
// 尝试解析output中的JSON字符串
|
|
|
try {
|
|
|
// 处理多重转义的情况
|
|
|
// 如果output是被双重转义的JSON字符串,需要先解析一次
|
|
|
if (outputStr.startsWith('"') && outputStr.endsWith('"')) {
|
|
|
try {
|
|
|
outputStr = JSON.parse(outputStr);
|
|
|
console.log('步骤3.2: 解析外层引号后的output:', outputStr);
|
|
|
} catch (e) {
|
|
|
// 如果解析失败,继续使用原始字符串
|
|
|
console.warn('步骤3.2: 解析外层引号失败,继续使用原始字符串');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 处理转义字符
|
|
|
let cleanedOutput = outputStr;
|
|
|
|
|
|
// 如果仍然是字符串,尝试多次解析转义
|
|
|
if (typeof cleanedOutput === 'string') {
|
|
|
// 尝试解析转义的JSON字符串
|
|
|
try {
|
|
|
// 如果看起来像转义的JSON,尝试解析
|
|
|
if (cleanedOutput.includes('\\"')) {
|
|
|
cleanedOutput = cleanedOutput.replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
// 忽略解析错误
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.log('步骤3.3: 清理后的output:', cleanedOutput);
|
|
|
console.log('步骤3.3: output类型:', typeof cleanedOutput);
|
|
|
|
|
|
// 使用正则表达式提取字段值
|
|
|
// 匹配格式: "name":"值" 或 name:"值" 或 name=值
|
|
|
const outputContent = typeof cleanedOutput === 'string' ? cleanedOutput : JSON.stringify(cleanedOutput);
|
|
|
|
|
|
const nameMatch = outputContent.match(/["']?name["']?\s*[:=]\s*["']([^"']+)["']/);
|
|
|
const priceMatch = outputContent.match(/["']?price["']?\s*[:=]\s*["']([^"']+)["']/);
|
|
|
const chengseMatch = outputContent.match(/["']?chengse["']?\s*[:=]\s*["']([^"']+)["']/);
|
|
|
const gradeMatch = outputContent.match(/["']?grade["']?\s*[:=]\s*["']([^"']+)["']/);
|
|
|
const suggMatch = outputContent.match(/["']?sugg["']?\s*[:=]\s*["']([^"']+)["']/);
|
|
|
|
|
|
console.log('步骤3.4: 正则匹配结果:');
|
|
|
console.log(' name:', nameMatch ? nameMatch[1] : '未匹配');
|
|
|
console.log(' price:', priceMatch ? priceMatch[1] : '未匹配');
|
|
|
console.log(' chengse:', chengseMatch ? chengseMatch[1] : '未匹配');
|
|
|
console.log(' grade:', gradeMatch ? gradeMatch[1] : '未匹配');
|
|
|
console.log(' sugg:', suggMatch ? (suggMatch[1].substring(0, 50) + '...') : '未匹配');
|
|
|
|
|
|
// 如果正则匹配失败,尝试直接解析为JSON对象
|
|
|
let parsedOutput = null;
|
|
|
try {
|
|
|
// 尝试构建一个完整的JSON对象
|
|
|
if (outputContent.includes('name') && outputContent.includes('price')) {
|
|
|
// 尝试提取JSON部分
|
|
|
const jsonMatch = outputContent.match(/\{[\s\S]*\}/);
|
|
|
if (jsonMatch) {
|
|
|
parsedOutput = JSON.parse(jsonMatch[0]);
|
|
|
console.log('步骤3.5: 成功解析为JSON对象:', parsedOutput);
|
|
|
} else {
|
|
|
// 尝试手动构建JSON字符串
|
|
|
const jsonStr = '{' + outputContent.replace(/\n/g, ',') + '}';
|
|
|
try {
|
|
|
parsedOutput = JSON.parse(jsonStr);
|
|
|
console.log('步骤3.5: 成功构建并解析JSON对象:', parsedOutput);
|
|
|
} catch (e) {
|
|
|
// 忽略错误
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.warn('步骤3.5: 解析JSON对象失败:', e.message);
|
|
|
}
|
|
|
|
|
|
// 优先使用解析后的对象,否则使用正则匹配
|
|
|
parsedResult = {
|
|
|
productName: parsedOutput?.name || nameMatch?.[1] || '--',
|
|
|
suggestedPrice: parsedOutput?.price || priceMatch?.[1] || '0.00',
|
|
|
conditionLevel: parsedOutput?.chengse || chengseMatch?.[1] || '--',
|
|
|
aiScore: parsedOutput?.grade || gradeMatch?.[1] || '--',
|
|
|
analysisReport: parsedOutput?.sugg || suggMatch?.[1] || 'AI分析完成',
|
|
|
rawOutput: outputContent
|
|
|
};
|
|
|
|
|
|
console.log('✅ 步骤3.6: 最终解析结果:', JSON.stringify(parsedResult, null, 2));
|
|
|
} catch (parseError) {
|
|
|
console.warn('⚠️ 解析output失败,使用原始数据:', parseError.message);
|
|
|
console.warn('解析错误堆栈:', parseError.stack);
|
|
|
parsedResult = {
|
|
|
productName: '--',
|
|
|
suggestedPrice: originalPrice ? (originalPrice * 0.8).toFixed(2) : '0.00',
|
|
|
conditionLevel: '--',
|
|
|
aiScore: '--',
|
|
|
analysisReport: outputStr.substring(0, 200),
|
|
|
rawOutput: outputStr
|
|
|
};
|
|
|
}
|
|
|
} else {
|
|
|
parsedResult = {
|
|
|
productName: '--',
|
|
|
suggestedPrice: originalPrice ? (originalPrice * 0.8).toFixed(2) : '0.00',
|
|
|
conditionLevel: '--',
|
|
|
aiScore: '--',
|
|
|
analysisReport: 'AI分析完成,但未获取到详细信息',
|
|
|
rawOutput: dataStr
|
|
|
};
|
|
|
}
|
|
|
} catch (parseError) {
|
|
|
console.error('❌ 解析响应数据失败:', parseError);
|
|
|
parsedResult = {
|
|
|
productName: '--',
|
|
|
suggestedPrice: originalPrice ? (originalPrice * 0.8).toFixed(2) : '0.00',
|
|
|
conditionLevel: '--',
|
|
|
aiScore: '--',
|
|
|
analysisReport: '解析响应数据失败: ' + parseError.message,
|
|
|
rawResponse: workflowResponse.data
|
|
|
};
|
|
|
}
|
|
|
} else {
|
|
|
console.warn('⚠️ 工作流响应中没有data字段');
|
|
|
console.warn('响应内容:', JSON.stringify(workflowResponse.data, null, 2));
|
|
|
|
|
|
// 如果响应中没有data,返回默认结果
|
|
|
parsedResult = {
|
|
|
productName: '--',
|
|
|
suggestedPrice: originalPrice ? (originalPrice * 0.8).toFixed(2) : '0.00',
|
|
|
conditionLevel: '--',
|
|
|
aiScore: '--',
|
|
|
analysisReport: 'AI分析完成,但未获取到详细信息',
|
|
|
rawResponse: workflowResponse.data
|
|
|
};
|
|
|
}
|
|
|
|
|
|
console.log('========== AI定价功能完成 ==========');
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: parsedResult,
|
|
|
debug: {
|
|
|
uploadResponse: uploadResponse ? uploadResponse.data : null,
|
|
|
workflowResponse: workflowResponse.data
|
|
|
}
|
|
|
};
|
|
|
|
|
|
} catch (workflowError) {
|
|
|
console.error('❌ 调用Coze工作流失败');
|
|
|
console.error('错误对象:', workflowError);
|
|
|
console.error('错误响应:', workflowError.response?.data);
|
|
|
console.error('错误状态码:', workflowError.response?.status);
|
|
|
console.error('错误消息:', workflowError.message);
|
|
|
|
|
|
// 提取详细的错误信息
|
|
|
let errorMsg = '调用Coze工作流失败';
|
|
|
if (workflowError.response?.data) {
|
|
|
const errorData = workflowError.response.data;
|
|
|
errorMsg = errorData.msg || errorData.message || errorMsg;
|
|
|
if (errorData.detail) {
|
|
|
errorMsg += '\n详情: ' + JSON.stringify(errorData.detail);
|
|
|
}
|
|
|
if (errorData.code) {
|
|
|
errorMsg += '\n错误代码: ' + errorData.code;
|
|
|
}
|
|
|
} else {
|
|
|
errorMsg += ': ' + workflowError.message;
|
|
|
}
|
|
|
|
|
|
throw new Error(errorMsg);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error('========== AI定价功能出错 ==========');
|
|
|
console.error('错误信息:', error.message);
|
|
|
console.error('错误堆栈:', error.stack);
|
|
|
|
|
|
return {
|
|
|
success: false,
|
|
|
error: error.message,
|
|
|
details: error.response?.data || error.stack
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 初始化T_category表(如果不存在则创建)
|
|
|
const initCategoryTable = async () => {
|
|
|
try {
|
|
|
// 默认类别数据
|
|
|
const defaultCategories = [
|
|
|
{ categoryName: '电子产品', description: '手机、电脑、配件', icon: '📱', iconBg: '#2196F3', sort: 1, enabled: true },
|
|
|
{ categoryName: '图书文具', description: '教材、小说、专业书籍、文具', icon: '📚', iconBg: '#4CAF50', sort: 2, enabled: true },
|
|
|
{ categoryName: '服装鞋帽', description: '衣服、鞋子、配饰', icon: '👕', iconBg: '#FF9800', sort: 3, enabled: true },
|
|
|
{ categoryName: '家居用品', description: '桌椅、小家电、生活用品', icon: '🛋️', iconBg: '#795548', sort: 4, enabled: true },
|
|
|
{ categoryName: '运动户外', description: '健身器材、球类运动、户外用品', icon: '⚽', iconBg: '#9C27B0', sort: 5, enabled: true },
|
|
|
{ categoryName: '美妆个护', description: '美妆护肤产品', icon: '💄', iconBg: '#FF6B9D', sort: 6, enabled: true },
|
|
|
{ categoryName: '其他', description: '其他各类商品', icon: '📦', iconBg: '#757575', sort: 7, enabled: true }
|
|
|
];
|
|
|
|
|
|
// 检查T_category表是否存在数据
|
|
|
const existing = await db.collection("T_category").get();
|
|
|
|
|
|
if (existing.data.length === 0) {
|
|
|
// 初始化类别数据(使用循环add,避免batch兼容性问题)
|
|
|
for (const category of defaultCategories) {
|
|
|
await db.collection("T_category").add({
|
|
|
data: {
|
|
|
...category,
|
|
|
createTime: new Date(),
|
|
|
updateTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
console.log('T_category表初始化成功');
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: 'T_category表已初始化'
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('初始化T_category表失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 获取所有商品类别(优先从T_category表获取,如果没有则从T_product表提取)
|
|
|
const getProductCategories = async () => {
|
|
|
try {
|
|
|
// 先尝试从T_category表获取
|
|
|
let categoriesResult = await db.collection("T_category")
|
|
|
.where({
|
|
|
enabled: true
|
|
|
})
|
|
|
.orderBy('sort', 'asc')
|
|
|
.get();
|
|
|
|
|
|
if (categoriesResult.data.length > 0) {
|
|
|
// 从T_category表获取类别
|
|
|
const categories = categoriesResult.data.map(item => ({
|
|
|
id: item.categoryName,
|
|
|
name: item.categoryName,
|
|
|
description: item.description || getCategoryDescription(item.categoryName),
|
|
|
icon: item.icon || getCategoryIcon(item.categoryName),
|
|
|
iconBg: item.iconBg || getCategoryBg(item.categoryName)
|
|
|
}));
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: categories,
|
|
|
source: 'T_category'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 如果T_category表为空,从T_product表提取(兼容旧数据)
|
|
|
console.log('T_category表为空,从T_product表提取类别');
|
|
|
const result = await db.collection("T_product").field({
|
|
|
productCategory: true
|
|
|
}).get();
|
|
|
|
|
|
// 提取不重复的类别
|
|
|
const categoriesSet = new Set();
|
|
|
result.data.forEach(item => {
|
|
|
if (item.productCategory) {
|
|
|
categoriesSet.add(item.productCategory);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// 转换为数组并返回
|
|
|
const categories = Array.from(categoriesSet).map(category => ({
|
|
|
id: category,
|
|
|
name: category,
|
|
|
description: getCategoryDescription(category),
|
|
|
icon: getCategoryIcon(category),
|
|
|
iconBg: getCategoryBg(category)
|
|
|
}));
|
|
|
|
|
|
// 如果数据库中没有类别,返回默认类别
|
|
|
if (categories.length === 0) {
|
|
|
return {
|
|
|
success: true,
|
|
|
data: [
|
|
|
{ id: '电子产品', name: '电子产品', description: '手机、电脑、配件', icon: '📱', iconBg: '#2196F3' },
|
|
|
{ id: '图书文具', name: '图书文具', description: '教材、小说、专业书籍、文具', icon: '📚', iconBg: '#4CAF50' },
|
|
|
{ id: '服装鞋帽', name: '服装鞋帽', description: '衣服、鞋子、配饰', icon: '👕', iconBg: '#FF9800' },
|
|
|
{ id: '家居用品', name: '家居用品', description: '桌椅、小家电、生活用品', icon: '🛋️', iconBg: '#795548' },
|
|
|
{ id: '运动户外', name: '运动户外', description: '健身器材、球类运动、户外用品', icon: '⚽', iconBg: '#9C27B0' },
|
|
|
{ id: '美妆个护', name: '美妆个护', description: '美妆护肤产品', icon: '💄', iconBg: '#FF6B9D' },
|
|
|
{ id: '其他', name: '其他', description: '其他各类商品', icon: '📦', iconBg: '#757575' }
|
|
|
],
|
|
|
source: 'default'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: categories,
|
|
|
source: 'T_product'
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('获取商品类别失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 获取类别描述
|
|
|
const getCategoryDescription = (category) => {
|
|
|
const descriptions = {
|
|
|
'电子产品': '手机、电脑、配件',
|
|
|
'图书文具': '教材、小说、专业书籍、文具',
|
|
|
'服装鞋帽': '衣服、鞋子、配饰',
|
|
|
'家居用品': '桌椅、小家电、生活用品',
|
|
|
'运动户外': '健身器材、球类运动、户外用品',
|
|
|
'美妆个护': '美妆护肤产品',
|
|
|
'其他': '其他各类商品'
|
|
|
};
|
|
|
return descriptions[category] || '各类商品';
|
|
|
};
|
|
|
|
|
|
// 获取类别图标(使用emoji)
|
|
|
const getCategoryIcon = (category) => {
|
|
|
const icons = {
|
|
|
'电子产品': '📱',
|
|
|
'图书文具': '📚',
|
|
|
'服装鞋帽': '👕',
|
|
|
'家居用品': '🛋️',
|
|
|
'运动户外': '⚽',
|
|
|
'美妆个护': '💄',
|
|
|
'其他': '📦'
|
|
|
};
|
|
|
return icons[category] || '📦';
|
|
|
};
|
|
|
|
|
|
// 获取类别背景色
|
|
|
const getCategoryBg = (category) => {
|
|
|
const colors = {
|
|
|
'电子产品': '#2196F3',
|
|
|
'图书文具': '#4CAF50',
|
|
|
'服装鞋帽': '#FF9800',
|
|
|
'家居用品': '#795548',
|
|
|
'运动户外': '#9C27B0',
|
|
|
'美妆个护': '#FF6B9D',
|
|
|
'其他': '#757575'
|
|
|
};
|
|
|
return colors[category] || '#757575';
|
|
|
};
|
|
|
|
|
|
// 更新用户兴趣
|
|
|
const updateUserInterests = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const openid = wxContext.OPENID;
|
|
|
const interests = event.interests || [];
|
|
|
|
|
|
console.log('更新用户兴趣,openid:', openid, 'interests:', interests);
|
|
|
|
|
|
if (!openid) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: 'openid不能为空'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 查询用户是否存在
|
|
|
const userResult = await db.collection("T_user").where({
|
|
|
_openid: openid
|
|
|
}).get();
|
|
|
|
|
|
if (userResult.data.length === 0) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '用户不存在'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 更新用户兴趣
|
|
|
const updateResult = await db.collection("T_user").where({
|
|
|
_openid: openid
|
|
|
}).update({
|
|
|
data: {
|
|
|
interests: interests,
|
|
|
updateTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
|
|
|
console.log('更新用户兴趣成功:', updateResult);
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
interests: interests,
|
|
|
updateResult: updateResult
|
|
|
}
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('更新用户兴趣失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 类别名称映射(兼容旧数据)
|
|
|
const mapCategoryName = (category) => {
|
|
|
const categoryMap = {
|
|
|
'化妆品': '美妆个护',
|
|
|
'二手书': '图书文具',
|
|
|
'运动器材': '运动户外',
|
|
|
'家具家电': '家居用品',
|
|
|
'文具用品': '图书文具',
|
|
|
'其他商品': '其他'
|
|
|
};
|
|
|
return categoryMap[category] || category;
|
|
|
};
|
|
|
|
|
|
// 记录用户行为(浏览、收藏、购买)
|
|
|
const recordUserBehavior = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const openid = wxContext.OPENID;
|
|
|
const { productId, behaviorType, productCategory } = event; // behaviorType: 'view', 'favorite', 'purchase'
|
|
|
|
|
|
console.log('记录用户行为,参数:', { openid, productId, behaviorType, productCategory });
|
|
|
|
|
|
if (!productId || !behaviorType) {
|
|
|
console.error('缺少必要参数:', { productId, behaviorType });
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '缺少必要参数'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
if (!openid) {
|
|
|
console.error('缺少openid');
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '用户未登录'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 行为权重:浏览1分,收藏3分,购买5分
|
|
|
const behaviorWeights = {
|
|
|
'view': 1,
|
|
|
'favorite': 3,
|
|
|
'purchase': 5
|
|
|
};
|
|
|
|
|
|
const score = behaviorWeights[behaviorType] || 1;
|
|
|
|
|
|
try {
|
|
|
// 检查是否已存在该行为记录
|
|
|
const existingRecord = await db.collection('T_user_behavior')
|
|
|
.where({
|
|
|
_openid: openid,
|
|
|
productId: productId,
|
|
|
behaviorType: behaviorType
|
|
|
})
|
|
|
.get();
|
|
|
|
|
|
if (existingRecord.data && existingRecord.data.length > 0) {
|
|
|
// 更新现有记录的时间
|
|
|
await db.collection('T_user_behavior')
|
|
|
.doc(existingRecord.data[0]._id)
|
|
|
.update({
|
|
|
data: {
|
|
|
updateTime: new Date(),
|
|
|
count: _.inc(1) // 增加行为次数
|
|
|
}
|
|
|
});
|
|
|
console.log('更新用户行为记录成功');
|
|
|
} else {
|
|
|
// 创建新记录
|
|
|
const result = await db.collection('T_user_behavior').add({
|
|
|
data: {
|
|
|
_openid: openid,
|
|
|
productId: productId,
|
|
|
productCategory: productCategory || '',
|
|
|
behaviorType: behaviorType,
|
|
|
score: score,
|
|
|
count: 1,
|
|
|
createTime: new Date(),
|
|
|
updateTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
console.log('创建用户行为记录成功,ID:', result._id);
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: '行为记录成功'
|
|
|
};
|
|
|
} catch (dbError) {
|
|
|
console.error('数据库操作失败:', dbError);
|
|
|
// 如果集合不存在,尝试创建
|
|
|
if (dbError.errMsg && (dbError.errMsg.includes('not exist') || dbError.errMsg.includes('not exists') || dbError.errCode === -502005)) {
|
|
|
console.log('集合不存在,尝试创建 T_user_behavior 集合');
|
|
|
try {
|
|
|
await db.createCollection('T_user_behavior');
|
|
|
console.log('集合创建成功,重新尝试添加记录');
|
|
|
|
|
|
// 重新尝试添加记录
|
|
|
await db.collection('T_user_behavior').add({
|
|
|
data: {
|
|
|
_openid: openid,
|
|
|
productId: productId,
|
|
|
productCategory: productCategory || '',
|
|
|
behaviorType: behaviorType,
|
|
|
score: score,
|
|
|
count: 1,
|
|
|
createTime: new Date(),
|
|
|
updateTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: '行为记录成功(集合已创建)'
|
|
|
};
|
|
|
} catch (createError) {
|
|
|
console.error('创建集合失败:', createError);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '集合创建失败: ' + createError.message
|
|
|
};
|
|
|
}
|
|
|
} else {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: dbError.message || '数据库操作失败'
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('记录用户行为失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 创建用户行为集合
|
|
|
const createBehaviorCollection = async () => {
|
|
|
try {
|
|
|
// 检查集合是否已存在
|
|
|
const collections = await db.listCollections();
|
|
|
const collectionNames = collections.data.map(col => col.name);
|
|
|
|
|
|
if (collectionNames.includes('T_user_behavior')) {
|
|
|
return {
|
|
|
success: true,
|
|
|
message: "集合已存在",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 创建 T_user_behavior 集合
|
|
|
await db.createCollection("T_user_behavior");
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: "T_user_behavior 集合创建成功",
|
|
|
};
|
|
|
} catch (e) {
|
|
|
// 如果集合已存在,也返回成功
|
|
|
if (e.message && e.message.includes('already exists')) {
|
|
|
return {
|
|
|
success: true,
|
|
|
message: "集合已存在",
|
|
|
};
|
|
|
}
|
|
|
console.error('创建 T_user_behavior 集合失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message,
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 管理员登录验证
|
|
|
const adminLogin = async (event) => {
|
|
|
try {
|
|
|
const { username, password } = event;
|
|
|
|
|
|
if (!username || !password) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '缺少必要参数'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 查询管理员表(如果不存在,使用默认管理员)
|
|
|
try {
|
|
|
const adminResult = await db.collection('T_admin')
|
|
|
.where({
|
|
|
username: username,
|
|
|
password: password,
|
|
|
status: 'active'
|
|
|
})
|
|
|
.get();
|
|
|
|
|
|
if (adminResult.data && adminResult.data.length > 0) {
|
|
|
const admin = adminResult.data[0];
|
|
|
return {
|
|
|
success: true,
|
|
|
adminId: admin._id,
|
|
|
username: admin.username,
|
|
|
name: admin.name || '管理员',
|
|
|
role: admin.role || 'admin'
|
|
|
};
|
|
|
}
|
|
|
} catch (err) {
|
|
|
// 如果集合不存在,使用默认管理员账号
|
|
|
if (err.errMsg && err.errMsg.includes('not exist')) {
|
|
|
// 默认管理员账号:admin / admin123
|
|
|
if (username === 'admin' && password === 'admin123') {
|
|
|
return {
|
|
|
success: true,
|
|
|
adminId: 'default_admin',
|
|
|
username: 'admin',
|
|
|
name: '系统管理员',
|
|
|
role: 'admin'
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '账号或密码错误'
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('管理员登录失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 获取管理员统计数据
|
|
|
const getAdminStats = async () => {
|
|
|
try {
|
|
|
console.log('开始获取管理员统计数据...');
|
|
|
|
|
|
// 获取商品总数(包括所有状态的商品)
|
|
|
const productsResult = await db.collection('T_product').count();
|
|
|
const totalProducts = productsResult.total || 0;
|
|
|
console.log('商品总数:', totalProducts);
|
|
|
|
|
|
// 获取用户总数
|
|
|
const usersResult = await db.collection('T_user').count();
|
|
|
const totalUsers = usersResult.total || 0;
|
|
|
console.log('用户总数:', totalUsers);
|
|
|
|
|
|
// 获取订单总数和总销售额
|
|
|
let totalOrders = 0;
|
|
|
let totalSales = 0;
|
|
|
try {
|
|
|
const ordersResult = await db.collection('T_order')
|
|
|
.where({
|
|
|
status: _.in(['已完成', '已支付'])
|
|
|
})
|
|
|
.get();
|
|
|
|
|
|
totalOrders = ordersResult.data.length;
|
|
|
totalSales = ordersResult.data.reduce((sum, order) => {
|
|
|
return sum + (parseFloat(order.totalPrice) || 0);
|
|
|
}, 0);
|
|
|
console.log('订单总数:', totalOrders, '总销售额:', totalSales);
|
|
|
} catch (err) {
|
|
|
console.error('获取订单数据失败:', err);
|
|
|
}
|
|
|
|
|
|
// 获取最近6个月的月度商品发布量
|
|
|
const monthlyProducts = await getMonthlyProducts();
|
|
|
|
|
|
// 获取最近6个月的月度销售额
|
|
|
const monthlySales = await getMonthlySales();
|
|
|
|
|
|
// 获取商品分类统计
|
|
|
const categoryStats = await getCategoryStats();
|
|
|
|
|
|
const result = {
|
|
|
success: true,
|
|
|
data: {
|
|
|
totalProducts,
|
|
|
totalUsers,
|
|
|
totalSales: Math.round(totalSales * 100) / 100,
|
|
|
totalOrders,
|
|
|
monthlyProducts,
|
|
|
monthlySales,
|
|
|
categoryStats
|
|
|
}
|
|
|
};
|
|
|
|
|
|
console.log('统计数据获取成功:', JSON.stringify(result.data, null, 2));
|
|
|
|
|
|
return result;
|
|
|
} catch (e) {
|
|
|
console.error('获取统计数据失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 获取月度商品发布量
|
|
|
const getMonthlyProducts = async () => {
|
|
|
try {
|
|
|
const now = new Date();
|
|
|
const months = [];
|
|
|
|
|
|
for (let i = 5; i >= 0; i--) {
|
|
|
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
|
const nextDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
|
|
|
|
|
|
const monthStr = `${date.getMonth() + 1}月`;
|
|
|
|
|
|
const result = await db.collection('T_product')
|
|
|
.where({
|
|
|
createTime: _.gte(date).and(_.lt(nextDate))
|
|
|
})
|
|
|
.count();
|
|
|
|
|
|
months.push({
|
|
|
month: monthStr,
|
|
|
count: result.total || 0
|
|
|
});
|
|
|
}
|
|
|
|
|
|
return months;
|
|
|
} catch (e) {
|
|
|
console.error('获取月度商品发布量失败:', e);
|
|
|
return [];
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 获取月度销售额
|
|
|
const getMonthlySales = async () => {
|
|
|
try {
|
|
|
const now = new Date();
|
|
|
const months = [];
|
|
|
|
|
|
for (let i = 5; i >= 0; i--) {
|
|
|
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
|
const nextDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
|
|
|
|
|
|
const monthStr = `${date.getMonth() + 1}月`;
|
|
|
|
|
|
try {
|
|
|
const result = await db.collection('T_order')
|
|
|
.where({
|
|
|
createTime: _.gte(date).and(_.lt(nextDate)),
|
|
|
status: _.in(['已完成', '已支付'])
|
|
|
})
|
|
|
.get();
|
|
|
|
|
|
const sales = result.data.reduce((sum, order) => {
|
|
|
return sum + (parseFloat(order.totalPrice) || 0);
|
|
|
}, 0);
|
|
|
|
|
|
months.push({
|
|
|
month: monthStr,
|
|
|
sales: Math.round(sales * 100) / 100
|
|
|
});
|
|
|
} catch (err) {
|
|
|
months.push({
|
|
|
month: monthStr,
|
|
|
sales: 0
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return months;
|
|
|
} catch (e) {
|
|
|
console.error('获取月度销售额失败:', e);
|
|
|
return [];
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 获取商品分类统计
|
|
|
const getCategoryStats = async () => {
|
|
|
try {
|
|
|
const result = await db.collection('T_product')
|
|
|
.field({
|
|
|
productCategory: true
|
|
|
})
|
|
|
.get();
|
|
|
|
|
|
const categoryMap = {};
|
|
|
result.data.forEach(item => {
|
|
|
const category = item.productCategory || '其他';
|
|
|
categoryMap[category] = (categoryMap[category] || 0) + 1;
|
|
|
});
|
|
|
|
|
|
return Object.entries(categoryMap).map(([category, count]) => ({
|
|
|
category,
|
|
|
count
|
|
|
})).sort((a, b) => b.count - a.count).slice(0, 8);
|
|
|
} catch (e) {
|
|
|
console.error('获取分类统计失败:', e);
|
|
|
return [];
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 管理端:聚合求购关键词词云(基于T_want)
|
|
|
const getWantedKeywordCloud = async (event) => {
|
|
|
try {
|
|
|
const limit = event && event.limit ? parseInt(event.limit) : 60;
|
|
|
// 拉取活跃求购数据(字段:productName、purchaseDescription、expectedBrand、expectedModel、productCategory)
|
|
|
const res = await db.collection('T_want')
|
|
|
.field({ productName: true, description: true, purchaseDescription: true, expectedBrand: true, expectedModel: true, productCategory: true, status: true })
|
|
|
.get();
|
|
|
|
|
|
const stopwords = new Set(['求购','求','购买','需要','急需','求助','有没有','二手','全新','新','的','和','及','或','我','我们','联系','微信','电话','价','价格','预算','希望','求一个','一个','一台','一本','一套']);
|
|
|
const freq = {};
|
|
|
|
|
|
const toTokens = (text) => {
|
|
|
if (!text || typeof text !== 'string') return [];
|
|
|
const cleaned = text.replace(/[\s\n\r\t]+/g, ' ')
|
|
|
.replace(/[,。、“”‘’!!??::;;、,/\.\-]+/g, ' ')
|
|
|
.trim();
|
|
|
const parts = cleaned.split(' ').filter(Boolean);
|
|
|
return parts;
|
|
|
};
|
|
|
|
|
|
res.data.forEach(item => {
|
|
|
if (item.status && item.status !== 'active') return; // 只统计活跃求购
|
|
|
const texts = [item.productName, item.purchaseDescription || item.description, item.expectedBrand, item.expectedModel];
|
|
|
const tokens = texts.flatMap(t => toTokens(t));
|
|
|
tokens.forEach(tok => {
|
|
|
const token = (tok || '').trim();
|
|
|
if (!token) return;
|
|
|
if (token.length < 2) return; // 过滤过短词
|
|
|
if (stopwords.has(token)) return;
|
|
|
// 统一大小写处理英文
|
|
|
const key = /[A-Za-z]/.test(token) ? token.toLowerCase() : token;
|
|
|
freq[key] = (freq[key] || 0) + 1;
|
|
|
});
|
|
|
});
|
|
|
|
|
|
const entries = Object.entries(freq).map(([text, count]) => ({ text, count }));
|
|
|
entries.sort((a, b) => b.count - a.count);
|
|
|
const top = entries.slice(0, limit);
|
|
|
const max = top.length > 0 ? top[0].count : 1;
|
|
|
const min = top.length > 0 ? top[top.length - 1].count : 1;
|
|
|
const norm = (c) => {
|
|
|
if (max === min) return 1;
|
|
|
return (c - min) / (max - min);
|
|
|
};
|
|
|
const keywords = top.map(k => ({
|
|
|
text: k.text,
|
|
|
weight: k.count,
|
|
|
score: norm(k.count) // 0..1
|
|
|
}));
|
|
|
|
|
|
return { success: true, data: { keywords, total: entries.length } };
|
|
|
} catch (e) {
|
|
|
console.error('聚合求购关键词失败:', e);
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 计算分类供需统计与发布建议(不分校区,支持个性化权重,并返回类别指引)
|
|
|
const getPublishRecommendations = async (event) => {
|
|
|
try {
|
|
|
const forType = event && event.forType; // 'product' | 'wanted' | admin
|
|
|
const campusFilter = ''; // 不分校区
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const callerOpenId = (event && event.openid) || (wxContext && wxContext.OPENID) || '';
|
|
|
let interestsFromEvent = Array.isArray(event && event.interests) ? (event.interests || []) : [];
|
|
|
|
|
|
// 辅助:从用户信息推断校区
|
|
|
const getCampusFromUser = (user) => {
|
|
|
if (!user) return '未知';
|
|
|
if (user.campus) return user.campus;
|
|
|
const dorm = user.sushe || '';
|
|
|
if (/闵行/.test(dorm)) return '闵行校区';
|
|
|
if (/徐汇/.test(dorm)) return '徐汇校区';
|
|
|
if (/松江/.test(dorm)) return '松江校区';
|
|
|
return '未知';
|
|
|
};
|
|
|
|
|
|
// 统计供给(商品在售/交易中),顺便收集sellerUserId用于校区映射
|
|
|
const productRes = await db.collection('T_product')
|
|
|
.field({ productCategory: true, status: true, transactionMethod: true, salePrice: true, suggestedPrice: true, sellerUserId: true, sellerOpenId: true })
|
|
|
.get();
|
|
|
|
|
|
// 批量获取卖家用户校区
|
|
|
const sellerIds = [...new Set(productRes.data.map(p => p.sellerUserId).filter(Boolean))];
|
|
|
let sellerUsers = { };
|
|
|
if (sellerIds.length > 0) {
|
|
|
try {
|
|
|
const sellerUserDocs = await db.collection('T_user').where({ _id: _.in(sellerIds) }).field({ _id: true, campus: true, sushe: true, interests: true, _openid: true }).get();
|
|
|
sellerUserDocs.data.forEach(u => { sellerUsers[u._id] = u; });
|
|
|
} catch (err) {
|
|
|
console.error('批量获取卖家用户信息失败:', err);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 统计需求(求购信息),收集openids用于校区映射
|
|
|
let wantResData = [];
|
|
|
try {
|
|
|
const wantRes = await db.collection('T_want')
|
|
|
.field({ productCategory: true, _openid: true })
|
|
|
.get();
|
|
|
wantResData = wantRes.data || [];
|
|
|
} catch (e) {
|
|
|
wantResData = [];
|
|
|
}
|
|
|
|
|
|
const wantOpenIds = [...new Set(wantResData.map(w => w._openid).filter(Boolean))];
|
|
|
let wantUsersByOpenId = { };
|
|
|
if (wantOpenIds.length > 0) {
|
|
|
try {
|
|
|
const wantUserDocs = await db.collection('T_user').where({ _openid: _.in(wantOpenIds) }).field({ _openid: true, campus: true, sushe: true, interests: true, _id: true }).get();
|
|
|
wantUserDocs.data.forEach(u => { wantUsersByOpenId[u._openid] = u; });
|
|
|
} catch (err) {
|
|
|
console.error('批量获取求购用户信息失败:', err);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 如果没有传入兴趣,尝试从当前调用者的用户信息获取
|
|
|
if (!interestsFromEvent || interestsFromEvent.length === 0) {
|
|
|
try {
|
|
|
const curUser = await db.collection('T_user').where({ _openid: callerOpenId }).field({ interests: true, campus: true, sushe: true }).get();
|
|
|
const userDoc = (curUser.data && curUser.data[0]) || null;
|
|
|
if (userDoc && Array.isArray(userDoc.interests)) {
|
|
|
interestsFromEvent = userDoc.interests;
|
|
|
}
|
|
|
} catch (err) {
|
|
|
// 忽略错误
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 供需统计(可选校区过滤)
|
|
|
const supplyMap = {};
|
|
|
const demandMap = {};
|
|
|
const campusBreakdown = {}; // { 校区: { category -> { supply, demand } } }
|
|
|
|
|
|
const supplyStatuses = new Set(['在售','交易中','已发布','待卖家确认','待发货','待收货']);
|
|
|
|
|
|
productRes.data.forEach(item => {
|
|
|
const cat = item.productCategory || '其他';
|
|
|
const status = item.status || '';
|
|
|
const isSupply = supplyStatuses.has(status);
|
|
|
const sellerUser = item.sellerUserId ? sellerUsers[item.sellerUserId] : null;
|
|
|
const campus = getCampusFromUser(sellerUser);
|
|
|
|
|
|
if (isSupply) {
|
|
|
// 总体
|
|
|
supplyMap[cat] = (supplyMap[cat] || 0) + 1;
|
|
|
// 校区分布
|
|
|
campusBreakdown[campus] = campusBreakdown[campus] || {};
|
|
|
campusBreakdown[campus][cat] = campusBreakdown[campus][cat] || { supply: 0, demand: 0 };
|
|
|
campusBreakdown[campus][cat].supply += 1;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
wantResData.forEach(item => {
|
|
|
const cat = item.productCategory || '其他';
|
|
|
const user = wantUsersByOpenId[item._openid];
|
|
|
const campus = getCampusFromUser(user);
|
|
|
demandMap[cat] = (demandMap[cat] || 0) + 1;
|
|
|
campusBreakdown[campus] = campusBreakdown[campus] || {};
|
|
|
campusBreakdown[campus][cat] = campusBreakdown[campus][cat] || { supply: 0, demand: 0 };
|
|
|
campusBreakdown[campus][cat].demand += 1;
|
|
|
});
|
|
|
|
|
|
// 如果指定校区,则使用该校区下的供需统计替换总体视图
|
|
|
let effectiveSupplyMap = supplyMap;
|
|
|
let effectiveDemandMap = demandMap;
|
|
|
if (campusFilter && campusBreakdown[campusFilter]) {
|
|
|
const campusStats = campusBreakdown[campusFilter];
|
|
|
effectiveSupplyMap = {};
|
|
|
effectiveDemandMap = {};
|
|
|
Object.keys(campusStats).forEach(cat => {
|
|
|
effectiveSupplyMap[cat] = campusStats[cat].supply || 0;
|
|
|
effectiveDemandMap[cat] = campusStats[cat].demand || 0;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// 汇总所有出现过的类别
|
|
|
const allCats = new Set([...Object.keys(effectiveSupplyMap), ...Object.keys(effectiveDemandMap)]);
|
|
|
const stats = Array.from(allCats).map(cat => {
|
|
|
const supply = effectiveSupplyMap[cat] || 0;
|
|
|
const demand = effectiveDemandMap[cat] || 0;
|
|
|
return {
|
|
|
category: cat,
|
|
|
supply,
|
|
|
demand,
|
|
|
gap: demand - supply
|
|
|
};
|
|
|
}).sort((a, b) => Math.abs(b.gap) - Math.abs(a.gap));
|
|
|
|
|
|
// 价格与交易方式指引
|
|
|
const categoryGuides = {};
|
|
|
const methodCountByCat = {};
|
|
|
const priceByCat = {};
|
|
|
productRes.data.forEach(item => {
|
|
|
const status = item.status || '';
|
|
|
if (!supplyStatuses.has(status)) return;
|
|
|
const cat = item.productCategory || '其他';
|
|
|
const price = (typeof item.salePrice === 'number' && item.salePrice > 0) ? item.salePrice
|
|
|
: ((typeof item.suggestedPrice === 'number' && item.suggestedPrice > 0) ? item.suggestedPrice : null);
|
|
|
if (price !== null) {
|
|
|
priceByCat[cat] = priceByCat[cat] || [];
|
|
|
priceByCat[cat].push(price);
|
|
|
}
|
|
|
const method = item.transactionMethod || '面交';
|
|
|
methodCountByCat[cat] = methodCountByCat[cat] || {};
|
|
|
methodCountByCat[cat][method] = (methodCountByCat[cat][method] || 0) + 1;
|
|
|
});
|
|
|
Object.keys(allCats).forEach(() => {}); // no-op to satisfy lint
|
|
|
for (const cat of allCats) {
|
|
|
const prices = priceByCat[cat] || [];
|
|
|
let guide = { avgPrice: 0, minPrice: 0, maxPrice: 0, typicalPriceRange: '', commonTrade: '面交', methodsRanked: [] };
|
|
|
if (prices.length > 0) {
|
|
|
const sum = prices.reduce((a, b) => a + b, 0);
|
|
|
const avg = Math.round((sum / prices.length) * 100) / 100;
|
|
|
const min = Math.min(...prices);
|
|
|
const max = Math.max(...prices);
|
|
|
guide.avgPrice = avg;
|
|
|
guide.minPrice = min;
|
|
|
guide.maxPrice = max;
|
|
|
guide.typicalPriceRange = `${Math.round(min)}-${Math.round(max)}`;
|
|
|
}
|
|
|
const mc = methodCountByCat[cat] || {};
|
|
|
const ranked = Object.entries(mc).sort((a, b) => b[1] - a[1]).map(([m]) => m);
|
|
|
guide.commonTrade = ranked[0] || '面交';
|
|
|
guide.methodsRanked = ranked;
|
|
|
categoryGuides[cat] = guide;
|
|
|
}
|
|
|
|
|
|
// 生成建议(支持forType与兴趣权重)
|
|
|
const shortage = stats.filter(s => s.gap >= 3).sort((a, b) => b.gap - a.gap).slice(0, 5);
|
|
|
const oversupply = stats.filter(s => (s.supply - s.demand) >= 3).sort((a, b) => (b.supply - b.demand) - (a.supply - a.demand)).slice(0, 5);
|
|
|
const hotDemand = [...stats].sort((a, b) => b.demand - a.demand).slice(0, 5);
|
|
|
const richSupply = [...stats].sort((a, b) => b.supply - a.supply).slice(0, 5);
|
|
|
|
|
|
const interestSet = new Set((interestsFromEvent || []).map(i => i));
|
|
|
let candidates = [];
|
|
|
let baseMessage = '';
|
|
|
if (forType === 'product') {
|
|
|
candidates = (shortage.length > 0 ? shortage : hotDemand);
|
|
|
baseMessage = shortage.length > 0 ? '以下类别买家需求旺盛、供给偏少,优先发布:' : '以下类别近期需求较高,发布更易成交:';
|
|
|
} else if (forType === 'wanted') {
|
|
|
candidates = (oversupply.length > 0 ? oversupply : richSupply);
|
|
|
baseMessage = oversupply.length > 0 ? '以下类别货源充足、响应更快,建议求购:' : '以下类别当前货源较多,求购更容易被响应:';
|
|
|
} else {
|
|
|
candidates = shortage.slice(0, 3);
|
|
|
baseMessage = '供需缺口较大的类别:';
|
|
|
}
|
|
|
|
|
|
// 应用兴趣加权
|
|
|
const scored = candidates.map(c => {
|
|
|
const interestBoost = interestSet.has(c.category) ? 1 : 0;
|
|
|
const score = (Math.abs(c.gap) || 0) + interestBoost;
|
|
|
return { cat: c.category, score };
|
|
|
}).sort((a, b) => b.score - a.score);
|
|
|
|
|
|
const recommended = scored.map(s => s.cat);
|
|
|
const message = `${baseMessage}${recommended.join('、')}`;
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
stats,
|
|
|
shortage,
|
|
|
oversupply,
|
|
|
hotDemand,
|
|
|
richSupply,
|
|
|
recommended,
|
|
|
message,
|
|
|
campus: '全部校区',
|
|
|
interestsUsed: Array.from(interestSet),
|
|
|
categoryGuides,
|
|
|
campusBreakdown
|
|
|
}
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('计算发布建议失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 管理员:一键推送建议到用户群(不分校区)
|
|
|
const adminPushRecommendations = async (event) => {
|
|
|
try {
|
|
|
const categories = Array.isArray(event.categories) ? event.categories : [];
|
|
|
const target = event.target || 'sellers'; // sellers | buyers | all
|
|
|
const campus = ''; // 不分校区
|
|
|
const customMessage = event.message || '';
|
|
|
|
|
|
// 本地工具:从用户资料推断校区
|
|
|
const getCampusFromUserLocal = (user) => {
|
|
|
if (!user) return '未知';
|
|
|
if (user.campus) return user.campus;
|
|
|
const dorm = user.dorm || user.sushe || '';
|
|
|
if (/闵行/.test(dorm)) return '闵行校区';
|
|
|
if (/徐汇/.test(dorm)) return '徐汇校区';
|
|
|
if (/松江/.test(dorm)) return '松江校区';
|
|
|
return '未知';
|
|
|
};
|
|
|
|
|
|
if (categories.length === 0) {
|
|
|
return { success: false, error: '缺少要推送的类别列表' };
|
|
|
}
|
|
|
|
|
|
// 查询用户(按兴趣筛选,不分校区)
|
|
|
const userQuery = {};
|
|
|
// 拉取可能的兴趣用户(这里不在where里使用数组匹配,改为拉取后过滤,兼容旧数据)
|
|
|
const usersRes = await db.collection('T_user').where(userQuery).field({ _id: true, _openid: true, interests: true, campus: true, sushe: true, notificationEnabled: true, sname: true }).get();
|
|
|
|
|
|
// 可能的买家(发布过求购)
|
|
|
let wantUsersOpenIds = new Set();
|
|
|
try {
|
|
|
const wantRes = await db.collection('T_want').where({ productCategory: _.in(categories) }).field({ _openid: true }).get();
|
|
|
wantRes.data.forEach(w => { if (w._openid) wantUsersOpenIds.add(w._openid); });
|
|
|
} catch (err) {
|
|
|
// ignore
|
|
|
}
|
|
|
|
|
|
// 过滤用户:兴趣命中或是活跃求购用户
|
|
|
const getCampus = (u) => u.campus || getCampusFromUserLocal(u);
|
|
|
const recipients = [];
|
|
|
usersRes.data.forEach(u => {
|
|
|
const enabled = (u.notificationEnabled !== false);
|
|
|
if (!enabled) return;
|
|
|
const campusOk = true;
|
|
|
const interests = Array.isArray(u.interests) ? u.interests : [];
|
|
|
const interestHit = interests.some(i => categories.includes(i));
|
|
|
const wantHit = wantUsersOpenIds.has(u._openid);
|
|
|
if (campusOk && (interestHit || (target !== 'sellers' && wantHit) || target === 'all')) {
|
|
|
recipients.push(u);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// 写入通知集合
|
|
|
let successCount = 0;
|
|
|
for (const user of recipients) {
|
|
|
try {
|
|
|
const content = customMessage || `当前【${categories.join('、')}】类别存在供需变化,建议关注并发布/求购。`;
|
|
|
await db.collection('T_notify').add({
|
|
|
data: {
|
|
|
userId: user._id,
|
|
|
_openid: user._openid,
|
|
|
type: 'recommendation',
|
|
|
title: '供需发布建议',
|
|
|
content,
|
|
|
categories,
|
|
|
campus: '全部校区',
|
|
|
target,
|
|
|
read: false,
|
|
|
createTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
successCount += 1;
|
|
|
} catch (err) {
|
|
|
console.error('写入通知失败:', err);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return { success: true, pushed: successCount, recipients: recipients.length };
|
|
|
} catch (e) {
|
|
|
console.error('管理员推送建议失败:', e);
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 管理员更新商品
|
|
|
const adminUpdateProduct = async (event) => {
|
|
|
try {
|
|
|
const { productId, data } = event;
|
|
|
|
|
|
if (!productId) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '缺少商品ID'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
await db.collection('T_product').doc(productId).update({
|
|
|
data: {
|
|
|
...data,
|
|
|
updateTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: '更新成功'
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('更新商品失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 管理员删除商品(下架)
|
|
|
const adminDeleteProduct = async (event) => {
|
|
|
try {
|
|
|
const { productId } = event;
|
|
|
|
|
|
if (!productId) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '缺少商品ID'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 更新商品状态为已下架
|
|
|
await db.collection('T_product').doc(productId).update({
|
|
|
data: {
|
|
|
status: '已下架',
|
|
|
updateTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: '商品已下架'
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('下架商品失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 管理员获取用户列表
|
|
|
const adminGetUsers = async (event) => {
|
|
|
try {
|
|
|
const { page = 0, pageSize = 20, keyword = '' } = event;
|
|
|
|
|
|
let queryCondition = {};
|
|
|
|
|
|
if (keyword) {
|
|
|
// 微信小程序云数据库使用db.RegExp
|
|
|
const regex = db.RegExp({
|
|
|
regexp: keyword,
|
|
|
options: 'i'
|
|
|
});
|
|
|
queryCondition = _.or([
|
|
|
{ sno: regex },
|
|
|
{ sname: regex },
|
|
|
{ phone: regex }
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
const result = await db.collection('T_user')
|
|
|
.where(queryCondition)
|
|
|
.skip(page * pageSize)
|
|
|
.limit(pageSize)
|
|
|
.orderBy('createTime', 'desc')
|
|
|
.get();
|
|
|
|
|
|
const countResult = await db.collection('T_user')
|
|
|
.where(queryCondition)
|
|
|
.count();
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: {
|
|
|
users: result.data,
|
|
|
total: countResult.total,
|
|
|
page: page,
|
|
|
pageSize: pageSize
|
|
|
}
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('获取用户列表失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 管理员更新用户信息
|
|
|
const adminUpdateUser = async (event) => {
|
|
|
try {
|
|
|
const { userId, data } = event;
|
|
|
|
|
|
console.log('管理员更新用户信息,userId:', userId, 'data:', JSON.stringify(data, null, 2));
|
|
|
|
|
|
if (!userId) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '缺少用户ID'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 检查用户是否存在
|
|
|
const userDoc = await db.collection('T_user').doc(userId).get();
|
|
|
if (!userDoc.data) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '用户不存在'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 更新用户信息
|
|
|
const updateResult = await db.collection('T_user').doc(userId).update({
|
|
|
data: {
|
|
|
...data,
|
|
|
updateTime: new Date()
|
|
|
}
|
|
|
});
|
|
|
|
|
|
console.log('更新用户信息成功,结果:', updateResult);
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: '更新成功',
|
|
|
data: updateResult
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('更新用户失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message || '更新失败'
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 安全订单状态更新(基于角色和状态流转校验)
|
|
|
const updateOrderStatus = async (event) => {
|
|
|
try {
|
|
|
const { orderId, action, callerUserId } = event || {};
|
|
|
if (!orderId || !action) {
|
|
|
return { success: false, error: '缺少必要参数:orderId 或 action' };
|
|
|
}
|
|
|
|
|
|
const ctx = cloud.getWXContext();
|
|
|
const callerOpenId = ctx.OPENID || event.openid || '';
|
|
|
|
|
|
const orderRes = await db.collection('T_order').doc(orderId).get();
|
|
|
const order = orderRes.data;
|
|
|
if (!order) {
|
|
|
return { success: false, error: '订单不存在' };
|
|
|
}
|
|
|
|
|
|
const flows = {
|
|
|
buyerPay: { from: '待付款', to: '待确认付款', role: 'buyer' },
|
|
|
sellerConfirmPayment: { from: '待确认付款', to: '待发货', role: 'seller' },
|
|
|
sellerShip: { from: '待发货', to: '待收货', role: 'seller' },
|
|
|
buyerConfirmReceipt: { from: '待收货', to: '已完成', role: 'buyer' },
|
|
|
buyerCancel: { from: '待付款', to: '已取消', role: 'buyer' }
|
|
|
};
|
|
|
|
|
|
const flow = flows[action];
|
|
|
if (!flow) {
|
|
|
return { success: false, error: '不支持的动作' };
|
|
|
}
|
|
|
|
|
|
const isSeller = (
|
|
|
(order.sellerOpenId && order.sellerOpenId === callerOpenId) ||
|
|
|
(order.sellerUserId && callerUserId && order.sellerUserId === callerUserId)
|
|
|
);
|
|
|
const isBuyer = (
|
|
|
(order.buyerOpenId && order.buyerOpenId === callerOpenId) ||
|
|
|
(order.buyerUserId && callerUserId && order.buyerUserId === callerUserId)
|
|
|
);
|
|
|
|
|
|
if (flow.role === 'seller' && !isSeller) {
|
|
|
return { success: false, error: '无权执行:仅卖家可进行该操作' };
|
|
|
}
|
|
|
if (flow.role === 'buyer' && !isBuyer) {
|
|
|
return { success: false, error: '无权执行:仅买家可进行该操作' };
|
|
|
}
|
|
|
|
|
|
if (order.status !== flow.from) {
|
|
|
return { success: false, error: `状态不匹配:当前为${order.status},期望${flow.from}` };
|
|
|
}
|
|
|
|
|
|
const now = new Date();
|
|
|
const updates = { status: flow.to, updateTime: now };
|
|
|
|
|
|
switch (action) {
|
|
|
case 'buyerPay':
|
|
|
updates.paymentTime = now;
|
|
|
break;
|
|
|
case 'sellerConfirmPayment':
|
|
|
updates.sellerConfirmTime = now;
|
|
|
break;
|
|
|
case 'sellerShip':
|
|
|
updates.shipTime = now;
|
|
|
if (event.shipInfo) updates.shipInfo = event.shipInfo;
|
|
|
break;
|
|
|
case 'buyerConfirmReceipt':
|
|
|
updates.finishTime = now;
|
|
|
break;
|
|
|
case 'buyerCancel':
|
|
|
updates.cancelTime = now;
|
|
|
break;
|
|
|
}
|
|
|
|
|
|
const updateRes = await db.collection('T_order')
|
|
|
.where({ _id: orderId, status: flow.from })
|
|
|
.update({ data: updates });
|
|
|
|
|
|
if (!updateRes.stats || updateRes.stats.updated !== 1) {
|
|
|
return { success: false, error: '更新失败或状态已变化,请刷新重试' };
|
|
|
}
|
|
|
|
|
|
// 写入系统聊天消息(买家/卖家互相通知订单状态)
|
|
|
try {
|
|
|
const buyerOpenId = order.buyerOpenId || '';
|
|
|
const sellerOpenId = order.sellerOpenId || '';
|
|
|
if (buyerOpenId && sellerOpenId) {
|
|
|
const sessionKey = [buyerOpenId, sellerOpenId].sort().join('|');
|
|
|
let fromOpenId = '';
|
|
|
let toOpenId = '';
|
|
|
let content = '';
|
|
|
|
|
|
if (action === 'buyerPay') {
|
|
|
fromOpenId = buyerOpenId;
|
|
|
toOpenId = sellerOpenId;
|
|
|
content = `[系统] 买家已付款:订单号 ${order.orderNumber},金额 ¥${order.totalPrice}`;
|
|
|
} else if (action === 'sellerConfirmPayment') {
|
|
|
fromOpenId = sellerOpenId;
|
|
|
toOpenId = buyerOpenId;
|
|
|
content = `[系统] 卖家已确认收款:订单号 ${order.orderNumber}`;
|
|
|
} else if (action === 'sellerShip') {
|
|
|
fromOpenId = sellerOpenId;
|
|
|
toOpenId = buyerOpenId;
|
|
|
const shipText = event.shipInfo ? `,物流:${event.shipInfo}` : '';
|
|
|
content = `[系统] 卖家已发货:订单号 ${order.orderNumber}${shipText}`;
|
|
|
} else if (action === 'buyerConfirmReceipt') {
|
|
|
fromOpenId = buyerOpenId;
|
|
|
toOpenId = sellerOpenId;
|
|
|
content = `[系统] 买家已确认收货:订单号 ${order.orderNumber}`;
|
|
|
} else if (action === 'buyerCancel') {
|
|
|
fromOpenId = buyerOpenId;
|
|
|
toOpenId = sellerOpenId;
|
|
|
content = `[系统] 买家已取消订单:订单号 ${order.orderNumber}`;
|
|
|
}
|
|
|
|
|
|
if (fromOpenId && toOpenId && content) {
|
|
|
const buyerUserId = order.buyerUserId || '';
|
|
|
const sellerUserId = order.sellerUserId || '';
|
|
|
const sessionKeyUser = (buyerUserId && sellerUserId) ? [buyerUserId, sellerUserId].sort().join('|') : '';
|
|
|
|
|
|
let fromUserId = '';
|
|
|
let toUserId = '';
|
|
|
if (action === 'buyerPay' || action === 'buyerConfirmReceipt' || action === 'buyerCancel') {
|
|
|
fromUserId = buyerUserId;
|
|
|
toUserId = sellerUserId;
|
|
|
} else {
|
|
|
// sellerConfirmPayment / sellerShip
|
|
|
fromUserId = sellerUserId;
|
|
|
toUserId = buyerUserId;
|
|
|
}
|
|
|
|
|
|
const chatDoc = {
|
|
|
fromOpenId,
|
|
|
toOpenId,
|
|
|
fromUserId,
|
|
|
toUserId,
|
|
|
sessionKey,
|
|
|
sessionKeyUser,
|
|
|
contentType: 'text',
|
|
|
content,
|
|
|
timestamp: Date.now(),
|
|
|
orderId: orderId,
|
|
|
productId: order.productId || (Array.isArray(order.products) && order.products.length === 1 ? order.products[0].productId : ''),
|
|
|
isSystem: true
|
|
|
};
|
|
|
try {
|
|
|
await db.collection('T_chat').add({ data: chatDoc });
|
|
|
} catch (e) {
|
|
|
// 如果集合不存在,先创建再重试一次
|
|
|
if (String(e.errMsg || '').includes('does not exist') || e.errCode === -502005) {
|
|
|
await createChatCollection();
|
|
|
await db.collection('T_chat').add({ data: chatDoc });
|
|
|
} else {
|
|
|
throw e;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
// 聊天消息写入失败不影响主流程
|
|
|
console.error('写入订单状态聊天消息失败:', e);
|
|
|
}
|
|
|
|
|
|
if (action === 'buyerConfirmReceipt' && order.productId) {
|
|
|
try {
|
|
|
await db.collection('T_product').doc(order.productId).update({
|
|
|
data: { status: '已售', updateTime: now }
|
|
|
});
|
|
|
} catch (e) {
|
|
|
console.error('更新商品为已售失败:', e);
|
|
|
return { success: true, warning: '订单完成成功,但商品状态更新失败', newStatus: flow.to };
|
|
|
}
|
|
|
try {
|
|
|
const pr = await db.collection('T_product').doc(order.productId).get();
|
|
|
const p = pr.data || {};
|
|
|
const name = String(p.tradeLandmarkName || p.tradeLocationName || p.tradeAddress || '').trim();
|
|
|
const lat = Number(p.tradeLocationLat);
|
|
|
const lng = Number(p.tradeLocationLng);
|
|
|
if (name && Number.isFinite(lat) && Number.isFinite(lng)) {
|
|
|
await upsertCampusLandmark({ name, latitude: lat, longitude: lng, address: String(p.tradeAddress || ''), source: 'product', productId: order.productId, selling: false });
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('同步地标为非在售失败:', e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return { success: true, newStatus: flow.to };
|
|
|
} catch (e) {
|
|
|
console.error('安全更新订单状态失败:', e);
|
|
|
return { success: false, error: e.message };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 创建论坛集合
|
|
|
const createForumCollection = async () => {
|
|
|
try {
|
|
|
const collections = await db.listCollections();
|
|
|
const collectionNames = collections.data.map(col => col.name);
|
|
|
|
|
|
const collectionsToCreate = ['T_forum_post', 'T_forum_comment', 'T_forum_like'];
|
|
|
const created = [];
|
|
|
|
|
|
for (const collectionName of collectionsToCreate) {
|
|
|
if (!collectionNames.includes(collectionName)) {
|
|
|
try {
|
|
|
await db.createCollection(collectionName);
|
|
|
created.push(collectionName);
|
|
|
console.log(`${collectionName} 集合创建成功`);
|
|
|
} catch (err) {
|
|
|
if (err.message && !err.message.includes('already exists')) {
|
|
|
console.error(`创建 ${collectionName} 失败:`, err);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
message: created.length > 0 ? `成功创建 ${created.join(', ')} 集合` : "所有集合已存在",
|
|
|
created: created
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('创建论坛集合失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message,
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 计算余弦相似度
|
|
|
const cosineSimilarity = (vecA, vecB) => {
|
|
|
let dotProduct = 0;
|
|
|
let normA = 0;
|
|
|
let normB = 0;
|
|
|
|
|
|
const keys = new Set([...Object.keys(vecA), ...Object.keys(vecB)]);
|
|
|
|
|
|
for (const key of keys) {
|
|
|
const a = vecA[key] || 0;
|
|
|
const b = vecB[key] || 0;
|
|
|
dotProduct += a * b;
|
|
|
normA += a * a;
|
|
|
normB += b * b;
|
|
|
}
|
|
|
|
|
|
if (normA === 0 || normB === 0) {
|
|
|
return 0;
|
|
|
}
|
|
|
|
|
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
|
};
|
|
|
|
|
|
// 基于用户的协同过滤推荐
|
|
|
const userBasedCF = async (openid, limit = 10) => {
|
|
|
try {
|
|
|
// 获取所有用户行为数据
|
|
|
const allBehaviors = await db.collection('T_user_behavior').get();
|
|
|
|
|
|
if (allBehaviors.data.length === 0) {
|
|
|
return [];
|
|
|
}
|
|
|
|
|
|
// 构建用户-商品评分矩阵
|
|
|
const userProductMatrix = {};
|
|
|
allBehaviors.data.forEach(behavior => {
|
|
|
const userId = behavior._openid;
|
|
|
const productId = behavior.productId;
|
|
|
const score = behavior.score * (behavior.count || 1); // 考虑行为次数
|
|
|
|
|
|
if (!userProductMatrix[userId]) {
|
|
|
userProductMatrix[userId] = {};
|
|
|
}
|
|
|
userProductMatrix[userId][productId] = (userProductMatrix[userId][productId] || 0) + score;
|
|
|
});
|
|
|
|
|
|
// 获取当前用户的行为数据
|
|
|
const currentUserProducts = userProductMatrix[openid] || {};
|
|
|
|
|
|
if (Object.keys(currentUserProducts).length === 0) {
|
|
|
return []; // 新用户,没有行为数据
|
|
|
}
|
|
|
|
|
|
// 计算当前用户与其他用户的相似度
|
|
|
const userSimilarities = [];
|
|
|
for (const userId in userProductMatrix) {
|
|
|
if (userId === openid) continue;
|
|
|
|
|
|
const similarity = cosineSimilarity(currentUserProducts, userProductMatrix[userId]);
|
|
|
if (similarity > 0) {
|
|
|
userSimilarities.push({
|
|
|
userId: userId,
|
|
|
similarity: similarity
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 按相似度排序
|
|
|
userSimilarities.sort((a, b) => b.similarity - a.similarity);
|
|
|
|
|
|
// 获取相似用户喜欢的商品(当前用户未交互过的)
|
|
|
const recommendedProducts = {};
|
|
|
const topSimilarUsers = userSimilarities.slice(0, 20); // 取前20个相似用户
|
|
|
|
|
|
for (const similarUser of topSimilarUsers) {
|
|
|
const similarUserProducts = userProductMatrix[similarUser.userId];
|
|
|
for (const productId in similarUserProducts) {
|
|
|
if (!currentUserProducts[productId]) {
|
|
|
// 当前用户未交互过的商品
|
|
|
const score = similarUserProducts[productId] * similarUser.similarity;
|
|
|
recommendedProducts[productId] = (recommendedProducts[productId] || 0) + score;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 转换为数组并排序
|
|
|
const productScores = Object.entries(recommendedProducts)
|
|
|
.map(([productId, score]) => ({ productId, score }))
|
|
|
.sort((a, b) => b.score - a.score)
|
|
|
.slice(0, limit);
|
|
|
|
|
|
return productScores.map(item => item.productId);
|
|
|
} catch (e) {
|
|
|
console.error('基于用户的协同过滤失败:', e);
|
|
|
return [];
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 基于物品的协同过滤推荐
|
|
|
const itemBasedCF = async (openid, limit = 10) => {
|
|
|
try {
|
|
|
// 获取所有用户行为数据
|
|
|
const allBehaviors = await db.collection('T_user_behavior').get();
|
|
|
|
|
|
if (allBehaviors.data.length === 0) {
|
|
|
return [];
|
|
|
}
|
|
|
|
|
|
// 构建商品-用户评分矩阵
|
|
|
const productUserMatrix = {};
|
|
|
allBehaviors.data.forEach(behavior => {
|
|
|
const productId = behavior.productId;
|
|
|
const userId = behavior._openid;
|
|
|
const score = behavior.score * (behavior.count || 1);
|
|
|
|
|
|
if (!productUserMatrix[productId]) {
|
|
|
productUserMatrix[productId] = {};
|
|
|
}
|
|
|
productUserMatrix[productId][userId] = (productUserMatrix[productId][userId] || 0) + score;
|
|
|
});
|
|
|
|
|
|
// 获取当前用户交互过的商品
|
|
|
const currentUserBehaviors = await db.collection('T_user_behavior')
|
|
|
.where({
|
|
|
_openid: openid
|
|
|
})
|
|
|
.get();
|
|
|
|
|
|
if (currentUserBehaviors.data.length === 0) {
|
|
|
return []; // 新用户
|
|
|
}
|
|
|
|
|
|
const currentUserProducts = {};
|
|
|
currentUserBehaviors.data.forEach(behavior => {
|
|
|
currentUserProducts[behavior.productId] = behavior.score * (behavior.count || 1);
|
|
|
});
|
|
|
|
|
|
// 计算当前用户交互过的商品与其他商品的相似度
|
|
|
const productSimilarities = {};
|
|
|
|
|
|
for (const productId in currentUserProducts) {
|
|
|
if (!productUserMatrix[productId]) continue;
|
|
|
|
|
|
const currentProductUsers = productUserMatrix[productId];
|
|
|
|
|
|
// 计算与其他商品的相似度
|
|
|
for (const otherProductId in productUserMatrix) {
|
|
|
if (otherProductId === productId || currentUserProducts[otherProductId]) {
|
|
|
continue; // 跳过自己或已交互过的商品
|
|
|
}
|
|
|
|
|
|
const similarity = cosineSimilarity(currentProductUsers, productUserMatrix[otherProductId]);
|
|
|
if (similarity > 0) {
|
|
|
const weightedScore = currentUserProducts[productId] * similarity;
|
|
|
productSimilarities[otherProductId] = (productSimilarities[otherProductId] || 0) + weightedScore;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 转换为数组并排序
|
|
|
const productScores = Object.entries(productSimilarities)
|
|
|
.map(([productId, score]) => ({ productId, score }))
|
|
|
.sort((a, b) => b.score - a.score)
|
|
|
.slice(0, limit);
|
|
|
|
|
|
return productScores.map(item => item.productId);
|
|
|
} catch (e) {
|
|
|
console.error('基于物品的协同过滤失败:', e);
|
|
|
return [];
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 根据用户兴趣推荐商品(保留原有功能作为备用)
|
|
|
const getRecommendedProductsByInterest = async (userInterests, limit) => {
|
|
|
const mappedInterests = userInterests.map(cat => mapCategoryName(cat));
|
|
|
|
|
|
let queryCondition = {
|
|
|
status: '在售'
|
|
|
};
|
|
|
|
|
|
if (mappedInterests.length > 0) {
|
|
|
queryCondition.productCategory = _.in(mappedInterests);
|
|
|
}
|
|
|
|
|
|
const result = await db.collection("T_product")
|
|
|
.where(queryCondition)
|
|
|
.orderBy('createTime', 'desc')
|
|
|
.limit(limit)
|
|
|
.get();
|
|
|
|
|
|
return result.data.map(item => item._id);
|
|
|
};
|
|
|
|
|
|
// 根据用户兴趣推荐商品(增强版:结合协同过滤)
|
|
|
const getRecommendedProducts = async (event) => {
|
|
|
try {
|
|
|
const wxContext = cloud.getWXContext();
|
|
|
const openid = wxContext.OPENID;
|
|
|
const userInterests = event.interests || [];
|
|
|
const limit = event.limit || 8;
|
|
|
const useCF = event.useCF !== false; // 默认使用协同过滤
|
|
|
|
|
|
console.log('获取推荐商品,openid:', openid, '使用协同过滤:', useCF);
|
|
|
|
|
|
let recommendedProductIds = [];
|
|
|
|
|
|
// 优先使用协同过滤算法
|
|
|
if (useCF) {
|
|
|
try {
|
|
|
// 尝试基于用户的协同过滤
|
|
|
const userCFIds = await userBasedCF(openid, limit);
|
|
|
console.log('基于用户的CF推荐数量:', userCFIds.length);
|
|
|
|
|
|
// 如果基于用户的CF结果不足,尝试基于物品的CF
|
|
|
if (userCFIds.length < limit) {
|
|
|
const itemCFIds = await itemBasedCF(openid, limit - userCFIds.length);
|
|
|
console.log('基于物品的CF推荐数量:', itemCFIds.length);
|
|
|
|
|
|
// 合并结果,去重
|
|
|
const allCFIds = [...new Set([...userCFIds, ...itemCFIds])];
|
|
|
recommendedProductIds = allCFIds.slice(0, limit);
|
|
|
} else {
|
|
|
recommendedProductIds = userCFIds;
|
|
|
}
|
|
|
|
|
|
console.log('协同过滤推荐商品ID:', recommendedProductIds);
|
|
|
} catch (cfError) {
|
|
|
console.error('协同过滤推荐失败,使用兴趣推荐:', cfError);
|
|
|
recommendedProductIds = [];
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 如果协同过滤结果不足,使用兴趣推荐补充
|
|
|
if (recommendedProductIds.length < limit && userInterests.length > 0) {
|
|
|
const interestIds = await getRecommendedProductsByInterest(userInterests, limit - recommendedProductIds.length);
|
|
|
// 去重并合并
|
|
|
const allIds = [...new Set([...recommendedProductIds, ...interestIds])];
|
|
|
recommendedProductIds = allIds.slice(0, limit);
|
|
|
}
|
|
|
|
|
|
// 如果还是没有足够的推荐,获取热门商品
|
|
|
if (recommendedProductIds.length < limit) {
|
|
|
const hotProducts = await db.collection("T_product")
|
|
|
.where({
|
|
|
status: '在售'
|
|
|
})
|
|
|
.orderBy('createTime', 'desc')
|
|
|
.limit(limit - recommendedProductIds.length)
|
|
|
.get();
|
|
|
|
|
|
const hotIds = hotProducts.data.map(item => item._id);
|
|
|
const allIds = [...new Set([...recommendedProductIds, ...hotIds])];
|
|
|
recommendedProductIds = allIds.slice(0, limit);
|
|
|
}
|
|
|
|
|
|
// 根据ID获取商品详细信息
|
|
|
const products = [];
|
|
|
for (const productId of recommendedProductIds) {
|
|
|
try {
|
|
|
const productDoc = await db.collection("T_product").doc(productId).get();
|
|
|
if (productDoc.data && productDoc.data.status === '在售') {
|
|
|
const item = productDoc.data;
|
|
|
products.push({
|
|
|
id: item._id,
|
|
|
name: item.productName || '商品名称',
|
|
|
price: item.salePrice || item.suggestedPrice || item.originalPrice || 0,
|
|
|
image: Array.isArray(item.productImage) ? (item.productImage[0] || '') : (item.productImage || 'https://via.placeholder.com/280x200/4285F4/ffffff?text=商品'),
|
|
|
tag: item.productCategory || '其他',
|
|
|
category: item.productCategory || '其他',
|
|
|
description: item.productDescription || '',
|
|
|
createTime: item.createTime
|
|
|
});
|
|
|
}
|
|
|
} catch (err) {
|
|
|
console.error('获取商品详情失败:', productId, err);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.log('最终推荐商品数量:', products.length);
|
|
|
|
|
|
return {
|
|
|
success: true,
|
|
|
data: products,
|
|
|
count: products.length,
|
|
|
message: products.length === 0 ? '暂无推荐商品' : '推荐商品加载成功',
|
|
|
algorithm: useCF && recommendedProductIds.length > 0 ? '协同过滤' : '兴趣推荐'
|
|
|
};
|
|
|
} catch (e) {
|
|
|
console.error('获取推荐商品失败:', e);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: e.message
|
|
|
};
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 云函数入口函数
|
|
|
exports.main = async (event, context) => {
|
|
|
try {
|
|
|
// 获取操作类型,支持多种参数格式
|
|
|
const type = event.type || event.userInfo?.type || (event.tcbContext && event.tcbContext.type);
|
|
|
|
|
|
console.log('云函数被调用,type:', type);
|
|
|
console.log('event参数:', JSON.stringify(event, null, 2));
|
|
|
|
|
|
if (!type) {
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '缺少操作类型参数(type)',
|
|
|
event: event
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 添加调试日志
|
|
|
console.log('开始执行switch,type值:', type, '类型:', typeof type);
|
|
|
|
|
|
switch (type) {
|
|
|
case "getOpenId":
|
|
|
return await getOpenId();
|
|
|
case "getMiniProgramCode":
|
|
|
return await getMiniProgramCode();
|
|
|
case "createCollection":
|
|
|
return await createCollection();
|
|
|
case "selectRecord":
|
|
|
return await selectRecord();
|
|
|
case "updateRecord":
|
|
|
return await updateRecord(event);
|
|
|
case "insertRecord":
|
|
|
return await insertRecord(event);
|
|
|
case "deleteRecord":
|
|
|
return await deleteRecord(event);
|
|
|
case "queryTUser":
|
|
|
return await queryTUser();
|
|
|
case "addTestUser":
|
|
|
return await addTestUser(event);
|
|
|
case "analyzeProductPrice":
|
|
|
return await analyzeProductPrice(event);
|
|
|
case "getUserByOpenId":
|
|
|
return await getUserByOpenId(event);
|
|
|
case "getProductCategories":
|
|
|
return await getProductCategories();
|
|
|
case "updateUserInterests":
|
|
|
return await updateUserInterests(event);
|
|
|
case "getRecommendedProducts":
|
|
|
console.log('匹配到getRecommendedProducts case');
|
|
|
return await getRecommendedProducts(event);
|
|
|
case "getPublishRecommendations":
|
|
|
// 基于分类供需对比的发布建议(商品/求购)
|
|
|
return await getPublishRecommendations(event);
|
|
|
case "adminPushRecommendations":
|
|
|
// 管理员一键推送建议到指定用户群
|
|
|
return await adminPushRecommendations(event);
|
|
|
case "recordUserBehavior":
|
|
|
return await recordUserBehavior(event);
|
|
|
case "createBehaviorCollection":
|
|
|
return await createBehaviorCollection();
|
|
|
case "createForumCollection":
|
|
|
return await createForumCollection();
|
|
|
case "createNotifyCollection":
|
|
|
return await createNotifyCollection();
|
|
|
case "createChatCollection":
|
|
|
return await createChatCollection();
|
|
|
case "createMessageCollection":
|
|
|
return await createMessageCollection();
|
|
|
case "sendChatMessage":
|
|
|
return await sendChatMessage(event);
|
|
|
case "getChatMessages":
|
|
|
return await getChatMessages(event);
|
|
|
case "listChatSessions":
|
|
|
return await listChatSessions();
|
|
|
case "updateChatReadState":
|
|
|
return await updateChatReadState(event);
|
|
|
case "revokeChatMessage":
|
|
|
return await revokeChatMessage(event);
|
|
|
case "backfillSellerUserId":
|
|
|
// 批量为商品补齐缺失的卖家用户ID(通过 sellerOpenId 关联 T_user)
|
|
|
return await backfillSellerUserId(event);
|
|
|
case "createAddressCollection":
|
|
|
return await createAddressCollection();
|
|
|
case "createCampusLandmarksCollection":
|
|
|
return await createCampusLandmarksCollection();
|
|
|
case "seedDefaultAddress":
|
|
|
return await seedDefaultAddress(event);
|
|
|
case "adminLogin":
|
|
|
console.log('匹配到adminLogin case');
|
|
|
return await adminLogin(event);
|
|
|
case "getAdminStats":
|
|
|
return await getAdminStats();
|
|
|
case "getWantedKeywordCloud":
|
|
|
// 管理端词云:求购关键词聚合
|
|
|
return await getWantedKeywordCloud(event);
|
|
|
case "adminUpdateProduct":
|
|
|
return await adminUpdateProduct(event);
|
|
|
case "adminDeleteProduct":
|
|
|
return await adminDeleteProduct(event);
|
|
|
case "adminGetUsers":
|
|
|
return await adminGetUsers(event);
|
|
|
case "adminUpdateUser":
|
|
|
return await adminUpdateUser(event);
|
|
|
case "updateOrderStatus":
|
|
|
// 安全更新订单状态(带角色和状态校验)
|
|
|
return await updateOrderStatus(event);
|
|
|
case "initCategoryTable":
|
|
|
return await initCategoryTable();
|
|
|
case "cloudReverseGeocode":
|
|
|
return await cloudReverseGeocode(event);
|
|
|
case "cloudGeocode":
|
|
|
return await cloudGeocode(event);
|
|
|
case "fixProductCoords":
|
|
|
// 批量修复商品缺失坐标(地标匹配 + 地址地理编码)
|
|
|
return await fixProductCoords(event);
|
|
|
case "upsertCampusLandmark":
|
|
|
// 上报/同步单条地标
|
|
|
return await upsertCampusLandmark(event);
|
|
|
case "syncProductLandmarks":
|
|
|
// 批量从商品同步地标到 T_campus_landmarks
|
|
|
return await syncProductLandmarks(event);
|
|
|
default:
|
|
|
console.log('未匹配到任何case,type:', type, 'type类型:', typeof type);
|
|
|
return {
|
|
|
success: false,
|
|
|
error: '未知的操作类型: ' + type
|
|
|
};
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('========== 云函数入口函数错误 ==========');
|
|
|
console.error('错误信息:', error.message);
|
|
|
console.error('错误堆栈:', error.stack);
|
|
|
console.error('event:', JSON.stringify(event, null, 2));
|
|
|
|
|
|
const type = event.type || event.userInfo?.type || (event.tcbContext && event.tcbContext.type);
|
|
|
|
|
|
return {
|
|
|
success: false,
|
|
|
error: error.message || '云函数执行失败',
|
|
|
details: error.stack,
|
|
|
type: type
|
|
|
};
|
|
|
}
|
|
|
};
|