聊天修改
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
18631081161 2026-04-02 01:09:02 +08:00
parent 5a04e003ca
commit b359070a0e
26 changed files with 1640 additions and 353 deletions

View File

@ -148,9 +148,15 @@ async function viewChat(row) {
chatMessages.value = []
try {
const res = await request.get('/admin/chat-messages', {
params: { ownerUserId: row.ownerId, runnerUserId: row.runnerId }
})
const params = {}
// ID
if (row.imGroupId) {
params.groupId = row.imGroupId
} else {
//
params.groupId = `order_${row.id}`
}
const res = await request.get('/admin/chat-messages', { params })
chatMessages.value = parseIMMessages(res, row)
} catch (e) {
console.error('拉取聊天记录失败:', e)
@ -160,7 +166,8 @@ async function viewChat(row) {
}
function parseIMMessages(imResponse, orderInfo) {
const msgList = imResponse.MsgList || imResponse.msgList || []
// RspMsgList
const msgList = imResponse.RspMsgList || imResponse.rspMsgList || imResponse.MsgList || imResponse.msgList || []
if (!msgList.length) return []
const ownerImId = `user_${orderInfo.ownerId}`

View File

@ -153,6 +153,13 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/mine/profile",
"style": {
"navigationBarTitleText": "编辑资料",
"navigationStyle": "custom"
}
},
{
"path": "pages/runner/certification",
"style": {

View File

@ -379,6 +379,10 @@ export default {
border: none;
}
.accept-btn::after {
border: none;
}
/* 弹窗样式(复用) */
.modal-mask {
position: fixed;

View File

@ -203,7 +203,6 @@
<script>
import {
getOrderDetail,
getOrderByChatUser,
createPriceChange,
respondPriceChange as respondPriceChangeApi
} from '../../utils/api'
@ -212,7 +211,6 @@
} from '../../stores/user'
import {
initIM,
logoutIM,
onNewMessage,
offNewMessage,
getMessageList,
@ -234,7 +232,7 @@
scrollTop: 0,
showMorePanel: false,
imUserId: '',
targetImUserId: '',
imGroupId: null,
imReady: false,
nextReqMessageID: '',
historyCompleted: false,
@ -316,38 +314,26 @@
userId
} = await initIM()
this.imUserId = userId
if (this.targetImUserIdFromParam) {
this.targetImUserId = this.targetImUserIdFromParam
} else if (this.orderInfo.id) {
const userStore = useUserStore()
this.targetImUserId = this.orderInfo.ownerId === userStore.userId ?
`user_${this.orderInfo.runnerId}` : `user_${this.orderInfo.ownerId}`
// ID
if (this.orderInfo.imGroupId) {
this.imGroupId = this.orderInfo.imGroupId
} else if (this.orderId) {
// ID order_{orderId}
this.imGroupId = `order_${this.orderId}`
}
this.imReady = true
// orderId ID
if (!this.orderId && this.targetImUserId) {
try {
const targetUid = this.targetImUserId.replace('user_', '')
const res = await getOrderByChatUser(targetUid)
if (res && res.found && res.orderId) {
this.orderId = res.orderId
await this.loadOrderInfo()
}
} catch (e) {
console.error('[聊天] 查找关联订单失败:', e)
}
}
onNewMessage((msgList) => {
for (const msg of msgList) {
if (msg.from === this.targetImUserId || msg.from === this.imUserId) {
//
if (msg.conversationID === `GROUP${this.imGroupId}`) {
const formatted = formatIMMessage(msg, this.imUserId)
// ID
if (this.orderId && formatted.orderId && String(formatted.orderId) !== String(this.orderId)) continue
//
if (formatted.type === 'price-change-response') {
const card = this.chatMessages.find(m => m.type === 'price-change' && m.priceChangeId === formatted.priceChangeId)
if (card) card.status = formatted.status
//
this.chatMessages.push({
id: formatted.id,
type: 'system',
@ -359,13 +345,11 @@
}
}
}
this.scrollToBottom()
//
if (this.targetImUserId) setMessageRead(this.targetImUserId)
if (this.imGroupId) setMessageRead(this.imGroupId)
})
await this.loadHistory()
//
if (this.targetImUserId) setMessageRead(this.targetImUserId)
if (this.imGroupId) setMessageRead(this.imGroupId)
} catch (e) {
console.error('[IM] 初始化失败:', e)
uni.showToast({
@ -374,21 +358,17 @@
})
}
},
async loadHistory() {
if (!this.imReady || !this.targetImUserId) return
async loadHistory(scrollToEnd = true) {
if (!this.imReady || !this.imGroupId) return
this.loadingHistory = true
try {
const res = await getMessageList(this.targetImUserId, this.nextReqMessageID)
const res = await getMessageList(this.imGroupId, this.nextReqMessageID)
const allFormatted = res.messageList.map(m => formatIMMessage(m, this.imUserId))
// IDorderId
const filtered = this.orderId
? allFormatted.filter(m => !m.orderId || String(m.orderId) === String(this.orderId))
: allFormatted
//
//
const formatted = []
for (const m of filtered) {
for (const m of allFormatted) {
if (m.type === 'price-change-response') {
const card = filtered.find(c => c.type === 'price-change' && c.priceChangeId === m.priceChangeId)
const card = allFormatted.find(c => c.type === 'price-change' && c.priceChangeId === m.priceChangeId)
if (card) card.status = m.status
formatted.push({ id: m.id, type: 'system', content: `${m.isSelf ? '您' : '对方'}${m.action === 'Accepted' ? '同意' : '拒绝'}${m.changeTypeLabel}改价` })
} else {
@ -398,7 +378,7 @@
this.chatMessages = [...formatted, ...this.chatMessages]
this.nextReqMessageID = res.nextReqMessageID
this.historyCompleted = res.isCompleted
this.scrollToBottom()
if (scrollToEnd) this.scrollToBottom()
} catch (e) {
console.error('[IM] 拉取历史消息失败:', e)
} finally {
@ -407,7 +387,7 @@
},
async loadMoreHistory() {
if (this.historyCompleted || this.loadingHistory) return
await this.loadHistory()
await this.loadHistory(false)
},
async sendMessage() {
if (!this.inputText.trim()) return
@ -421,7 +401,7 @@
const text = this.inputText
this.inputText = ''
try {
const msg = await sendTextMessage(this.targetImUserId, text, this.orderId)
const msg = await sendTextMessage(this.imGroupId, text)
this.chatMessages.push(formatIMMessage(msg, this.imUserId))
this.scrollToBottom()
} catch (e) {
@ -454,7 +434,7 @@
sourceType: ['album', 'camera'],
success: async (res) => {
try {
const msg = await sendImageMessage(this.targetImUserId, res, this.orderId)
const msg = await sendImageMessage(this.imGroupId, res)
this.chatMessages.push(formatIMMessage(msg, this.imUserId))
this.scrollToBottom()
} catch (e) {
@ -523,7 +503,7 @@
this.showPriceChangePopup = false
const changeTypeLabel = this.priceChangeType === 'Commission' ? '跑腿佣金' : '商品总额'
if (this.imReady) {
await sendCustomMessage(this.targetImUserId, {
await sendCustomMessage(this.imGroupId, {
bizType: 'price-change',
priceChangeId: res.id,
changeTypeLabel,
@ -532,7 +512,7 @@
difference: res.difference,
status: res.status,
description: `发起了${changeTypeLabel}改价申请`
}, this.orderId)
})
}
this.chatMessages.push({
id: `pc_${res.id}`,
@ -559,6 +539,11 @@
},
async respondPriceChange(priceChangeId, action) {
try {
console.log('[聊天] 响应改价:', this.orderId, priceChangeId, action)
if (!this.orderId) {
uni.showToast({ title: '订单信息未加载', icon: 'none' })
return
}
const res = await respondPriceChangeApi(this.orderId, priceChangeId, {
action
})
@ -570,18 +555,18 @@
// IM
if (this.imReady) {
//
await sendCustomMessage(this.targetImUserId, {
//
await sendCustomMessage(this.imGroupId, {
bizType: 'price-change-response',
priceChangeId: priceChangeId,
action: action,
status: res.status,
changeTypeLabel,
description: `${actionLabel}${changeTypeLabel}改价申请`
}, this.orderId)
//
})
//
try {
const imMsg = await sendTextMessage(this.targetImUserId, `[系统提示] 单主已${actionLabel}${changeTypeLabel}改价`, this.orderId)
const imMsg = await sendTextMessage(this.imGroupId, `[系统提示] 单主已${actionLabel}${changeTypeLabel}改价`)
this.chatMessages.push(formatIMMessage(imMsg, this.imUserId))
} catch (ex) {}
} else {
@ -597,10 +582,10 @@
if (res.paymentParams) {
try {
await this.wxPay(res.paymentParams)
// IM
if (this.imReady && this.targetImUserId) {
// IM
if (this.imReady && this.imGroupId) {
try {
const imMsg = await sendTextMessage(this.targetImUserId, '[系统提示] 单主已完成补缴支付', this.orderId)
const imMsg = await sendTextMessage(this.imGroupId, '[系统提示] 单主已完成补缴支付')
this.chatMessages.push(formatIMMessage(imMsg, this.imUserId))
} catch (ex) {}
}
@ -619,13 +604,13 @@
const refundText = res.refundSuccess
? `改价退款¥${Math.abs(res.difference).toFixed(2)}已原路返回`
: '退款处理中,请稍后查看'
// IM
if (this.imReady && this.targetImUserId) {
// IM
if (this.imReady && this.imGroupId) {
try {
await sendCustomMessage(this.targetImUserId, {
await sendCustomMessage(this.imGroupId, {
bizType: 'order-status',
description: refundText
}, this.orderId)
})
} catch (ex) {}
}
this.chatMessages.push({ id: `sys_r_${Date.now()}`, type: 'system', content: refundText })
@ -665,26 +650,18 @@
const msg = statusMessages[key]
if (!msg) return
//
const lastMsg = [...this.chatMessages].reverse().find(m => m.type === 'system')
if (lastMsg && lastMsg.content === msg) return
//
const isDuplicate = this.chatMessages.some(m =>
(m.type === 'system' && m.content === msg) ||
(m.type === 'text' && m.content && m.content.includes(msg))
)
if (isDuplicate) return
// IM
if (this.imReady && this.targetImUserId) {
try {
await sendCustomMessage(this.targetImUserId, {
bizType: 'order-status',
action: key,
description: msg
}, this.orderId)
} catch (e) {}
}
this.chatMessages.push({
id: `sys_status_${Date.now()}`,
type: 'system',
content: msg
})
this.scrollToBottom()
},
onCompleteOrder() {
this.showMorePanel = false
@ -753,9 +730,9 @@
await this.wxPay(this.pendingPayment)
uni.showToast({ title: '补缴成功', icon: 'success' })
//
if (this.imReady && this.targetImUserId) {
if (this.imReady && this.imGroupId) {
try {
const imMsg = await sendTextMessage(this.targetImUserId, '[系统提示] 单主已完成补缴支付', this.orderId)
const imMsg = await sendTextMessage(this.imGroupId, '[系统提示] 单主已完成补缴支付')
this.chatMessages.push(formatIMMessage(imMsg, this.imUserId))
this.scrollToBottom()
} catch (ex) {}

View File

@ -47,9 +47,16 @@
<!-- 聊天记录用户列表 -->
<view class="section-title" v-if="chatList.length > 0">聊天记录</view>
<view
class="chat-item"
class="chat-item-wrap"
v-for="item in chatList"
:key="item.orderId"
>
<view
class="chat-item"
:style="{ transform: `translateX(${item.slideX || 0}px)` }"
@touchstart="onTouchStart($event, item)"
@touchmove="onTouchMove($event, item)"
@touchend="onTouchEnd(item)"
@click="goChat(item)"
>
<view class="avatar-wrap">
@ -69,6 +76,10 @@
<text>{{ getStatusLabel(item.status) }}</text>
</view>
</view>
<view class="delete-btn" @click="onDeleteChat(item)">
<text>删除</text>
</view>
</view>
<!-- 空状态 -->
<view v-if="chatList.length === 0" class="empty-chat">
@ -124,16 +135,16 @@ export default {
try {
const list = await getChatOrderList()
this.chatList = (list || []).map(item => ({ ...item, imUnread: 0 }))
// IM
// IM
try {
await initIM()
const convList = await getConversationList()
const unreadMap = {}
convList.forEach(c => { unreadMap[c.targetUserId] = c.unreadCount || 0 })
convList.forEach(c => { unreadMap[c.groupId] = c.unreadCount || 0 })
let totalImUnread = 0
this.chatList.forEach(item => {
const imUserId = `user_${item.targetUserId}`
item.imUnread = unreadMap[imUserId] || 0
const groupId = item.imGroupId || `order_${item.orderId}`
item.imUnread = unreadMap[groupId] || 0
totalImUnread += item.imUnread
})
// tabBar badge + IM
@ -202,7 +213,7 @@ export default {
/** 格式化时间显示 */
formatTime(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const date = new Date(typeof dateStr === 'string' && !dateStr.endsWith('Z') ? dateStr + 'Z' : dateStr)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
const pad = (n) => String(n).padStart(2, '0')
@ -210,6 +221,39 @@ export default {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
}
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
},
/** 触摸开始 */
onTouchStart(e, item) {
item._startX = e.touches[0].clientX
item._startY = e.touches[0].clientY
item._moving = false
},
/** 触摸移动 */
onTouchMove(e, item) {
const dx = e.touches[0].clientX - item._startX
const dy = e.touches[0].clientY - item._startY
//
if (Math.abs(dx) < Math.abs(dy)) return
item._moving = true
//
const x = Math.max(-70, Math.min(0, dx + (item._prevX || 0)))
item.slideX = x
},
/** 触摸结束 */
onTouchEnd(item) {
//
if (item.slideX < -35) {
item.slideX = -70
item._prevX = -70
} else {
item.slideX = 0
item._prevX = 0
}
},
/** 删除聊天记录 */
onDeleteChat(item) {
this.chatList = this.chatList.filter(c => c.orderId !== item.orderId)
}
}
}
@ -328,13 +372,40 @@ export default {
margin: 30rpx 0 16rpx;
}
.chat-item-wrap {
position: relative;
overflow: hidden;
margin-bottom: 16rpx;
border-radius: 16rpx;
}
.chat-item {
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx 30rpx;
margin-bottom: 16rpx;
transition: transform 0.2s;
position: relative;
z-index: 1;
}
.delete-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 70px;
background: #ff4d4f;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0 16rpx 16rpx 0;
}
.delete-btn text {
color: #fff;
font-size: 28rpx;
}
.chat-avatar {

View File

@ -86,28 +86,8 @@
/>
</view>
<view class="form-item">
<text class="form-label">收款方式</text>
<view class="payment-options">
<view
class="payment-option"
:class="{ active: withdrawForm.paymentMethod === 'WeChat' }"
@click="withdrawForm.paymentMethod = 'WeChat'"
><text>微信</text></view>
<view
class="payment-option"
:class="{ active: withdrawForm.paymentMethod === 'Alipay' }"
@click="withdrawForm.paymentMethod = 'Alipay'"
><text>支付宝</text></view>
</view>
</view>
<view class="form-item">
<text class="form-label">收款二维码</text>
<view class="upload-area" @click="chooseQrImage">
<image v-if="withdrawForm.qrImage" class="qr-preview" :src="withdrawForm.qrImage" mode="aspectFit"></image>
<text v-else class="upload-placeholder">点击上传</text>
</view>
<view class="withdraw-tip">
<text>提现将自动转入您的微信零钱</text>
</view>
<view class="modal-actions">
@ -260,24 +240,12 @@ export default {
return
}
if (!this.withdrawForm.qrImage) {
uni.showToast({ title: '请上传收款二维码', icon: 'none' })
return
}
this.withdrawSubmitting = true
try {
//
let qrCodeImage = this.withdrawForm.qrImageUrl
if (this.withdrawForm.qrImage && !qrCodeImage) {
const uploadRes = await uploadFile(this.withdrawForm.qrImage)
qrCodeImage = uploadRes.url
}
await applyWithdraw({
amount,
paymentMethod: this.withdrawForm.paymentMethod,
qrCodeImage
paymentMethod: 'WeChat',
qrCodeImage: ''
})
uni.showToast({ title: '提现申请已提交', icon: 'success' })
@ -570,6 +538,16 @@ export default {
font-size: 28rpx;
}
.withdraw-tip {
text-align: center;
padding: 16rpx 0;
}
.withdraw-tip text {
font-size: 24rpx;
color: #999;
}
.payment-options {
display: flex;
gap: 20rpx;

View File

@ -147,6 +147,8 @@ export default {
onUserClick() {
if (!this.isLoggedIn) {
uni.navigateTo({ url: '/pages/login/login' })
} else {
uni.navigateTo({ url: '/pages/mine/profile' })
}
},
goMyOrders(status) {

View File

@ -0,0 +1,164 @@
<template>
<view class="profile-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="nav-back" @click="goBack">
<image class="back-icon" src="/static/ic_back.png" mode="aspectFit"></image>
</view>
<text class="navbar-title">编辑资料</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 头像 -->
<view class="profile-section">
<view class="profile-item">
<text class="item-label">头像</text>
<view class="item-right">
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image class="avatar-preview" :src="form.avatarUrl || '/static/logo.png'" mode="aspectFill"></image>
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
</button>
</view>
</view>
<view class="profile-item">
<text class="item-label">昵称</text>
<view class="item-right">
<input class="nickname-input" type="nickname" v-model="form.nickname" placeholder="请输入昵称" maxlength="20" @blur="onNicknameBlur" />
</view>
</view>
</view>
<!-- 保存按钮 -->
<view class="save-section">
<button class="save-btn" @click="onSave" :loading="saving" :disabled="saving">保存</button>
</view>
</view>
</template>
<script>
import { updateProfile } from '../../utils/api'
import { uploadFile } from '../../utils/request'
import { useUserStore } from '../../stores/user'
export default {
data() {
return {
statusBarHeight: 0,
form: {
nickname: '',
avatarUrl: ''
},
saving: false
}
},
onLoad() {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 0
const userStore = useUserStore()
this.form.nickname = userStore.userInfo.nickname || ''
this.form.avatarUrl = userStore.userInfo.avatarUrl || ''
},
methods: {
goBack() { uni.navigateBack() },
/** 微信选择头像回调 */
async onChooseAvatar(e) {
const tempUrl = e.detail.avatarUrl
if (!tempUrl) return
try {
uni.showLoading({ title: '上传中...' })
const uploadRes = await uploadFile(tempUrl)
this.form.avatarUrl = uploadRes.url
uni.hideLoading()
} catch (err) {
uni.hideLoading()
//
this.form.avatarUrl = tempUrl
}
},
/** 昵称输入框失焦(微信昵称选择后触发) */
onNicknameBlur(e) {
if (e.detail.value) {
this.form.nickname = e.detail.value
}
},
/** 保存 */
async onSave() {
if (!this.form.nickname.trim()) {
uni.showToast({ title: '请输入昵称', icon: 'none' })
return
}
this.saving = true
try {
const res = await updateProfile({
nickname: this.form.nickname.trim(),
avatarUrl: this.form.avatarUrl
})
//
const userStore = useUserStore()
const info = { ...userStore.userInfo, nickname: res.nickname, avatarUrl: res.avatarUrl }
userStore.setLoginInfo(userStore.token, info)
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 1000)
} catch (e) {
// request
} finally {
this.saving = false
}
}
}
}
</script>
<style scoped>
.profile-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.custom-navbar { position: fixed; top: 0; left: 0; width: 100%; z-index: 999; background: #FFB700; }
.navbar-content { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 20rpx; }
.nav-back { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.back-icon { width: 40rpx; height: 40rpx; }
.navbar-title { font-size: 34rpx; font-weight: bold; color: #363636; }
.nav-placeholder { width: 60rpx; }
.profile-section {
margin: 20rpx 24rpx;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.profile-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.profile-item:last-child { border-bottom: none; }
.item-label { font-size: 30rpx; color: #333; flex-shrink: 0; }
.item-right { display: flex; align-items: center; flex: 1; justify-content: flex-end; }
.avatar-preview { width: 100rpx; height: 100rpx; border-radius: 50%; margin-right: 16rpx; }
.avatar-btn {
display: flex; align-items: center; background: none; border: none;
padding: 0; margin: 0; line-height: normal;
}
.avatar-btn::after { border: none; }
.arrow-icon { width: 28rpx; height: 28rpx; }
.nickname-input { text-align: right; font-size: 30rpx; color: #333; flex: 1; }
.wx-tip { font-size: 24rpx; color: #999; margin-right: 12rpx; }
.save-section { padding: 40rpx 24rpx; }
.save-btn {
width: 100%; height: 88rpx; line-height: 88rpx;
background: #FAD146; color: #fff; font-size: 32rpx;
font-weight: bold; border-radius: 44rpx; border: none;
}
.save-btn::after { border: none; }
.save-btn[disabled] { opacity: 0.6; }
</style>

View File

@ -60,7 +60,7 @@
<text class="card-item-name">门店数量{{ order.shopCount || 0 }}</text>
<view class="card-commission">
<text class="commission-label">跑腿费</text>
<text class="commission-value">{{ order.commission }}</text>
<text class="commission-value">{{ formatMoney(order.commission) }}</text>
</view>
</view>
<!-- 美食数量 -->
@ -92,7 +92,7 @@
<text class="card-item-name">{{ getItemTitle(order) }}</text>
<view class="card-commission">
<text class="commission-label">跑腿费</text>
<text class="commission-value">{{ order.commission }}</text>
<text class="commission-value">{{ formatMoney(order.commission) }}</text>
</view>
</view>
@ -293,7 +293,11 @@
getHelpTotalAmount(order) {
const commission = parseFloat(order.commission) || 0
const goodsAmount = parseFloat(order.goodsAmount) || 0
return (commission + goodsAmount).toFixed(1)
return (commission + goodsAmount).toFixed(2)
},
/** 格式化金额为2位小数 */
formatMoney(val) {
return (parseFloat(val) || 0).toFixed(2)
},
/** 获取卡片标题 */
getItemTitle(order) {

View File

@ -169,12 +169,12 @@ export default {
// IM
try {
await initIM()
const targetImUserId = `user_${this.orderInfo.ownerId}`
await sendCustomMessage(targetImUserId, {
const groupId = this.orderInfo.imGroupId || `order_${this.orderId}`
await sendCustomMessage(groupId, {
bizType: 'order-status',
action: 'InProgress→WaitConfirm',
description: '跑腿已提交完成,请在订单详情中确认'
}, this.orderId)
})
} catch (ex) {}
uni.showToast({ title: '已提交完成', icon: 'success' })

View File

@ -235,6 +235,7 @@
<script>
import { getOrderDetail, cancelOrder, submitReview, confirmOrder, rejectOrder } from '../../utils/api'
import { useUserStore } from '../../stores/user'
import { initIM, sendCustomMessage } from '../../utils/im'
export default {
data() {
@ -378,10 +379,17 @@ export default {
try {
await confirmOrder(this.orderId)
uni.showToast({ title: '订单已完成', icon: 'success' })
//
try {
await initIM()
const groupId = this.order.imGroupId || `order_${this.orderId}`
await sendCustomMessage(groupId, {
bizType: 'order-status',
description: '订单已完成'
})
} catch (ex) {}
this.loadDetail()
} catch (e) {
// request
}
} catch (e) {}
}
})
},
@ -396,10 +404,17 @@ export default {
try {
await rejectOrder(this.orderId)
uni.showToast({ title: '已拒绝,订单继续进行', icon: 'none' })
//
try {
await initIM()
const groupId2 = this.order.imGroupId || `order_${this.orderId}`
await sendCustomMessage(groupId2, {
bizType: 'order-status',
description: '单主已拒绝完成,订单继续进行'
})
} catch (ex) {}
this.loadDetail()
} catch (e) {
// request
}
} catch (e) {}
}
})
},

View File

@ -22,6 +22,11 @@ export function wxLogin(data) {
return request({ url: '/api/auth/wx-login', method: 'POST', data })
}
/** 更新用户信息 */
export function updateProfile(data) {
return request({ url: '/api/user/profile', method: 'PUT', data })
}
// ==================== Banner ====================
/** 获取已启用的 Banner 列表 */

View File

@ -1,6 +1,6 @@
/**
* 腾讯 IM SDK 封装
* 负责初始化登录收发消息
* 腾讯 IM SDK 封装群聊模式
* 每个订单一个群消息按订单隔离
*/
import TencentCloudChat from '@tencentcloud/chat'
import TIMUploadPlugin from 'tim-upload-plugin'
@ -10,131 +10,72 @@ let chat = null
let isReady = false
let onMessageCallback = null
/**
* 获取 IM 实例单例
*/
/** 获取 IM 实例 */
export function getChatInstance() {
return chat
}
/**
* 初始化并登录 IM
* @returns {Promise<{sdkAppId, userId}>}
*/
/** 初始化并登录 IM */
export async function initIM() {
// 从后端获取 UserSig
const res = await request({ url: '/api/im/usersig' })
const { sdkAppId, userId, userSig } = res
// 创建 SDK 实例
if (!chat) {
chat = TencentCloudChat.create({ SDKAppID: sdkAppId })
// 注册上传插件(发送图片/文件必需)
chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin })
chat.setLogLevel(1) // Release 级别日志
chat.setLogLevel(1)
// 监听 SDK 就绪
chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
console.log('[IM] SDK 就绪')
isReady = true
})
// 监听新消息
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, (event) => {
const msgList = event.data
if (onMessageCallback) {
onMessageCallback(msgList)
}
if (onMessageCallback) onMessageCallback(event.data)
})
// 监听被踢下线
chat.on(TencentCloudChat.EVENT.KICKED_OUT, () => {
console.log('[IM] 被踢下线')
isReady = false
})
}
// 登录
await chat.login({ userID: userId, userSig })
console.log('[IM] 登录成功:', userId)
// 同步用户资料(昵称+头像)到 IM
try {
const userInfo = JSON.parse(uni.getStorageSync('userInfo') || '{}')
if (userInfo.nickname || userInfo.avatarUrl) {
const profileData = {}
if (userInfo.nickname) profileData.nick = userInfo.nickname
if (userInfo.avatarUrl) profileData.avatar = userInfo.avatarUrl
await chat.updateMyProfile(profileData)
console.log('[IM] 用户资料已同步')
}
} catch (e) {
console.warn('[IM] 同步用户资料失败:', e)
}
return { sdkAppId, userId }
}
/**
* 登出 IM
*/
/** 登出 IM */
export async function logoutIM() {
if (chat) {
await chat.logout()
isReady = false
console.log('[IM] 已登出')
}
}
/**
* 设置新消息回调
* @param {Function} callback - 接收消息列表的回调
*/
/** 设置新消息回调 */
export function onNewMessage(callback) {
onMessageCallback = callback
}
/**
* 移除新消息回调
*/
/** 移除新消息回调 */
export function offNewMessage() {
onMessageCallback = null
}
/**
* 获取会话 IDC2C 单聊
* @param {string} targetUserId - 对方用户 ID user_123
*/
export function getConversationId(targetUserId) {
return `C2C${targetUserId}`
/** 获取群会话 ID */
export function getGroupConversationId(groupId) {
return `GROUP${groupId}`
}
/**
* 标记会话为已读
* @param {string} targetUserId - 对方用户 ID
*/
export async function setMessageRead(targetUserId) {
if (!chat || !isReady) return
try {
const conversationID = getConversationId(targetUserId)
await chat.setMessageRead({ conversationID })
} catch (e) {
console.error('[IM] 标记已读失败:', e)
}
}
/**
* 获取会话列表用于消息页展示聊天记录
* @returns {Promise<Array>} 会话列表
*/
/** 获取会话列表(群聊模式) */
export async function getConversationList() {
if (!chat || !isReady) return []
try {
const res = await chat.getConversationList()
const list = res.data.conversationList || []
// 只返回 C2C 单聊会话
return list
.filter(c => c.type === TencentCloudChat.TYPES.CONV_C2C)
.filter(c => c.type === TencentCloudChat.TYPES.CONV_GROUP)
.map(c => {
const lastMsg = c.lastMessage
let lastMessageText = ''
@ -144,16 +85,14 @@ export async function getConversationList() {
} else if (lastMsg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
lastMessageText = '[图片]'
} else if (lastMsg.type === TencentCloudChat.TYPES.MSG_CUSTOM) {
lastMessageText = lastMsg.payload?.description || '[自定义消息]'
lastMessageText = lastMsg.payload?.description || '[消息]'
} else {
lastMessageText = '[消息]'
}
}
return {
conversationID: c.conversationID,
targetUserId: c.userProfile?.userID || c.conversationID.replace('C2C', ''),
nickname: c.userProfile?.nick || c.conversationID.replace('C2C', ''),
avatarUrl: c.userProfile?.avatar || '',
groupId: c.groupProfile?.groupID || c.conversationID.replace('GROUP', ''),
lastMessage: lastMessageText,
lastMessageTime: lastMsg ? lastMsg.lastTime * 1000 : 0,
unreadCount: c.unreadCount || 0
@ -166,14 +105,9 @@ export async function getConversationList() {
}
}
/**
* 拉取历史消息
* @param {string} targetUserId - 对方用户 ID
* @param {string} [nextReqMessageID] - 分页标记
* @returns {Promise<{messageList, isCompleted, nextReqMessageID}>}
*/
export async function getMessageList(targetUserId, nextReqMessageID) {
const conversationID = getConversationId(targetUserId)
/** 拉取群历史消息 */
export async function getMessageList(groupId, nextReqMessageID) {
const conversationID = getGroupConversationId(groupId)
const res = await chat.getMessageList({
conversationID,
nextReqMessageID,
@ -186,173 +120,120 @@ export async function getMessageList(targetUserId, nextReqMessageID) {
}
}
/**
* 发送文本消息
* @param {string} targetUserId - 对方用户 ID
* @param {string} text - 文本内容
* @param {string|number} [orderId] - 关联订单ID
*/
export async function sendTextMessage(targetUserId, text, orderId) {
/** 标记群会话已读 */
export async function setMessageRead(groupId) {
if (!chat || !isReady) return
try {
const conversationID = getGroupConversationId(groupId)
await chat.setMessageRead({ conversationID })
} catch (e) {
console.error('[IM] 标记已读失败:', e)
}
}
/** 发送群文本消息 */
export async function sendTextMessage(groupId, text) {
const message = chat.createTextMessage({
to: targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
to: groupId,
conversationType: TencentCloudChat.TYPES.CONV_GROUP,
payload: { text }
})
if (orderId) message.cloudCustomData = JSON.stringify({ orderId: String(orderId) })
const res = await chat.sendMessage(message)
return res.data.message
}
/**
* 发送图片消息
* @param {string} targetUserId - 对方用户 ID
* @param {Object} fileRes - uni.chooseImage success 回调结果
* @param {string|number} [orderId] - 关联订单ID
*/
export async function sendImageMessage(targetUserId, fileRes, orderId) {
/** 发送群图片消息 */
export async function sendImageMessage(groupId, fileRes) {
const message = chat.createImageMessage({
to: targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
to: groupId,
conversationType: TencentCloudChat.TYPES.CONV_GROUP,
payload: { file: fileRes }
})
if (orderId) message.cloudCustomData = JSON.stringify({ orderId: String(orderId) })
const res = await chat.sendMessage(message)
return res.data.message
}
/**
* 发送自定义消息改价申请等
* @param {string} targetUserId - 对方用户 ID
* @param {Object} customData - 自定义数据
* @param {string|number} [orderId] - 关联订单ID
*/
export async function sendCustomMessage(targetUserId, customData, orderId) {
/** 发送群自定义消息 */
export async function sendCustomMessage(groupId, customData) {
const message = chat.createCustomMessage({
to: targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
to: groupId,
conversationType: TencentCloudChat.TYPES.CONV_GROUP,
payload: {
data: JSON.stringify(customData),
description: customData.description || '',
extension: customData.extension || ''
}
})
if (orderId) message.cloudCustomData = JSON.stringify({ orderId: String(orderId) })
const res = await chat.sendMessage(message)
return res.data.message
}
/**
* IM 消息转换为聊天页展示格式
* @param {Object} msg - IM SDK 消息对象
* @param {string} currentImUserId - 当前用户 IM ID
* @returns {Object} 聊天页消息格式
*/
/** 将 IM 消息转换为聊天页展示格式 */
export function formatIMMessage(msg, currentImUserId) {
const isSelf = msg.from === currentImUserId
const avatar = msg.avatar || '/static/logo.png'
// 提取消息关联的订单ID
let msgOrderId = null
if (msg.cloudCustomData) {
try {
const cd = JSON.parse(msg.cloudCustomData)
msgOrderId = cd.orderId || null
} catch (e) {}
}
// 文本消息
if (msg.type === TencentCloudChat.TYPES.MSG_TEXT) {
return {
id: msg.ID,
type: 'text',
content: msg.payload.text,
isSelf,
avatar,
time: msg.time * 1000,
orderId: msgOrderId
id: msg.ID, type: 'text', content: msg.payload.text,
isSelf, avatar, time: msg.time * 1000
}
}
// 图片消息
if (msg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
const imgArr = msg.payload.imageInfoArray || []
// [0]原图 [1]大图 [2]缩略图,显示用大图,预览用原图
const displayUrl = imgArr[1]?.url || imgArr[0]?.url || ''
const originUrl = imgArr[0]?.url || imgArr[1]?.url || ''
return {
id: msg.ID,
type: 'image',
content: displayUrl,
originUrl: originUrl,
isSelf,
avatar,
time: msg.time * 1000,
orderId: msgOrderId
id: msg.ID, type: 'image', content: displayUrl, originUrl,
isSelf, avatar, time: msg.time * 1000
}
}
// 自定义消息(改价、订单状态等)
// 自定义消息
if (msg.type === TencentCloudChat.TYPES.MSG_CUSTOM) {
try {
const data = JSON.parse(msg.payload.data)
if (data.bizType === 'price-change') {
return {
id: msg.ID,
type: 'price-change',
isSelf,
id: msg.ID, type: 'price-change', isSelf,
priceChangeId: data.priceChangeId,
changeTypeLabel: data.changeTypeLabel,
originalPrice: data.originalPrice,
newPrice: data.newPrice,
difference: data.difference,
status: data.status,
time: msg.time * 1000,
orderId: msgOrderId
time: msg.time * 1000
}
}
if (data.bizType === 'price-change-response') {
return {
id: msg.ID,
type: 'price-change-response',
id: msg.ID, type: 'price-change-response',
priceChangeId: data.priceChangeId,
action: data.action,
status: data.status,
action: data.action, status: data.status,
changeTypeLabel: data.changeTypeLabel,
content: data.description || '',
isSelf,
time: msg.time * 1000,
orderId: msgOrderId
isSelf, time: msg.time * 1000
}
}
if (data.bizType === 'order-status') {
return {
id: msg.ID,
type: 'system',
id: msg.ID, type: 'system',
content: data.description || '[订单状态变更]',
time: msg.time * 1000,
orderId: msgOrderId
time: msg.time * 1000
}
}
} catch (e) {}
return {
id: msg.ID,
type: 'text',
content: msg.payload.description || '[自定义消息]',
isSelf,
avatar,
time: msg.time * 1000,
orderId: msgOrderId
id: msg.ID, type: 'text',
content: msg.payload.description || '[消息]',
isSelf, avatar, time: msg.time * 1000
}
}
// 其他类型
return {
id: msg.ID,
type: 'text',
content: '[暂不支持的消息类型]',
isSelf,
avatar,
time: msg.time * 1000,
orderId: msgOrderId
id: msg.ID, type: 'text', content: '[暂不支持的消息类型]',
isSelf, avatar, time: msg.time * 1000
}
}

View File

@ -66,9 +66,17 @@ function request(options) {
return
}
// 未登录或 token 过期,静默清除凭证,不自动跳转
// 未登录或 token 过期
if (statusCode === 401) {
// 清除过期的凭证
clearAuth()
// 非静默请求时提示用户重新登录
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const isTabPage = ['pages/index/index', 'pages/order-hall/order-hall', 'pages/message/message', 'pages/mine/mine'].includes(currentPage?.route)
if (!isTabPage) {
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
}
reject(new Error('未登录或登录已过期'))
return
}
@ -114,7 +122,6 @@ function uploadFile(filePath, url = '/api/upload/image') {
header,
success: (res) => {
if (res.statusCode === 401) {
clearAuth()
reject(new Error('未登录或登录已过期'))
return
}

View File

@ -478,15 +478,25 @@ public static class AdminEndpoints
return Results.BadRequest(new { code = 400, message = "请填写取消原因" });
// 更新订单状态
var wasPending = order.Status == OrderStatus.Pending || order.Status == OrderStatus.InProgress
|| order.Status == OrderStatus.WaitConfirm || order.Status == OrderStatus.Appealing;
order.Status = OrderStatus.Cancelled;
await db.SaveChangesAsync();
// 发起微信退款(原路返回)
// 只对已支付的订单发起退款Unpaid 状态无需退款)
var refundResult = false;
if (wasPending)
{
var totalFen = (int)(order.TotalAmount * 100);
var refundNo = $"R{order.OrderNo}";
var refundResult = await wxPay.Refund(order.OrderNo, refundNo, totalFen, totalFen, request.Reason);
refundResult = await wxPay.Refund(order.OrderNo, refundNo, totalFen, totalFen, request.Reason);
Console.WriteLine($"[管理端] 取消订单 {order.OrderNo},原因:{request.Reason},退款:{(refundResult ? "" : "")}");
}
else
{
Console.WriteLine($"[管理端] 取消订单 {order.OrderNo}(未支付,无需退款),原因:{request.Reason}");
refundResult = true; // 未支付不需要退款,视为成功
}
return Results.Ok(new
{
@ -626,16 +636,26 @@ public static class AdminEndpoints
}).RequireAuthorization("AdminOnly");
// 管理端拉取聊天记录
app.MapGet("/api/admin/chat-messages", async (int ownerUserId, int runnerUserId, TencentIMService imService) =>
app.MapGet("/api/admin/chat-messages", async (string? groupId, int? ownerUserId, int? runnerUserId, TencentIMService imService) =>
{
try
{
// 优先用群ID拉取群聊模式
if (!string.IsNullOrEmpty(groupId))
{
var result = await imService.GetGroupMessagesAsync(groupId);
return Results.Ok(result);
}
// 兼容旧数据用C2C拉取
if (ownerUserId.HasValue && runnerUserId.HasValue)
{
var fromImId = $"user_{ownerUserId}";
var toImId = $"user_{runnerUserId}";
try
{
var result = await imService.GetRoamMessagesAsync(fromImId, toImId);
return Results.Ok(result);
}
return Results.BadRequest(new { code = 400, message = "请提供 groupId 或 ownerUserId+runnerUserId" });
}
catch (Exception ex)
{
return Results.BadRequest(new { code = 400, message = $"拉取聊天记录失败: {ex.Message}" });

View File

@ -133,6 +133,34 @@ public static class AuthEndpoints
app.MapGet("/api/protected", () => Results.Ok("ok"))
.RequireAuthorization();
// 更新用户信息(昵称、头像)
app.MapPut("/api/user/profile", async (UpdateProfileRequest request, HttpContext httpContext, AppDbContext db) =>
{
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (userIdClaim == null) return Results.Unauthorized();
var userId = int.Parse(userIdClaim.Value);
var user = await db.Users.FindAsync(userId);
if (user == null) return Results.NotFound(new { code = 404, message = "用户不存在" });
if (!string.IsNullOrWhiteSpace(request.Nickname))
user.Nickname = request.Nickname.Trim();
if (!string.IsNullOrWhiteSpace(request.AvatarUrl))
user.AvatarUrl = request.AvatarUrl.Trim();
await db.SaveChangesAsync();
return Results.Ok(new UserInfo
{
Id = user.Id,
Uid = user.Uid,
Phone = user.Phone,
Nickname = user.Nickname,
AvatarUrl = user.AvatarUrl,
Role = user.Role.ToString()
});
}).RequireAuthorization();
// 管理员账号密码登录接口
app.MapPost("/api/admin/auth/login", async (
AdminLoginRequest request,

View File

@ -2,6 +2,7 @@ using CampusErrand.Data;
using CampusErrand.Models;
using CampusErrand.Models.Dtos;
using CampusErrand.Helpers;
using CampusErrand.Services;
using Microsoft.EntityFrameworkCore;
namespace CampusErrand.Endpoints;
@ -106,13 +107,10 @@ public static class EarningEndpoints
if (request.Amount != Math.Round(request.Amount, 2))
return Results.BadRequest(new { code = 400, message = "请输入正确的提现金额" });
// 收款方式校验
if (!Enum.TryParse<PaymentMethod>(request.PaymentMethod, true, out var paymentMethod) || !Enum.IsDefined(paymentMethod))
return Results.BadRequest(new { code = 400, message = "收款方式不合法" });
// 收款二维码校验
if (string.IsNullOrWhiteSpace(request.QrCodeImage))
return Results.BadRequest(new { code = 400, message = "收款二维码不能为空" });
// 收款方式默认微信零钱
var paymentMethod = PaymentMethod.WeChat;
if (!string.IsNullOrEmpty(request.PaymentMethod))
Enum.TryParse<PaymentMethod>(request.PaymentMethod, true, out paymentMethod);
// 先执行冻结解冻逻辑
await BusinessHelpers.UnfreezeEarnings(db);
@ -220,7 +218,7 @@ public static class EarningEndpoints
}).RequireAuthorization("AdminOnly");
// 管理端审核提现(通过/拒绝)
app.MapPut("/api/admin/withdrawals/{id}", async (int id, AdminWithdrawalRequest request, AppDbContext db) =>
app.MapPut("/api/admin/withdrawals/{id}", async (int id, AdminWithdrawalRequest request, AppDbContext db, WxPayService wxPay) =>
{
var withdrawal = await db.Withdrawals.FindAsync(id);
if (withdrawal == null)
@ -231,6 +229,22 @@ public static class EarningEndpoints
if (request.Action == "approve")
{
// 先调用微信商家转账到零钱
var user = await db.Users.FindAsync(withdrawal.UserId);
if (user == null || string.IsNullOrEmpty(user.OpenId))
return Results.BadRequest(new { code = 400, message = "用户信息异常,无法转账" });
var amountFen = (int)(withdrawal.Amount * 100);
var batchNo = $"W{withdrawal.Id}_{DateTime.UtcNow:yyyyMMddHHmmss}";
var detailNo = $"D{withdrawal.Id}_{DateTime.UtcNow:yyyyMMddHHmmss}";
var (transferSuccess, transferError) = await wxPay.TransferToWallet(batchNo, detailNo, user.OpenId, amountFen, "跑腿提现到账");
if (!transferSuccess)
{
Console.WriteLine($"[提现] 转账失败: {transferError}");
return Results.BadRequest(new { code = 400, message = $"转账失败,请稍后重试", detail = transferError });
}
withdrawal.Status = WithdrawalStatus.Completed;
withdrawal.ProcessedAt = DateTime.UtcNow;

View File

@ -185,7 +185,8 @@ public static class OrderEndpoints
OwnerAvatar = o.Owner!.AvatarUrl,
RunnerNickname = o.Runner!.Nickname,
RunnerAvatar = o.Runner!.AvatarUrl,
LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt
LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt,
o.ImGroupId
})
.ToListAsync();
@ -208,7 +209,8 @@ public static class OrderEndpoints
TargetUserId = targetId,
TargetNickname = nickname,
TargetAvatar = avatar ?? "",
o.LastTime
o.LastTime,
o.ImGroupId
};
});
@ -334,6 +336,8 @@ public static class OrderEndpoints
RunnerPhone = runnerPhone
};
response.ImGroupId = order.ImGroupId;
// 查询佣金抽成信息(有 Earning 记录用实际值,否则实时计算预估值)
if (order.RunnerId.HasValue)
{
@ -455,7 +459,7 @@ public static class OrderEndpoints
}).AllowAnonymous();
// 接单
app.MapPost("/api/orders/{id}/accept", async (int id, HttpContext httpContext, AppDbContext db) =>
app.MapPost("/api/orders/{id}/accept", async (int id, HttpContext httpContext, AppDbContext db, TencentIMService imService) =>
{
// 获取当前用户
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
@ -515,6 +519,24 @@ public static class OrderEndpoints
return Results.Conflict(new { code = 409, message = "该订单已被接取" });
}
// 创建 IM 群组
try
{
var groupId = $"order_{order.Id}";
var ownerImId = $"user_{order.OwnerId}";
var runnerImId = $"user_{userId}";
var result = await imService.CreateGroupAsync(groupId, $"订单{order.OrderNo}", ownerImId, runnerImId);
if (result != null)
{
order.ImGroupId = result;
await db.SaveChangesAsync();
}
}
catch (Exception ex)
{
Console.WriteLine($"[IM] 创建群组失败: {ex.Message}");
}
return Results.Ok(new AcceptOrderResponse
{
Id = order.Id,
@ -1041,7 +1063,7 @@ public static class OrderEndpoints
var refundAmount = Math.Abs(totalDiff);
var refundFen = (int)(refundAmount * 100);
var totalFen = (int)(oldTotal * 100);
var refundNo = $"RP{order.OrderNo}{DateTime.UtcNow:mmss}";
var refundNo = $"RP{order.OrderNo}{DateTime.UtcNow:HHmmss}{Random.Shared.Next(100, 999)}";
refundSuccess = await wxPay.Refund(order.OrderNo, refundNo, totalFen, refundFen, "改价退款");
Console.WriteLine($"[改价] 退款{(refundSuccess ? "" : "")}: ¥{refundAmount}");
}

View File

@ -0,0 +1,916 @@
// <auto-generated />
using System;
using CampusErrand.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CampusErrand.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260401162116_AddOrderImGroupId")]
partial class AddOrderImGroupId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CampusErrand.Models.Appeal", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<string>("Result")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("Appeals");
});
modelBuilder.Entity("CampusErrand.Models.Banner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("ImageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("LinkType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("SortOrder");
b.ToTable("Banners");
});
modelBuilder.Entity("CampusErrand.Models.CommissionRule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal?>("MaxAmount")
.HasColumnType("decimal(10,2)");
b.Property<decimal>("MinAmount")
.HasColumnType("decimal(10,2)");
b.Property<decimal>("Rate")
.HasColumnType("decimal(10,4)");
b.Property<string>("RateType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CommissionRules");
});
modelBuilder.Entity("CampusErrand.Models.Dish", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Photo")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<decimal>("Price")
.HasColumnType("decimal(10,2)");
b.Property<int>("ShopId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ShopId");
b.ToTable("Dishes");
});
modelBuilder.Entity("CampusErrand.Models.Earning", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("Commission")
.HasColumnType("decimal(10,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("FrozenUntil")
.HasColumnType("datetime2");
b.Property<decimal?>("GoodsAmount")
.HasColumnType("decimal(10,2)");
b.Property<decimal>("NetEarning")
.HasColumnType("decimal(10,2)");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<decimal>("PlatformFee")
.HasColumnType("decimal(10,2)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("UserId");
b.ToTable("Earnings");
});
modelBuilder.Entity("CampusErrand.Models.FoodOrderItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DishId")
.HasColumnType("int");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("ShopId")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(10,2)");
b.HasKey("Id");
b.HasIndex("DishId");
b.HasIndex("OrderId");
b.HasIndex("ShopId");
b.ToTable("FoodOrderItems");
});
modelBuilder.Entity("CampusErrand.Models.MessageRead", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MessageId")
.HasColumnType("int");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("ReadAt")
.HasColumnType("datetime2");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId", "MessageType", "MessageId")
.IsUnique();
b.ToTable("MessageReads");
});
modelBuilder.Entity("CampusErrand.Models.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("datetime2");
b.Property<decimal>("Commission")
.HasColumnType("decimal(10,2)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<string>("CompletionProof")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("DeliveryLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<decimal?>("GoodsAmount")
.HasColumnType("decimal(10,2)");
b.Property<string>("ImGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsReviewed")
.HasColumnType("bit");
b.Property<string>("ItemName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("OrderNo")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("OrderType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("OwnerId")
.HasColumnType("int");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("PickupLocation")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Remark")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<int?>("RunnerId")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(10,2)");
b.HasKey("Id");
b.HasIndex("OrderNo")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("RunnerId");
b.HasIndex("Status");
b.ToTable("Orders");
});
modelBuilder.Entity("CampusErrand.Models.PriceChange", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ChangeType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("InitiatorId")
.HasColumnType("int");
b.Property<decimal>("NewPrice")
.HasColumnType("decimal(10,2)");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<decimal>("OriginalPrice")
.HasColumnType("decimal(10,2)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("InitiatorId");
b.HasIndex("OrderId");
b.ToTable("PriceChanges");
});
modelBuilder.Entity("CampusErrand.Models.Review", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("IsDisabled")
.HasColumnType("bit");
b.Property<int>("OrderId")
.HasColumnType("int");
b.Property<int>("Rating")
.HasColumnType("int");
b.Property<int>("RunnerId")
.HasColumnType("int");
b.Property<int>("ScoreChange")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("RunnerId");
b.ToTable("Reviews");
});
modelBuilder.Entity("CampusErrand.Models.RunnerCertification", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RealName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("RunnerCertifications");
});
modelBuilder.Entity("CampusErrand.Models.ServiceEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("PagePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("SortOrder");
b.ToTable("ServiceEntries");
});
modelBuilder.Entity("CampusErrand.Models.Shop", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Notice")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal>("PackingFeeAmount")
.HasColumnType("decimal(10,2)");
b.Property<string>("PackingFeeType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Photo")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.HasKey("Id");
b.ToTable("Shops");
});
modelBuilder.Entity("CampusErrand.Models.ShopBanner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ImageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<int>("ShopId")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ShopId");
b.ToTable("ShopBanners");
});
modelBuilder.Entity("CampusErrand.Models.SystemConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("SystemConfigs");
});
modelBuilder.Entity("CampusErrand.Models.SystemMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("TargetType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TargetUserIds")
.HasColumnType("nvarchar(max)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.ToTable("SystemMessages");
});
modelBuilder.Entity("CampusErrand.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AvatarUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("IsBanned")
.HasColumnType("bit");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("OpenId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("RunnerScore")
.HasColumnType("int");
b.Property<string>("Uid")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.HasKey("Id");
b.HasIndex("OpenId")
.IsUnique();
b.HasIndex("Phone");
b.ToTable("Users");
});
modelBuilder.Entity("CampusErrand.Models.Withdrawal", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(10,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("PaymentMethod")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("QrCodeImage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Withdrawals");
});
modelBuilder.Entity("CampusErrand.Models.Appeal", b =>
{
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Order");
});
modelBuilder.Entity("CampusErrand.Models.Dish", b =>
{
b.HasOne("CampusErrand.Models.Shop", "Shop")
.WithMany("Dishes")
.HasForeignKey("ShopId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Shop");
});
modelBuilder.Entity("CampusErrand.Models.Earning", b =>
{
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.FoodOrderItem", b =>
{
b.HasOne("CampusErrand.Models.Dish", "Dish")
.WithMany()
.HasForeignKey("DishId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany("FoodOrderItems")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.Shop", "Shop")
.WithMany()
.HasForeignKey("ShopId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Dish");
b.Navigation("Order");
b.Navigation("Shop");
});
modelBuilder.Entity("CampusErrand.Models.MessageRead", b =>
{
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.Order", b =>
{
b.HasOne("CampusErrand.Models.User", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.User", "Runner")
.WithMany()
.HasForeignKey("RunnerId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Owner");
b.Navigation("Runner");
});
modelBuilder.Entity("CampusErrand.Models.PriceChange", b =>
{
b.HasOne("CampusErrand.Models.User", "Initiator")
.WithMany()
.HasForeignKey("InitiatorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Initiator");
b.Navigation("Order");
});
modelBuilder.Entity("CampusErrand.Models.Review", b =>
{
b.HasOne("CampusErrand.Models.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CampusErrand.Models.User", "Runner")
.WithMany()
.HasForeignKey("RunnerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Runner");
});
modelBuilder.Entity("CampusErrand.Models.RunnerCertification", b =>
{
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.ShopBanner", b =>
{
b.HasOne("CampusErrand.Models.Shop", "Shop")
.WithMany("ShopBanners")
.HasForeignKey("ShopId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Shop");
});
modelBuilder.Entity("CampusErrand.Models.Withdrawal", b =>
{
b.HasOne("CampusErrand.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CampusErrand.Models.Order", b =>
{
b.Navigation("FoodOrderItems");
});
modelBuilder.Entity("CampusErrand.Models.Shop", b =>
{
b.Navigation("Dishes");
b.Navigation("ShopBanners");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CampusErrand.Migrations
{
/// <inheritdoc />
public partial class AddOrderImGroupId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImGroupId",
table: "Orders",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImGroupId",
table: "Orders");
}
}
}

View File

@ -286,6 +286,10 @@ namespace CampusErrand.Migrations
b.Property<decimal?>("GoodsAmount")
.HasColumnType("decimal(10,2)");
b.Property<string>("ImGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsReviewed")
.HasColumnType("bit");

View File

@ -68,3 +68,14 @@ public class AdminLoginRequest
[Required(ErrorMessage = "密码不能为空")]
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 更新用户信息请求
/// </summary>
public class UpdateProfileRequest
{
/// <summary>昵称</summary>
public string? Nickname { get; set; }
/// <summary>头像URL</summary>
public string? AvatarUrl { get; set; }
}

View File

@ -89,6 +89,8 @@ public class OrderResponse
public decimal? PlatformFee { get; set; }
/// <summary>跑腿实得佣金(订单完成后可见)</summary>
public decimal? NetEarning { get; set; }
/// <summary>IM 群组 ID</summary>
public string? ImGroupId { get; set; }
public List<FoodOrderItemResponse>? FoodItems { get; set; }
}

View File

@ -75,6 +75,10 @@ public class Order
/// <summary>完成时间</summary>
public DateTime? CompletedAt { get; set; }
/// <summary>IM 群组 ID接单时创建</summary>
[MaxLength(64)]
public string? ImGroupId { get; set; }
// 导航属性
[ForeignKey(nameof(OwnerId))]
public User? Owner { get; set; }

View File

@ -82,4 +82,65 @@ public class TencentIMService
Console.WriteLine($"[IM] 拉取漫游消息: {fromUserId} -> {toUserId}, 响应: {json}");
return JsonSerializer.Deserialize<JsonElement>(json);
}
/// <summary>
/// 创建 IM 群组(每个订单一个群)
/// </summary>
public async Task<string?> CreateGroupAsync(string groupId, string groupName, string ownerImId, string runnerImId)
{
var adminSig = GenerateUserSig(AdminAccount);
var random = Random.Shared.Next(100000, 999999);
var url = $"https://console.tim.qq.com/v4/group_open_http_svc/create_group?sdkappid={_sdkAppId}&identifier={AdminAccount}&usersig={adminSig}&random={random}&contenttype=json";
var body = new
{
Type = "Private",
GroupId = groupId,
Name = groupName,
Owner_Account = ownerImId,
MemberList = new[]
{
new { Member_Account = runnerImId }
}
};
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content);
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[IM] 创建群组: {groupId}, 响应: {json}");
var result = JsonSerializer.Deserialize<JsonElement>(json);
var actionStatus = result.GetProperty("ActionStatus").GetString();
if (actionStatus == "OK")
return result.GetProperty("GroupId").GetString();
// 群已存在也算成功
var errorCode = result.GetProperty("ErrorCode").GetInt32();
if (errorCode == 10021)
return groupId;
return null;
}
/// <summary>
/// 拉取群漫游消息(管理后台用)
/// </summary>
public async Task<JsonElement> GetGroupMessagesAsync(string groupId, int reqMsgSeq = 0, int reqMsgNumber = 20)
{
var adminSig = GenerateUserSig(AdminAccount);
var random = Random.Shared.Next(100000, 999999);
var url = $"https://console.tim.qq.com/v4/group_open_http_svc/group_msg_get_simple?sdkappid={_sdkAppId}&identifier={AdminAccount}&usersig={adminSig}&random={random}&contenttype=json";
var body = new
{
GroupId = groupId,
ReqMsgSeq = reqMsgSeq,
ReqMsgNumber = reqMsgNumber
};
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content);
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<JsonElement>(json);
}
}

View File

@ -175,6 +175,60 @@ public class WxPayService
return true;
}
/// <summary>
/// 商家转账到零钱(提现用)
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> TransferToWallet(string outBatchNo, string outDetailNo, string openId, int amountFen, string remark = "提现到账")
{
var requestBody = new
{
appid = _appId,
out_batch_no = outBatchNo,
batch_name = "跑腿提现",
batch_remark = remark,
total_amount = amountFen,
total_num = 1,
transfer_detail_list = new[]
{
new
{
out_detail_no = outDetailNo,
transfer_amount = amountFen,
transfer_remark = remark,
openid = openId
}
}
};
var json = JsonSerializer.Serialize(requestBody);
var url = "/v3/transfer/batches";
var fullUrl = $"https://api.mch.weixin.qq.com{url}";
var request = new HttpRequestMessage(HttpMethod.Post, fullUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Guid.NewGuid().ToString("N");
var signature = Sign("POST", url, timestamp, nonce, json);
request.Headers.Add("Authorization", $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{_serialNo}\",signature=\"{signature}\"");
request.Headers.Add("Accept", "application/json");
request.Headers.Add("User-Agent", "CampusErrand/1.0");
var response = await _httpClient.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"[微信转账] 转账失败: {responseBody}");
return (false, responseBody);
}
Console.WriteLine($"[微信转账] 转账成功: {outBatchNo}");
return (true, null);
}
/// <summary>
/// 生成签名
/// </summary>