企迹(qiji) 政企周报管理系统 — v0.1

后端: FastAPI + SQLAlchemy 2.0 (async) + Alembic + MinIO + Casdoor + 企微
前端: Vue 3 + Vite + TypeScript + Element Plus + Pinia

功能清单:
- 8 张数据表自动建表 / Casdoor OIDC 登录 / 企微静默登录
- 双布局: 移动端(填报) + PC端(汇总管理)
- 拜访记录 CRUD + MinIO 照片直传 + 缩略图预览 + 同访人草稿
- 今日纪要 (6 分类) / 工作计划 / 小微商机 / 要客拜访 CRUD
- 客户档案: 备注/收支费用/联系人/归属分配/批量转移
- 客户导入导出 + 模板下载 + 搜索/分页/筛选
- 仪表盘: 四卡统计 + 填报进度 (拜访+纪要双维度)
- 周报详情: 五 Tab + 按人/客户筛选 + 时间轴
- 用户管理 / 客户经理 PC 端工作台
- 企微: 催办/公告/定时提醒 / 时区修正
- Docker 部署配置

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 01:35:46 +08:00
parent 2a8225c31f
commit a1886074dd
97 changed files with 8830 additions and 0 deletions
View File
+106
View File
@@ -0,0 +1,106 @@
from uuid import uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import httpx
from app.config import settings
from app.models.user import User
from app.utils.security import create_access_token
async def get_or_create_user_from_casdoor(
db: AsyncSession, casdoor_id: str, name: str,
role: str = "manager", department: str = ""
) -> User:
"""Find existing user by casdoor_id, or create a new one."""
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
user = result.scalar_one_or_none()
if user is None:
user = User(
casdoor_id=casdoor_id,
name=name,
role=role,
department=department,
)
db.add(user)
await db.commit()
await db.refresh(user)
else:
# Update name/department if changed
if user.name != name or user.department != department:
user.name = name
user.department = department
await db.commit()
await db.refresh(user)
return user
async def exchange_casdoor_code(code: str) -> dict | None:
"""Exchange Casdoor OIDC authorization code for user info."""
import logging
logger = logging.getLogger("uvicorn")
token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
async with httpx.AsyncClient() as client:
try:
resp = await client.post(token_url, data={
"grant_type": "authorization_code",
"client_id": settings.CASDOOR_CLIENT_ID,
"client_secret": settings.CASDOOR_CLIENT_SECRET,
"code": code,
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
return None
token_data = resp.json()
access_token = token_data.get("access_token", "")
if not access_token:
logger.error(f"[Casdoor] no access_token in response: {token_data}")
return None
except Exception as e:
logger.error(f"[Casdoor] token exchange exception: {e}")
return None
userinfo_url = f"{settings.CASDOOR_ENDPOINT}/api/userinfo"
async with httpx.AsyncClient() as client:
try:
resp = await client.get(userinfo_url, headers={
"Authorization": f"Bearer {access_token}"
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}")
return None
return resp.json()
except Exception as e:
logger.error(f"[Casdoor] userinfo exception: {e}")
return None
return None
async def bind_wecom_user(db: AsyncSession, casdoor_id: str, wecom_userid: str) -> User | None:
"""Bind a WeChat Work userid to a Casdoor user."""
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
user = result.scalar_one_or_none()
if user is None:
return None
user.wecom_userid = wecom_userid
await db.commit()
await db.refresh(user)
return user
async def get_user_by_wecom_id(db: AsyncSession, wecom_userid: str) -> User | None:
"""Find user by wecom_userid."""
result = await db.execute(select(User).where(User.wecom_userid == wecom_userid))
return result.scalar_one_or_none()
def build_token_for_user(user: User) -> str:
"""Build a JWT token for the given user."""
return create_access_token(data={
"sub": str(user.id),
"user_id": str(user.id),
"casdoor_id": user.casdoor_id,
"name": user.name,
"role": user.role,
"department": user.department,
})
+253
View File
@@ -0,0 +1,253 @@
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.customer import Customer
from app.models.user import User
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.utils.timezone import today_cst
def get_week_range(reference_date: date | None = None):
today = reference_date or date.today()
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
async def get_dashboard_stats(db: AsyncSession) -> dict:
"""Get dashboard statistics for the current week."""
monday, sunday = get_week_range()
today = date.today()
visits_count = (await db.execute(
select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
)).scalar() or 0
plans_count = (await db.execute(
select(func.count(WorkPlan.id))
)).scalar() or 0
mini_biz_count = (await db.execute(
select(func.count(MiniBusiness.id))
)).scalar() or 0
key_visit_count = (await db.execute(
select(func.count(KeyVisit.id))
)).scalar() or 0
return {
"week_visits": visits_count,
"work_plans": plans_count,
"mini_business": mini_biz_count,
"key_visits": key_visit_count,
"week_start": str(monday),
"week_end": str(sunday),
}
async def get_reporting_progress(db: AsyncSession) -> list[dict]:
"""Get per-manager reporting progress for the current week."""
monday, sunday = get_week_range()
# Get all managers
managers_result = await db.execute(select(User).where(User.role == "manager"))
managers = managers_result.scalars().all()
# Get visit counts per manager this week
visits_result = await db.execute(
select(Visit.manager_id, func.count(Visit.id))
.where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
.group_by(Visit.manager_id)
)
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
progress = []
for m in managers:
count = visit_map.get(str(m.id), 0)
# Calculate expected working days (Mon-Fri)
days_passed = min((date.today() - monday).days + 1, 5)
expected = days_passed # At least 1 visit per working day
progress.append({
"manager_id": str(m.id),
"manager_name": m.name,
"department": m.department,
"visit_count": count,
"expected": expected,
"completed": count >= expected,
"has_reported_today": False, # Will be set below
})
# Check today's reporting — visits OR daily notes
today = today_cst()
today_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
)
today_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()}
for p in progress:
p["has_reported_today"] = p["manager_id"] in reported_today
return progress
async def get_weekly_report(
db: AsyncSession, user_id: UUID, role: str,
filter_manager_id: UUID | None = None,
filter_customer_id: UUID | None = None,
) -> dict:
"""Get full weekly report data organized by module."""
monday, sunday = get_week_range()
# Base filters respecting role visibility
customer_map = {}
user_map = {}
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {c.id: c.name for c in customers_result.all()}
users_result = await db.execute(select(User.id, User.name))
user_map = {u.id: u.name for u in users_result.all()}
def build_manager_filter(existing_filter=None):
"""If role is manager, only see own data. Otherwise optionally filter by manager_id."""
if role == "manager":
return str(user_id)
return str(filter_manager_id) if filter_manager_id else None
# ── Visits ──
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
if role == "manager":
visit_query = visit_query.where(Visit.manager_id == user_id)
elif filter_manager_id:
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
if filter_customer_id:
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
visit_query = visit_query.order_by(Visit.visit_date.desc())
visits_result = await db.execute(visit_query)
visits = visits_result.scalars().all()
visits_data = []
for v in visits:
visits_data.append({
"id": str(v.id),
"customer_id": str(v.customer_id),
"customer_name": customer_map.get(v.customer_id, ""),
"visit_date": str(v.visit_date),
"visit_method": v.visit_method,
"time_range": v.time_range,
"communication_content": v.communication_content,
"customer_demand": v.customer_demand,
"companions": [str(c) for c in (v.companions or [])],
"photos": v.photos or [],
"manager_id": str(v.manager_id),
"manager_name": user_map.get(v.manager_id, ""),
"created_at": str(v.created_at),
})
# ── Work Plans ──
wp_query = select(WorkPlan)
if role == "manager":
wp_query = wp_query.where(WorkPlan.manager_id == user_id)
elif filter_manager_id:
wp_query = wp_query.where(WorkPlan.manager_id == filter_manager_id)
if filter_customer_id:
wp_query = wp_query.where(WorkPlan.customer_id == filter_customer_id)
wp_result = await db.execute(wp_query)
work_plans_data = []
for w in wp_result.scalars():
work_plans_data.append({
"id": str(w.id),
"customer_id": str(w.customer_id),
"customer_name": customer_map.get(w.customer_id, ""),
"plan_content": w.plan_content,
"plan_date": str(w.plan_date),
"manager_id": str(w.manager_id),
"manager_name": user_map.get(w.manager_id, ""),
"status": w.status,
})
# ── Mini Business ──
mb_query = select(MiniBusiness)
if role == "manager":
mb_query = mb_query.where(MiniBusiness.manager_id == user_id)
elif filter_manager_id:
mb_query = mb_query.where(MiniBusiness.manager_id == filter_manager_id)
if filter_customer_id:
mb_query = mb_query.where(MiniBusiness.customer_id == filter_customer_id)
mb_result = await db.execute(mb_query)
mini_biz_data = []
for m in mb_result.scalars():
mini_biz_data.append({
"id": str(m.id),
"customer_id": str(m.customer_id),
"customer_name": customer_map.get(m.customer_id, ""),
"product_type": m.product_type,
"amount": m.amount,
"follow_up_detail": m.follow_up_detail,
"status": m.status,
"manager_id": str(m.manager_id),
"manager_name": user_map.get(m.manager_id, ""),
"expected_revenue_date": m.expected_revenue_date,
})
# ── Key Visits ──
kv_query = select(KeyVisit)
if role == "manager":
kv_query = kv_query.where(KeyVisit.manager_id == user_id)
elif filter_manager_id:
kv_query = kv_query.where(KeyVisit.manager_id == filter_manager_id)
if filter_customer_id:
kv_query = kv_query.where(KeyVisit.customer_id == filter_customer_id)
kv_result = await db.execute(kv_query)
key_visits_data = []
for k in kv_result.scalars():
key_visits_data.append({
"id": str(k.id),
"customer_id": str(k.customer_id),
"customer_name": customer_map.get(k.customer_id, ""),
"urgency_level": k.urgency_level,
"description": k.description,
"progress_status": k.progress_status,
"planned_date": k.planned_date,
"planned_visitor": k.planned_visitor,
"visit_target": k.visit_target,
"manager_id": str(k.manager_id),
"manager_name": user_map.get(k.manager_id, ""),
})
# ── Daily Notes ──
dn_query = select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
if role == "manager":
dn_query = dn_query.where(DailyNote.manager_id == user_id)
elif filter_manager_id:
dn_query = dn_query.where(DailyNote.manager_id == filter_manager_id)
dn_query = dn_query.order_by(DailyNote.note_date.desc())
dn_result = await db.execute(dn_query)
daily_notes_data = []
for d in dn_result.scalars():
daily_notes_data.append({
"id": str(d.id),
"note_date": str(d.note_date),
"category": d.category,
"content": d.content,
"time_range": d.time_range,
"manager_id": str(d.manager_id),
"manager_name": user_map.get(d.manager_id, ""),
})
return {
"week_start": str(monday),
"week_end": str(sunday),
"visits": visits_data,
"daily_notes": daily_notes_data,
"work_plans": work_plans_data,
"mini_business": mini_biz_data,
"key_visits": key_visits_data,
}
+137
View File
@@ -0,0 +1,137 @@
import io
from datetime import date, timedelta
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.customer import Customer
from app.models.user import User
def get_week_range(reference_date: date | None = None):
"""Get the Monday and Sunday of the week containing reference_date (defaults to today)."""
today = reference_date or date.today()
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO:
"""Generate a 4-sheet xlsx matching the existing weekly report template."""
monday, sunday = get_week_range(reference_date)
wb = Workbook()
# Pre-fetch lookups
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {str(c.id): c.name for c in customers_result.all()}
users_result = await db.execute(select(User.id, User.name))
user_map = {str(u.id): u.name for u in users_result.all()}
thin_border = Border(
left=Side(style='thin'), right=Side(style='thin'),
top=Side(style='thin'), bottom=Side(style='thin')
)
header_font = Font(bold=True)
# ── Sheet 1: 每日拜访记录 ──
ws1 = wb.active
ws1.title = "每日拜访记录"
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "沟通内容", "客户需求", "同访人员", "客户经理"]
ws1.append(headers1)
for col in range(1, len(headers1) + 1):
cell = ws1.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
visits_result = await db.execute(
select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
)
for v in visits_result.scalars():
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
ws1.append([
customer_map.get(str(v.customer_id), ""),
str(v.visit_date),
v.visit_method,
v.time_range,
v.communication_content,
v.customer_demand,
", ".join(companions_names),
user_map.get(str(v.manager_id), ""),
])
# ── Sheet 2: 下周工作计划 ──
ws2 = wb.create_sheet("下周工作计划")
headers2 = ["客户单位", "工作计划", "计划拜访时间", "客户经理", "状态"]
ws2.append(headers2)
for col in range(1, len(headers2) + 1):
cell = ws2.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
plans_result = await db.execute(select(WorkPlan))
for p in plans_result.scalars():
ws2.append([
customer_map.get(str(p.customer_id), ""),
p.plan_content,
str(p.plan_date),
user_map.get(str(p.manager_id), ""),
p.status,
])
# ── Sheet 3: 小微业务商机 ──
ws3 = wb.create_sheet("小微业务商机")
headers3 = ["客户单位", "产品类型", "金额", "跟进内容具体情况", "跟进状态", "客户经理", "预计列收时间"]
ws3.append(headers3)
for col in range(1, len(headers3) + 1):
cell = ws3.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
mb_result = await db.execute(select(MiniBusiness))
for m in mb_result.scalars():
ws3.append([
customer_map.get(str(m.customer_id), ""),
m.product_type,
m.amount,
m.follow_up_detail,
m.status,
user_map.get(str(m.manager_id), ""),
m.expected_revenue_date,
])
# ── Sheet 4: 要客拜访计划 ──
ws4 = wb.create_sheet("要客拜访计划")
headers4 = ["客户单位", "紧急重要度", "内容描述", "进展状态", "计划拜访时间", "计划拜访人", "拜访对象", "客户经理"]
ws4.append(headers4)
for col in range(1, len(headers4) + 1):
cell = ws4.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
kv_result = await db.execute(select(KeyVisit))
for k in kv_result.scalars():
ws4.append([
customer_map.get(str(k.customer_id), ""),
k.urgency_level,
k.description,
k.progress_status,
k.planned_date,
k.planned_visitor,
k.visit_target,
user_map.get(str(k.manager_id), ""),
])
# Adjust column widths
for ws in [ws1, ws2, ws3, ws4]:
for col_cells in ws.columns:
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output
+78
View File
@@ -0,0 +1,78 @@
import uuid
from datetime import datetime, date
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import openpyxl
from app.models.customer import Customer
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.user import User
async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict:
"""Parse old weekly report Excel and import data. Returns summary stats."""
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0}
# Resolve customer name -> id cache
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {c.name: c.id for c in customers_result.all()}
# ── Parse Sheet 1: 每日拜访记录 ──
if "每日拜访记录" in wb.sheetnames:
ws = wb["每日拜访记录"]
for row in ws.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
cust_name, visit_date_str, visit_method, time_range, content, demand, companions_str, mgr_str = \
row[0], str(row[1]) if row[1] else str(date.today()), \
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
str(row[6]) if row[6] else "", str(row[7]) if row[7] else ""
customer_id = customer_map.get(cust_name)
if not customer_id:
stats["skipped"] += 1
continue
# Parse date
try:
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
except ValueError:
visit_date = date.today()
# Check for duplicates (same day, same person, same customer)
existing = await db.execute(
select(Visit).where(
Visit.visit_date == visit_date,
Visit.manager_id == manager_id,
Visit.customer_id == customer_id,
)
)
if existing.scalar_one_or_none():
stats["skipped"] += 1
continue
visit = Visit(
customer_id=customer_id,
visit_date=visit_date,
visit_method=visit_method if visit_method in ["上门", "电话", "微信", "出差"] else "上门",
time_range=time_range,
communication_content=content,
customer_demand=demand,
manager_id=manager_id,
)
db.add(visit)
stats["visits"] += 1
except Exception:
stats["skipped"] += 1
await db.commit()
return stats
import io
+43
View File
@@ -0,0 +1,43 @@
from datetime import timedelta
from minio import Minio
from app.config import settings
_client: Minio | None = None
def get_minio_client() -> Minio:
global _client
if _client is None:
_client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
# Ensure bucket exists
if not _client.bucket_exists(settings.MINIO_BUCKET):
_client.make_bucket(settings.MINIO_BUCKET)
return _client
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
"""Generate a presigned PUT URL for direct upload to MinIO."""
client = get_minio_client()
return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
"""Generate a presigned GET URL for viewing/downloading an object."""
client = get_minio_client()
return client.presigned_get_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
def delete_objects(object_keys: list[str]) -> None:
"""Delete multiple objects from MinIO."""
if not object_keys:
return
client = get_minio_client()
from minio.deleteobjects import DeleteObject
errors = client.remove_objects(settings.MINIO_BUCKET, [DeleteObject(k) for k in object_keys])
for err in errors:
pass # Log errors in production
+54
View File
@@ -0,0 +1,54 @@
from datetime import date, datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.visit import Visit
from app.models.daily_note import DailyNote
from app.models.user import User
from app.services.wecom import wecom_client
from app.utils.timezone import today_cst
async def check_daily_reporting(db: AsyncSession) -> dict:
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
today = today_cst()
weekday = today.weekday()
if weekday >= 5: # Skip weekends
return {"status": "weekend", "date": str(today)}
# Get all managers
result = await db.execute(select(User).where(User.role == "manager"))
managers = result.scalars().all()
# Get managers who have reported today
reported_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
)
reported_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
reported_map = {str(uid): True for uid, in reported_visits.all()}
for uid, in reported_notes.all():
reported_map[str(uid)] = True
not_reported = []
for m in managers:
if str(m.id) not in reported_map:
not_reported.append(m)
if not_reported and managers:
content = f"📋 今日填报提醒({today}\n\n以下同事尚未提交今日拜访记录:\n"
for m in not_reported:
content += f"{m.name}\n"
content += "\n请尽快完成今日拜访填报 🙏"
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
if user_ids:
await wecom_client.send_text_message(user_ids, content)
return {
"status": "ok",
"date": str(today),
"total_managers": len(managers),
"reported": len(reported_map),
"not_reported": len(not_reported),
}
+55
View File
@@ -0,0 +1,55 @@
import httpx
from app.config import settings
class WecomClient:
"""Minimal WeChat Work API client for sending app messages."""
def __init__(self):
self.corp_id = settings.WECOM_CORP_ID
self.agent_id = settings.WECOM_AGENT_ID
self.secret = settings.WECOM_SECRET
self._access_token: str | None = None
async def _get_token(self) -> str:
if self._access_token:
return self._access_token
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
self._access_token = data["access_token"]
return self._access_token
raise Exception(f"Failed to get wecom token: {data}")
async def get_userinfo_by_code(self, code: str) -> dict | None:
"""Exchange OAuth2 code for userid (used in silent login)."""
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID:
return False # Not configured, skip silently in dev
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"content": content},
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=body, timeout=10)
data = resp.json()
return data.get("errcode") == 0
wecom_client = WecomClient()