feat: AI虚拟用户新闻互动系统 v1.3.0 初始提交

- 虚拟用户管理(昵称/头像/性别/简介/邮箱同步到目标平台)
- AI互动调度(点赞/收藏/评论/转发)
- 日志时间改为北京时间
- 评论达上限后继续执行点赞收藏转发
- 一键登出全部功能
- 浅色主题UI
This commit is contained in:
stefanfeng
2026-03-31 10:20:57 +08:00
commit 0cfc9bf9c8
53 changed files with 8457 additions and 0 deletions

View File

@@ -0,0 +1,729 @@
"""
新闻平台对接服务
登录: POST {auth}/open/login/token (formData)
签名: 完全对应 sign.js 的实现
"""
import uuid
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Optional
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.models import VirtualUser, SystemConfig, LoginLog
from app.utils.crypto import decrypt
from app.core.redis_client import set_session, get_session, delete_session
from app.core.logger import logger
class NewsPlatformService:
# ─── 配置读取 ──────────────────────────────────────────────
async def _cfg(self, db: AsyncSession, key: str, default: str = "") -> str:
result = await db.execute(select(SystemConfig).where(SystemConfig.config_key == key))
row = result.scalar_one_or_none()
return row.config_value if row else default
async def _biz_url(self, db: AsyncSession) -> str:
return await self._cfg(db, "news_platform_base_url", "http://192.168.1.200:63120")
async def _auth_url(self, db: AsyncSession) -> str:
return await self._cfg(db, "auth_base_url", "http://192.168.1.200:60040")
async def _client(self, db: AsyncSession) -> dict:
return {
"appId": await self._cfg(db, "platform_app_id", ""),
"accessId": await self._cfg(db, "platform_access_id", ""),
"accessSecret": await self._cfg(db, "platform_access_secret", ""),
"clientCode": await self._cfg(db, "platform_client_code", ""),
"orgId": await self._cfg(db, "platform_org_id", ""),
}
# ─── 签名(完全对应 sign.js 逻辑) ─────────────────────────
@staticmethod
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
"""
完全对应 sign.js:
1. 所有参数 key 排序
2. 过滤掉 signature / accessSecret / 空值 / 空数组
3. 拼接 key=value& ... accessSecret=secretKey
4. MD5/SHA256 大写
"""
SIGN_KEY = "signature"
SECRET_KEY = "accessSecret"
keys = sorted(params.keys())
str_parts = []
for k in keys:
if k in (SIGN_KEY, SECRET_KEY):
continue
v = params.get(k)
if v is None or v == "" or v == []:
continue
if isinstance(v, list):
continue
str_parts.append(f"{k}={v}")
sign_str = "&".join(str_parts) + f"&{SECRET_KEY}={secret_key}"
if sign_type.upper() == "SHA256":
return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper()
else:
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
@staticmethod
def _get_nonce() -> str:
import random, math
return str(random.random())[2:][: random.randint(8, 12)]
@staticmethod
def _get_timestamp() -> str:
"""yyyyMMddhhmmss — 注意 sign.js 用小写 hh(12小时制)"""
now = datetime.now()
# sign.js 用 yyyyMMddhhmmss (12小时制小写hh)
return now.strftime("%Y%m%d%I%M%S")
def _build_form(self, extra: dict, cfg: dict) -> dict:
"""构建带签名的 formData"""
sign_type = "MD5"
sign_version = "1.0"
secret_key = cfg.get("accessSecret", "")
base = {
"appId": cfg.get("appId", ""),
"accessId": cfg.get("accessId", ""),
"timestamp": self._get_timestamp(),
"signType": sign_type,
"signVersion": sign_version,
"accessSecret": secret_key,
"nonce": self._get_nonce(),
}
base.update(extra)
# 计算签名
signature = self._make_sign(base, secret_key, sign_type) if secret_key else ""
base["signature"] = signature
# 移除 accessSecret不发送到服务器
base.pop("accessSecret", None)
return base
# ─── 登录 ──────────────────────────────────────────────────
async def login(self, db: AsyncSession, user: VirtualUser) -> bool:
password = decrypt(user.password_enc)
if not password:
logger.error(f"[登录] {user.account} 密码解密失败")
return False
auth = await self._auth_url(db)
cfg = await self._client(db)
await db.execute(update(VirtualUser).where(VirtualUser.id == user.id).values(status=1))
await db.commit()
extra = {
"username": user.account,
"password": password,
"loginType": "password",
"grantType": "password",
"isRegister": "false",
}
if cfg.get("clientCode"):
extra["clientCode"] = cfg["clientCode"]
form = self._build_form(extra, cfg)
exc = None
try:
async with httpx.AsyncClient(
timeout=30,
follow_redirects=True, # 自动跟随 HTTP 重定向
) as c:
# 登录接口路径:需要加 /usercenter 前缀(通过网关路由)
# auth_base_url 配置为完整前缀,如 https://fat-open.99hui.com/api/usercenter
login_url = f"{auth}/open/login/token"
resp = await c.post(login_url, data=form)
# 详细记录原始响应,便于排查
logger.info(
f"[登录] {user.account} 原始响应: "
f"status={resp.status_code} "
f"content-type={resp.headers.get('content-type','')} "
f"body={resp.text[:500]}"
)
# 防止空响应体崩溃
if not resp.text.strip():
logger.warning(f"[登录] {user.account} 服务器返回空响应体,接口可能不存在或被重定向")
raise ValueError(f"服务器返回空响应 HTTP={resp.status_code}")
# 尝试解析 JSON
try:
body = resp.json()
except Exception as je:
logger.warning(f"[登录] {user.account} 响应非JSON: {resp.text[:200]}")
raise ValueError(f"响应非JSON: {resp.text[:100]}")
if resp.status_code == 200 and body.get("code") in [0, 200]:
raw = body.get("data")
access_token, platform_uid = self._extract_token(raw)
if access_token:
sid = str(uuid.uuid4())
# 登录成功后尝试获取用户组织信息
org_id = await self._fetch_org_id(db, access_token, platform_uid, cfg)
if org_id and not cfg.get("orgId"):
# 如果系统没有配置 orgId则自动保存用户所属组织
await self._save_org_id(db, org_id)
# 从登录响应中提取用户信息
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
sync_nickname = user_info.get("nickName") or user_info.get("username") or ""
sync_real_name = user_info.get("realName") or ""
sync_sex = int(user_info.get("sex") or 0)
sync_avatar = user_info.get("avatar") or ""
await set_session(user.id, {
"token": access_token,
"session_id": sid,
"platform_uid": platform_uid,
"org_id": org_id or cfg.get("orgId", ""),
"login_time": datetime.now().isoformat(),
# 缓存用户信息供 sync 使用
"nickname": sync_nickname,
"real_name": sync_real_name,
"sex": sync_sex,
"avatar": sync_avatar,
}, expire=86400)
# 更新本地数据库,同步平台用户信息
update_vals = dict(
status=2, session_token=access_token,
session_expires_at=datetime.now() + timedelta(hours=24),
last_login_at=datetime.now(),
platform_uid=platform_uid,
)
if sync_nickname: update_vals["nickname"] = sync_nickname
if sync_real_name: update_vals["real_name"] = sync_real_name
if sync_sex: update_vals["sex"] = sync_sex
if sync_avatar: update_vals["avatar_url"] = sync_avatar
await db.execute(update(VirtualUser).where(VirtualUser.id == user.id).values(**update_vals))
await db.commit()
await self._write_login_log(db, user, "login", sid)
logger.info(f"✅ [登录] {user.account} 成功 orgId={org_id}")
return True
logger.warning(f"[登录] {user.account} 无token: {body}")
else:
logger.warning(f"[登录] {user.account} 失败: HTTP={resp.status_code} {body}")
except Exception as e:
exc = e
logger.error(f"[登录] {user.account} 异常: {e}")
await db.execute(update(VirtualUser).where(VirtualUser.id == user.id).values(status=3))
await db.commit()
await self._write_login_log(db, user, "fail", error_msg=str(exc or "登录失败"))
return False
async def _fetch_org_id(
self, db: AsyncSession, token: str, platform_uid: str, cfg: dict
) -> str:
"""登录成功后,调用接口获取用户所属组织 orgId"""
biz = await self._biz_url(db)
headers = self._bearer(token)
# 尝试常见的用户信息接口
endpoints = [
f"/app/user/info",
f"/open/user/info",
f"/user/info",
]
for ep in endpoints:
try:
form = self._build_form({}, cfg)
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(
f"{biz}{ep}",
headers=headers,
params={k: v for k, v in form.items() if k not in ("username","password")}
)
if r.status_code == 200:
d = r.json()
data = d.get("data") or {}
# 从各种可能的字段中提取 orgId
org_id = (
data.get("orgId") or data.get("defaultOrgId") or
data.get("org", {}).get("id") if isinstance(data.get("org"), dict) else None
)
if org_id:
return str(org_id)
except Exception as e:
logger.debug(f"获取orgId失败({ep}): {e}")
return ""
async def _save_org_id(self, db: AsyncSession, org_id: str):
"""自动保存获取到的 orgId 到系统配置"""
result = await db.execute(
select(SystemConfig).where(SystemConfig.config_key == "platform_org_id")
)
cfg = result.scalar_one_or_none()
if cfg:
cfg.config_value = org_id
else:
db.add(SystemConfig(
config_key="platform_org_id",
config_value=org_id,
description="平台组织Id自动获取"
))
await db.commit()
logger.info(f"✅ 自动获取并保存 orgId={org_id}")
@staticmethod
def _extract_token(raw) -> tuple[str, str]:
if isinstance(raw, str) and raw:
return raw, ""
if isinstance(raw, dict):
token = (raw.get("access_token") or raw.get("accessToken") or raw.get("token") or "")
# openid 是平台用户ID登录响应里 data.openid = data.userInfo.id
uid = str(
raw.get("openid") or
raw.get("userId") or raw.get("user_id") or raw.get("id") or
(raw.get("userInfo") or {}).get("id") or ""
)
return token, uid
return "", ""
async def logout(self, db: AsyncSession, user_id: int):
user_r = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
user = user_r.scalar_one_or_none()
if user:
sess = await get_session(user_id)
await self._write_login_log(db, user, "logout",
sess.get("session_id") if sess else None)
await delete_session(user_id)
await db.execute(update(VirtualUser).where(VirtualUser.id == user_id).values(
status=0, session_token=None, session_expires_at=None))
await db.commit()
async def check_session(self, db: AsyncSession, user: VirtualUser) -> bool:
sess = await get_session(user.id)
if not sess:
return False
biz = await self._biz_url(db)
cfg = await self._client(db)
try:
params = self._build_form({}, cfg)
params.update({"orgId": cfg["orgId"] or "1", "pageNum": 1, "pageSize": 1, "status": "approved"})
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{biz}/news/list", headers=self._bearer(sess["token"]), params=params)
if r.status_code == 200 and r.json().get("code") in [0, 200]:
return True
await self.logout(db, user.id)
return False
except Exception:
return False
# ─── 新闻列表 ──────────────────────────────────────────────
async def get_news_list(self, db, user, count=5, interest_tags=None) -> list:
"""
GET /business/member/square/list 广场数据分页查询
type=1 表示新闻orgId 选填(不填则查全平台新闻,无需配置 orgId
返回字段id(广场ID), recordId(新闻实际ID), title, orgId, orgName
"""
sess = await get_session(user.id)
if not sess:
return []
biz = await self._biz_url(db)
cfg = await self._client(db)
org_id = sess.get("org_id") or cfg.get("orgId") or ""
# 先查总数再随机翻页避免每次都取第1页相同内容
import math
# 第一次查询获取总页数
first_params = self._build_form({
"pageNum": 1,
"pageSize": 50,
"type": "1",
"isPlatformShow": "true",
"isAdmin": "false",
}, cfg)
if org_id:
first_params["orgId"] = org_id
total_pages = 1
try:
async with httpx.AsyncClient(timeout=10) as _c:
_r = await _c.get(
f"{biz}/business/member/square/list",
headers=self._bearer(sess["token"]),
params=first_params
)
_d = _r.json()
if _d.get("code") in [0, 200]:
total_size = _d.get("data", {}).get("totalSize", 0)
total_pages = max(1, math.ceil(total_size / 50))
except Exception:
pass
# 随机选择一页
import random as _random
rand_page = _random.randint(1, min(total_pages, 10)) # 最多取前10页随机
params = self._build_form({
"pageNum": rand_page,
"pageSize": 50,
"type": "1",
"isPlatformShow": "true",
"isAdmin": "false",
}, cfg)
if org_id:
params["orgId"] = org_id # 选填,有则按组织过滤
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(
f"{biz}/business/member/square/list",
headers=self._bearer(sess["token"]),
params=params
)
if r.status_code == 200:
d = r.json()
if d.get("code") in [0, 200]:
nd = d.get("data", {})
items = nd.get("data") or nd.get("list") or nd.get("records") or []
# 过滤本人发布的文章
platform_uid = sess.get("platform_uid", "")
if platform_uid:
items = [x for x in items if x.get("createUser") != platform_uid]
# 过滤已知无效新闻(详情为空或不存在)
INVALID_IDS = {
"1965670408480907266","2029092495693975554","1960652956793597953",
"1960651987045347330","1960596408620838914","1960596083193180161",
"1960595664341594113",
}
items = [x for x in items
if (x.get("recordId") or x.get("id")) not in INVALID_IDS]
logger.info(f"[广场新闻] {user.account} 获取到 {len(items)} 条(已过滤本人+无效文章)")
import random as _rand
return _rand.sample(items, min(count, len(items))) if items else []
logger.warning(f"[广场新闻] {user.account} code={d.get('code')} msg={d.get('message')}")
except Exception as e:
logger.error(f"[广场新闻] {user.account}: {e}")
return []
async def read_news(self, db, user, news_id: str) -> bool:
sess = await get_session(user.id)
if not sess:
return False
biz = await self._biz_url(db)
cfg = await self._client(db)
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.patch(
f"{biz}/news/read/{news_id}",
headers=self._bearer(sess["token"]),
data=self._build_form({}, cfg),
)
return r.status_code == 200
except Exception:
return False
async def post_comment(self, db, user, news_id, news_title, content, news_author_id="", org_id="") -> tuple[bool, str]:
sess = await get_session(user.id)
if not sess:
return False, "未登录"
biz = await self._biz_url(db)
cfg = await self._client(db)
uid = sess.get("platform_uid", "")
# org_id 优先取文章自带的(从广场数据获取),否则取 session/配置
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
body = {
"module": "news", "topicId": news_id, "title": news_title,
"content": content, "orgId": final_org_id,
"toUserId": news_author_id or uid, "userId": uid,
"userName": user.nickname, "avatar": user.avatar_url or "",
}
return await self._json_post(f"{biz}/message/comment", self._bearer(sess["token"]), body)
async def post_reply(self, db, user, news_id, comment_id, content) -> tuple[bool, str]:
sess = await get_session(user.id)
if not sess:
return False, "未登录"
biz = await self._biz_url(db)
uid = sess.get("platform_uid", "")
body = {
"module": "news", "topicId": news_id, "commentId": comment_id,
"commentUserId": uid, "content": content,
"fromUserName": user.nickname, "avatar": user.avatar_url or "",
}
return await self._json_post(f"{biz}/message/comment/reply", self._bearer(sess["token"]), body)
async def get_comments(self, db, user, news_id) -> list:
sess = await get_session(user.id)
if not sess:
return []
biz = await self._biz_url(db)
cfg = await self._client(db)
try:
params = self._build_form({"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}, cfg)
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{biz}/message/comment", headers=self._bearer(sess["token"]), params=params)
if r.status_code == 200:
return r.json().get("data", {}).get("data") or []
except Exception:
pass
return []
async def like_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
sess = await get_session(user.id)
if not sess:
return False, "未登录"
biz = await self._biz_url(db)
uid = sess.get("platform_uid", "")
body = {
"module": "news",
"topicId": news_id,
"userId": uid,
"toUserId": to_user_id or uid,
"orgId": org_id or sess.get("org_id", "") or "",
"title": title,
}
return await self._json_post(f"{biz}/message/praise", self._bearer(sess["token"]), body)
async def collect_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
"""收藏新闻:复用点赞接口(平台收藏=点赞同一接口)"""
return await self.like_news(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)
async def forward_news(self, db, user, news_id) -> tuple[bool, str]:
sess = await get_session(user.id)
if not sess:
return False, "未登录"
biz = await self._biz_url(db)
cfg = await self._client(db)
org_id = sess.get("org_id") or cfg.get("orgId") or "1"
headers = self._bearer(sess["token"])
try:
async with httpx.AsyncClient(timeout=8) as c:
await c.get(f"{biz}/news/share/wechat/{news_id}", headers=headers,
params=self._build_form({}, cfg))
except Exception:
pass
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(
f"{biz}/points/forward/news/{org_id}",
headers=headers,
data=self._build_form({}, cfg),
)
return self._ok(r)
except Exception as e:
return False, str(e)
@staticmethod
def _bearer(token: str) -> dict:
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
@staticmethod
def _ok(resp: httpx.Response) -> tuple[bool, str]:
if resp.status_code in [200, 201]:
try:
d = resp.json()
if d.get("code") in [0, 200]:
return True, ""
return False, d.get("message") or "业务失败"
except Exception:
return True, ""
return False, f"HTTP {resp.status_code}"
async def _json_post(self, url, headers, body) -> tuple[bool, str]:
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(url, json=body, headers=headers)
return self._ok(r)
except Exception as e:
return False, str(e)
async def _write_login_log(self, db, user, action, session_id=None, error_msg=None):
try:
log = LoginLog(
user_id=user.id, user_account=user.account,
action=action, session_id=session_id, error_msg=error_msg,
)
db.add(log)
await db.commit()
except Exception:
pass
# ─── 取消互动 ────────────────────────────────────────────────────
async def cancel_like(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
"""DELETE /message/praise/cancel 取消点赞"""
sess = await get_session(user.id)
if not sess:
return False, "未登录"
biz = await self._biz_url(db)
uid = sess.get("platform_uid", "")
body = {
"module": "news",
"topicId": news_id,
"userId": uid,
"toUserId": to_user_id or uid,
"orgId": org_id or sess.get("org_id", "") or "",
"title": title,
}
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.delete(
f"{biz}/message/praise/cancel",
json=body,
headers=self._bearer(sess["token"])
)
d = r.json()
if d.get("code") in [0, 200]:
return True, ""
return False, d.get("message", "取消点赞失败")
except Exception as e:
return False, str(e)
async def cancel_comment(self, db, user, news_id: str, comment_id: str) -> tuple[bool, str]:
"""DELETE /message/comment/{topicId}/{id} 删除评论"""
sess = await get_session(user.id)
if not sess:
return False, "未登录"
biz = await self._biz_url(db)
cfg = await self._client(db)
# 签名参数放 formData路径里是 topicId 和 comment_id
params = self._build_form({}, cfg)
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.delete(
f"{biz}/message/comment/{news_id}/{comment_id}",
headers=self._bearer(sess["token"]),
params=params
)
d = r.json()
if d.get("code") in [0, 200]:
return True, ""
return False, d.get("message", "删除评论失败")
except Exception as e:
return False, str(e)
async def cancel_collect(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
"""取消收藏(复用取消点赞接口)"""
return await self.cancel_like(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)
# ─── 修改目标系统用户信息 ─────────────────────────────────────
async def update_user_profile(
self, db: AsyncSession, user: VirtualUser,
nick_name: str = None, real_name: str = None,
sex: int = None, avatar: str = None,
description: str = None, email: str = None,
) -> tuple[bool, str]:
"""
调用 POST /usercenter/user/change/userInfo 修改用户信息
支持:昵称、真实姓名、性别、头像、简介、邮箱
同时同步更新本地数据库
"""
sess = await get_session(user.id)
if not sess:
return False, "用户未登录,请先登录"
auth = await self._auth_url(db)
cfg = await self._client(db)
token = sess.get("token", "")
platform_uid = sess.get("platform_uid", "")
if not platform_uid:
return False, "缺少平台用户ID请重新登录"
# 构建请求体(只传有值的字段)
# 构建请求体,确保至少有 nickName 字段(平台 SQL 要求 SET 子句不为空)
body = {
"id": platform_uid,
"nickName": nick_name if nick_name is not None else (user.nickname or ""),
"realName": real_name if real_name is not None else (user.real_name or ""),
"sex": sex if sex is not None else (user.sex or 0),
}
if avatar is not None: body["avatar"] = avatar
if description is not None: body["description"] = description
if email is not None: body["email"] = email
# 使用 PATCH /v2/users/current 接口(支持修改昵称)
headers = dict(self._bearer(token))
headers["Content-Type"] = "application/json"
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.patch(
f"{auth}/v2/users/current",
json=body,
headers=headers,
)
d = r.json()
if d.get("code") in [0, 200]:
# 同步到本地数据库
local_vals = {}
if nick_name is not None: local_vals["nickname"] = nick_name
if real_name is not None: local_vals["real_name"] = real_name
if sex is not None: local_vals["sex"] = sex
if avatar is not None: local_vals["avatar_url"] = avatar
if local_vals:
from sqlalchemy import update
await db.execute(update(VirtualUser).where(
VirtualUser.id == user.id).values(**local_vals))
await db.commit()
logger.info(f"✅ 用户 {user.account} 信息已同步到目标系统")
return True, ""
err = d.get("message") or f"code={d.get('code')}"
logger.warning(f"[修改用户信息] {user.account} 失败: {err} body={r.text[:200]}")
return False, err
except Exception as e:
logger.warning(f"[修改用户信息] {user.account} 异常: {e}")
return False, str(e)
async def upload_avatar(
self, db: AsyncSession, user: VirtualUser, file_bytes: bytes, filename: str
) -> tuple[bool, str]:
"""
上传头像图片到平台 filecenter返回图片 URL
POST /filecenter/fileUpload (multipart/form-data)
"""
sess = await get_session(user.id)
if not sess:
return False, "用户未登录"
cfg = await self._client(db)
token = sess.get("token", "")
# filecenter 服务地址
biz_base = await self._biz_url(db)
# filecenter 与 huihuibusiness 同网关,替换服务名
filecenter_url = biz_base.replace("/huihuibusiness", "/filecenter")
sign_params = self._build_form({"module": "userInfo", "service": "kccloud"}, cfg)
headers = {"Authorization": f"Bearer {token}"}
try:
import mimetypes
mime = mimetypes.guess_type(filename)[0] or "image/jpeg"
files = {"file": (filename, file_bytes, mime)}
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(
f"{filecenter_url}/fileUpload",
files=files,
data=sign_params,
headers=headers,
)
d = r.json()
if d.get("code") in [0, 200]:
url = d.get("data") or d.get("url") or ""
if isinstance(url, dict):
url = url.get("url") or url.get("path") or ""
logger.info(f"✅ 头像上传成功: {url}")
return True, url
return False, d.get("message") or "上传失败"
except Exception as e:
return False, str(e)
news_service = NewsPlatformService()