Compare commits

..

22 Commits

Author SHA1 Message Date
aaron
1bc0f64a0f Merge branch 'main' into payment
# Conflicts:
#	jobs.sqlite
2025-03-31 10:06:47 +08:00
aaron
0ed2b1d732 update 2025-03-30 16:52:57 +08:00
aaron
9376f9a785 update 2025-03-30 14:28:46 +08:00
aaron
8ac6961a4d update 2025-03-30 14:00:37 +08:00
aaron
fc862d00c3 update 2025-03-30 13:48:18 +08:00
aaron
bfff5ada51 update 2025-03-30 13:41:18 +08:00
aaron
1b34452c1f update 2025-03-30 13:24:29 +08:00
aaron
c907b1518c update 2025-03-30 12:08:07 +08:00
aaron
f843a4ceb1 update 2025-03-30 11:51:56 +08:00
aaron
d7efef091f update 2025-03-30 11:47:11 +08:00
aaron
f1871c74ea update 2025-03-30 11:42:30 +08:00
aaron
13eafc1772 update 2025-03-30 11:42:19 +08:00
aaron
d535eb5466 update 2025-03-30 11:39:03 +08:00
aaron
0f00caf23a update 2025-03-30 11:33:16 +08:00
aaron
5448a04be4 update 2025-03-29 22:40:21 +08:00
aaron
8c489288eb update 2025-03-29 22:22:44 +08:00
aaron
468b51ab0a update 2025-03-29 22:17:34 +08:00
aaron
611e689218 update 2025-03-29 22:15:09 +08:00
aaron
be8a478676 update 2025-03-29 22:08:02 +08:00
aaron
87dda933e2 update 2025-03-28 22:25:36 +08:00
aaron
fcc0e6a43d update 2025-03-28 20:17:30 +08:00
aaron
a985b3205b update 2025-03-28 19:28:43 +08:00
9 changed files with 1099 additions and 120 deletions

4
.gitignore vendored
View File

@ -40,3 +40,7 @@ ENV/
# Local development
.env.local
*.db
# sqlite
*.sqlite
*.sqlite3

View File

@ -310,6 +310,9 @@ async def complete_order(
if not order:
return error_response(code=404, message="订单不存在")
if merchant_user.userid != order.merchant.user_id:
return error_response(code=403, message="不是你的订单,无权限完成")
if order.status not in [MerchantOrderStatus.DELIVERING, MerchantOrderStatus.PICKUP_READY]:
return error_response(code=400, message="订单状态不正确")
@ -326,24 +329,31 @@ async def complete_order(
order_id = order.order_id
)
# 对商家进行结算
account_manager = AccountManager(db)
settlement_amount = float(order.MerchantProductDB.settlement_amount) * order.MerchantOrderDB.qty
if settlement_amount > 0:
account_manager.change_balance(
user_id=order.merchant.user_id,
amount=settlement_amount,
description=order.merchant_product.name,
transaction_id=order.order_id
)
user = db.query(UserDB).filter(
UserDB.userid == order.user_id
).first()
product = db.query(MerchantProductDB).filter(
MerchantProductDB.id == order.merchant_product_id
).first()
if product:
product.sold_total += order.qty
# 更新商品销量
if order.merchant_product:
order.merchant_product.sold_total += order.qty
db.commit()
# 发送商家订单完成消息
user = db.query(UserDB).filter(
UserDB.userid == order.user_id
).first()
if user and user.mp_openid:
data={
"character_string7": order_id,
"thing5": product.name,
"thing5": order.merchant_product.name,
"character_string24": order.qty,
"time10": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
@ -467,61 +477,53 @@ async def verify_order(
):
"""核销订单"""
# 查询订单及相关信息
order = db.query(
MerchantOrderDB,
MerchantProductDB,
MerchantDB
).join(
MerchantProductDB,
MerchantOrderDB.merchant_product_id == MerchantProductDB.id
).join(
MerchantDB,
MerchantProductDB.merchant_id == MerchantDB.id
).filter(
order = db.query(MerchantOrderDB).filter(
MerchantOrderDB.order_verify_code == request.verify_code,
MerchantOrderDB.status == MerchantOrderStatus.PICKUP_READY,
MerchantDB.user_id == merchant_user.userid
MerchantOrderDB.status == MerchantOrderStatus.PICKUP_READY
).first()
if not order:
return error_response(code=404, message="订单不存在或已核销")
if merchant_user.userid != order.merchant.user_id:
return error_response(code=403, message="不是你的订单,无权限核销")
try:
# 更新核销时间和核销用户
order.MerchantOrderDB.verify_time = datetime.now(timezone.utc)
order.MerchantOrderDB.verify_user_id = merchant_user.userid
order.MerchantOrderDB.status = MerchantOrderStatus.COMPLETED
order.verify_time = datetime.now(timezone.utc)
order.verify_user_id = merchant_user.userid
order.status = MerchantOrderStatus.COMPLETED
# 如果有积分奖励,赠送积分
if order.MerchantProductDB.gift_points > 0:
if order.gift_points > 0:
point_manager = PointManager(db)
point_manager.add_points(
user_id=order.MerchantOrderDB.user_id,
points=order.MerchantProductDB.gift_points,
description=f"团购券核销奖励",
order_id=order.MerchantOrderDB.order_id
user_id=order.user_id,
points=order.gift_points,
description=order.merchant_product.name,
order_id=order.order_id
)
# 对商家进行结算
account_manager = AccountManager(db)
settlement_amount = float(order.MerchantProductDB.settlement_amount)
settlement_amount = float(order.settlement_amount) * order.qty
if settlement_amount > 0:
account_manager.change_balance(
user_id=order.MerchantDB.user_id,
user_id=order.merchant.user_id,
amount=settlement_amount,
description=f"团购券核销",
transaction_id=order.MerchantOrderDB.order_id
description=order.merchant_product.name,
transaction_id=order.order_id
)
# 更新商品销量
if order.MerchantProductDB:
order.MerchantProductDB.sold_total += order.MerchantOrderDB.qty
if order.merchant_product:
order.merchant_product.sold_total += order.qty
db.commit()
return success_response(
message="核销成功",
data=MerchantOrderInfo.model_validate(order.MerchantOrderDB)
data=MerchantOrderInfo.model_validate(order)
)
except Exception as e:
db.rollback()

View File

@ -8,7 +8,7 @@ from app.models.merchant_product import (
MerchantProductInfo
)
from app.models.database import get_db
from app.api.deps import get_admin_user
from app.api.deps import get_admin_user, get_merchant_user
from app.models.user import UserDB
from app.core.response import success_response, error_response, ResponseModel
from app.models.merchant import MerchantDB
@ -77,15 +77,42 @@ async def update_product(
db.rollback()
return error_response(code=500, message=f"更新失败: {str(e)}")
@router.get("/merchant_product_list", response_model=ResponseModel)
async def merchant_product_list(
db: Session = Depends(get_db),
current_user: UserDB = Depends(get_merchant_user)
):
"""获取商家产品列表(商家端)"""
merchant = db.query(MerchantDB).filter(MerchantDB.user_id == current_user.userid).first()
if not merchant:
return error_response(code=404, message="商家不存在")
products = db.query(MerchantProductDB).filter(MerchantProductDB.merchant_id == merchant.id).all()
product_list = []
for product in products:
product_list.append(
{
**MerchantProductInfo.model_validate(product).model_dump(),
"merchant": MerchantInfo.model_validate(merchant).model_dump(),
"total_sales_amount": 0,
"total_profit_amount": 0
}
)
return success_response(data=product_list)
@router.get("/list", response_model=ResponseModel)
async def list_merchant_products(
user_id: Optional[int] = None,
community_id: Optional[int] = None,
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
):
"""获取商品列表"""
"""获取商品列表(用户端)"""
# 联表查询商家信息
query = db.query(MerchantProductDB).options(
joinedload(MerchantProductDB.merchant)

View File

@ -639,10 +639,10 @@ async def deliveryman_get_order_status_count(
)
# 待接单的订单,只显示今天以及今天以前的订单
if status == OrderStatus.CREATED:
query = query.filter(
ShippingOrderDB.delivery_date <= datetime.now().date()
)
# if status == OrderStatus.CREATED:
# query = query.filter(
# ShippingOrderDB.delivery_date <= datetime.now().date()
# )
count = query.count()
result.append({
@ -699,10 +699,10 @@ async def deliveryman_get_community_building_order_count(
)
# 待接单的订单,只显示今天以及今天以前的订单
if OrderStatus.CREATED in status:
query = query.filter(
ShippingOrderDB.delivery_date <= datetime.now().date()
)
# if OrderStatus.CREATED in status:
# query = query.filter(
# ShippingOrderDB.delivery_date <= datetime.now().date()
# )
building_order_count = query.group_by(
ShippingOrderDB.address_community_building_id
@ -958,8 +958,8 @@ async def deliveryman_orders(
query = query.filter(ShippingOrderDB.deliveryman_user_id == deliveryman.userid)
# 待接单的订单,只显示今天以及今天以前的订单
if OrderStatus.CREATED in statuses:
query = query.filter(ShippingOrderDB.delivery_date <= datetime.now().date())
# if OrderStatus.CREATED in statuses:
# query = query.filter(ShippingOrderDB.delivery_date <= datetime.now().date())
# 楼栋筛选
if building_id:
@ -1242,21 +1242,23 @@ async def deliveryman_complete_order(
db.commit()
# 如果当前订单是首单,如果有邀请人,给邀请人发放优惠券
if order.is_first_order and order_user.referral_code:
# 有邀请人,给邀请人积分奖励
if order_user.referral_code:
# 查询邀请人
invite_user = db.query(UserDB).filter(
UserDB.user_code == order_user.referral_code
).first()
if invite_user:
expire_time = datetime.now() + timedelta(days=settings.FIRST_ORDER_REFERRAL_COUPON_EXPIRE_DAYS)
manager = CouponManager(db)
manager.add_coupon(
points = settings.FIRST_ORDER_REFERRAL_POINT if order.is_first_order else settings.COMMON_ORDER_REFERRAL_POINT
desc = f"蜜友首单奖励" if order.is_first_order else f"蜜友下单奖励"
# 邀请人赠送积分
point_manager = PointManager(db)
point_manager.add_points(
user_id=invite_user.userid,
coupon_id=settings.FIRST_ORDER_REFERRAL_COUPON_ID,
expire_time=expire_time,
count=settings.FIRST_ORDER_REFERRAL_COUPON_COUNT
points=points,
description=desc,
order_id=order.orderid
)
# 发送企业微信消息
@ -1505,10 +1507,12 @@ async def get_orders(
"coupon_discount_amount": order.coupon_discount_amount,
"point_discount_amount": order.point_discount_amount,
"cancel_reason": order.cancel_reason,
"additional_fee_amount": order.additional_fee_amount,
"is_delivery_cancel": order.cancel_user_id == order.deliveryman_user_id,
"complete_images": order.optimized_complete_images,
"completed_time": order.completed_time,
"final_amount": order.final_amount,
"is_first_order": order.is_first_order,
"packages": package_list,
"address": {
"name": order.address_customer_name,

View File

@ -15,11 +15,16 @@ from app.models.user import UserDB
from app.api.deps import get_current_user
from fastapi import Depends
from sqlalchemy.orm import Session
from app.core.response import error_response, success_response
from app.core.response import error_response, success_response, ResponseModel
from pydantic import BaseModel
from app.models.wecom_external_chat import WecomExternalChatDB, WecomExternalChatInfo, WecomExternalChatMemberDB, WecomExternalChatMemberInfo
from datetime import datetime
router = APIRouter()
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
def decrypt_msg(msg_encrypt: str, signature: str, timestamp: str, nonce: str) -> typing.Optional[str]:
"""解密企业微信消息"""
try:
@ -61,7 +66,7 @@ def decrypt_msg(msg_encrypt: str, signature: str, timestamp: str, nonce: str) ->
return xml_content.decode()
except Exception as e:
logging.exception("解密企业微信消息失败")
logger.exception("解密企业微信消息失败")
return None
@router.get("")
@ -81,7 +86,7 @@ async def verify_callback(
return Response(content=decrypted_str, media_type="text/plain")
except Exception as e:
logging.exception("验证回调配置失败")
logger.exception("验证回调配置失败")
return Response(status_code=403)
@router.post("")
@ -109,44 +114,103 @@ async def wechat_corp_callback(
# 解析解密后的XML
msg_root = ET.fromstring(decrypted_msg)
print(f"企业微信回调消息:{decrypted_msg}")
logger.info(f"企业微信回调消息:{decrypted_msg}")
# 解析基本信息
msg_type = msg_root.find('MsgType').text
print(f"msg_type: {msg_type}")
logger.info(f"msg_type: {msg_type}")
# 处理事件消息
if msg_type == 'event':
event = msg_root.find('Event').text
print(f"event: {event}")
logger.info(f"event: {event}")
# 处理进群事件
# 处理外部群聊变更事件
if event == 'change_external_chat':
chat_id = msg_root.find('ChatId').text
change_type = msg_root.find('ChangeType').text
update_detail = msg_root.find('UpdateDetail').text
print(f"chat_id: {chat_id}")
print(f"change_type: {change_type}")
print(f"update_detail: {update_detail}")
# 处理进群事件
join_user_id = None
quit_user_ids = []
# 处理成员加入事件
if update_detail == 'add_member':
print(f"发送欢迎消息")
# 发送欢迎消息
# await wecom_client.send_welcome_message(chat_id)
logger.info(f"有新成员加入群聊")
join_scene_elem = msg_root.find('JoinScene')
join_scene = int(join_scene_elem.text) if join_scene_elem is not None else 0
logger.info(f"加入场景: {join_scene}")
# 获取加入成员列表
mem_change_list = msg_root.find('MemChangeList')
if mem_change_list is not None:
for item in mem_change_list.findall('Item'):
join_user_id = item.text
if join_user_id:
logger.info(f"从MemChangeList中获取到新加入成员: {join_user_id}")
# 处理群聊变更事件 - 添加单个成员
await wecom_client.handle_chat_change_event(
chat_id=chat_id,
change_type=change_type,
update_detail=update_detail,
join_user_id=join_user_id
)
# 兼容旧格式如果找不到MemChangeList尝试JoinUserID
elif msg_root.find('JoinUserID') is not None:
join_user_id = msg_root.find('JoinUserID').text
if join_user_id:
logger.info(f"从JoinUserID中获取到新加入成员: {join_user_id}")
# 处理群聊变更事件 - 添加单个成员
await wecom_client.handle_chat_change_event(
chat_id=chat_id,
change_type=change_type,
update_detail=update_detail,
join_user_id=join_user_id
)
# 处理成员离开事件
elif update_detail == 'del_member':
logger.info(f"有成员离开群聊")
quit_scene_elem = msg_root.find('QuitScene')
quit_scene = int(quit_scene_elem.text) if quit_scene_elem is not None else 0
# 获取离开成员列表
mem_change_list = msg_root.find('MemChangeList')
if mem_change_list is not None:
for item in mem_change_list.findall('Item'):
quit_user_id = item.text
if quit_user_id:
quit_user_ids.append(quit_user_id)
# 处理成员离开
await wecom_client.handle_chat_change_event(
chat_id=chat_id,
change_type=change_type,
update_detail=update_detail,
join_user_id=quit_user_id # 复用这个参数名来表示离开的用户ID
)
logger.info(f"离开场景: {quit_scene}, 离开成员: {quit_user_ids}")
logger.info(f"chat_id: {chat_id}, change_type: {change_type}, update_detail: {update_detail}, join_user_id: {join_user_id}, quit_user_ids: {quit_user_ids}")
# 处理其他群聊变更事件不是add_member和del_member
if update_detail != 'add_member' and update_detail != 'del_member':
# 其他群聊变更事件
await wecom_client.handle_chat_change_event(
chat_id=chat_id,
change_type=change_type,
update_detail=update_detail
)
return Response(content="success", media_type="text/plain")
except Exception as e:
logging.exception("处理企业微信回调消息异常")
logger.exception("处理企业微信回调消息异常")
return Response(content="success", media_type="text/plain")
class UnionidToExternalUseridRequest(BaseModel):
unionid: str
openid: str
@ -159,3 +223,315 @@ async def unionid_to_external_userid(
result = await wecom_client.unionid_to_external_userid(request.unionid, request.openid)
print(f"根据unionid获取external_userid结果: {result}")
return success_response(message="获取external_userid成功", data=result)
@router.get("/external-chats", response_model=ResponseModel)
async def get_external_chats(
db: Session = Depends(get_db),
current_user: UserDB = Depends(get_current_user)
):
"""获取企业微信外部群聊列表"""
try:
# 检查是否为管理员
if current_user.userid != settings.PLATFORM_USER_ID:
return error_response(code=403, message="权限不足")
# 获取群聊列表
chats = db.query(WecomExternalChatDB).filter(
WecomExternalChatDB.is_active == True
).order_by(WecomExternalChatDB.update_time.desc()).all()
# 转换为Pydantic模型
chat_list = [WecomExternalChatInfo.model_validate(chat) for chat in chats]
return success_response(message="获取群聊列表成功", data=chat_list)
except Exception as e:
logger.exception("获取群聊列表异常")
return error_response(code=500, message=f"获取群聊列表失败: {str(e)}")
@router.get("/external-chats/{chat_id}/members", response_model=ResponseModel)
async def get_external_chat_members(
chat_id: str,
db: Session = Depends(get_db),
current_user: UserDB = Depends(get_current_user)
):
"""获取企业微信外部群聊成员列表"""
try:
# 检查是否为管理员
if current_user.userid != settings.PLATFORM_USER_ID:
return error_response(code=403, message="权限不足")
# 检查群聊是否存在
chat = db.query(WecomExternalChatDB).filter(
WecomExternalChatDB.chat_id == chat_id
).first()
if not chat:
return error_response(code=404, message="群聊不存在")
# 获取成员列表
members = db.query(WecomExternalChatMemberDB).filter(
WecomExternalChatMemberDB.chat_id == chat_id
).order_by(WecomExternalChatMemberDB.join_time.desc()).all()
# 转换为Pydantic模型
member_list = [WecomExternalChatMemberInfo.model_validate(member) for member in members]
return success_response(message="获取群聊成员列表成功", data=member_list)
except Exception as e:
logger.exception("获取群聊成员列表异常")
return error_response(code=500, message=f"获取群聊成员列表失败: {str(e)}")
@router.post("/sync-chat/{chat_id}", response_model=ResponseModel)
async def sync_external_chat(
chat_id: str,
db: Session = Depends(get_db),
current_user: UserDB = Depends(get_current_user)
):
"""同步企业微信外部群聊信息"""
try:
# 检查是否为管理员
if current_user.userid != settings.PLATFORM_USER_ID:
return error_response(code=403, message="权限不足")
# 获取群聊信息
result = await wecom_client.handle_chat_change_event(
chat_id=chat_id,
change_type="create",
update_detail=""
)
if not result:
return error_response(code=500, message="同步群聊信息失败")
return success_response(message="同步群聊信息成功")
except Exception as e:
logger.exception("同步群聊信息异常")
return error_response(code=500, message=f"同步群聊信息失败: {str(e)}")
@router.get("/chat-dashboard")
async def chat_dashboard(
current_user: UserDB = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""显示企业微信外部群聊信息的HTML页面"""
# 检查是否为管理员
if current_user.userid != settings.PLATFORM_USER_ID:
return Response(content="权限不足", media_type="text/html")
# 获取群聊列表
chats = db.query(WecomExternalChatDB).filter(
WecomExternalChatDB.is_active == True
).order_by(WecomExternalChatDB.update_time.desc()).all()
# 生成HTML
html = """
<!DOCTYPE html>
<html>
<head>
<title>企业微信外部群聊信息</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
h1, h2 { color: #333; }
table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
tr:nth-child(even) { background-color: #f9f9f9; }
.container { max-width: 1200px; margin: 0 auto; }
.btn {
display: inline-block;
padding: 6px 12px;
margin-bottom: 0;
font-size: 14px;
font-weight: 400;
line-height: 1.42857143;
text-align: center;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
background-image: none;
border: 1px solid transparent;
border-radius: 4px;
color: #fff;
background-color: #337ab7;
text-decoration: none;
}
.btn:hover { background-color: #286090; }
.stats {
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.stat-item {
display: inline-block;
margin-right: 30px;
font-size: 16px;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #337ab7;
}
</style>
<script>
function loadMembers(chatId) {
fetch(`/api/wecom/external-chats/${chatId}/members`)
.then(response => response.json())
.then(data => {
if (data.code === 0) {
const members = data.data;
let html = '<table>';
html += '<tr><th>ID</th><th>用户ID</th><th>类型</th><th>姓名</th><th>加入时间</th><th>是否已发送欢迎</th></tr>';
members.forEach(member => {
html += `<tr>
<td>${member.id}</td>
<td>${member.user_id}</td>
<td>${member.type}</td>
<td>${member.name || '未知'}</td>
<td>${new Date(member.join_time).toLocaleString()}</td>
<td>${member.welcome_sent ? '' : ''}</td>
</tr>`;
});
html += '</table>';
document.getElementById('members-' + chatId).innerHTML = html;
} else {
alert('获取成员列表失败: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('获取成员列表失败');
});
}
function syncChat(chatId) {
fetch(`/api/wecom/sync-chat/${chatId}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.code === 0) {
alert('同步成功');
location.reload();
} else {
alert('同步失败: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('同步失败');
});
}
function getChatStats() {
fetch(`/api/wecom/chat-stats`)
.then(response => response.json())
.then(data => {
if (data.code === 0) {
const stats = data.data;
document.getElementById('total-chats').innerText = stats.total_chats;
document.getElementById('total-members').innerText = stats.total_members;
document.getElementById('active-chats').innerText = stats.active_chats;
} else {
console.error('获取统计数据失败:', data.message);
}
})
.catch(error => {
console.error('Error:', error);
});
}
// 页面加载完成后获取统计数据
window.onload = function() {
getChatStats();
};
</script>
</head>
<body>
<div class="container">
<h1>企业微信外部群聊信息</h1>
<div class="stats">
<div class="stat-item">
<div>总群聊数</div>
<div class="stat-number" id="total-chats">-</div>
</div>
<div class="stat-item">
<div>总成员数</div>
<div class="stat-number" id="total-members">-</div>
</div>
<div class="stat-item">
<div>活跃群聊数</div>
<div class="stat-number" id="active-chats">-</div>
</div>
</div>
"""
if not chats:
html += "<p>暂无群聊信息</p>"
else:
for chat in chats:
html += f"""
<div style="margin-bottom: 30px; border: 1px solid #ddd; padding: 15px; border-radius: 5px;">
<h2>{chat.name or '未命名群聊'} ({chat.chat_id})</h2>
<p>
<strong>创建时间:</strong> {chat.create_time.strftime('%Y-%m-%d %H:%M:%S')}<br>
<strong>更新时间:</strong> {chat.update_time.strftime('%Y-%m-%d %H:%M:%S') if chat.update_time else ''}<br>
<strong>成员数量:</strong> {chat.member_count}<br>
<strong>群主:</strong> {chat.owner or '未知'}<br>
<strong>公告:</strong> {chat.notice or ''}<br>
</p>
<button class="btn" onclick="loadMembers('{chat.chat_id}')">查看成员</button>
<button class="btn" onclick="syncChat('{chat.chat_id}')">同步群信息</button>
<div id="members-{chat.chat_id}" style="margin-top: 15px;"></div>
</div>
"""
html += """
</div>
</body>
</html>
"""
return Response(content=html, media_type="text/html")
@router.get("/chat-stats", response_model=ResponseModel)
async def get_chat_stats(
db: Session = Depends(get_db),
current_user: UserDB = Depends(get_current_user)
):
"""获取企业微信外部群聊统计数据"""
try:
# 检查是否为管理员
if current_user.userid != settings.PLATFORM_USER_ID:
return error_response(code=403, message="权限不足")
# 计算统计数据
total_chats = db.query(WecomExternalChatDB).count()
active_chats = db.query(WecomExternalChatDB).filter(WecomExternalChatDB.is_active == True).count()
total_members = db.query(WecomExternalChatMemberDB).count()
# 获取今日新增成员数
today = datetime.now().date()
today_start = datetime.combine(today, datetime.min.time())
today_members = db.query(WecomExternalChatMemberDB).filter(
WecomExternalChatMemberDB.join_time >= today_start
).count()
# 返回统计数据
stats = {
"total_chats": total_chats,
"active_chats": active_chats,
"total_members": total_members,
"today_members": today_members
}
return success_response(message="获取统计数据成功", data=stats)
except Exception as e:
logger.exception("获取统计数据异常")
return error_response(code=500, message=f"获取统计数据失败: {str(e)}")

View File

@ -29,10 +29,11 @@ class Settings(BaseSettings):
ORDER_EXTRA_PACKAGE_PRICE: float = 0.5 # 额外包裹费用
ORDER_EXTRA_PACKAGE_THRESHOLD: int = 5 # 额外收费阈值
# 邀请新人赠送优惠券ID
FIRST_ORDER_REFERRAL_COUPON_ID: int = 1
FIRST_ORDER_REFERRAL_COUPON_COUNT: int = 1
FIRST_ORDER_REFERRAL_COUPON_EXPIRE_DAYS: int = 7
# 邀请新人赠送积分
FIRST_ORDER_REFERRAL_POINT: int = 5
COMMON_ORDER_REFERRAL_POINT: int = 1
# JWT 配置
SECRET_KEY: str = "s10GmiRMmplfYWXYZLSsE3X36Ld4gVZxHgAcdqFGC20v3llv7UdOeWLBEEP3e40p"
@ -134,6 +135,12 @@ class Settings(BaseSettings):
QWEN_API_KEY: str = "sk-caa199589f1c451aaac471fad2986e28"
QWEN_API_URL: str = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
# UniSMS 配置
UNISMS_ACCESS_KEY_ID: str = "xxxxxx" # 替换为您的 UniSMS Access Key ID
UNISMS_ACCESS_KEY_SECRET: str = "xxxxxx" # 替换为您的 UniSMS Access Key Secret
UNISMS_SIGNATURE: str = "蜂快到家" # 短信签名
UNISMS_VERIFICATION_TEMPLATE_ID: str = "pub_verif_8dgk" # 验证码短信模板ID
class Config:
case_sensitive = True
env_file = ".env"
@ -187,10 +194,6 @@ class ProdSettings(Settings):
REDIS_PASSWORD: str = "redis_tjcZif"
VERIFICATION_CODE_EXPIRE_SECONDS: int = 300
FIRST_ORDER_REFERRAL_COUPON_ID: int = 2
FIRST_ORDER_REFERRAL_COUPON_COUNT: int = 1
FIRST_ORDER_REFERRAL_COUPON_EXPIRE_DAYS: int = 3
class Config:
env_file = ".env.prod"

144
app/core/unisms.py Normal file
View File

@ -0,0 +1,144 @@
import json
import time
import hmac
import hashlib
import base64
import uuid
import logging
import aiohttp
from typing import List, Dict, Any, Optional
from app.core.config import settings
class UniSMSClient:
"""UniSMS短信客户端"""
def __init__(self):
self.access_key_id = settings.UNISMS_ACCESS_KEY_ID
self.access_key_secret = settings.UNISMS_ACCESS_KEY_SECRET
self.base_url = "https://uni.apistd.com"
self.endpoint = "/2022-12-28/sms/send"
self.signature_method = "HMAC-SHA256"
self.signature_version = "1"
def _generate_signature(self, string_to_sign: str) -> str:
"""生成签名"""
key = self.access_key_secret.encode('utf-8')
message = string_to_sign.encode('utf-8')
sign = hmac.new(key, message, digestmod=hashlib.sha256).digest()
return base64.b64encode(sign).decode('utf-8')
def _build_request_headers(self, body: Dict[str, Any]) -> Dict[str, str]:
"""构建请求头"""
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4()).replace('-', '')
content_md5 = hashlib.md5(json.dumps(body).encode('utf-8')).hexdigest()
# 构建待签名字符串
string_to_sign = "\n".join([
self.endpoint,
timestamp,
nonce,
content_md5
])
# 生成签名
signature = self._generate_signature(string_to_sign)
# 构建请求头
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Uni-Timestamp": timestamp,
"X-Uni-Nonce": nonce,
"X-Uni-Content-MD5": content_md5,
"X-Uni-Signature-Method": self.signature_method,
"X-Uni-Signature-Version": self.signature_version,
"X-Uni-Signature": signature,
"X-Uni-AccessKeyId": self.access_key_id,
}
return headers
async def send_sms(
self,
to: str,
signature: str,
template_id: str,
template_data: Dict[str, Any] = None
) -> Dict[str, Any]:
"""
发送短信
Args:
to: 接收短信的手机号码
signature: 短信签名
template_id: 短信模板ID
template_data: 短信模板参数
Returns:
Dict[str, Any]: 发送结果
"""
try:
# 构建请求体
body = {
"to": to,
"signature": signature,
"templateId": template_id,
}
if template_data:
body["templateData"] = template_data
# 构建请求头
headers = self._build_request_headers(body)
# 发送请求
url = f"{self.base_url}{self.endpoint}"
async with aiohttp.ClientSession() as session:
async with session.post(url, json=body, headers=headers) as response:
result = await response.json()
logging.info(f"UniSMS响应: {result}")
if result.get("status") != "success":
logging.error(f"发送短信失败: {result}")
return {
"success": False,
"error": result.get("message", "发送短信失败")
}
return {
"success": True,
"message_id": result.get("data", {}).get("messageId", ""),
"fee": result.get("data", {}).get("fee", 0)
}
except Exception as e:
logging.error(f"发送短信异常: {str(e)}")
return {
"success": False,
"error": f"发送短信异常: {str(e)}"
}
async def send_verification_code(self, phone: str, code: str) -> Dict[str, Any]:
"""
发送验证码短信
Args:
phone: 手机号码
code: 验证码
Returns:
Dict[str, Any]: 发送结果
"""
# 使用验证码短信模板
template_id = settings.UNISMS_VERIFICATION_TEMPLATE_ID
signature = settings.UNISMS_SIGNATURE
template_data = {"code": code}
return await self.send_sms(
to=phone,
signature=signature,
template_id=template_id,
template_data=template_data
)

View File

@ -5,6 +5,10 @@ import logging
from app.core.config import settings
from typing import Dict, Any, Optional, List
import aiohttp
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class WecomClient:
"""企业微信客户端"""
@ -32,7 +36,7 @@ class WecomClient:
else:
raise Exception(f"获取access_token失败: {result}")
except Exception as e:
logging.error(f"获取access_token异常: {str(e)}")
logger.error(f"获取access_token异常: {str(e)}")
return None
async def code2session(self, js_code: str) -> Optional[Dict[str, Any]]:
@ -64,11 +68,11 @@ class WecomClient:
"session_key": result.get("session_key")
}
else:
logging.error(f"code2session失败: {result}")
logger.error(f"code2session失败: {result}")
return None
except Exception as e:
logging.error(f"code2session异常: {str(e)}")
logger.error(f"code2session异常: {str(e)}")
return None
async def get_unionid_from_userid(self, userid: str) -> Optional[str]:
@ -90,7 +94,7 @@ class WecomClient:
return result.get("unionid")
except Exception as e:
logging.error(f"get_unionid_from_userid异常: {str(e)}")
logger.error(f"get_unionid_from_userid异常: {str(e)}")
return None
async def unionid_to_external_userid(self, unionid: str, openid: str) -> Optional[str]:
@ -109,48 +113,365 @@ class WecomClient:
return result
except Exception as e:
logging.error(f"unionid_to_external_userid异常: {str(e)}")
logger.error(f"unionid_to_external_userid异常: {str(e)}")
return None
async def send_welcome_message(self, chat_id: str) -> bool:
"""发送欢迎消息"""
async def get_external_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
"""获取外部群聊信息
Args:
chat_id: 群聊ID
Returns:
Dict: 群聊信息
"""
try:
# 1. 获取 access_token
access_token = await self.get_access_token()
if not access_token:
logging.error("获取access_token失败")
return False
logger.error("获取access_token失败")
return None
# 2. 发送欢迎消息
url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={access_token}"
url = f"https://qyapi.weixin.qq.com/cgi-bin/externalcontact/groupchat/get?access_token={access_token}"
data = {
"chatid": chat_id,
"msgtype": "text",
"text": {
"content": f"""🥳 欢迎您进群,在群内可以享受📦【代取快递】跑腿服务。
微信下单快递到家
🎁 新人礼包
𝟏 赠送𝟏𝟓张𝟑元跑腿券
𝟐 赠送𝟔枚鲜鸡蛋首次下单
🎊
点击小程序领券下单 &"""
},
"safe": 0
"chat_id": chat_id
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=data) as response:
result = await response.json()
if result.get("errcode") == 0:
return True
return result.get("group_chat")
else:
logging.error(f"发送欢迎消息失败: {result}")
return False
logger.error(f"获取外部群聊信息失败: {result}")
return None
except Exception as e:
logging.error(f"发送欢迎消息异常: {str(e)}")
logger.error(f"获取外部群聊信息异常: {str(e)}")
return None
async def get_external_contact_info(self, external_userid: str) -> Optional[Dict[str, Any]]:
"""获取外部联系人信息
Args:
external_userid: 外部联系人ID
Returns:
Dict: 外部联系人信息
"""
try:
access_token = await self.get_access_token()
if not access_token:
logger.error("获取access_token失败")
return None
url = f"https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get?access_token={access_token}"
params = {
"external_userid": external_userid
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params) as response:
result = await response.json()
if result.get("errcode") == 0:
logger.info(f"获取外部联系人信息成功: {result}")
return result.get("external_contact")
else:
logger.error(f"获取外部联系人信息失败: {result}")
return None
except Exception as e:
logger.error(f"获取外部联系人信息异常: {str(e)}")
return None
async def get_internal_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
"""获取企业内部成员信息
Args:
user_id: 企业成员userid
Returns:
Dict: 企业成员信息
"""
try:
access_token = await self.get_access_token()
if not access_token:
logger.error("获取access_token失败")
return None
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={user_id}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
result = await response.json()
if result.get("errcode") == 0:
logger.info(f"获取内部成员信息成功: {result}")
return result
else:
logger.error(f"获取内部成员信息失败: {result}")
return None
except Exception as e:
logger.error(f"获取内部成员信息异常: {str(e)}")
return None
async def handle_chat_change_event(self, chat_id: str, change_type: str, update_detail: str, join_user_id: str = None) -> bool:
"""处理群聊变更事件
Args:
chat_id: 群聊ID
change_type: 变更类型 create/update/dismiss
update_detail: 变更详情 add_member/del_member/change_owner/change_name/change_notice
join_user_id: 加入/离开的用户ID
Returns:
bool: 处理是否成功
"""
from app.models.wecom_external_chat import WecomExternalChatDB, WecomExternalChatMemberDB
from app.models.wecom_external_chat import WecomExternalChatCreate, WecomExternalChatMemberCreate
from app.models.database import SessionLocal
try:
db = SessionLocal()
# 群创建事件
if change_type == "create":
# 获取群聊信息
chat_info = await self.get_external_chat_info(chat_id)
if not chat_info:
logger.warning(f"无法获取群聊信息: {chat_id}")
return False
logger.info(f"获取到群聊信息: {chat_info}")
# 保存群聊信息
chat_db = db.query(WecomExternalChatDB).filter(WecomExternalChatDB.chat_id == chat_id).first()
if not chat_db:
chat_create = WecomExternalChatCreate(
chat_id=chat_id,
name=chat_info.get("name"),
owner=chat_info.get("owner"),
member_count=len(chat_info.get("member_list", [])),
notice=chat_info.get("notice")
)
chat_db = WecomExternalChatDB(**chat_create.model_dump())
db.add(chat_db)
db.commit()
# 保存群成员信息
for member in chat_info.get("member_list", []):
user_id = member.get("userid")
# 处理数字类型: 1=企业成员, 2=外部联系人
member_type_num = member.get("type")
if member_type_num == 1:
member_type = "INTERNAL"
elif member_type_num == 2:
member_type = "EXTERNAL"
else:
member_type = "EXTERNAL" # 默认为外部联系人
logger.info(f"成员类型: {member_type}(原始值:{member_type_num}), 成员ID: {user_id}")
# 检查成员是否已存在
member_db = db.query(WecomExternalChatMemberDB).filter(
WecomExternalChatMemberDB.chat_id == chat_id,
WecomExternalChatMemberDB.user_id == user_id
).first()
if not member_db:
# 获取成员详情 - 根据成员类型调用不同API
user_info = None
if member_type == "EXTERNAL":
try:
user_info = await self.get_external_contact_info(user_id)
logger.info(f"获取到外部联系人详情: {user_info}")
except Exception as e:
logger.warning(f"获取外部联系人信息失败: {user_id}, 错误: {str(e)}")
elif member_type == "INTERNAL":
try:
user_info = await self.get_internal_user_info(user_id)
logger.info(f"获取到内部成员详情: {user_info}")
except Exception as e:
logger.warning(f"获取内部成员信息失败: {user_id}, 错误: {str(e)}")
# 获取名称和unionid (内部成员可能没有unionid)
member_name = None
member_unionid = None
if user_info:
if member_type == "EXTERNAL":
member_name = user_info.get("name")
member_unionid = user_info.get("unionid")
elif member_type == "INTERNAL":
member_name = user_info.get("name")
# 注意: 内部成员可能没有unionid字段
# 如果API获取失败则使用群聊详情中的信息
if not member_name:
member_name = "未知成员" # 没有任何名称信息时的默认值
member_create = WecomExternalChatMemberCreate(
chat_id=chat_id,
user_id=user_id,
type=member_type,
name=member_name,
unionid=member_unionid
)
member_db = WecomExternalChatMemberDB(**member_create.model_dump())
db.add(member_db)
db.commit()
return True
# 群更新事件 - 添加成员
elif change_type == "update" and update_detail == "add_member":
if not join_user_id:
return False
# 检查群聊是否存在
chat_db = db.query(WecomExternalChatDB).filter(WecomExternalChatDB.chat_id == chat_id).first()
if not chat_db:
# 获取群聊信息并创建
chat_info = await self.get_external_chat_info(chat_id)
if chat_info:
chat_create = WecomExternalChatCreate(
chat_id=chat_id,
name=chat_info.get("name"),
owner=chat_info.get("owner"),
member_count=len(chat_info.get("member_list", [])),
notice=chat_info.get("notice")
)
chat_db = WecomExternalChatDB(**chat_create.model_dump())
db.add(chat_db)
db.commit()
# 检查成员是否已存在
member_db = db.query(WecomExternalChatMemberDB).filter(
WecomExternalChatMemberDB.chat_id == chat_id,
WecomExternalChatMemberDB.user_id == join_user_id
).first()
if not member_db:
# 尝试获取群聊成员信息来确定成员类型
member_type = "EXTERNAL" # 默认为外部联系人
member_name = None
user_info = None
# 获取群聊详情,查找成员类型
try:
chat_info = await self.get_external_chat_info(chat_id)
if chat_info and "member_list" in chat_info:
for member in chat_info["member_list"]:
if member.get("userid") == join_user_id:
# 处理数字类型: 1=企业成员, 2=外部联系人
member_type_num = member.get("type")
if member_type_num == 1:
member_type = "INTERNAL"
elif member_type_num == 2:
member_type = "EXTERNAL"
else:
member_type = "EXTERNAL" # 默认为外部联系人
member_name = member.get("name")
logger.info(f"从群聊详情中确定成员类型: {member_type}(原始值:{member_type_num}), 成员: {join_user_id}")
break
except Exception as e:
logger.warning(f"获取群聊详情失败: {str(e)}")
# 如果是内部成员,不做任何处理
if member_type == "INTERNAL":
logger.info(f"成员 {join_user_id} 是内部成员,不做处理")
return True
# 如果是外部联系人,获取详细信息
user_info = None
member_name = None
member_unionid = None
if member_type == "EXTERNAL":
try:
user_info = await self.get_external_contact_info(join_user_id)
if user_info:
member_name = user_info.get("name")
member_unionid = user_info.get("unionid")
logger.info(f"获取到外部联系人详情: {member_name}, unionid: {member_unionid}")
except Exception as e:
logger.warning(f"获取外部联系人信息失败,可能是内部成员: {str(e)}")
if "84061" in str(e) or "not external contact" in str(e):
logger.info(f"用户 {join_user_id} 可能是内部成员,不做处理")
return True
# 如果API获取失败则使用群聊详情中的信息
if not member_name:
member_name = "未知成员" # 没有任何名称信息时的默认值
member_create = WecomExternalChatMemberCreate(
chat_id=chat_id,
user_id=join_user_id,
type=member_type,
name=member_name,
unionid=member_unionid
)
member_db = WecomExternalChatMemberDB(**member_create.model_dump())
db.add(member_db)
db.commit()
# 更新群成员数量
if chat_db:
chat_db.member_count = chat_db.member_count + 1
db.commit()
return True
# 群更新事件 - 移除成员
elif change_type == "update" and update_detail == "del_member":
if not join_user_id:
return False
logger.info(f"处理成员离开群聊事件: 群ID={chat_id}, 成员ID={join_user_id}")
# 删除成员记录
member_db = db.query(WecomExternalChatMemberDB).filter(
WecomExternalChatMemberDB.chat_id == chat_id,
WecomExternalChatMemberDB.user_id == join_user_id
).first()
if member_db:
logger.info(f"从数据库中删除成员记录: {member_db.user_id}, 姓名: {member_db.name}")
db.delete(member_db)
db.commit()
else:
logger.warning(f"未找到要删除的成员记录: chat_id={chat_id}, user_id={join_user_id}")
# 更新群成员数量
chat_db = db.query(WecomExternalChatDB).filter(WecomExternalChatDB.chat_id == chat_id).first()
if chat_db:
chat_db.member_count = max(0, chat_db.member_count - 1)
logger.info(f"更新群成员数量: {chat_db.member_count}")
db.commit()
else:
logger.warning(f"未找到群聊记录: chat_id={chat_id}")
return True
# 群解散事件
elif change_type == "dismiss":
# 标记群聊为非活跃
chat_db = db.query(WecomExternalChatDB).filter(WecomExternalChatDB.chat_id == chat_id).first()
if chat_db:
chat_db.is_active = False
db.commit()
logger.info(f"群聊解散: {chat_id}")
return True
return False
except Exception as e:
logger.error(f"处理群聊变更事件异常: {str(e)}")
import traceback
logger.error(traceback.format_exc())
if 'db' in locals():
db.close()
return False
finally:
if 'db' in locals():
db.close()
wecom_client = WecomClient()

View File

@ -0,0 +1,98 @@
from sqlalchemy import Column, String, Integer, DateTime, JSON, Boolean, ForeignKey, Text
from sqlalchemy.sql import func
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from .database import Base
class WecomExternalChatDB(Base):
"""企业微信外部群聊表"""
__tablename__ = "wecom_external_chats"
id = Column(Integer, primary_key=True, autoincrement=True)
chat_id = Column(String(64), nullable=False, unique=True, index=True) # 群聊ID
name = Column(String(100), nullable=True) # 群名称
owner = Column(String(64), nullable=True) # 群主ID
create_time = Column(DateTime(timezone=True), server_default=func.now())
update_time = Column(DateTime(timezone=True), onupdate=func.now())
member_count = Column(Integer, nullable=False, default=0) # 成员数量
notice = Column(String(500), nullable=True) # 群公告
is_active = Column(Boolean, nullable=False, default=True) # 是否活跃
class WecomExternalChatMemberDB(Base):
"""企业微信外部群聊成员表"""
__tablename__ = "wecom_external_chat_members"
id = Column(Integer, primary_key=True, autoincrement=True)
chat_id = Column(String(64), nullable=False, index=True) # 群聊ID
user_id = Column(String(64), nullable=False, index=True) # 用户ID
type = Column(String(32), nullable=False) # 成员类型: INTERNAL(内部成员)、EXTERNAL(外部联系人)
join_time = Column(DateTime(timezone=True), server_default=func.now()) # 加入时间
unionid = Column(String(64), nullable=True) # 微信unionid
name = Column(String(100), nullable=True) # 成员名称
mobile = Column(String(20), nullable=True) # 手机号
welcome_sent = Column(Boolean, nullable=False, default=False) # 是否已发送欢迎消息
# 设置联合唯一索引
__table_args__ = (
{"mysql_charset": "utf8mb4"},
)
# Pydantic 模型
class WecomExternalChatCreate(BaseModel):
chat_id: str
name: Optional[str] = None
owner: Optional[str] = None
member_count: int = 0
notice: Optional[str] = None
is_active: bool = True
class WecomExternalChatUpdate(BaseModel):
name: Optional[str] = None
owner: Optional[str] = None
member_count: Optional[int] = None
notice: Optional[str] = None
is_active: Optional[bool] = None
class WecomExternalChatInfo(BaseModel):
id: int
chat_id: str
name: Optional[str] = None
owner: Optional[str] = None
create_time: datetime
update_time: Optional[datetime] = None
member_count: int = 0
notice: Optional[str] = None
is_active: bool = True
class Config:
from_attributes = True
class WecomExternalChatMemberCreate(BaseModel):
chat_id: str
user_id: str
type: str
unionid: Optional[str] = None
name: Optional[str] = None
mobile: Optional[str] = None
welcome_sent: bool = False
class WecomExternalChatMemberUpdate(BaseModel):
unionid: Optional[str] = None
name: Optional[str] = None
mobile: Optional[str] = None
welcome_sent: Optional[bool] = None
class WecomExternalChatMemberInfo(BaseModel):
id: int
chat_id: str
user_id: str
type: str
join_time: datetime
unionid: Optional[str] = None
name: Optional[str] = None
mobile: Optional[str] = None
welcome_sent: bool
class Config:
from_attributes = True