36 changed files with 11938 additions and 45 deletions
+2 -2
View File
@@ -51,7 +51,7 @@
所以切分支前先确认「库的迁移版本 == 目标分支的 head」,否则应用会因为
alembic 找不到 revision **直接起不来**(`run_migrations` 没有兜底,会带崩 lifespan)。
- **`main` 与 `dev` 的迁移链已经分叉成两个 head**(共同祖先是 `20261008_0052`):
`main` 走 `20261015_0053` → … → `20261106_0066`,`dev` 走 `20261008_0053`(账号租约)。
`main` 走 `20261015_0053` → … → `20261107_0067`,`dev` 走 `20261008_0053`(账号租约)。
两个 0053 的 **revision id 不同**,所以 alembic 不会报「重复 revision」,
而是报 **Multiple head revisions are present** —— 合并 `dev` 时必须先补一个
`alembic merge` 节点,否则 `alembic upgrade head` 会直接失败(部署会卡在迁移那一步)。
@@ -120,6 +120,6 @@ docker compose up -d --force-recreate douyu-login
| 本地服务 | `./dev.sh`(8800 端口,`--reload`);**启动时会自动跑迁移** |
| 本地 MySQL | 容器 `live-hub-py-mysql-1`,端口 3307;库见 §4 |
| 生产镜像库 | `douyu_login_prod`(`scripts/refresh_local_prod_mirror.sh` 同步,只读用) |
| 迁移 head | `main` = `20261106_0066`;`dev` = `20261008_0053`(**已经分叉成两个 head**,见 §4) |
| 迁移 head | `main` = `20261107_0067`;`dev` = `20261008_0053`(**已经分叉成两个 head**,见 §4) |
| 前端 | `tsc -b` 开了 `noUnusedLocals`,死代码/未用导入直接报 TS6133;镜像构建时会 `npm run build` |
| 测试 | `pytest tests/ -q`(用 `douyu_login_test`);`ruff format` + `ruff check` |
+41
View File
@@ -0,0 +1,41 @@
"""微博微豆基础客户端。"""
from .client import (
WEIBO_HOME_URL,
WeiboApiError,
WeiboAuthError,
WeiboBindError,
WeiboBindQrCode,
WeiboClient,
WeiboError,
WeiboExchangeError,
WeiboExchangeResult,
WeiboExchangeUncertainError,
WeiboHomeSnapshot,
WeiboPaymentStatus,
WeiboRechargeError,
WeiboRechargeOrder,
WeiboTransportError,
extract_bind_raw_url,
normalize_weibo_cookie,
)
__all__ = [
"WEIBO_HOME_URL",
"WeiboApiError",
"WeiboAuthError",
"WeiboBindError",
"WeiboBindQrCode",
"WeiboClient",
"WeiboError",
"WeiboExchangeError",
"WeiboExchangeResult",
"WeiboExchangeUncertainError",
"WeiboHomeSnapshot",
"WeiboPaymentStatus",
"WeiboRechargeError",
"WeiboRechargeOrder",
"WeiboTransportError",
"extract_bind_raw_url",
"normalize_weibo_cookie",
]
+1093
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,766 @@
---
type: 协议确认
description: 微博微豆自动化唯一协议,覆盖 CK 登录态、微信/QQ 绑定二维码、助力充值、支付二维码、微豆明细、商城列表、兑换和兑换记录。
tags: [微博, 微豆, VPlus, 助威, 充值, 兑换, 微信小程序, QQ小程序, URL Scheme, 二维码, CK]
generated:
by: ai/codex
at: 2026-11-07T19:03:41+08:00
status: stable
verified:
- { by: ai/codex, at: 2026-11-07T19:03:41+08:00 }
- { by: ai/codex, at: 2026-11-08T13:45:00+08:00 }
---
# 微博微豆 CK 接口与兑换链路确认
确认日期:2026-11-07
原始样本:用户提供的 `充值1元-查询明细-兑换列表-兑换1天会员.har`。该 HAR 含账号 Cookie、用户标识、昵称和支付数据,不纳入仓库。
## 1. 结论
微博业务接口只需要微博登录 Cookie(下文简称 CK),未观察到独立的 access token、CSRF token 或客户端签名 Header。绑定二维码转换接口 `GenMiniappScheme` 不需要微博、微信或 QQ 的账号 Cookie,但仍依赖微博链接内层的 `code`、`nonce`、`sig`、`t` 业务参数。
核心链路:
```text
CK + F
-> GET /aj/mall/home 获取用户、绑定状态、余额、任务、商城入口
|
+-> 未绑定:game_role.bind_url
| -> POST GenMiniappScheme(H5 直接拉起小程序)
| -> 微信/QQ urlScheme
| -> 或 POST getMiniAppQrcode(可直接扫码的小程序码)
| -> 展示微信/QQ 小程序码图片
|
+-> 助威得微豆:
GET /aj/vmember/gfsupport 获取助力目标 uid/vuid
GET /aj/paycomponent/cashierinfo 获取收银台、商品档位、活动参数
POST /aj/paycomponent/createorder 创建充值订单
打开 data.url,读取收银台 HTML 中的支付二维码
POST /aj/pay/status 轮询支付状态
GET /aj/point/get_point_recharge_history 查询微豆获取明细
|
+-> 微豆兑换:
GET /aj/mall/list 查询商城列表与余额
GET /aj/mall/detail 查询商品详情
GET /aj/game/exchangegoods 执行兑换
GET /aj/mall/list 复核余额和库存
GET /aj/mall/exchange 查询兑换记录
```
## 2. 共同会话与 Header
### 2.1 必需状态
- 微博 CK:必需。实测不带 Cookie 时:
- `/aj/mall/home` 返回 `code=400002`、`msg=未登陆`。
- `/aj/point/get_point_recharge_history` 返回 `code=400000`、`msg=user not login`。
- `F`:活动来源标识。样本为 `bowen_tjg`,应作为活动入口参数配置,不要推导算法。
- `uid`:微博用户 ID,从 `/aj/vmember/gfsupport`、收银台响应或页面状态获取。
- `vuid`:被助威的 V+ 账号 ID,从 `/vmember/subscribe` URL、`task_list.action_url` 或对应接口获取。
CK 是否可缩减为只有 `SUB` 尚未验证。样本请求中同时出现 `SUB`、`SUBP`、`SCF`、`ALF`、`PC_TOKEN`、`v!pay` 等 Cookie,建议先维护完整 Cookie Jar,再逐步做最小化测试。
### 2.2 请求头
样本未观察到:
- `Authorization`
- `X-CSRF-Token`
- 自定义签名 Header
- 客户端加密 Body
GET 请求至少携带:
```http
Cookie: <微博 CK>
Referer: <对应页面 URL>
```
POST 请求使用:
```http
Content-Type: application/x-www-form-urlencoded
Origin: https://vipclub.weibo.com
Referer: https://vipclub.weibo.com/vmember/subscribe?...
Cookie: <微博 CK>
```
前端每个 GET 请求都会自动追加毫秒时间戳:
```text
_t=<Date.now()>
```
`_t` 是缓存破坏参数,未观察到参与签名。
## 3. 用户、绑定和任务入口
```http
GET https://vipclub.e.weibo.com/aj/mall/home?F=<F>&_t=<毫秒时间戳>
```
关键响应字段:
| 字段 | 含义 |
| --- | --- |
| `data.user_info.uid` | 当前登录微博 uid |
| `data.game_role.is_bound` | 是否已绑定游戏角色 |
| `data.game_role.role_name` | 已绑定角色 |
| `data.game_role.bind_url` | 微博小程序绑定链接 |
| `data.game_role.can_rebind` | 当前是否允许换绑 |
| `data.game_role.rebind_remain_days` | 下次可换绑的剩余天数 |
| `data.weidou_balance.balance` | 当前微豆余额 |
| `data.task_list` | 微豆任务列表 |
| `data.recommend_list` | 推荐商品 |
| `data.mall_url` | 商城入口 |
### 3.1 微信/QQ 绑定二维码
本次 HAR 的状态是:
```json
{
"is_bound": true,
"can_rebind": false
}
```
因此本次没有重新走绑定动作。页面逻辑是:
- `is_bound=false`:展示“绑定角色”,点击后打开 `game_role.bind_url`。
- `is_bound=true && can_rebind=true`:进入换绑。
- `is_bound=true && can_rebind=false`:前端直接提示剩余天数,不打开链接。
`bind_url` 形如:
```text
sinaweibo://wbdiversion?username=<username>&path=<encoded-path>
```
输入也可能直接来自其他微博小程序跳转:
```text
sinaweibo://wbdiversion?username=gh_<username>&path=/pages/gameAccountBind/index?<inner_query>
```
其中:
- `username=gh_...` 是微博侧小程序标识,不传给来联接口。
- `sinaweibo://wbdiversion` 只用于微博客户端唤起,不传给来联接口。
- `<inner_query>` 是 `path=/pages/gameAccountBind/index?` 后面的全部原始 Query。
实测内层业务参数包括:
```text
actId
code
faceUrl
gameId
gameIdList
livePlatId
nickName
nonce
sig
t
v
```
必须保留原始百分号编码。不要先 `URLDecoder`、`parse_qs` 或重新拼 Query,否则 `code`、`faceUrl` 中的多重编码可能被改变。
#### 3.1.1 生成微信/QQ URL Scheme
页面入口是:
```text
https://livelink.qq.com/h5-open-mp/wx?<inner_query>
https://livelink.qq.com/h5-open-mp/qq?<inner_query>
```
PC 页面不会直接展示二维码,而是请求:
```http
POST https://livelinkbind.game.qq.com/livelink?c=Sdk&a=GenMiniappScheme&<path内层参数>
Content-Type: application/json
```
微信请求体:
```json
{"accountType":"wx"}
```
QQ 请求体:
```json
{"accountType":"qq"}
```
成功响应:
```json
{
"iRet": 0,
"sMsg": "ok",
"jData": {
"urlScheme": "<小程序 URL Scheme>"
},
"v": 2,
"apiName": "GenMiniappScheme"
}
```
`GenMiniappScheme` 返回的是 H5 页面内“打开小程序”按钮使用的 Scheme,不等于可扫码的小程序码。它不应作为二维码图片内容,也不应把原 `livelink.qq.com` 页面地址当二维码内容。
#### 3.1.2 Scheme 格式
微信:
```text
weixin://dl/business/?appid=wx707e2eb408780a5b&path=pages/gameAccountBind/index&query=scheme_id%3D<dynamic>
```
QQ:
```text
mqqapi://miniapp/open?_atype=0&_mappid=1111906964&_miniapptype=1&_mvid=&_path=pages%2FgameAccountBind%2Findex&_vt=3&via=qq_txyxll&_sig=<dynamic>&_nq=<encoded-inner-query>
```
本次样本确认:
| 字段 | 微信 | QQ |
| --- | --- | --- |
| 小程序 AppID | `wx707e2eb408780a5b` | `1111906964` |
| 页面路径 | `pages/gameAccountBind/index` | `pages/gameAccountBind/index` |
| 动态凭证 | `scheme_id` | `_sig`、`_nq` 内的业务参数 |
| 二维码内容 | 返回的 `urlScheme` | 返回的 `urlScheme` |
AppID、路径和渠道字段只代表本次活动样本;业务参数不能写死为常量。
#### 3.1.3 鉴权与时效
`GenMiniappScheme` 实测只发送:
```http
Content-Type: application/json
```
没有发送:
- 微博 Cookie
- 微信/QQ Cookie
- Bearer Token
- 微信/QQ access token
- 微信/QQ OAuth 回调参数
- 用户登录会话
微信和 QQ 两个请求均返回 HTTP 200、`iRet=0`,响应没有 `Set-Cookie`。
这里的“不需要鉴权”只表示 Scheme 生成接口不依赖账号登录态。`code`、`nonce`、`sig`、`t` 仍是来联业务的防篡改和时效参数,不能删除或改写。
- `t` 是 Unix 秒时间戳。
- `scheme_id` 和 QQ `_sig` 由服务端动态生成。
- 样本在 `t` 后约 54 分钟仍可生成 Scheme,但不足以确定固定 TTL。
- 每次都应从新的微博链接或新的 `bind_url` 提取内层 Query,不能长期缓存。
微信页面在“微信内直接唤起小程序”的分支会调用 `/api/common/getWxJsApiTicket` 配置 `wx-open-launch-weapp`;该 JS-SDK 分支不是二维码生成链路,二维码生成不需要它。
本次没有抓到 `is_bound=false` 的独立样本,因此未绑定场景下 `bind_url` 的值仍需下一次抓包确认;但当前已绑定响应中同样存在 `bind_url`,且同一 `GenMiniappScheme` 链路已实测可用。
#### 3.1.4 真正可扫的微信/QQ 小程序码
2026-11-08 的 QQ 绑定反馈确认:`https://livelink.qq.com/h5-open-mp/qq?<inner_query>` 是 H5 入口页。用户在 HAR 中复制的链接正好等于该入口页,页面加载后再从 `location.search` 取内层 Query,并在页面内调用 `GenMiniappScheme`。把 H5 入口地址或 Scheme 字符串直接编码成二维码,会走浏览器/深链分支,不等价于扫码绑定。
真正的小程序码接口是:
```http
POST https://livelinkbind.game.qq.com/api/qrcode/getMiniAppQrcode
Content-Type: application/json;charset=UTF-8
Origin: https://livelink.qq.com
Referer: https://livelink.qq.com/
Gap-Plat-Id: <inner_query.livePlatId>
Gap-Act-Id: <inner_query.actId>
Gap-User-From: pc
Vulcan-AccType: auth_in_h5
```
请求体:
```json
{
"queryString": "<path 内层原始 Query>&sessionId=<秒级时间戳_随机串>",
"accountType": "wx"
}
```
`accountType` 只能是 `wx` 或 `qq`。实测两个渠道返回不同的官方小程序码图片:
- `wx`:微信小程序码,图片右下角为微信图标。
- `qq`:QQ 小程序码,图片右下角为 QQ 图标。
- 响应 `jData.qrcode` 是 base64 图片,前端保存为 `data:image/...;base64,...` 后直接 `<img>` 展示。
- 响应 `jData.qrcodeToken` 可用于后续调用 `/api/qrcode/poolingQrcodeStatus` 查询扫码状态。
- 同一绑定链接连续刷新可能返回 `iRet=-1140603`、`sMsg=刷新二维码频率过快,请间隔5秒后重试`。前端应让用户先选渠道,再只生成当前渠道,不要一次预生成两个渠道。
HAR `qq-二维码-1.har` 的 `switchPlatToken` 请求体 `rawUrl` 与用户复制的 H5 Query 一致;随后 `bindPlatUser` 返回 `iRet=0`,说明内层参数本身被来联接受。问题在二维码载体混用了 H5 入口/URL Scheme,而不是 `actId/code/faceUrl/nonce/sig/t` 被重新编码。
## 4. 助威得微豆与充值
### 4.1 助威入口
`/aj/mall/home` 的 `task_list` 样本中包含:
```json
{
"title": "助威得微豆",
"button_type": "cashier",
"action_url": "sinaweibo://transparentbrowser?url=<encoded-subscribe-url>"
}
```
解码后进入:
```text
https://vipclub.weibo.com/vmember/subscribe?vuid=<vuid>&source_itemid=20601&F=<F>
```
### 4.2 获取助力目标
```http
GET https://vipclub.weibo.com/aj/vmember/gfsupport?vuid=<vuid>&source_itemid=20601&F=<F>&_t=<ms>
```
响应提供:
- `data.uid`
- `data.vuid`
- `data.u_name`
- 被助威账号信息
- 是否已助力/会员
### 4.3 获取收银台和商品档位
```http
GET https://vipclub.weibo.com/aj/paycomponent/cashierinfo
?F=<F>
&tab_id=536
&cashier_id=205
&cashier_version=0.2.3
&vuid=<vuid>
&source_itemid=20601
```
页面输入还包含空值参数:
```text
appName=&from=&wm=&profileuid=&device_id=&tab_type=&bid=
```
这些空参数可以保留以贴合浏览器行为,尚未逐一测试是否可省略。
关键响应:
```text
data.cashier_id
data.tab_info[].tab_id
data.tab_info[].tab_type
data.tab_info[].menu[]
data.user_info.uid
```
样本“1 元 = 100 微豆”档位:
| 字段 | 值 |
| --- | --- |
| `commodity_id` | `2060101` |
| `subitemid` | `2060101` |
| `itemid` | `20601` |
| `appkey` | `2000` |
| `cashier_id` | `205` |
| `tab_type` | `8` |
| `max_money` | `1000` |
| `discount_info.desc` | `1元=100微豆` |
页面允许自定义 1–1000 元,提交时把金额换算成:
```text
single_reward = 金额(元) * 100
```
所以 1 元对应 `single_reward=100`。
### 4.4 创建充值订单
```http
POST https://vipclub.weibo.com/aj/paycomponent/createorder?<与 Body 相同的业务参数>
Content-Type: application/x-www-form-urlencoded
Origin: https://vipclub.weibo.com
Referer: https://vipclub.weibo.com/vmember/subscribe?vuid=<vuid>&source_itemid=20601&F=<F>
Cookie: <微博 CK>
```
样本核心 Body:
```text
subitemid=2060101
itemid=20601
appkey=2000
commodity_id=2060101
num=1
cashier_id=205
F=<F>
is_from_cashier=1
cashierpw=1
vuid=<vuid>
tab_type=8
cashier_version=0.2.3
single_reward=100
ext={"bid":"<bid>","send_blog":1}
```
其他样本字段:
```text
salestrategy_id=
discount_id=
from=
device_id=
level_up_tag=0
to_uid=
return_url=
ori_t_id=
will_auto_renew=0
subpw=
account=
follow=false
coupon_id=
gc=
bid=
mid=
```
浏览器前端拦截器会把同一组参数同时放进 URL Query 和 form Body。样本服务端接受了这种重复传参。实现时建议保持 Query + Body 一致,除非后续实测证明只传 Body 也可以。
样本 Body 中还有 `is_eligible=undefined`,这是前端直接序列化未定义值产生的字符串,不应作为可靠业务参数。
成功响应关键字段:
| 字段 | 含义 |
| --- | --- |
| `data.url` | 微博支付收银台 URL |
| `data.orderid` | 支付订单号,后续微豆明细的 `source_id` |
| `data.pay_return_url` | 支付完成后的业务回跳地址 |
| `data.return_url` | 带订单号的回跳地址 |
| `data.channel` | 样本为 `weibo_web` |
### 4.5 支付收银台和二维码
使用同一 Cookie 打开 `data.url`,经过:
```text
GET /api/merchant/pay/cashier
-> 302 /pay/pc/cashier
-> 200 收银台 HTML
```
收银台 HTML 内直接包含支付信息:
```html
<input type="hidden" name="pay_id" value="<pay_id>">
<img node-type="QRCode" src="https://qr.weibo.cn/inf/gen?...">
<a href="https://pay.sc.weibo.com/pay/pc/comb?pay_id=<pay_id>...">立即支付</a>
```
支付二维码由收银台服务端生成带 `api_key`、`data`、`sign` 的 `qr.weibo.cn/inf/gen` URL。客户端不需要自行计算该签名,按 HTML 解析即可。
### 4.6 支付状态轮询
```http
POST https://pay.sc.weibo.com/aj/pay/status
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Origin: https://pay.sc.weibo.com
Referer: <收银台 URL>
Cookie: <微博 CK>
pay_id=<pay_id>&ispage=1&_t=0
```
样本状态:
```text
code=100000
data.status=PAY_STATUS_PENDING
data.sub_status=PAY_SUB_STATUS_INITIAL
```
样本约每 1 秒轮询一次,共 16 次待支付状态;用户在支付宝扫码并完成支付后,流程跳转成功页。支付动作本身属于外部支付流程,不应由 CK 自动完成或绕过。
### 4.7 到账确认
支付完成后再次请求 `/aj/mall/home`:
```text
data.weidou_balance.balance = 100
```
微豆明细出现:
```json
{
"source_type": 101,
"record_type": 208681,
"source_id": "<createorder.data.orderid>",
"source_name": "V+助威",
"point_num": 100
}
```
本次充值 1 元,到账 100 微豆。
### 4.8 `pay/modular/prepare`
样本还出现:
```http
POST https://pay.sc.weibo.com/aj/pay/modular/prepare
```
请求体是页面初始“超能爆发”档位的 `total_amount=6000`,响应为空。本次 1 元自定义订单没有从该响应读取任何字段,实际下单依赖的是 `/aj/paycomponent/createorder`。暂不把它列入 1 元充值必需链路。
## 5. 微豆明细
```http
GET https://vipclub.e.weibo.com/aj/point/get_point_recharge_history
?F=<F>
&page_no=1
&page_size=20
&_t=<ms>
```
响应:
```text
data.page_num
data.page_size
data.total
data.list[]
data.expire_remind
```
样本明细:
| 字段 | 含义 |
| --- | --- |
| `id` | 明细 ID |
| `source_type` | 来源类型,样本为 `101` |
| `record_type` | 记录类型,样本为 `208681` |
| `source_id` | 支付订单号 |
| `source_name` | 来源名,样本为 `V+助威` |
| `point_num` | 到账微豆,样本为 `100` |
| `create_time` | 到账时间 |
| `expire_time` | 微豆过期时间 |
该接口从名称和样本看是“微豆获取/充值明细”。兑换消耗记录使用 `/aj/mall/exchange`,不要把两个接口混为一谈。
## 6. 微豆商城列表
```http
GET https://vipclub.e.weibo.com/aj/mall/list?F=<F>&_t=<ms>
```
响应:
```text
data.list[]
data.weidou_balance
data.exchange_url
```
商品字段:
```text
id
goods_name
goods_image
price
remain_count
is_sold_out
goods_desc
```
本次抓包中的商品 ID:
| goods_id | 商品 | 价格/微豆 |
| --- | --- | ---: |
| 21 | 汪仔布丁大礼包 | 28000 |
| 22 | 套装-暮夜私语 | 9880 |
| 23 | 套装-墨青刃影 | 7880 |
| 12 | 套装-暗影哥特 | 8880 |
| 14 | 套装-浪漫天命 | 3600 |
| 13 | 黑色高级特训官上衣 | 3600 |
| 18 | 幸运币礼包(小) | 200 |
| 19 | 幸运币礼包(中) | 3000 |
| 20 | 幸运币礼包(大) | 9000 |
| 15 | 荣耀勋章礼包(小) | 200 |
| 16 | 荣耀勋章礼包(中) | 3000 |
| 17 | 荣耀勋章礼包(大) | 9000 |
| 42 | 微博VIP年卡 | 10800 |
| 41 | 微博VIP月卡 | 1000 |
| 40 | 微博VIP周卡 | 400 |
| 39 | 微博会员5天卡 | 300 |
| 38 | 微博会员3天卡 | 200 |
| 37 | 微博会员1天卡 | 100 |
`remain_count` 是实时库存,不能写成常量。
## 7. 商品详情与兑换
### 7.1 商品详情
```http
GET https://vipclub.e.weibo.com/aj/mall/detail?F=<F>&goods_id=<goods_id>&_t=<ms>
```
响应包含:
```text
goods_info
weidou_balance
payment_button
nickname
game_nickname
```
只有 `payment_button.clickable=true` 且 `payment_button.type=exchange` 时,前端才允许兑换。
### 7.2 执行兑换
```http
GET https://vipclub.e.weibo.com/aj/game/exchangegoods?F=<F>&goods_id=<goods_id>&_t=<ms>
```
请求参数只有 `F`、`goods_id`、`_t`,没有额外确认 Token。
成功响应:
```json
{
"code": 100000,
"msg": "success",
"data": null
}
```
样本兑换 `goods_id=37`(微博会员1天卡)后再次请求 `/aj/mall/list`:
- `data.weidou_balance` 从 `100` 变为 `0`;
- goods 37 的 `remain_count` 从 `4639` 变为 `4638`。
因此 `code=100000` 表示请求成功,但仍应刷新商城列表确认余额和库存变化。
注意:这是使用 GET 执行状态变更的接口。不能做自动重试、并发点击或把一次成功当成幂等。
### 7.3 兑换记录
```http
GET https://vipclub.e.weibo.com/aj/mall/exchange
?F=<F>
&page=1
&page_size=20
&_t=<ms>
```
响应:
```text
data.page
data.pageSize
data.list
data.totalCount
```
本次在兑换前查询两次,结果均为空列表。兑换后没有再请求该接口,因此兑换成功后的记录字段仍需补抓一次。
## 8. 同一页面的辅助接口
以下接口出现在页面加载流程中,但不是上述业务的必需前置:
| 接口 | 用途 |
| --- | --- |
| `/aj/paycomponent/userinfo` | 获取收银台 tab |
| `/aj/paycomponent/couponlist` | 优惠券状态 |
| `/aj/strategy/info` | 活动策略提示 |
| `/aj/paycomponent/privilege` | V+ 权益展示 |
| `/actionlog` | 前端埋点 |
## 9. 实现约束
1. 所有微博业务接口都依赖与目标用户一致的 Cookie;先验证 `/aj/mall/home` 返回 `code=100000`。
2. 使用 Cookie Jar,保留响应和收银台页面产生的 Cookie 更新。
3. `_t` 使用当前毫秒时间,不复用抓包值。
4. `uid`、`vuid`、`cashier_id`、`tab_id`、`itemid`、`appkey` 和 `single_reward` 来自页面响应,业务代码只保留活动配置。
5. 充值金额以 `single_reward` 为准:1 元填 `100`。
6. 兑换接口是 GET 状态变更,必须设置本地防重锁并禁止自动重试。
7. 支付二维码从 `createorder.data.url` 对应的收银台 HTML 中解析,不要重新计算 `qr.weibo.cn` 的 `sign`。
8. 不记录或回显完整 Cookie、支付 URL、二维码短链、uid、vuid、昵称和订单号。
9. 已导入 CK 的单行复制走 `POST /api/weibo/accounts/{account_id}/ck/copy`,由独立权限 `weibo:account:ck_export` 控制;列表/详情不返回 CK 明文,复制动作写 `weibo:account:ck_export` 审计,但审计 detail 只记账号 ID 与 UID,不写 CK 本体。
## 10. 鉴权与错误处理
已确认:
- 没有独立业务 Token;CK 是核心认证状态。
- 没有观察到 CSRF Header 或请求签名算法。
- 未登录时接口会返回 `400002`、`400000` 或跳转微博登录页。
建议处理:
| code | 处理 |
| --- | --- |
| `100000` | 成功 |
| `100002` | 登录失效,停止任务并要求更新 CK |
| `100004` | 进入微博错误页,停止任务 |
| `400002` | `未登陆`,CK 无效 |
| `400000` | `user not login`,CK 无效 |
| 其他 | 保留服务端 `msg` 和 `trace_id`,不要盲目重试状态变更接口 |
### 10.1 登录页 HTML 与 `retcode=6102`
部分 V+ / 支付域接口在登录态失效时仍返回 HTTP 200,但 `Content-Type` 为 `text/html`,正文是“新浪通行证”跳转页,并包含:
```text
retcode=6102
```
2026-11-08 实测到同一账号可以正常请求:
```text
GET https://vipclub.e.weibo.com/aj/mall/home
-> code=100000
```
但后续请求:
```text
GET https://vipclub.weibo.com/aj/vmember/gfsupport
GET https://vipclub.weibo.com/aj/paycomponent/userinfo
-> text/html + retcode=6102
```
这说明 CK 已经不能访问 V+ 支付/活动域,不能归类为普通“非 JSON 查询失败”。处理约定:
- `retcode=6102` 或跳转 `/sso/login.php` 归类为 `WeiboAuthError`。
- 账号 `ck_check_status` 写为 `invalid`,UI 显示「失效」。
- 提示用户重新登录微博,并在 V+ 页面可用后重新导出、导入完整 CK。
- 不要通过反复刷新、重试支付接口来“恢复”状态。
- 其他未知 HTML 或网关错误保留为 `error`,UI 显示「查询失败」,不要误标成 CK 失效。
## 11. 尚未确认
- 是否只需要 `SUB`,还是必须完整 Cookie Jar。
- 本次未抓 `is_bound=false` 的绑定响应。
- 本次未抓兑换后的 `/aj/mall/exchange` 记录。
- `/aj/game/exchangegoods` 在余额不足、重复兑换、角色未绑定时的错误码。
- 支付订单在支付宝侧取消、超时、退款后的业务状态。
- 各活动的 `F`、`itemid`、`source_itemid`、`cashier_id`、`tab_id` 是否跨活动变化;实现时应从页面动态读取。
+5 -4
View File
@@ -580,6 +580,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
| POST | `/api/huya/external-orders/activity/query` | `huya:collab:edit` | 勾选多行查积分 + 兑换记录(串行带间隔,每行写一条生命周期快照;§5.7) |
| POST | `/api/huya/external-orders/{id}/activity/query` | `huya:collab:edit` | 查这一行的积分 + 兑换记录(单行「查询」按钮) |
| GET | `/api/huya/external-orders/{id}/metrics` | `huya:collab:view` | 账号全生命周期时间线(每次查询的快照,新→旧;§3.5) |
| POST | `/api/huya/external-orders/{id}/ck/copy` | **`huya:collab:ck_export`** | **复制单行 CK 明文**(前端写剪贴板;逐行写审计;§11.8) |
| POST | `/api/huya/external-orders/ck/export` | **`huya:collab:ck_export`** | **导出勾选行的 CK 明文**(前端写剪贴板;逐行写审计;最多 50 条;§11.8) |
| POST | `/api/huya/external-orders/import` | `huya:collab:edit` | 粘贴批量导入 |
| GET | `/api/huya/collab-shops` | `huya:collab:view` | 店铺字典(含停用的,管理弹窗用) |
@@ -927,9 +928,9 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
| 面 | 落地 |
| --- | --- |
| 权限 | 新增权限点 **`huya:collab:ck_export`**("导出外部 CK 协作台账的 CK");**默认只给运营**,客服默认没有(贴 CK 是"录入",导出是"把凭据带走",两件事不共用一个权限;口径同 `huya:cookie:export`) |
| 权限 | 权限点 **`huya:collab:ck_export`**("复制/导出外部 CK 协作台账的 CK");单行复制和勾选批量导出共用;**默认运营和客服都有**,权限页的自定义权限仍可覆盖 |
| 服务层 | `export_cks(db, current, orders)`:只读 + 逐行写审计;返回 `{total, exported, skipped[], items[]}`,`items[].ck_text` 是明文(**只有这个接口会返回**);上限 `PROBE_BATCH_MAX=50` |
| 接口 | `POST /api/huya/external-orders/ck/export`(字面量路径,排在 `/{order_id}` 之前),body `{ids: [...]}` |
| 接口 | 单行:`POST /api/huya/external-orders/{id}/ck/copy`;批量:`POST /api/huya/external-orders/ck/export`(字面量路径,排在 `/{order_id}` 之前),body `{ids: [...]}`;两者共用同一个权限依赖 |
| 审计 | 逐行写 `huya:collab:ck_export`,`target=huya_external_order:<id>`,detail 只记 `order_no / uid / ck_status / batch_size` —— **绝不写 CK 本体**(测试里用 `assert "C"*152 not in detail` 钉死) |
| 前端 | 工具栏「导出 CK(N)」→ 拉明文 → 弹窗预览 + 「复制到剪贴板」(走 `utils/clipboard.ts`,兼容 http 远程访问的 `execCommand` 回退) |
@@ -943,7 +944,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
界面上点名"其中 N 条已失效/已停用" —— 不替用户做隐含决策(服务层只管"有没有凭据");
6. **上限 50 行**(和批量验证/查询一致),一次别点太多,人看不过来也难追溯。
**待跟进**:导出动作目前只写审计;如果以后要做"谁在什么时候导出了什么"的专门报表,
**待跟进**:单行复制和批量导出目前都只写审计;如果以后要做"谁在什么时候导出了什么"的专门报表,
`audit_logs.action = 'huya:collab:ck_export'` 已经可以直接筛(不用加表)。
### 11.9 2026-10-17 第六轮:批量查询拆成「查积分」+「查兑换记录」两个按钮
@@ -1083,7 +1084,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
| --- | --- |
| 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 |
| 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 |
| CK 被导出后外传(导出到剪贴板 = 凭据出门) | 独立权限点 `huya:collab:ck_export`(默认只给运营)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) |
| CK 被导出后外传(导出到剪贴板 = 凭据出门) | 单行复制与批量导出统一走 `huya:collab:ck_export`(默认运营和客服可用,权限页可覆盖)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) |
| 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) |
| 自动改状态误判(接口抖动 → 判成"抢购失败") | 三态判定:取不到数据一律 `unknown` 且不动状态;只有 `scripted` 的单才自动改;每次查询留快照,人工可回退(`rollback`) |
| 积分中心 `queryAllocation` 拉不到 / 返回空 → 礼包列表空白 | 只影响「拉礼包」,不影响 CK 有效性;提示用户稍后点「强制刷新礼包」。旧 `getActPrizeList` 的 SID 过期问题只保留给精英宝典工作台(§11.4) |
+26 -14
View File
@@ -16,6 +16,7 @@ import pytest
from core.huya.taf_protocol import TafOutputStream, TafType
from tests.support.db import get_test_engine, make_test_session
from web.backend.deps import require_permission
from web.backend.huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_OUTER_ACT_ID,
@@ -3090,22 +3091,33 @@ class TestCollabCkExport(TestCollabBase):
collab.export_cks(self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1))
assert excinfo.value.code == "ck_export_too_large"
def test_single_row_copy_is_super_admin_only(self):
"""操作列「复制 CK」只给管理员;运营/客服即使有导出权限也不能走这个入口。"""
def test_single_row_copy_uses_the_same_export_permission(self):
"""单行复制与批量导出共用权限点,运营和客服默认都可用。"""
order = self._create(order_no="EXP-ADMIN-1", ck_text=fake_ck("1199647000099"))
with pytest.raises(collab_api.HTTPException) as excinfo:
collab_api.copy_external_order_ck(order.id, self.db, self.user)
assert excinfo.value.status_code == 403
checker = require_permission("huya:collab:ck_export")
assert checker(self.user) == self.user
assert checker(self.other) == self.other
admin = User(username="collab_admin", password_hash="h", role="super_admin")
self.db.add(admin)
self.db.commit()
result = collab_api.copy_external_order_ck(order.id, self.db, admin)
result = collab_api.copy_external_order_ck(order.id, self.db, self.other)
assert result["exported"] == 1
assert result["items"][0]["order_no"] == "EXP-ADMIN-1"
assert result["items"][0]["ck_text"] == order.cookie
assert self._audits(order)[-1].username == "collab_admin"
assert self._audits(order)[-1].username == self.other.username
def test_single_row_copy_rejects_user_without_export_permission(self):
checker = require_permission("huya:collab:ck_export")
denied = User(
username="collab_denied",
password_hash="h",
role="support",
custom_permissions=[],
is_active=True,
)
with pytest.raises(collab_api.HTTPException) as excinfo:
checker(denied)
assert excinfo.value.status_code == 403
class TestProbeCache(TestCollabBase):
@@ -3162,10 +3174,10 @@ class TestCollabPermissions:
assert "file:view" in ROLE_PERMISSIONS[role]
assert "file:upload" in ROLE_PERMISSIONS[role]
def test_ck_export_is_operation_only_by_default(self):
"""导出 CK = 把凭据带出系统:默认只给运营(要发给客服就在权限页单独勾)。"""
assert "huya:collab:ck_export" in ROLE_PERMISSIONS["operation"]
assert "huya:collab:ck_export" not in ROLE_PERMISSIONS["support"]
def test_ck_export_is_available_to_operation_and_support_by_default(self):
"""单行复制与批量导出共用权限,默认运营、客服都可用。"""
for role in ("operation", "support"):
assert "huya:collab:ck_export" in ROLE_PERMISSIONS[role]
assert "huya:collab:ck_export" in ROLE_PERMISSIONS["super_admin"]
def test_collab_module_does_not_touch_huya_accounts(self):
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261107_0067"
HEAD_REVISION = "20261108_0071"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
+679
View File
@@ -0,0 +1,679 @@
"""微博 CK 导入与绑定二维码接口契约。"""
from __future__ import annotations
from typing import ClassVar
import pytest
from fastapi import HTTPException
from sqlalchemy import text
from core.weibo import (
WeiboAuthError,
WeiboBindError,
WeiboBindQrCode,
WeiboExchangeResult,
WeiboHomeSnapshot,
WeiboPaymentStatus,
WeiboRechargeError,
WeiboRechargeOrder,
)
from web.backend.models import AuditLog, User, WeiboAccount
from web.backend.routers import weibo as weibo_router
from web.backend.routers.weibo import (
copy_account_ck,
create_bind_qrcodes,
disable_account,
enable_account,
get_account,
import_cookies,
list_accounts,
refresh_account,
update_account,
)
from web.backend.schemas import (
WeiboAccountDisableRequest,
WeiboAccountUpdate,
WeiboCookieImport,
WeiboExchangeRequest,
WeiboPaymentStatusRequest,
WeiboRechargeRequest,
)
from web.backend.services import weibo_service
BIND_URL = (
"sinaweibo://wbdiversion?username=gh_test"
"&path=%2Fpages%2FgameAccountBind%2Findex%3FactId%3D16722"
"%26code%3Dabc%252Bdef%253D%26gameId%3Dcjm"
"%26livePlatId%3Dweiyouxi%26nonce%3Dabc123"
"%26sig%3D0123456789abcdef%26t%3D1794046086%26v%3D2.0"
)
class StubWeiboClient:
snapshot = WeiboHomeSnapshot(
uid="4014000001",
nickname="测试微博",
is_bound=False,
role_name="",
can_rebind=True,
rebind_remain_days=0,
weidou_balance=100,
bind_url=BIND_URL,
)
scheme_errors: ClassVar[dict[str, str]] = {}
payment_error: ClassVar[WeiboRechargeError | None] = None
calls: ClassVar[list[tuple[str, str]]] = []
def __init__(self, cookie: str = "", **_kwargs):
self.cookie = cookie
def fetch_home(self, activity: str = ""):
self.calls.append(("home", activity))
if "expired" in self.cookie:
raise WeiboAuthError("未登陆")
return self.snapshot
def check_payment_access(self, activity: str = "", *, home=None):
_ = home
self.calls.append(("payment", activity))
if self.payment_error is not None:
raise self.payment_error
def generate_bind_scheme(self, bind_url: str, account_type: str):
self.calls.append(("scheme", account_type))
error = self.scheme_errors.get(account_type)
if error:
raise WeiboBindError(error)
prefix = (
"weixin://dl/business/?appid=wx-test"
if account_type == "wx"
else "mqqapi://miniapp/open?_mappid=qq-test"
)
return prefix
def generate_bind_qrcode(self, bind_url: str, account_type: str):
self.calls.append(("qrcode", account_type))
error = self.scheme_errors.get(account_type)
if error:
raise WeiboBindError(error)
mime = "png" if account_type == "wx" else "jpeg"
return WeiboBindQrCode(
image_src=f"data:image/{mime};base64,test-{account_type}",
token=f"token-{account_type}",
)
@pytest.fixture
def current_user(db):
user = User(username="weibo-operator", password_hash="hash", role="super_admin")
db.add(user)
db.commit()
return user
@pytest.fixture(autouse=True)
def stub_client(monkeypatch):
StubWeiboClient.calls = []
StubWeiboClient.scheme_errors = {}
StubWeiboClient.payment_error = None
StubWeiboClient.snapshot = WeiboHomeSnapshot(
uid="4014000001",
nickname="测试微博",
is_bound=False,
role_name="",
can_rebind=True,
rebind_remain_days=0,
weidou_balance=100,
bind_url=BIND_URL,
)
monkeypatch.setattr(weibo_service, "WeiboClient", StubWeiboClient)
def test_import_creates_encrypted_account_and_is_idempotent(db, current_user):
first = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag="运营"),
db=db,
current=current_user,
)
second = import_cookies(
WeiboCookieImport(text="SCF=c; SUB=d", tag="新标签"),
db=db,
current=current_user,
)
assert first.count == 1
assert second.count == 1
assert db.query(WeiboAccount).count() == 1
account = db.query(WeiboAccount).one()
assert account.uid == "4014000001"
assert account.cookie == "SCF=c; SUB=d"
assert account.tag == "新标签"
assert account.ck_check_status == "valid"
raw_cookie = db.execute(text("SELECT cookie FROM weibo_accounts")).scalar_one()
assert raw_cookie.startswith("enc:v1:")
assert "SUB=d" not in raw_cookie
dumped = second.accounts[0].model_dump()
assert dumped["uid"] == "4014000001"
assert dumped["is_bound"] is False
assert dumped["weidou_balance"] == 100
assert "cookie" not in dumped
def test_import_reports_invalid_ck_per_line_and_keeps_valid_account(db, current_user):
result = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=expired\nSCF=b; SUB=valid", tag=""),
db=db,
current=current_user,
)
assert result.count == 1
assert len(result.errors) == 1
assert result.errors[0].line == 1
assert "未登陆" in result.errors[0].message
assert db.query(WeiboAccount).count() == 1
def test_import_reports_payment_access_failure(db, current_user):
StubWeiboClient.payment_error = WeiboRechargeError("触发微博账号风控")
result = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
)
assert result.count == 0
assert len(result.errors) == 1
assert "触发微博账号风控" in result.errors[0].message
assert db.query(WeiboAccount).count() == 0
def test_refresh_marks_payment_access_failure(db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_out.id).one()
StubWeiboClient.payment_error = WeiboRechargeError("触发微博账号风控")
with pytest.raises(WeiboRechargeError, match="触发微博账号风控"):
weibo_service.refresh_weibo_account(db, account)
assert account.ck_check_status == "error"
assert "触发微博账号风控" in account.ck_check_message
def test_refresh_marks_payment_login_expiry_as_invalid(db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_out.id).one()
StubWeiboClient.payment_error = WeiboAuthError("支付域 retcode=6102")
with pytest.raises(WeiboAuthError, match="6102"):
weibo_service.refresh_weibo_account(db, account)
assert account.ck_check_status == "invalid"
assert "6102" in account.ck_check_message
def test_copy_account_ck_returns_plaintext_and_audits_without_ck(db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
result = copy_account_ck(account_out.id, db=db, current=current_user)
dumped = result.model_dump()
assert dumped["account_id"] == account_out.id
assert dumped["ck_text"] == "SCF=a; SUB=b"
audit = (
db.query(AuditLog).filter(AuditLog.action == "weibo:account:ck_export").one()
)
assert dumped["uid"] in audit.detail
assert "SUB=b" not in audit.detail
def test_copy_account_ck_uses_export_permission_defaults(db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
support = User(
username="weibo-copy-support",
password_hash="hash",
role="support",
is_active=True,
)
db.add(support)
db.commit()
with pytest.raises(HTTPException) as exc_info:
copy_account_ck(account_out.id, db=db, current=support)
assert exc_info.value.status_code == 403
operator = User(
username="weibo-copy-operator",
password_hash="hash",
role="operation",
is_active=True,
)
db.add(operator)
db.commit()
result = copy_account_ck(account_out.id, db=db, current=operator)
assert result.ck_text == "SCF=a; SUB=b"
def test_list_accounts_returns_parallel_page_shape(db, current_user):
assert (
import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).count
== 1
)
page = list_accounts(page=1, page_size=20, db=db, current=current_user)
assert page["total"] == 1
dumped = page["items"][0].model_dump()
assert set(dumped) >= {
"id",
"uid",
"nickname",
"tag",
"remark",
"is_enabled",
"is_bound",
"can_rebind",
"rebind_remain_days",
"weidou_balance",
"ck_check_status",
}
def test_account_crud_and_lifecycle(db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag="初始"),
db=db,
current=current_user,
).accounts[0]
detail = get_account(
account_out.id,
db=db,
current=current_user,
).model_dump()
assert detail["is_enabled"] is True
assert detail["remark"] == ""
updated = update_account(
account_out.id,
WeiboAccountUpdate(tag="重点", remark="客户 A"),
db=db,
current=current_user,
).model_dump()
assert updated["tag"] == "重点"
assert updated["remark"] == "客户 A"
disabled = disable_account(
account_out.id,
WeiboAccountDisableRequest(reason="暂停使用"),
db=db,
current=current_user,
).model_dump()
assert disabled["is_enabled"] is False
assert disabled["disabled_reason"] == "暂停使用"
disabled_page = list_accounts(
enabled=False,
page=1,
page_size=20,
db=db,
current=current_user,
)
assert disabled_page["total"] == 1
assert disabled_page["items"][0].model_dump()["is_enabled"] is False
with pytest.raises(HTTPException) as exc_info:
refresh_account(account_out.id, db=db, current=current_user)
assert exc_info.value.status_code == 409
disabled_actions = [
lambda: create_bind_qrcodes(account_out.id, db=db, current=current_user),
lambda: weibo_router.list_mall_goods(
account_out.id,
db=db,
current=current_user,
),
lambda: weibo_router.exchange_goods(
account_out.id,
WeiboExchangeRequest(goods_id=37, expected_price=100, confirm=True),
db=db,
current=current_user,
),
lambda: weibo_router.create_recharge_order(
account_out.id,
WeiboRechargeRequest(amount_yuan=1),
db=db,
current=current_user,
),
]
for action in disabled_actions:
with pytest.raises(HTTPException) as disabled_exc:
action()
assert disabled_exc.value.status_code == 409
import_cookies(
WeiboCookieImport(text="SCF=c; SUB=d", tag=""),
db=db,
current=current_user,
)
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_out.id).one()
assert account.is_enabled is False
assert account.tag == "重点"
enabled = enable_account(
account_out.id,
db=db,
current=current_user,
).model_dump()
assert enabled["is_enabled"] is True
assert enabled["disabled_reason"] == ""
def test_bind_qrcodes_returns_both_channels_and_partial_error(db, current_user):
account = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
StubWeiboClient.scheme_errors = {"qq": "QQ Scheme 暂时不可用"}
result = create_bind_qrcodes(account.id, db=db, current=current_user)
dumped = result.model_dump()
assert dumped["account_id"] == account.id
assert dumped["is_bound"] is False
assert dumped["can_rebind"] is True
by_channel = {item["channel"]: item for item in dumped["schemes"]}
assert by_channel["wx"]["scheme"].startswith("weixin://")
assert by_channel["wx"]["error"] == ""
assert by_channel["qq"]["scheme"] == ""
assert by_channel["qq"]["error"] == "QQ Scheme 暂时不可用"
def test_bind_qrcodes_selected_channel_returns_scannable_image(db, current_user):
account = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
result = create_bind_qrcodes(
account.id,
channel="qq",
db=db,
current=current_user,
)
dumped = result.model_dump()
assert len(dumped["schemes"]) == 1
qq = dumped["schemes"][0]
assert qq["channel"] == "qq"
assert qq["qrcode_image"] == "data:image/jpeg;base64,test-qq"
assert qq["qrcode_token"] == "token-qq"
assert qq["scheme"].startswith("mqqapi://miniapp/open")
assert [call for call in StubWeiboClient.calls if call[0] == "qrcode"] == [
("qrcode", "qq")
]
def test_bind_qrcodes_rejects_bound_account_without_rebind(db, current_user):
account = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
StubWeiboClient.snapshot = WeiboHomeSnapshot(
uid=account.uid,
nickname=account.nickname,
is_bound=True,
role_name="测试角色",
can_rebind=False,
rebind_remain_days=7,
weidou_balance=100,
bind_url=BIND_URL,
)
with pytest.raises(HTTPException) as exc_info:
create_bind_qrcodes(account.id, db=db, current=current_user)
assert exc_info.value.status_code == 409
assert "7 天" in str(exc_info.value.detail)
def test_manual_mall_exchange_and_point_routes_serialize(monkeypatch, db, current_user):
account = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
monkeypatch.setattr(
weibo_router,
"query_weibo_mall",
lambda *_args, **_kwargs: {
"weidou_balance": 120,
"list": [
{
"id": 37,
"goods_name": "会员1天卡",
"price": 100,
"remain_count": 99,
"is_sold_out": False,
}
],
},
)
monkeypatch.setattr(
weibo_router,
"query_weibo_exchange_records",
lambda *_args, **_kwargs: {
"page": 1,
"pageSize": 20,
"totalCount": 1,
"list": [
{
"goods_name": "会员1天卡",
"game_account": "测试角色",
"cost_weidou": 100,
"exchange_time": "2026-11-07 18:00:00",
"reward_status": 2,
}
],
},
)
monkeypatch.setattr(
weibo_router,
"query_weibo_point_history",
lambda *_args, **_kwargs: {
"page_num": 1,
"page_size": 20,
"total": 1,
"list": [
{
"id": 1,
"source_name": "V+助威",
"point_num": 100,
"source_id": "order-1",
}
],
},
)
mall = weibo_router.list_mall_goods(
account.id,
db=db,
current=current_user,
).model_dump()
exchange = weibo_router.list_exchange_records(
account.id,
db=db,
current=current_user,
).model_dump()
points = weibo_router.list_point_history(
account.id,
db=db,
current=current_user,
).model_dump()
assert mall["goods"][0]["goods_name"] == "会员1天卡"
assert mall["weidou_balance"] == 120
exchange_item = exchange["items"][0]
assert set(exchange_item) == {
"goods_name",
"game_account",
"cost_weidou",
"exchange_time",
"reward_status",
"raw",
}
assert exchange_item["game_account"] == "测试角色"
assert exchange_item["cost_weidou"] == 100
assert exchange_item["exchange_time"] == "2026-11-07 18:00:00"
assert exchange_item["reward_status"] == 2
assert "id" not in exchange_item
assert exchange["total"] == 1
assert points["items"][0]["source_id"] == "order-1"
def test_manual_recharge_order_and_status_routes_serialize(
monkeypatch, db, current_user
):
account = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
monkeypatch.setattr(
weibo_router,
"create_weibo_recharge_order",
lambda *_args, **_kwargs: WeiboRechargeOrder(
order_id="order-1",
pay_id="pay-1",
amount_yuan=1,
single_reward=100,
alipay_qr_code="https://qr.alipay.com/upx-test",
desktop_pay_url="https://pay.sc.weibo.com/pay/pc/comb?pay_id=pay-1",
),
)
monkeypatch.setattr(
weibo_router,
"query_weibo_payment_status",
lambda *_args, **_kwargs: WeiboPaymentStatus(
pay_id="pay-1",
out_pay_id="order-1",
status="PAY_STATUS_PENDING",
sub_status="PAY_SUB_STATUS_INITIAL",
),
)
order = weibo_router.create_recharge_order(
account.id,
WeiboRechargeRequest(amount_yuan=1),
db=db,
current=current_user,
).model_dump()
status = weibo_router.get_recharge_status(
account.id,
WeiboPaymentStatusRequest(pay_id="pay-1"),
db=db,
current=current_user,
).model_dump()
assert order["single_reward"] == 100
assert order["alipay_qr_code"] == "https://qr.alipay.com/upx-test"
assert status["status"] == "PAY_STATUS_PENDING"
def test_recharge_failure_keeps_valid_ck_status(monkeypatch, db, current_user):
account_out = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_out.id).one()
def reject_recharge(*_args, **_kwargs):
raise WeiboRechargeError("微博支付页拒绝了当前账号")
monkeypatch.setattr(
StubWeiboClient,
"create_recharge_order",
reject_recharge,
raising=False,
)
with pytest.raises(WeiboRechargeError, match="支付页拒绝"):
weibo_service.create_weibo_recharge_order(
db,
account,
amount_yuan=1,
)
assert account.ck_check_status == "valid"
assert account.ck_check_message == ""
def test_manual_exchange_requires_confirm_and_serializes_result(
monkeypatch, db, current_user
):
account = import_cookies(
WeiboCookieImport(text="SCF=a; SUB=b", tag=""),
db=db,
current=current_user,
).accounts[0]
monkeypatch.setattr(
weibo_router,
"exchange_weibo_goods",
lambda *_args, **_kwargs: WeiboExchangeResult(
goods_id=37,
goods_name="微博会员1天卡",
price=100,
balance_before=200,
balance_after=100,
remain_before=10,
remain_after=9,
verified=True,
),
)
with pytest.raises(HTTPException) as exc_info:
weibo_router.exchange_goods(
account.id,
WeiboExchangeRequest(goods_id=37, expected_price=100, confirm=False),
db=db,
current=current_user,
)
assert exc_info.value.status_code == 400
result = weibo_router.exchange_goods(
account.id,
WeiboExchangeRequest(goods_id=37, expected_price=100, confirm=True),
db=db,
current=current_user,
).model_dump()
assert result["goods_id"] == 37
assert result["balance_after"] == 100
assert result["verified"] is True
+771
View File
@@ -0,0 +1,771 @@
"""微博微豆基础客户端协议测试。"""
from __future__ import annotations
from typing import Any
import pytest
import requests
from core.weibo import (
WeiboApiError,
WeiboAuthError,
WeiboBindError,
WeiboBindQrCode,
WeiboClient,
WeiboExchangeError,
WeiboExchangeUncertainError,
WeiboHomeSnapshot,
WeiboRechargeError,
WeiboTransportError,
extract_bind_raw_url,
normalize_weibo_cookie,
)
BIND_URL = (
"sinaweibo://wbdiversion?username=gh_test"
"&path=%2Fpages%2FgameAccountBind%2Findex%3FactId%3D16722"
"%26code%3Dabc%252Bdef%253D%26gameId%3Dcjm"
"%26livePlatId%3Dweiyouxi%26nonce%3Dabc123"
"%26sig%3D0123456789abcdef%26t%3D1794046086%26v%3D2.0"
)
class FakeResponse:
def __init__(
self,
payload,
url: str = "",
status_code: int = 200,
headers: dict[str, str] | None = None,
):
self.payload = payload
self.text = payload if isinstance(payload, str) else ""
self.url = url
self.status_code = status_code
self.headers = {key.lower(): value for key, value in (headers or {}).items()}
def raise_for_status(self):
if self.status_code >= 400:
raise requests.HTTPError(f"HTTP {self.status_code}")
def json(self):
if not isinstance(self.payload, dict):
raise requests.exceptions.JSONDecodeError("not json", self.text, 0)
return self.payload
class FakeSession(requests.Session):
def __init__(self, responses):
super().__init__()
self.responses = list(responses)
self.calls = []
def request(self, method, url, **_kwargs):
kwargs = _kwargs
self.calls.append((method, url, kwargs))
result = self.responses.pop(0)
if isinstance(result, Exception):
raise result
response = result if isinstance(result, FakeResponse) else FakeResponse(result)
if not response.url:
response.url = url
return response
def test_normalize_cookie_accepts_prefix_and_multiline():
raw = "Cookie: SCF=a;\n SUB=b;SUBP=c\n"
assert normalize_weibo_cookie(raw) == "SCF=a; SUB=b; SUBP=c"
def test_extract_bind_raw_url_preserves_inner_encoding():
raw = extract_bind_raw_url(BIND_URL)
assert raw.startswith("actId=16722&code=abc%2Bdef%3D")
assert "faceUrl" not in raw
# 内层 code 的多重编码不能被再次解开
assert "abc+def=" not in raw
def test_extract_bind_raw_url_rejects_wrong_path():
with pytest.raises(WeiboBindError, match="绑定小程序路径"):
extract_bind_raw_url("sinaweibo://wbdiversion?path=%2Fwrong")
def test_fetch_home_returns_basic_snapshot():
session = FakeSession(
[
{
"code": 100000,
"msg": "success",
"data": {
"user_info": {"uid": "4014000001", "nickname": "测试微博"},
"game_role": {
"is_bound": False,
"role_name": "",
"can_rebind": True,
"rebind_remain_days": 0,
"bind_url": BIND_URL,
},
"weidou_balance": {"balance": 100},
"task_list": [
{
"button_type": "cashier",
"action_url": (
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
}
],
},
}
]
)
client = WeiboClient("SCF=a; SUB=b", session=session)
snapshot = client.fetch_home("bowen_tjg")
assert snapshot.uid == "4014000001"
assert snapshot.nickname == "测试微博"
assert snapshot.is_bound is False
assert snapshot.can_rebind is True
assert snapshot.weidou_balance == 100
assert snapshot.activity == "bowen_tjg"
assert "source_itemid=20601" in snapshot.recharge_url
method, url, kwargs = session.calls[0]
assert method == "GET"
assert "F=bowen_tjg" in url
assert "Cookie" not in kwargs["headers"]
assert session.cookies.get("SUB", domain=".weibo.com") == "b"
def test_fetch_home_marks_auth_error():
session = FakeSession([{"code": 400002, "msg": "未登陆"}])
client = WeiboClient("SUB=expired", session=session)
with pytest.raises(WeiboAuthError, match="未登陆"):
client.fetch_home()
def test_fetch_home_keeps_trace_id_for_other_api_error():
session = FakeSession(
[{"code": 100003, "msg": "活动异常", "trace_id": "trace-001"}]
)
client = WeiboClient("SUB=test", session=session)
with pytest.raises(WeiboApiError, match="trace-001"):
client.fetch_home()
def test_fetch_home_treats_sina_login_redirect_as_auth_error():
session = FakeSession(
[
(
"<html><head><meta http-equiv='refresh' "
"content=\"0; url='https://vipclub.weibo.com/aj/vmember/"
"gfsupport?retcode=6102'\"></head></html>"
)
]
)
client = WeiboClient("SUB=expired", session=session)
with pytest.raises(WeiboAuthError, match="重新登录微博"):
client.fetch_home()
def test_fetch_home_reports_non_json_endpoint_path():
session = FakeSession(["<html><body>gateway error</body></html>"])
client = WeiboClient("SUB=test", session=session)
with pytest.raises(WeiboTransportError, match="/aj/mall/home"):
client.fetch_home()
def test_generate_bind_scheme_posts_account_type_without_weibo_cookie():
session = FakeSession(
[
{
"iRet": 0,
"sMsg": "ok",
"jData": {
"urlScheme": "weixin://dl/business/?appid=wx-test&query=scheme_id%3D1"
},
"apiName": "GenMiniappScheme",
}
]
)
client = WeiboClient(session=session)
scheme = client.generate_bind_scheme(BIND_URL, "wx")
assert scheme.startswith("weixin://dl/business/")
method, url, kwargs = session.calls[0]
assert method == "POST"
assert "c=Sdk&a=GenMiniappScheme&actId=16722" in url
assert kwargs["json"] == {"accountType": "wx"}
assert "Cookie" not in kwargs["headers"]
def test_generate_bind_scheme_rejects_missing_url_scheme():
session = FakeSession([{"iRet": 0, "sMsg": "ok", "jData": {}}])
client = WeiboClient(session=session)
with pytest.raises(WeiboBindError, match="urlScheme"):
client.generate_bind_scheme(BIND_URL, "qq")
def test_generate_bind_qrcode_posts_channel_and_preserves_raw_query():
session = FakeSession(
[
{
"iRet": 0,
"sMsg": "ok",
"jData": {
"qrcode": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB",
"qrcodeToken": "token-1",
},
}
]
)
client = WeiboClient(session=session)
qrcode = client.generate_bind_qrcode(BIND_URL, "qq")
assert isinstance(qrcode, WeiboBindQrCode)
assert qrcode.image_src == (
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
)
assert qrcode.token == "token-1"
method, url, kwargs = session.calls[0]
assert method == "POST"
assert url == "https://livelinkbind.game.qq.com/api/qrcode/getMiniAppQrcode"
body = kwargs["json"]
raw_url = extract_bind_raw_url(BIND_URL)
assert body["queryString"].startswith(f"{raw_url}&sessionId=")
assert body["accountType"] == "qq"
assert kwargs["headers"]["Gap-Act-Id"] == "16722"
assert kwargs["headers"]["Gap-Plat-Id"] == "weiyouxi"
assert kwargs["headers"]["Gap-User-From"] == "pc"
assert "Cookie" not in kwargs["headers"]
def test_generate_bind_qrcode_supports_jpeg_and_server_rate_limit():
jpeg_session = FakeSession(
[
{
"iRet": 0,
"sMsg": "ok",
"jData": {"qrcode": "/9j/4AAQSkZJRgABAQ", "qrcodeToken": "token-2"},
}
]
)
qrcode = WeiboClient(session=jpeg_session).generate_bind_qrcode(BIND_URL, "wx")
assert qrcode.image_src == "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ"
rate_limited = FakeSession(
[{"iRet": -1140603, "sMsg": "刷新二维码频率过快,请间隔5秒后重试"}]
)
with pytest.raises(WeiboBindError, match="间隔5秒"):
WeiboClient(session=rate_limited).generate_bind_qrcode(BIND_URL, "qq")
def test_mall_exchange_and_point_queries():
session = FakeSession(
[
{
"code": 100000,
"data": {
"list": [{"id": 37, "goods_name": "会员1天卡", "price": 100}],
"weidou_balance": 100,
},
},
{
"code": 100000,
"data": {
"page": 1,
"pageSize": 20,
"totalCount": 0,
"list": [],
},
},
{
"code": 100000,
"data": {
"page_num": 1,
"page_size": 20,
"total": 1,
"list": [{"id": 1, "source_name": "V+助威", "point_num": 100}],
},
},
]
)
client = WeiboClient("SUB=valid", session=session)
mall = client.list_mall("bowen_tjg")
records = client.list_exchange_records("bowen_tjg")
points = client.list_point_history("bowen_tjg")
assert mall["weidou_balance"] == 100
assert records["totalCount"] == 0
assert points["total"] == 1
assert session.calls[0][2]["params"]["F"] == "bowen_tjg"
def test_create_recharge_order_full_manual_chain():
home = {
"code": 100000,
"data": {
"user_info": {"uid": "4014000001", "nickname": "测试微博"},
"game_role": {
"is_bound": True,
"role_name": "测试角色",
"can_rebind": False,
"rebind_remain_days": 7,
"bind_url": BIND_URL,
},
"weidou_balance": {"balance": 0},
"task_list": [
{
"button_type": "cashier",
"action_url": (
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
}
],
},
}
userinfo = {
"code": 100000,
"data": {
"cashier_id": 205,
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
}
],
},
}
cashierinfo = {
"code": 100000,
"data": {
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
"menu": [
{
"commodity_id": "2060101",
"subitemid": "2060101",
"itemid": 20601,
"appkey": 2000,
"max_money": 1000,
"discount_info": {"desc": "1元=100微豆"},
}
],
}
]
},
}
privilege = {
"code": 100000,
"data": {
"privilege": {
"url": "https://vipclub.weibo.com/appreciate/description?bid=1000294191"
}
},
}
createorder = {
"code": 100000,
"msg": "下单成功",
"data": {
"url": "https://pay.sc.weibo.com/api/merchant/pay/cashier?sign=test",
"orderid": "26111000000000000001",
},
}
cashier_html = """
<input type="hidden" value="1220351218153064346" node-type="pay_id" name="pay_id">
<img node-type="QRCode" src="https://qr.weibo.cn/inf/gen?sign=test">
<a href="https://pay.sc.weibo.com/pay/pc/comb?pay_id=1220351218153064346">立即支付</a>
"""
alipay_form_html = """
<form action="https://mapi.alipay.com/gateway.do" method="GET">
<input name="out_trade_no" value="1020351218229058766">
<input name="total_fee" value="1.00">
</form>
"""
alipay_html = """
<input name="qrCode" type="hidden" value="https://qr.alipay.com/upx-test" id="J_qrCode" />
"""
session = FakeSession(
[
home,
{"code": 100000, "data": {}},
userinfo,
cashierinfo,
privilege,
createorder,
cashier_html,
alipay_form_html,
alipay_html,
]
)
client = WeiboClient("SCF=a; SUB=b", session=session)
order = client.create_recharge_order(amount_yuan=1, activity="bowen_tjg")
assert order.order_id == "26111000000000000001"
assert order.pay_id == "1220351218153064346"
assert order.single_reward == 100
assert order.alipay_qr_code == "https://qr.alipay.com/upx-test"
assert order.desktop_pay_url.startswith("https://pay.sc.weibo.com/")
create_call = next(
call for call in session.calls if call[1].endswith("/paycomponent/createorder")
)
assert create_call[2]["data"]["single_reward"] == "100"
assert '"bid":"1000294191"' in create_call[2]["data"]["ext"]
assert create_call[2]["params"] == create_call[2]["data"]
assert not any("/aj/pay/modular/prepare" in call[1] for call in session.calls)
def test_create_recharge_order_reports_payment_rejection():
client = WeiboClient("SCF=a; SUB=b", session=FakeSession([]))
def fake_context(
activity: str,
*,
home: WeiboHomeSnapshot | None = None,
) -> tuple[str, dict[str, str], dict[str, Any], dict[str, Any]]:
_ = (activity, home)
return (
"4014000001",
{"vuid": "vuid", "source_itemid": "20601", "F": "bowen_tjg"},
{"tab_type": "8", "cashier_id": "205"},
{
"tab_info": [
{
"is_current": 1,
"tab_type": "8",
"cashier_id": "205",
"menu": [
{
"subitemid": "2060101",
"itemid": 20601,
"appkey": 2000,
"commodity_id": "2060101",
"max_money": 1000,
}
],
}
]
},
)
def fake_bid(
vuid: str,
source_itemid: str,
tab_type: str,
activity: str,
) -> str:
_ = (vuid, source_itemid, tab_type, activity)
return "1000294191"
client._load_recharge_context = fake_context
client._recharge_bid = fake_bid
client._request_json = lambda *_args, **_kwargs: {
"code": 100000,
"data": {
"url": "https://pay.sc.weibo.com/api/merchant/pay/cashier?sign=test",
"orderid": "order-1",
},
}
def rejected_page(*_args, **_kwargs):
client._last_text_url = "https://pay.sc.weibo.com/sorry"
return "<title>404错误</title>"
client._request_text = rejected_page
with pytest.raises(WeiboRechargeError, match=r"支付页拒绝.*/sorry"):
client.create_recharge_order(amount_yuan=1, activity="bowen_tjg")
@pytest.mark.parametrize(
("location", "message"),
[
(
"https://pay.sc.weibo.com/sso/login.php?url=test",
"微博支付未登录",
),
(
"https://weibo.com/sorry?usernotexists",
"支付账号不存在",
),
],
)
def test_recharge_preflight_detects_payment_auth_redirect(location, message):
home = WeiboHomeSnapshot(
uid="4014000001",
nickname="测试微博",
is_bound=False,
role_name="",
can_rebind=True,
rebind_remain_days=0,
weidou_balance=100,
bind_url=BIND_URL,
recharge_url=(
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
)
session = FakeSession(
[
{"code": 100000, "data": {}},
{
"code": 100000,
"data": {
"cashier_id": 205,
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
}
],
},
},
{
"code": 100000,
"data": {
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
"menu": [
{
"price": 6000,
"weibo_unified_params": {
"seller_id": 6329048632,
"modular_id": "7d5d81dfb1",
"default_cashier": "ali_cashier",
},
}
],
}
]
},
},
FakeResponse(
"",
status_code=302,
headers={"Location": location},
),
]
)
client = WeiboClient("SCF=a; SUB=b", session=session)
with pytest.raises(WeiboRechargeError, match=message):
client.check_payment_access("bowen_tjg", home=home)
def test_create_recharge_order_rejects_missing_dynamic_menu():
home = {
"code": 100000,
"data": {
"user_info": {"uid": "1", "nickname": "测试"},
"game_role": {
"is_bound": False,
"role_name": "",
"can_rebind": True,
"rebind_remain_days": 0,
"bind_url": BIND_URL,
},
"weidou_balance": {"balance": 0},
"task_list": [
{
"button_type": "cashier",
"action_url": (
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
}
],
},
}
userinfo = {
"code": 100000,
"data": {
"cashier_id": 205,
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
}
],
},
}
cashierinfo = {
"code": 100000,
"data": {
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
"menu": [],
}
]
},
}
session = FakeSession([home, {"code": 100000, "data": {}}, userinfo, cashierinfo])
client = WeiboClient("SUB=valid", session=session)
with pytest.raises(WeiboRechargeError, match="自定义微豆充值档位"):
client.create_recharge_order(amount_yuan=1)
def test_get_payment_status():
session = FakeSession(
[
{
"code": "100000",
"msg": "操作成功",
"data": {
"pay_id": "pay-1",
"out_pay_id": "out-1",
"status": "PAY_STATUS_PENDING",
"sub_status": "PAY_SUB_STATUS_INITIAL",
},
}
]
)
client = WeiboClient("SUB=valid", session=session)
status = client.get_payment_status("pay-1")
assert status.status == "PAY_STATUS_PENDING"
assert session.calls[0][2]["data"]["pay_id"] == "pay-1"
def test_exchange_goods_executes_once_and_verifies_balance_and_stock():
before = {
"code": 100000,
"data": {
"weidou_balance": 200,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 10,
"is_sold_out": False,
}
],
},
}
after = {
"code": 100000,
"data": {
"weidou_balance": 100,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 9,
"is_sold_out": False,
}
],
},
}
session = FakeSession(
[
before,
{"code": 100000, "msg": "success", "data": None},
after,
]
)
client = WeiboClient("SUB=valid", session=session)
result = client.exchange_goods(
37,
expected_price=100,
activity="bowen_tjg",
)
assert result.goods_name == "微博会员1天卡"
assert result.balance_before == 200
assert result.balance_after == 100
assert result.remain_before == 10
assert result.remain_after == 9
assert result.verified is True
exchange_calls = [
call for call in session.calls if call[1].endswith("/game/exchangegoods")
]
assert len(exchange_calls) == 1
assert exchange_calls[0][2]["params"]["goods_id"] == 37
def test_exchange_goods_rejects_insufficient_balance_before_write():
session = FakeSession(
[
{
"code": 100000,
"data": {
"weidou_balance": 99,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 10,
"is_sold_out": False,
}
],
},
}
]
)
client = WeiboClient("SUB=valid", session=session)
with pytest.raises(WeiboExchangeError, match="余额不足"):
client.exchange_goods(37, expected_price=100)
assert not any(call[1].endswith("/game/exchangegoods") for call in session.calls)
def test_exchange_goods_marks_transport_failure_as_uncertain():
session = FakeSession(
[
{
"code": 100000,
"data": {
"weidou_balance": 200,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 10,
"is_sold_out": False,
}
],
},
},
requests.ConnectionError("connection reset"),
]
)
client = WeiboClient("SUB=valid", session=session)
with pytest.raises(WeiboExchangeUncertainError, match="结果未知"):
client.exchange_goods(37, expected_price=100)
+168
View File
@@ -0,0 +1,168 @@
"""微博外部单协作核心流程测试。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from web.backend.models import User, WeiboAccount, WeiboExternalOrderMetric
from web.backend.services import weibo_collab_service as collab
def make_account(db: Session, uid: str = "4014000001") -> WeiboAccount:
account = WeiboAccount(
uid=uid,
nickname="测试微博",
cookie="SCF=a; SUB=b",
tag="",
remark="",
activity="bowen_tjg",
is_enabled=True,
ck_check_status="valid",
is_bound=True,
game_role_name="角色A",
can_rebind=False,
rebind_remain_days=7,
weidou_balance=0,
)
db.add(account)
db.commit()
db.refresh(account)
return account
def make_user(db: Session) -> User:
user = User(username="weibo-collab", password_hash="hash", role="super_admin")
db.add(user)
db.commit()
db.refresh(user)
return user
def test_create_orders_groups_and_balance_check(monkeypatch, db):
user = make_user(db)
account = make_account(db)
class FakeClient:
def __init__(self, *_args, **_kwargs):
pass
def list_mall(self, *_args, **_kwargs):
return {"weidou_balance": 400, "list": []}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
first = collab.create_order(
db,
user,
order_no="WB-001",
shop="店铺A",
account_id=account.id,
goods_name="商品A",
required_weidou=100,
home_images=["proof/a.png"],
require_verified=False,
)
second = collab.create_order(
db,
user,
order_no="WB-002",
shop="店铺A",
account_id=account.id,
goods_name="商品B",
required_weidou=200,
require_verified=False,
)
assert first.status == collab.STATUS_TO_RECHARGE
assert second.status == collab.STATUS_TO_RECHARGE
assert first.game_role_name == "角色A"
result = collab.check_balance_groups(
db,
user,
[first],
threshold=100,
activity="bowen_tjg",
)
assert result["updated"] == 2
assert result["groups"][0]["balance"] == 400
assert result["groups"][0]["required_total"] == 300
assert result["groups"][0]["surplus"] == 100
db.refresh(first)
db.refresh(second)
assert first.status == collab.STATUS_RECHARGED
assert second.status == collab.STATUS_RECHARGED
assert first.recharged_at is not None
assert db.query(WeiboExternalOrderMetric).count() == 2
def test_exchange_record_unique_match_and_archive(monkeypatch, db):
user = make_user(db)
account = make_account(db)
first = collab.create_order(
db,
user,
order_no="WB-101",
shop="店铺A",
account_id=account.id,
goods_name="商品A",
required_weidou=100,
require_verified=False,
)
second = collab.create_order(
db,
user,
order_no="WB-102",
shop="店铺A",
account_id=account.id,
goods_name="商品B",
required_weidou=200,
require_verified=False,
)
class FakeClient:
def __init__(self, *_args, **_kwargs):
pass
def list_mall(self, *_args, **_kwargs):
return {"weidou_balance": 500, "list": []}
def list_exchange_records(self, *_args, **_kwargs):
return {
"list": [
{
"goods_name": "商品A",
"game_account": "角色A",
"cost_weidou": 100,
"exchange_time": "2099-01-01 00:00:00",
"reward_status": 2,
}
]
}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
collab.check_balance_groups(db, user, [first], threshold=100)
collab.set_status(db, user, first, collab.STATUS_EXCHANGE_PENDING)
collab.set_status(db, user, second, collab.STATUS_EXCHANGE_PENDING)
result = collab.check_exchange_records(db, user, [first, second])
assert result["success"] == 1
assert result["unknown"] == 1
db.refresh(first)
db.refresh(second)
assert first.status == collab.STATUS_DONE
assert first.exchange_verdict == collab.VERDICT_SUCCESS
assert second.status == collab.STATUS_EXCHANGE_PENDING
assert second.exchange_verdict == collab.VERDICT_UNKNOWN
archived = collab.archive_orders(db, user, [first.id], reason="客户验收")
assert archived["archived"] == 1
db.refresh(first)
assert first.archived_at is not None
assert first.archive_reason == "客户验收"
timeline = collab.order_timeline(db, first.id)
assert timeline
assert any(item["action"] == "weibo:collab:archive" for item in timeline)
+1
View File
@@ -155,6 +155,7 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = (
("login_tasks", "cookie"),
("huya_accounts", "account_password"),
("huya_accounts", "cookie"),
("weibo_accounts", "cookie"),
("huya_register_batches", "fixed_password"),
("huya_register_items", "password"),
("huya_register_items", "cookie"),
+4
View File
@@ -33,6 +33,8 @@ from .routers import (
login,
proxy,
users,
weibo,
weibo_collab,
yyb,
)
from .schemas import AppInfo
@@ -165,6 +167,8 @@ app.include_router(login.router)
app.include_router(proxy.router)
app.include_router(cookies.router)
app.include_router(huya.router)
app.include_router(weibo.router)
app.include_router(weibo_collab.router)
app.include_router(douyu.router)
app.include_router(douyu_collab.router)
app.include_router(yyb.router)
@@ -0,0 +1,55 @@
"""微博微豆账号基础表。
第一版只支持 CK 导入、账号资料刷新和绑定二维码展示。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261107_0068"
down_revision: str | None = "20261107_0067"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "weibo_accounts"
def upgrade() -> None:
bind = op.get_bind()
if TABLE in set(sa.inspect(bind).get_table_names()):
return
op.create_table(
TABLE,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("uid", sa.String(length=32), nullable=False),
sa.Column("nickname", sa.String(length=128), nullable=False, server_default=""),
# EncryptedText 在应用层加解密,DDL 是普通文本列。
sa.Column("cookie", sa.Text(), nullable=False),
sa.Column("cookie_updated_at", sa.DateTime(), nullable=True),
sa.Column("tag", sa.String(length=64), nullable=False, server_default=""),
sa.Column(
"ck_check_status", sa.String(length=16), nullable=False, server_default=""
),
sa.Column("ck_check_message", sa.Text(), nullable=False),
sa.Column("ck_checked_at", sa.DateTime(), nullable=True),
sa.Column("is_bound", sa.Boolean(), nullable=True),
sa.Column(
"game_role_name", sa.String(length=128), nullable=False, server_default=""
),
sa.Column("can_rebind", sa.Boolean(), nullable=True),
sa.Column("rebind_remain_days", sa.Integer(), nullable=True),
sa.Column("weidou_balance", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(f"ix_{TABLE}_uid", TABLE, ["uid"], unique=True)
def downgrade() -> None:
bind = op.get_bind()
if TABLE in set(sa.inspect(bind).get_table_names()):
op.drop_table(TABLE)
@@ -0,0 +1,34 @@
"""记录微博账号最近识别的活动标识。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261107_0069"
down_revision: str | None = "20261107_0068"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "weibo_accounts"
def upgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns(TABLE)}
if "activity" not in columns:
op.add_column(
TABLE,
sa.Column(
"activity", sa.String(length=64), nullable=False, server_default=""
),
)
def downgrade() -> None:
bind = op.get_bind()
columns = {column["name"] for column in sa.inspect(bind).get_columns(TABLE)}
if "activity" in columns:
op.drop_column(TABLE, "activity")
@@ -0,0 +1,71 @@
"""增加微博账号备注与启停生命周期。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261108_0070"
down_revision: str | None = "20261107_0069"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "weibo_accounts"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns(TABLE)}
if "remark" not in columns:
op.add_column(
TABLE,
sa.Column(
"remark",
sa.String(length=256),
nullable=False,
server_default="",
),
)
if "is_enabled" not in columns:
op.add_column(
TABLE,
sa.Column(
"is_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
)
op.create_index(
f"ix_{TABLE}_is_enabled",
TABLE,
["is_enabled"],
unique=False,
)
if "disabled_at" not in columns:
op.add_column(TABLE, sa.Column("disabled_at", sa.DateTime(), nullable=True))
if "disabled_reason" not in columns:
op.add_column(
TABLE,
sa.Column(
"disabled_reason",
sa.String(length=256),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns(TABLE)}
indexes = {index["name"] for index in inspector.get_indexes(TABLE)}
if f"ix_{TABLE}_is_enabled" in indexes:
op.drop_index(f"ix_{TABLE}_is_enabled", table_name=TABLE)
for column in ("disabled_reason", "disabled_at", "is_enabled", "remark"):
if column in columns:
op.drop_column(TABLE, column)
@@ -0,0 +1,251 @@
"""增加微博外部单协作台账。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261108_0071"
down_revision: str | None = "20261108_0070"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _create_shops() -> None:
op.create_table(
"weibo_collab_shops",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")
),
sa.Column("remark", sa.String(length=255), nullable=False, server_default=""),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
)
op.create_index(
"ix_weibo_collab_shops_name", "weibo_collab_shops", ["name"], unique=True
)
op.create_index(
"ix_weibo_collab_shops_is_active", "weibo_collab_shops", ["is_active"]
)
def _create_goods() -> None:
op.create_table(
"weibo_collab_goods",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("price", sa.Integer(), nullable=False, server_default="0"),
sa.Column("goods_id", sa.Integer(), nullable=True),
sa.Column(
"goods_image", sa.String(length=512), nullable=False, server_default=""
),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")
),
sa.Column("remark", sa.String(length=255), nullable=False, server_default=""),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
)
op.create_index(
"ix_weibo_collab_goods_name", "weibo_collab_goods", ["name"], unique=True
)
op.create_index(
"ix_weibo_collab_goods_is_active", "weibo_collab_goods", ["is_active"]
)
def _create_orders() -> None:
op.create_table(
"weibo_external_orders",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("order_no", sa.String(length=64), nullable=False),
sa.Column("shop", sa.String(length=64), nullable=False, server_default=""),
sa.Column("owner_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column(
"account_uid", sa.String(length=32), nullable=False, server_default=""
),
sa.Column(
"account_nickname",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column(
"game_role_name", sa.String(length=128), nullable=False, server_default=""
),
sa.Column("bind_snapshot_at", sa.DateTime(), nullable=True),
sa.Column("goods_id", sa.Integer(), nullable=True),
sa.Column(
"goods_name", sa.String(length=128), nullable=False, server_default=""
),
sa.Column(
"goods_image", sa.String(length=512), nullable=False, server_default=""
),
sa.Column("required_weidou", sa.Integer(), nullable=True),
sa.Column("home_images", sa.JSON(), nullable=True),
sa.Column("remark", sa.String(length=512), nullable=False, server_default=""),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default="incomplete",
),
sa.Column("recharged_at", sa.DateTime(), nullable=True),
sa.Column("exchange_started_at", sa.DateTime(), nullable=True),
sa.Column("done_at", sa.DateTime(), nullable=True),
sa.Column(
"void_reason", sa.String(length=256), nullable=False, server_default=""
),
sa.Column("weidou_balance", sa.Integer(), nullable=True),
sa.Column("weidou_required_total", sa.Integer(), nullable=True),
sa.Column("weidou_surplus", sa.Integer(), nullable=True),
sa.Column("weidou_threshold", sa.Integer(), nullable=True),
sa.Column("weidou_checked_at", sa.DateTime(), nullable=True),
sa.Column(
"weidou_check_reason",
sa.String(length=16),
nullable=False,
server_default="",
),
sa.Column(
"weidou_check_message",
sa.String(length=512),
nullable=False,
server_default="",
),
sa.Column("weidou_check_detail", sa.JSON(), nullable=True),
sa.Column("exchange_checked_at", sa.DateTime(), nullable=True),
sa.Column(
"exchange_record_key",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column("exchange_record_snapshot", sa.JSON(), nullable=True),
sa.Column(
"exchange_verdict",
sa.String(length=16),
nullable=False,
server_default="",
),
sa.Column(
"exchange_message",
sa.String(length=512),
nullable=False,
server_default="",
),
sa.Column("archived_at", sa.DateTime(), nullable=True),
sa.Column("archived_by", sa.Integer(), nullable=True),
sa.Column(
"archive_reason", sa.String(length=256), nullable=False, server_default=""
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["account_id"], ["weibo_accounts.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
sa.ForeignKeyConstraint(["archived_by"], ["users.id"]),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
)
for column in (
"order_no",
"shop",
"owner_id",
"account_id",
"account_uid",
"status",
"exchange_verdict",
"archived_at",
"created_by",
"updated_at",
):
op.create_index(
f"ix_weibo_external_orders_{column}",
"weibo_external_orders",
[column],
unique=column == "order_no",
)
def _create_metrics() -> None:
op.create_table(
"weibo_external_order_metrics",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column("order_no", sa.String(length=64), nullable=False, server_default=""),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column(
"account_uid", sa.String(length=32), nullable=False, server_default=""
),
sa.Column("weidou_balance", sa.Integer(), nullable=True),
sa.Column("weidou_required_total", sa.Integer(), nullable=True),
sa.Column("weidou_surplus", sa.Integer(), nullable=True),
sa.Column("records_total", sa.Integer(), nullable=False, server_default="0"),
sa.Column("records_matched", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"verdict", sa.String(length=16), nullable=False, server_default="unknown"
),
sa.Column("message", sa.String(length=512), nullable=False, server_default=""),
sa.Column(
"source", sa.String(length=24), nullable=False, server_default="manual"
),
sa.Column("raw", sa.JSON(), nullable=True),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["order_id"], ["weibo_external_orders.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
)
for column in ("order_id", "account_id", "verdict", "created_at"):
op.create_index(
f"ix_weibo_external_order_metrics_{column}",
"weibo_external_order_metrics",
[column],
)
def upgrade() -> None:
bind = op.get_bind()
existing = set(sa.inspect(bind).get_table_names())
if "weibo_collab_shops" not in existing:
_create_shops()
if "weibo_collab_goods" not in existing:
_create_goods()
if "weibo_external_orders" not in existing:
_create_orders()
if "weibo_external_order_metrics" not in existing:
_create_metrics()
def downgrade() -> None:
bind = op.get_bind()
existing = set(sa.inspect(bind).get_table_names())
if "weibo_external_order_metrics" in existing:
op.drop_table("weibo_external_order_metrics")
if "weibo_external_orders" in existing:
op.drop_table("weibo_external_orders")
if "weibo_collab_goods" in existing:
op.drop_table("weibo_collab_goods")
if "weibo_collab_shops" in existing:
op.drop_table("weibo_collab_shops")
+242
View File
@@ -912,6 +912,248 @@ class HuyaAccount(Base):
tasks: Mapped[list[HuyaTask]] = relationship("HuyaTask", back_populates="account")
class WeiboAccount(Base):
"""微博微豆账号。
第一版只保存 CK 与微豆首页快照,不承载登录密码、支付或兑换数据。
"""
__tablename__ = "weibo_accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
uid: Mapped[str] = mapped_column(
String(32), unique=True, nullable=False, index=True
)
nickname: Mapped[str] = mapped_column(String(128), default="")
cookie: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
cookie_updated_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
tag: Mapped[str] = mapped_column(String(64), default="")
remark: Mapped[str] = mapped_column(
String(256), nullable=False, default="", server_default=""
)
activity: Mapped[str] = mapped_column(String(64), default="")
is_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1", index=True
)
disabled_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
disabled_reason: Mapped[str] = mapped_column(
String(256), nullable=False, default="", server_default=""
)
ck_check_status: Mapped[str] = mapped_column(
String(16), nullable=False, default="", server_default=""
)
ck_check_message: Mapped[str] = mapped_column(Text, default="")
ck_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
is_bound: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
game_role_name: Mapped[str] = mapped_column(String(128), default="")
can_rebind: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
rebind_remain_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
weidou_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class WeiboCollabShop(Base):
"""微博外部单协作的店铺字典。"""
__tablename__ = "weibo_collab_shops"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="1", index=True
)
remark: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class WeiboCollabGoods(Base):
"""微博外部单协作的商品字典,微豆价格仅作下单快照兜底。"""
__tablename__ = "weibo_collab_goods"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(
String(128), unique=True, nullable=False, index=True
)
price: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
goods_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
goods_image: Mapped[str] = mapped_column(String(512), default="", server_default="")
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="1", index=True
)
remark: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class WeiboExternalOrder(Base):
"""微博外部单协作台账:一行 = 一单,引用现有微博账号池。"""
__tablename__ = "weibo_external_orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_no: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
shop: Mapped[str] = mapped_column(
String(64), default="", server_default="", index=True
)
owner_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("weibo_accounts.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
account_uid: Mapped[str] = mapped_column(
String(32), default="", server_default="", index=True
)
account_nickname: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
game_role_name: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_snapshot_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
goods_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
goods_name: Mapped[str] = mapped_column(String(128), default="", server_default="")
goods_image: Mapped[str] = mapped_column(String(512), default="", server_default="")
required_weidou: Mapped[int | None] = mapped_column(Integer, nullable=True)
home_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
remark: Mapped[str] = mapped_column(String(512), default="", server_default="")
status: Mapped[str] = mapped_column(
String(20), default="incomplete", server_default="incomplete", index=True
)
recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
exchange_started_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
done_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
void_reason: Mapped[str] = mapped_column(String(256), default="", server_default="")
weidou_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
weidou_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
weidou_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True)
weidou_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True)
weidou_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
weidou_check_reason: Mapped[str] = mapped_column(
String(16), default="", server_default=""
)
weidou_check_message: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
weidou_check_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
exchange_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
exchange_record_key: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
exchange_record_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
exchange_verdict: Mapped[str] = mapped_column(
String(16), default="", server_default="", index=True
)
exchange_message: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
archived_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True, index=True
)
archived_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
archive_reason: Mapped[str] = mapped_column(
String(256), default="", server_default=""
)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow, index=True
)
class WeiboExternalOrderMetric(Base):
"""微博协作单的核账与兑换记录快照,只追加,不保存 CK。"""
__tablename__ = "weibo_external_order_metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(
ForeignKey("weibo_external_orders.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), default="", server_default="")
account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
account_uid: Mapped[str] = mapped_column(String(32), default="", server_default="")
weidou_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
weidou_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
weidou_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True)
records_total: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
records_matched: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
verdict: Mapped[str] = mapped_column(
String(16), default="unknown", server_default="unknown", index=True
)
message: Mapped[str] = mapped_column(String(512), default="", server_default="")
source: Mapped[str] = mapped_column(
String(24), default="manual", server_default="manual"
)
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, index=True
)
class HuyaRegisterBatch(Base):
"""虎牙自动注册批次(持久化元数据)"""
+29 -4
View File
@@ -41,6 +41,18 @@ PERMISSIONS = {
"yyb:recharge": "应用宝充值与付款",
"yyb:manage": "接管并操作他人的应用宝任务",
"yyb:history": "查看应用宝充值历史",
# 微博微豆
"weibo:view": "查看微博账号",
"weibo:import": "导入/刷新微博 CK",
"weibo:manage": "编辑、停用和启用微博账号",
"weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)",
"weibo:bind": "生成微博绑定二维码",
"weibo:recharge": "创建微博助威充值订单",
"weibo:exchange": "执行微博微豆兑换",
"weibo:delete": "删除微博账号",
"weibo:collab:view": "查看微博外部单协作",
"weibo:collab:edit": "编辑微博外部单协作",
"weibo:collab:manage": "删除微博外部单协作及字典",
# 虎牙
"huya:account": "虎牙账号管理(兼容旧权限)",
"huya:view_all": "查看所有虎牙账号",
@@ -59,8 +71,8 @@ PERMISSIONS = {
"huya:collab:view": "查看外部 CK 协作台账",
"huya:collab:edit": "编辑外部 CK 协作台账",
"huya:collab:manage": "删除外部 CK 协作台账",
# CK 是凭据:导到剪贴板 = 把凭据带出系统,单独一个点(同 cookie:export 的口径),默认只给运营
"huya:collab:ck_export": "导出外部 CK 协作台账的 CK(写剪贴板)",
# CK 是凭据:单行复制与批量导出共用这个权限点,默认运营和客服都有。
"huya:collab:ck_export": "复制/导出外部 CK 协作台账的 CK(写剪贴板)",
# 代理 & 白名单
"proxy:manage": "代理配置管理",
"whitelist:manage": "白名单配置管理",
@@ -101,6 +113,17 @@ ROLE_PERMISSIONS = {
"yyb:recharge",
"yyb:manage",
"yyb:history",
"weibo:view",
"weibo:import",
"weibo:manage",
"weibo:account:ck_export",
"weibo:bind",
"weibo:recharge",
"weibo:exchange",
"weibo:delete",
"weibo:collab:view",
"weibo:collab:edit",
"weibo:collab:manage",
"huya:account",
"huya:view_all",
"huya:import",
@@ -116,6 +139,8 @@ ROLE_PERMISSIONS = {
# 协作台账是共享表:运营既能改也能删。
"huya:collab:view",
"huya:collab:edit",
"weibo:collab:view",
"weibo:collab:edit",
"huya:collab:manage",
"huya:collab:ck_export",
"proxy:manage",
@@ -140,8 +165,8 @@ ROLE_PERMISSIONS = {
# 客服能看能改(共享表,靠审计留痕),但删除只给运营。
"huya:collab:view",
"huya:collab:edit",
# 注意:**不给** huya:collab:ck_export —— 贴 CK 是「录入」,导出是「把凭据带走」,
# 需要时在权限页单独勾(口径同 huya:cookie:export,见设计文档 §11.8)。
# 单行复制与批量导出共用权限点,客服默认也可用,仍逐行写审计。
"huya:collab:ck_export",
"file:view",
"file:upload",
],
+7 -11
View File
@@ -30,7 +30,7 @@ from fastapi import (
from sqlalchemy.orm import Session
from ...database import get_db
from ...deps import authenticate_websocket, get_current_user, require_permission
from ...deps import authenticate_websocket, require_permission
from ...models import AuditLog, HuyaExternalOrder, User
from ...permissions import get_user_permissions
from ...schemas import (
@@ -625,9 +625,9 @@ def export_external_orders_ck(
):
"""勾选多行导出 **CK 明文**(前端写进剪贴板)。
单独一个权限点 ``huya:collab:ck_export``(默认只给运营):贴 CK 是「录入」,
导出是「把凭据带走」。逐行写审计(``huya:collab:ck_export``),但**只记单号**,
不把 CK 本体写进审计;没 CK 的行在 ``skipped`` 里显式回原因。
与单行复制共用权限点 ``huya:collab:ck_export``,默认运营和客服都有。
逐行写审计,但**只记单号**,不把 CK 本体写进审计;
没 CK 的行在 ``skipped`` 里显式回原因。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
@@ -643,16 +643,12 @@ def export_external_orders_ck(
def copy_external_order_ck(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
current: User = Depends(require_permission("huya:collab:ck_export")),
):
"""复制单行 CK 明文(**仅超级管理员**;前端直接写剪贴板)。
"""复制单行 CK 明文(由 ``huya:collab:ck_export`` 权限控制)。
这个入口比勾选导出更敏感:它把「单行凭据」做成一键复制,因此只给管理员。
仍然复用 :func:`collab.export_cks`:逐行写 ``huya:collab:ck_export`` 审计,
但审计里只记单号,不写 CK 本体。
与勾选导出共用同一个权限点和审计动作,审计只记单号,不写 CK 本体。
"""
if current.role != "super_admin":
raise HTTPException(status_code=403, detail="仅超级管理员可复制 CK")
try:
order = collab.get_order(db, order_id)
return collab.export_cks(db, current, [order])
+758
View File
@@ -0,0 +1,758 @@
"""微博微豆:CK 导入、账号刷新与绑定二维码。"""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
from sqlalchemy import or_
from sqlalchemy.orm import Session, defer
from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError
from ..database import get_db
from ..deps import get_current_user
from ..models import User, WeiboAccount
from ..permissions import user_has_permission
from ..schemas import (
WeiboAccountCkOut,
WeiboAccountDisableRequest,
WeiboAccountOut,
WeiboAccountUpdate,
WeiboBindQrOut,
WeiboBindSchemeOut,
WeiboCookieImport,
WeiboExchangeOut,
WeiboExchangeRecordItem,
WeiboExchangeRecordsOut,
WeiboExchangeRequest,
WeiboGoodsOut,
WeiboImportError,
WeiboImportResult,
WeiboMallListOut,
WeiboPaymentStatusOut,
WeiboPaymentStatusRequest,
WeiboPointHistoryOut,
WeiboPointRecordItem,
WeiboRechargeOrderOut,
WeiboRechargeRequest,
)
from ..services.audit_service import record_audit
from ..services.weibo_service import (
create_weibo_recharge_order,
disable_weibo_account,
enable_weibo_account,
exchange_weibo_goods,
generate_weibo_bind_qrcode,
generate_weibo_bind_schemes,
import_weibo_cookies,
query_weibo_exchange_records,
query_weibo_mall,
query_weibo_payment_status,
query_weibo_point_history,
refresh_weibo_account,
update_weibo_account_metadata,
)
router = APIRouter(prefix="/api/weibo", tags=["微博微豆"])
WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip()
def _require_weibo_perm(user: User, permission: str) -> None:
if not user_has_permission(user, permission):
raise HTTPException(status_code=403, detail="权限不足")
def _query_str(value) -> str:
return value.strip() if isinstance(value, str) else ""
def _int_or_none(value: object) -> int | None:
if value is None or value == "":
return None
try:
return int(str(value))
except (TypeError, ValueError):
return None
def _get_account(db: Session, account_id: int) -> WeiboAccount:
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
if account is None:
raise HTTPException(status_code=404, detail="微博账号不存在")
return account
def _account_out(account: WeiboAccount) -> WeiboAccountOut:
return WeiboAccountOut.model_validate(account)
def _ensure_enabled(account: WeiboAccount) -> None:
if not account.is_enabled:
raise HTTPException(status_code=409, detail="微博账号已停用,请先启用")
def _refresh_or_http(account: WeiboAccount, db: Session):
try:
return refresh_weibo_account(
db,
account,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except WeiboAuthError as exc:
raise HTTPException(status_code=400, detail=f"CK 已失效: {exc}") from exc
except WeiboError as exc:
raise HTTPException(status_code=502, detail=f"刷新微博账号失败: {exc}") from exc
def _manual_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, WeiboAuthError):
return HTTPException(status_code=400, detail=f"CK 已失效: {exc}")
return HTTPException(status_code=502, detail=str(exc))
def _record_audit_safely(
db: Session,
current: User,
*,
action: str,
target: str,
detail: dict,
) -> None:
"""审计是旁路,失败只记 warning,不回滚已经完成的账号操作。"""
try:
record_audit(
db,
current,
action=action,
target=target,
detail=detail,
)
db.commit()
except Exception as exc: # noqa: BLE001 旁路写入不能带崩主流程
db.rollback()
logger.warning(f"记录微博审计失败: {exc}")
@router.get("/accounts")
def list_accounts(
search: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看已导入的微博账号。"""
_require_weibo_perm(current, "weibo:view")
query = db.query(WeiboAccount)
search_text = _query_str(search)
if search_text:
pattern = f"%{search_text}%"
query = query.filter(
or_(
WeiboAccount.uid.ilike(pattern),
WeiboAccount.nickname.ilike(pattern),
WeiboAccount.game_role_name.ilike(pattern),
WeiboAccount.tag.ilike(pattern),
WeiboAccount.remark.ilike(pattern),
)
)
if isinstance(enabled, bool):
query = query.filter(WeiboAccount.is_enabled.is_(enabled))
bind_value = _query_str(bind_status)
if bind_value == "unbound":
query = query.filter(WeiboAccount.is_bound.is_(False))
elif bind_value == "bound":
query = query.filter(WeiboAccount.is_bound.is_(True))
elif bind_value == "rebindable":
query = query.filter(
WeiboAccount.is_bound.is_(True),
WeiboAccount.can_rebind.is_(True),
)
total = query.count()
rows = (
query.options(defer(WeiboAccount.cookie))
.order_by(WeiboAccount.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {
"items": [_account_out(account) for account in rows],
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/accounts/{account_id}", response_model=WeiboAccountOut)
def get_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看一个微博账号的本地资料。"""
_require_weibo_perm(current, "weibo:view")
return _account_out(_get_account(db, account_id))
@router.put("/accounts/{account_id}", response_model=WeiboAccountOut)
def update_account(
account_id: int,
req: WeiboAccountUpdate,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""更新账号的人工标签和备注。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
payload = req.model_dump(exclude_unset=True)
changes = update_weibo_account_metadata(
db,
account,
tag=payload.get("tag"),
remark=payload.get("remark"),
)
if changes:
_record_audit_safely(
db,
current,
action="weibo:account:update",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid, "changes": changes},
)
return _account_out(account)
@router.post("/accounts/{account_id}/disable", response_model=WeiboAccountOut)
def disable_account(
account_id: int,
req: WeiboAccountDisableRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""停用账号,保留 CK 和历史资料。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
disable_weibo_account(db, account, reason=req.reason)
_record_audit_safely(
db,
current,
action="weibo:account:disable",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"reason": account.disabled_reason,
},
)
return _account_out(account)
@router.post("/accounts/{account_id}/enable", response_model=WeiboAccountOut)
def enable_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""重新检测 CK 与支付风险,通过后启用账号。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
_refresh_or_http(account, db)
enable_weibo_account(db, account)
_record_audit_safely(
db,
current,
action="weibo:account:enable",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
return _account_out(account)
@router.post("/accounts/import-cookies", response_model=WeiboImportResult)
def import_cookies(
req: WeiboCookieImport,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""导入微博 CK;每个非空行一条完整 Cookie。"""
_require_weibo_perm(current, "weibo:import")
accounts, errors, skipped = import_weibo_cookies(
db,
req.text,
req.tag,
WEIBO_DEFAULT_ACTIVITY,
)
typed_errors = [
WeiboImportError(line=item.line, message=item.message) for item in errors
]
if accounts:
_record_audit_safely(
db,
current,
action="weibo:account:import",
target=f"count:{len(accounts)}",
detail={
"uids": [account.uid for account in accounts],
"count": len(accounts),
"skipped": skipped,
"errors": len(errors),
},
)
message = f"成功导入/更新 {len(accounts)} 个微博账号"
if errors:
message += f",失败 {len(errors)} 条"
if skipped:
message += f",跳过 {skipped} 条重复"
return WeiboImportResult(
success=bool(accounts),
message=message,
count=len(accounts),
skipped=skipped,
errors=typed_errors,
accounts=[_account_out(account) for account in accounts],
)
@router.post("/accounts/{account_id}/refresh", response_model=WeiboAccountOut)
def refresh_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""立即刷新一个微博账号的 CK 状态和资料。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
_ensure_enabled(account)
_refresh_or_http(account, db)
_record_audit_safely(
db,
current,
action="weibo:account:refresh",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
return _account_out(account)
@router.post("/accounts/{account_id}/ck/copy", response_model=WeiboAccountCkOut)
def copy_account_ck(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""复制已导入的微博 CK 明文;只走单独权限并保留审计。"""
_require_weibo_perm(current, "weibo:account:ck_export")
account = _get_account(db, account_id)
ck_text = (account.cookie or "").strip()
if not ck_text:
raise HTTPException(status_code=409, detail="该账号没有保存 CK")
_record_audit_safely(
db,
current,
action="weibo:account:ck_export",
target=f"weibo_account:{account.id}",
detail={"account_id": account.id, "uid": account.uid},
)
return WeiboAccountCkOut(
account_id=account.id,
uid=account.uid,
ck_text=ck_text,
)
@router.post("/accounts/{account_id}/bind-qrcodes", response_model=WeiboBindQrOut)
def create_bind_qrcodes(
account_id: int,
channel: str = Query("", pattern="^(wx|qq)?$"),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""生成微信和 QQ 绑定 Scheme,供前端直接渲染二维码。"""
_require_weibo_perm(current, "weibo:bind")
selected_channel = channel.strip() if isinstance(channel, str) else ""
account = _get_account(db, account_id)
_ensure_enabled(account)
snapshot = _refresh_or_http(account, db)
if snapshot.is_bound and not snapshot.can_rebind:
detail = (
f"账号已绑定角色「{snapshot.role_name}」,当前不可换绑"
if snapshot.role_name
else "账号已绑定角色,当前不可换绑"
)
if snapshot.rebind_remain_days:
detail += f",还需等待 {snapshot.rebind_remain_days} 天"
raise HTTPException(status_code=409, detail=detail)
if not snapshot.bind_url:
raise HTTPException(status_code=409, detail="微博没有返回当前账号的绑定链接")
if selected_channel:
items = [
generate_weibo_bind_qrcode(account, snapshot.bind_url, selected_channel)
]
else:
# 不传 channel 时保留旧接口:一次返回微信、QQ 两个 Scheme。
items = generate_weibo_bind_schemes(account, snapshot.bind_url)
schemes = [WeiboBindSchemeOut(**item) for item in items]
successful = [
item
for item in schemes
if (item.qrcode_image if selected_channel else item.scheme)
]
if not successful:
prefix = (
f"{schemes[0].label}绑定二维码生成失败"
if selected_channel and schemes
else "微信和 QQ 绑定二维码都生成失败"
)
raise HTTPException(
status_code=502,
detail=prefix
+ ": "
+ "; ".join(f"{item.label}={item.error}" for item in schemes if item.error),
)
_record_audit_safely(
db,
current,
action="weibo:account:bind_qrcode",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"channels": [
{
"channel": item.channel,
"success": bool(item.scheme),
"error": item.error,
}
for item in schemes
],
},
)
return WeiboBindQrOut(
account_id=account.id,
is_bound=snapshot.is_bound,
can_rebind=snapshot.can_rebind,
rebind_remain_days=snapshot.rebind_remain_days,
schemes=schemes,
)
@router.post("/accounts/{account_id}/mall-list", response_model=WeiboMallListOut)
def list_mall_goods(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询微豆商城商品列表。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
_ensure_enabled(account)
try:
data = query_weibo_mall(
db,
account,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
goods: list[WeiboGoodsOut] = []
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
try:
goods_id = int(item.get("id") or 0)
except (TypeError, ValueError):
continue
if goods_id <= 0:
continue
goods.append(
WeiboGoodsOut(
id=goods_id,
goods_name=str(item.get("goods_name") or ""),
goods_image=str(item.get("goods_image") or ""),
price=_int_or_none(item.get("price")) or 0,
remain_count=_int_or_none(item.get("remain_count")),
is_sold_out=bool(item.get("is_sold_out")),
goods_desc=str(item.get("goods_desc") or ""),
)
)
return WeiboMallListOut(
account_id=account.id,
weidou_balance=int(data.get("weidou_balance") or 0),
goods=goods,
)
@router.post(
"/accounts/{account_id}/exchange",
response_model=WeiboExchangeOut,
)
def exchange_goods(
account_id: int,
req: WeiboExchangeRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动兑换商品;服务端只提交一次,不自动重试。"""
_require_weibo_perm(current, "weibo:exchange")
if not req.confirm:
raise HTTPException(status_code=400, detail="请先确认兑换商品")
account = _get_account(db, account_id)
_ensure_enabled(account)
try:
result = exchange_weibo_goods(
db,
account,
goods_id=req.goods_id,
expected_price=req.expected_price,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except WeiboExchangeUncertainError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
_record_audit_safely(
db,
current,
action="weibo:mall:exchange",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"goods_id": result.goods_id,
"goods_name": result.goods_name,
"price": result.price,
"balance_before": result.balance_before,
"balance_after": result.balance_after,
"verified": result.verified,
},
)
return WeiboExchangeOut(
account_id=account.id,
goods_id=result.goods_id,
goods_name=result.goods_name,
price=result.price,
balance_before=result.balance_before,
balance_after=result.balance_after,
remain_before=result.remain_before,
remain_after=result.remain_after,
verified=result.verified,
verify_error=result.verify_error,
)
@router.post(
"/accounts/{account_id}/exchange-records",
response_model=WeiboExchangeRecordsOut,
)
def list_exchange_records(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询微豆兑换记录。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
_ensure_enabled(account)
try:
data = query_weibo_exchange_records(
db,
account,
page=page,
page_size=page_size,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
items: list[WeiboExchangeRecordItem] = []
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
items.append(
WeiboExchangeRecordItem(
goods_name=str(item.get("goods_name") or ""),
game_account=str(item.get("game_account") or ""),
cost_weidou=_int_or_none(item.get("cost_weidou")),
exchange_time=str(item.get("exchange_time") or ""),
reward_status=_int_or_none(item.get("reward_status")),
raw=item,
)
)
return WeiboExchangeRecordsOut(
account_id=account.id,
page=int(data.get("page") or data.get("page_no") or page),
page_size=int(data.get("pageSize") or data.get("page_size") or page_size),
total=int(data.get("totalCount") or data.get("total") or 0),
items=items,
)
@router.post(
"/accounts/{account_id}/point-history",
response_model=WeiboPointHistoryOut,
)
def list_point_history(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询微豆获取和充值明细。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
_ensure_enabled(account)
try:
data = query_weibo_point_history(
db,
account,
page=page,
page_size=page_size,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
items: list[WeiboPointRecordItem] = []
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
items.append(
WeiboPointRecordItem(
id=str(item.get("id") or ""),
source_type=_int_or_none(item.get("source_type")),
record_type=_int_or_none(item.get("record_type")),
source_id=str(item.get("source_id") or ""),
source_name=str(item.get("source_name") or ""),
point_num=_int_or_none(item.get("point_num")) or 0,
create_time=str(item.get("create_time") or ""),
expire_time=str(item.get("expire_time") or ""),
)
)
return WeiboPointHistoryOut(
account_id=account.id,
page=int(data.get("page_num") or page),
page_size=int(data.get("page_size") or page_size),
total=int(data.get("total") or 0),
items=items,
)
@router.post(
"/accounts/{account_id}/recharge/orders",
response_model=WeiboRechargeOrderOut,
)
def create_recharge_order(
account_id: int,
req: WeiboRechargeRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动创建助威充值订单并返回支付二维码。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id)
_ensure_enabled(account)
try:
order = create_weibo_recharge_order(
db,
account,
amount_yuan=req.amount_yuan,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
_record_audit_safely(
db,
current,
action="weibo:recharge:create_order",
target=f"weibo_account:{account.id}",
detail={
"account_id": account.id,
"uid": account.uid,
"amount_yuan": order.amount_yuan,
"single_reward": order.single_reward,
"order_id": order.order_id,
},
)
return WeiboRechargeOrderOut(
account_id=account.id,
order_id=order.order_id,
pay_id=order.pay_id,
amount_yuan=order.amount_yuan,
single_reward=order.single_reward,
alipay_qr_code=order.alipay_qr_code,
desktop_pay_url=order.desktop_pay_url,
)
@router.post(
"/accounts/{account_id}/recharge/status",
response_model=WeiboPaymentStatusOut,
)
def get_recharge_status(
account_id: int,
req: WeiboPaymentStatusRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""手动查询支付状态;支付动作仍由用户在微信/支付宝完成。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id)
_ensure_enabled(account)
try:
status = query_weibo_payment_status(db, account, pay_id=req.pay_id)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
return WeiboPaymentStatusOut(
pay_id=status.pay_id,
out_pay_id=status.out_pay_id,
status=status.status,
sub_status=status.sub_status,
)
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""删除一个微博账号。"""
_require_weibo_perm(current, "weibo:delete")
account = _get_account(db, account_id)
uid = account.uid
db.delete(account)
db.commit()
_record_audit_safely(
db,
current,
action="weibo:account:delete",
target=f"weibo_account:{account_id}",
detail={"account_id": account_id, "uid": uid},
)
return {"message": "微博账号已删除", "success": True}
+640
View File
@@ -0,0 +1,640 @@
"""微博外部单协作接口。"""
from __future__ import annotations
import asyncio
import os
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
WebSocket,
WebSocketDisconnect,
)
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import authenticate_websocket, require_permission
from ..models import User, WeiboAccount, WeiboCollabGoods, WeiboCollabShop
from ..permissions import get_user_permissions
from ..schemas import (
WeiboCollabAccountLookupOut,
WeiboCollabAccountLookupRequest,
WeiboCollabAccountOptionOut,
WeiboCollabAccountRequest,
WeiboCollabArchiveRequest,
WeiboCollabBalanceCheckRequest,
WeiboCollabExchangeCheckRequest,
WeiboCollabGoodsCreate,
WeiboCollabGoodsOut,
WeiboCollabGoodsUpdate,
WeiboCollabMetricOut,
WeiboCollabOrderCreate,
WeiboCollabOrderListOut,
WeiboCollabOrderOut,
WeiboCollabOrderUpdate,
WeiboCollabOwnerOut,
WeiboCollabShopCreate,
WeiboCollabShopOut,
WeiboCollabShopUpdate,
WeiboCollabStatusBatchRequest,
WeiboCollabStatusRequest,
WeiboCollabSummaryOut,
WeiboCollabTimelineOut,
)
from ..services import weibo_collab_service as collab
from ..services.weibo_collab_events import collab_events
router = APIRouter(prefix="/api/weibo", tags=["微博外部单协作"])
WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip()
def _http_error(exc: collab.CollabError) -> HTTPException:
return HTTPException(status_code=exc.status_code, detail=exc.detail)
def _one(db: Session, order) -> dict:
return collab.serialize_one(db, order)
def _publish_change(current: User, *, scope: str, action: str) -> None:
collab_events.publish(scope=scope, action=action, actor_id=int(current.id))
def _shop_out(shop: WeiboCollabShop) -> dict:
return {
"id": shop.id,
"name": shop.name,
"sort_order": shop.sort_order,
"is_active": shop.is_active,
"remark": shop.remark,
"created_by": shop.created_by,
"updated_by": shop.updated_by,
"created_at": shop.created_at,
"updated_at": shop.updated_at,
}
def _goods_out(goods: WeiboCollabGoods) -> dict:
return {
"id": goods.id,
"name": goods.name,
"price": goods.price,
"goods_id": goods.goods_id,
"goods_image": goods.goods_image,
"sort_order": goods.sort_order,
"is_active": goods.is_active,
"remark": goods.remark,
"created_by": goods.created_by,
"updated_by": goods.updated_by,
"created_at": goods.created_at,
"updated_at": goods.updated_at,
}
@router.websocket("/external-orders/ws")
async def ws_external_orders(websocket: WebSocket):
user = authenticate_websocket(websocket)
if not user or "weibo:collab:view" not in get_user_permissions(user):
await websocket.close(code=1008, reason="未授权")
return
await websocket.accept()
queue = collab_events.subscribe()
try:
await websocket.send_json(collab_events.ready_event())
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=25)
await websocket.send_json(event)
except TimeoutError:
await websocket.send_json(collab_events.ping_event())
except (WebSocketDisconnect, RuntimeError):
pass
finally:
collab_events.unsubscribe(queue)
@router.get("/external-orders/summary", response_model=WeiboCollabSummaryOut)
def external_orders_summary(
include_void: bool = Query(False),
archive_scope: str = Query("active"),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
return collab.summary(db, include_void=include_void, archive_scope=archive_scope)
@router.get("/external-orders/owners", response_model=list[WeiboCollabOwnerOut])
def external_orders_owners(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
rows = (
db.query(User.id, User.username)
.filter(User.is_active.is_(True))
.order_by(User.username)
.all()
)
return [
{"id": int(user_id), "username": str(username)} for user_id, username in rows
]
@router.get("/collab-shops", response_model=list[WeiboCollabShopOut])
def list_collab_shops(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
return [_shop_out(shop) for shop in collab.list_shops(db, active_only=False)]
@router.post("/collab-shops", response_model=WeiboCollabShopOut)
def create_collab_shop(
req: WeiboCollabShopCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
shop = collab.create_shop(db, current, name=req.name, remark=req.remark)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="shops", action="shop.create")
return _shop_out(shop)
@router.put("/collab-shops/{shop_id}", response_model=WeiboCollabShopOut)
def update_collab_shop(
shop_id: int,
req: WeiboCollabShopUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
shop = db.query(WeiboCollabShop).filter(WeiboCollabShop.id == shop_id).first()
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
try:
collab.update_shop(db, current, shop, req.model_dump(exclude_unset=True))
except collab.CollabError as exc:
raise _http_error(exc) from exc
db.refresh(shop)
_publish_change(current, scope="shops", action="shop.update")
return _shop_out(shop)
@router.delete("/collab-shops/{shop_id}")
def delete_collab_shop(
shop_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:manage")),
):
shop = db.query(WeiboCollabShop).filter(WeiboCollabShop.id == shop_id).first()
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
try:
collab.delete_shop(db, shop)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="shops", action="shop.delete")
return {"success": True}
@router.get("/collab-goods", response_model=list[WeiboCollabGoodsOut])
def list_collab_goods(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
return [_goods_out(goods) for goods in collab.list_goods(db, active_only=False)]
@router.post("/collab-goods", response_model=WeiboCollabGoodsOut)
def create_collab_goods(
req: WeiboCollabGoodsCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
goods = collab.create_goods(
db,
current,
name=req.name,
price=req.price,
goods_id=req.goods_id,
goods_image=req.goods_image,
remark=req.remark,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="gifts", action="goods.create")
return _goods_out(goods)
@router.put("/collab-goods/{goods_id}", response_model=WeiboCollabGoodsOut)
def update_collab_goods(
goods_id: int,
req: WeiboCollabGoodsUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
goods = db.query(WeiboCollabGoods).filter(WeiboCollabGoods.id == goods_id).first()
if goods is None:
raise HTTPException(status_code=404, detail="商品字典不存在")
try:
collab.update_goods(db, current, goods, req.model_dump(exclude_unset=True))
except collab.CollabError as exc:
raise _http_error(exc) from exc
db.refresh(goods)
_publish_change(current, scope="gifts", action="goods.update")
return _goods_out(goods)
@router.delete("/collab-goods/{goods_id}")
def delete_collab_goods(
goods_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:manage")),
):
goods = db.query(WeiboCollabGoods).filter(WeiboCollabGoods.id == goods_id).first()
if goods is None:
raise HTTPException(status_code=404, detail="商品字典不存在")
try:
collab.delete_goods(db, goods)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="gifts", action="goods.delete")
return {"success": True}
@router.post(
"/external-orders/accounts/lookup", response_model=WeiboCollabAccountLookupOut
)
def lookup_external_order_account(
req: WeiboCollabAccountLookupRequest,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
return collab.lookup_account(
db,
req.account_id,
with_goods=req.with_goods,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.get(
"/external-orders/accounts/options",
response_model=list[WeiboCollabAccountOptionOut],
)
def external_order_account_options(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
rows = (
db.query(WeiboAccount)
.filter(WeiboAccount.is_enabled.is_(True))
.order_by(WeiboAccount.id.desc())
.all()
)
return [
{
"id": row.id,
"uid": row.uid,
"nickname": row.nickname,
"game_role_name": row.game_role_name,
"weidou_balance": row.weidou_balance,
"ck_check_status": row.ck_check_status,
}
for row in rows
]
@router.get("/external-orders", response_model=WeiboCollabOrderListOut)
def list_external_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
archive_scope: str = Query("active"),
status: str = Query(""),
shop: str = Query(""),
owner_id: int | None = Query(None),
goods_name: str = Query(""),
keyword: str = Query(""),
include_void: bool = Query(False),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
return collab.list_orders(
db,
page=page,
page_size=page_size,
archive_scope=archive_scope,
status=status,
shop=shop,
owner_id=owner_id,
goods_name=goods_name,
keyword=keyword,
include_void=include_void,
)
@router.post("/external-orders", response_model=WeiboCollabOrderOut)
def create_external_order(
req: WeiboCollabOrderCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
order = collab.create_order(
db,
current,
order_no=req.order_no,
shop=req.shop,
owner_id=req.owner_id,
account_id=req.account_id,
goods_id=req.goods_id,
goods_name=req.goods_name,
goods_image=req.goods_image,
required_weidou=req.required_weidou,
home_images=req.home_images,
remark=req.remark,
draft=req.draft,
require_verified=req.require_verified,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.create")
return _one(db, order)
@router.post("/external-orders/verify/batch")
def verify_external_orders_batch(
ids: list[int],
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
rows = collab.list_orders_by_ids(db, ids)
results: list[dict] = []
for order in rows:
try:
with db.begin_nested():
collab.verify_order_account(
db,
current,
order,
activity=WEIBO_DEFAULT_ACTIVITY,
commit=False,
)
results.append(
{
"id": order.id,
"order_no": order.order_no,
"success": True,
"message": "",
}
)
except Exception as exc: # noqa: BLE001
results.append(
{
"id": order.id,
"order_no": order.order_no,
"success": False,
"message": str(exc),
}
)
db.commit()
_publish_change(current, scope="orders", action="order.verify_batch")
return {"results": results, "success": all(item["success"] for item in results)}
@router.post("/external-orders/balances/check")
def check_external_orders_balance(
req: WeiboCollabBalanceCheckRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
result = collab.check_balance_groups(
db,
current,
collab.list_orders_by_ids(db, req.ids),
threshold=req.threshold,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.balance_check")
return result
@router.post("/external-orders/exchange-records/check")
def check_external_orders_exchange_records(
req: WeiboCollabExchangeCheckRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
result = collab.check_exchange_records(
db,
current,
collab.list_orders_by_ids(db, req.ids),
activity=WEIBO_DEFAULT_ACTIVITY,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.exchange_check")
return result
@router.post("/external-orders/status/batch")
def set_external_orders_status_batch(
req: WeiboCollabStatusBatchRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
result = collab.set_status_batch(
db,
current,
collab.list_orders_by_ids(db, req.ids),
req.status,
reason=req.reason,
rollback=req.rollback,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.status_batch")
return result
@router.post("/external-orders/archive")
def archive_external_orders(
req: WeiboCollabArchiveRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
result = collab.archive_orders(db, current, req.ids, reason=req.reason)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.archive")
return result
@router.post("/external-orders/unarchive")
def unarchive_external_orders(
ids: list[int],
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
result = collab.unarchive_orders(db, current, ids)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.unarchive")
return result
@router.get("/external-orders/{order_id}", response_model=WeiboCollabOrderOut)
def get_external_order(
order_id: int,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
try:
return _one(db, collab.get_order(db, order_id))
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.put("/external-orders/{order_id}", response_model=WeiboCollabOrderOut)
def update_external_order(
order_id: int,
req: WeiboCollabOrderUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab.update_order(
db,
current,
order,
req.model_dump(exclude_unset=True),
expected_version=req.expected_version,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.update")
return _one(db, order)
@router.post("/external-orders/{order_id}/account", response_model=WeiboCollabOrderOut)
def set_external_order_account(
order_id: int,
req: WeiboCollabAccountRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab.set_order_account(
db,
current,
order,
req.account_id,
expected_version=req.expected_version,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.account")
return _one(db, order)
@router.post("/external-orders/{order_id}/verify", response_model=WeiboCollabOrderOut)
def verify_external_order(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab.verify_order_account(
db,
current,
order,
activity=WEIBO_DEFAULT_ACTIVITY,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.verify")
return _one(db, order)
@router.post("/external-orders/{order_id}/status", response_model=WeiboCollabOrderOut)
def set_external_order_status(
order_id: int,
req: WeiboCollabStatusRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab._assert_version(order, req.expected_version)
collab.set_status(
db,
current,
order,
req.status,
reason=req.reason,
rollback=req.rollback,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.status")
return _one(db, order)
@router.get(
"/external-orders/{order_id}/metrics",
response_model=list[WeiboCollabMetricOut],
)
def external_order_metrics(
order_id: int,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
return collab.order_metrics(db, order_id)
@router.get(
"/external-orders/{order_id}/timeline",
response_model=list[WeiboCollabTimelineOut],
)
def external_order_timeline(
order_id: int,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("weibo:collab:view")),
):
return collab.order_timeline(db, order_id, limit=limit)
@router.delete("/external-orders/{order_id}")
def delete_external_order(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("weibo:collab:manage")),
):
try:
order = collab.get_order(db, order_id)
except collab.CollabError as exc:
raise _http_error(exc) from exc
db.delete(order)
db.commit()
_publish_change(current, scope="orders", action="order.delete")
return {"success": True}
+440
View File
@@ -462,6 +462,446 @@ class AccountCheckBatchOut(BaseModel):
items: list[AccountCheckItemOut]
# ---- 微博微豆 ----
class WeiboCookieImport(BaseModel):
"""批量导入微博 CK;每行一个完整 Cookie。"""
text: str = Field(..., min_length=1)
tag: str = Field("", max_length=64)
class WeiboImportError(BaseModel):
line: int
message: str
class WeiboAccountOut(BaseModel):
id: int
uid: str
nickname: str = ""
tag: str = ""
remark: str = ""
activity: str = ""
is_enabled: bool = True
disabled_at: datetime | None = None
disabled_reason: str = ""
ck_check_status: str = ""
ck_check_message: str = ""
ck_checked_at: datetime | None = None
is_bound: bool | None = None
game_role_name: str = ""
can_rebind: bool | None = None
rebind_remain_days: int | None = None
weidou_balance: int | None = None
cookie_updated_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class WeiboAccountUpdate(BaseModel):
tag: str | None = Field(None, max_length=64)
remark: str | None = Field(None, max_length=256)
class WeiboAccountDisableRequest(BaseModel):
reason: str = Field("", max_length=256)
class WeiboAccountCkOut(BaseModel):
account_id: int
uid: str
ck_text: str
class WeiboImportResult(BaseModel):
success: bool
message: str
count: int
skipped: int
errors: list[WeiboImportError] = Field(default_factory=list)
accounts: list[WeiboAccountOut] = Field(default_factory=list)
class WeiboBindSchemeOut(BaseModel):
channel: str
label: str
scheme: str = ""
qrcode_image: str = ""
qrcode_token: str = ""
error: str = ""
class WeiboBindQrOut(BaseModel):
account_id: int
is_bound: bool
can_rebind: bool
rebind_remain_days: int
schemes: list[WeiboBindSchemeOut]
class WeiboGoodsOut(BaseModel):
id: int
goods_name: str = ""
goods_image: str = ""
price: int = 0
remain_count: int | None = None
is_sold_out: bool = False
goods_desc: str = ""
class WeiboMallListOut(BaseModel):
account_id: int
weidou_balance: int = 0
goods: list[WeiboGoodsOut] = Field(default_factory=list)
class WeiboExchangeRequest(BaseModel):
goods_id: int = Field(..., gt=0)
expected_price: int = Field(..., gt=0)
confirm: bool = False
class WeiboExchangeOut(BaseModel):
account_id: int
goods_id: int
goods_name: str
price: int
balance_before: int
balance_after: int | None = None
remain_before: int | None = None
remain_after: int | None = None
verified: bool
verify_error: str = ""
class WeiboExchangeRecordItem(BaseModel):
goods_name: str = ""
game_account: str = ""
cost_weidou: int | None = None
exchange_time: str = ""
reward_status: int | None = None
raw: dict[str, Any] = Field(default_factory=dict)
class WeiboExchangeRecordsOut(BaseModel):
account_id: int
page: int
page_size: int
total: int
items: list[WeiboExchangeRecordItem] = Field(default_factory=list)
class WeiboPointRecordItem(BaseModel):
id: str = ""
source_type: int | None = None
record_type: int | None = None
source_id: str = ""
source_name: str = ""
point_num: int = 0
create_time: str = ""
expire_time: str = ""
class WeiboPointHistoryOut(BaseModel):
account_id: int
page: int
page_size: int
total: int
items: list[WeiboPointRecordItem] = Field(default_factory=list)
class WeiboRechargeRequest(BaseModel):
amount_yuan: int = Field(1, ge=1, le=1000)
class WeiboRechargeOrderOut(BaseModel):
account_id: int
order_id: str
pay_id: str
amount_yuan: int
single_reward: int
alipay_qr_code: str
desktop_pay_url: str = ""
class WeiboPaymentStatusRequest(BaseModel):
pay_id: str = Field(..., min_length=1, max_length=128)
class WeiboPaymentStatusOut(BaseModel):
pay_id: str
out_pay_id: str = ""
status: str
sub_status: str = ""
# ==================== 微博外部单协作 ====================
class WeiboCollabShopCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
remark: str = Field("", max_length=255)
class WeiboCollabShopUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
remark: str | None = Field(None, max_length=255)
class WeiboCollabShopOut(BaseModel):
id: int
name: str
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by: int | None = None
updated_by: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class WeiboCollabGoodsCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
price: int = Field(..., ge=0)
goods_id: int | None = Field(None, gt=0)
goods_image: str = Field("", max_length=512)
remark: str = Field("", max_length=255)
class WeiboCollabGoodsUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=128)
price: int | None = Field(None, ge=0)
goods_id: int | None = Field(None, gt=0)
goods_image: str | None = Field(None, max_length=512)
sort_order: int | None = None
is_active: bool | None = None
remark: str | None = Field(None, max_length=255)
class WeiboCollabGoodsOut(BaseModel):
id: int
name: str
price: int = 0
goods_id: int | None = None
goods_image: str = ""
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by: int | None = None
updated_by: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class WeiboCollabLiveGoodsOut(BaseModel):
id: int
goods_name: str = ""
goods_image: str = ""
price: int = 0
remain_count: int | None = None
is_sold_out: bool = False
class WeiboCollabAccountLookupRequest(BaseModel):
account_id: int = Field(..., gt=0)
with_goods: bool = True
class WeiboCollabAccountOptionOut(BaseModel):
id: int
uid: str = ""
nickname: str = ""
game_role_name: str = ""
weidou_balance: int | None = None
ck_check_status: str = ""
class WeiboCollabAccountLookupOut(BaseModel):
ok: bool = False
message: str = ""
account_id: int
uid: str = ""
nickname: str = ""
is_enabled: bool = True
ck_check_status: str = ""
ck_check_message: str = ""
is_bound: bool | None = None
game_role_name: str = ""
weidou_balance: int | None = None
goods: list[WeiboCollabLiveGoodsOut] = Field(default_factory=list)
orders_using_account: list[dict[str, Any]] = Field(default_factory=list)
class WeiboCollabOrderCreate(BaseModel):
order_no: str = Field(..., min_length=1, max_length=64)
shop: str = Field("", max_length=64)
owner_id: int | None = None
account_id: int | None = None
goods_id: int | None = Field(None, gt=0)
goods_name: str = Field("", max_length=128)
goods_image: str = Field("", max_length=512)
required_weidou: int | None = Field(None, ge=0)
home_images: list[str] = Field(default_factory=list)
remark: str = Field("", max_length=512)
draft: bool = False
require_verified: bool = True
class WeiboCollabOrderUpdate(BaseModel):
order_no: str | None = Field(None, max_length=64)
shop: str | None = Field(None, max_length=64)
owner_id: int | None = None
goods_id: int | None = Field(None, gt=0)
goods_name: str | None = Field(None, max_length=128)
goods_image: str | None = Field(None, max_length=512)
required_weidou: int | None = Field(None, ge=0)
home_images: list[str] | None = None
remark: str | None = Field(None, max_length=512)
expected_version: int | None = None
class WeiboCollabAccountRequest(BaseModel):
account_id: int = Field(..., gt=0)
expected_version: int | None = None
class WeiboCollabStatusRequest(BaseModel):
status: str = Field(..., min_length=1, max_length=20)
reason: str = Field("", max_length=256)
rollback: bool = False
expected_version: int | None = None
class WeiboCollabStatusBatchRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
status: str = Field(..., min_length=1, max_length=20)
reason: str = Field("", max_length=256)
rollback: bool = False
class WeiboCollabBalanceCheckRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
threshold: int = Field(100, ge=0, le=1_000_000)
class WeiboCollabExchangeCheckRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
class WeiboCollabArchiveRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
reason: str = Field("", max_length=256)
class WeiboCollabOrderOut(BaseModel):
id: int
order_no: str
shop: str = ""
owner_id: int | None = None
owner_name: str = ""
account_id: int | None = None
account_uid: str = ""
account_nickname: str = ""
account_exists: bool = False
account_enabled: bool = False
account_ck_status: str = ""
account_ck_message: str = ""
account_is_bound: bool | None = None
game_role_name: str = ""
bind_snapshot_at: datetime | None = None
goods_id: int | None = None
goods_name: str = ""
goods_image: str = ""
required_weidou: int | None = None
home_images: list[str] = Field(default_factory=list)
remark: str = ""
status: str = "incomplete"
status_label: str = ""
next_status: str | None = None
missing_fields: list[str] = Field(default_factory=list)
weidou_balance: int | None = None
weidou_required_total: int | None = None
weidou_surplus: int | None = None
weidou_threshold: int | None = None
weidou_checked_at: datetime | None = None
weidou_check_reason: str = ""
weidou_check_message: str = ""
weidou_check_detail: dict[str, Any] | None = None
exchange_started_at: datetime | None = None
exchange_checked_at: datetime | None = None
exchange_record_key: str = ""
exchange_record_snapshot: dict[str, Any] | None = None
exchange_verdict: str = ""
exchange_message: str = ""
recharged_at: datetime | None = None
done_at: datetime | None = None
void_reason: str = ""
is_archived: bool = False
archived_at: datetime | None = None
archived_by: int | None = None
archived_by_name: str = ""
archive_reason: str = ""
created_by: int | None = None
created_by_name: str = ""
updated_by: int | None = None
updated_by_name: str = ""
version: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
account_cluster: dict[str, Any] | None = None
last_metric_at: datetime | None = None
last_metric_message: str = ""
class WeiboCollabOrderListOut(BaseModel):
items: list[WeiboCollabOrderOut]
total: int
page: int
page_size: int
class WeiboCollabSummaryOut(BaseModel):
total: int
by_status: dict[str, int]
account_missing: int = 0
account_disabled: int = 0
ck_invalid: int = 0
archived_total: int = 0
class WeiboCollabOwnerOut(BaseModel):
id: int
username: str
class WeiboCollabMetricOut(BaseModel):
id: int
order_id: int
order_no: str = ""
account_uid: str = ""
weidou_balance: int | None = None
weidou_required_total: int | None = None
weidou_surplus: int | None = None
records_total: int = 0
records_matched: int = 0
verdict: str = ""
verdict_label: str = ""
message: str = ""
source: str = ""
created_at: datetime | None = None
class WeiboCollabTimelineOut(BaseModel):
id: int
action: str
username: str = ""
detail: str = ""
success: bool | None = None
created_at: datetime | None = None
# ---- 虎牙 ----
class HuyaCookieImport(BaseModel):
"""批量导入虎牙 Cookie。支持纯 CK 或 账号----密码----CK。"""
+2 -2
View File
@@ -2506,8 +2506,8 @@ def export_cks(
这是本模块唯一一个把凭据**往外送**的读接口,所以三条约束写死在这里:
1. **权限单独一个点**(``huya:collab:ck_export``,默认只给运营)—— 贴 CK 是「录入」,
导出是「把凭据带走」,两件事不该共用一个权限(口径同 ``huya:cookie:export``);
1. **单行复制与批量导出共用一个权限点** ``huya:collab:ck_export``,
默认给运营和客服;权限页的自定义权限仍可覆盖;
2. **逐行写审计**(``huya:collab:ck_export``):只记「谁在什么时候导出了哪一单」,
**绝不把 CK 本体写进审计/日志**(列表接口也照旧不返回 cookie);
3. **没 CK 的行显式跳过并回原因**,不静默丢;也不替用户猜 ——
@@ -0,0 +1,82 @@
"""微博外部单协作的进程内实时失效通知。"""
from __future__ import annotations
import asyncio
import itertools
import threading
from datetime import UTC, datetime
from typing import Any
class CollabEventHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._revisions = itertools.count(1)
self._current_revision = 0
self._loop: asyncio.AbstractEventLoop | None = None
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
loop = asyncio.get_running_loop()
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
with self._lock:
self._loop = loop
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
with self._lock:
self._subscribers.discard(queue)
def ready_event(self) -> dict[str, Any]:
with self._lock:
return {"type": "collab.ready", "revision": self._current_revision}
def ping_event(self) -> dict[str, Any]:
with self._lock:
return {"type": "collab.ping", "revision": self._current_revision}
def publish(
self,
*,
scope: str,
action: str,
actor_id: int | None = None,
) -> dict[str, Any]:
with self._lock:
revision = next(self._revisions)
self._current_revision = revision
loop = self._loop
subscribers = list(self._subscribers)
event = {
"type": "collab.invalidate",
"revision": revision,
"scope": scope,
"action": action,
"actor_id": actor_id,
"at": datetime.now(UTC).isoformat(),
}
if loop is None:
return event
for queue in subscribers:
try:
loop.call_soon_threadsafe(self._offer, queue, event)
except RuntimeError:
continue
return event
@staticmethod
def _offer(queue: asyncio.Queue[dict[str, Any]], event: dict[str, Any]) -> None:
while queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty:
break
try:
queue.put_nowait(event)
except asyncio.QueueFull:
pass
collab_events = CollabEventHub()
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
"""微博微豆账号导入、刷新与绑定 Scheme 服务。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from core.weibo import (
WeiboAuthError,
WeiboClient,
WeiboError,
WeiboExchangeResult,
WeiboHomeSnapshot,
WeiboPaymentStatus,
WeiboRechargeOrder,
normalize_weibo_cookie,
)
from ..models import WeiboAccount
@dataclass(frozen=True, slots=True)
class WeiboImportIssue:
line: int
message: str
def _now() -> datetime:
return datetime.now(UTC)
def _error_status(exc: Exception) -> str:
return "invalid" if isinstance(exc, WeiboAuthError) else "error"
def _apply_snapshot(
account: WeiboAccount,
snapshot: WeiboHomeSnapshot,
cookie: str,
) -> None:
account.uid = snapshot.uid
account.nickname = snapshot.nickname
account.cookie = cookie
account.cookie_updated_at = _now()
account.is_bound = snapshot.is_bound
account.game_role_name = snapshot.role_name
account.can_rebind = snapshot.can_rebind
account.rebind_remain_days = snapshot.rebind_remain_days
account.weidou_balance = snapshot.weidou_balance
if snapshot.activity:
account.activity = snapshot.activity
account.ck_check_status = "valid"
account.ck_check_message = ""
account.ck_checked_at = _now()
def import_weibo_cookies(
db: Session,
text: str,
tag: str = "",
activity: str = "",
) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]:
"""逐行验证并导入微博 CK。
返回 ``(成功账号, 错误列表, 跳过数)``。单行 CK 失效或网络失败只记录该行,
不阻断其余账号导入。
"""
accounts: list[WeiboAccount] = []
errors: list[WeiboImportIssue] = []
skipped = 0
seen_uids: set[str] = set()
clean_tag = (tag or "").strip()
for line_no, raw_line in enumerate((text or "").splitlines(), 1):
raw = raw_line.strip()
if not raw:
continue
try:
cookie = normalize_weibo_cookie(raw)
client = WeiboClient(cookie)
snapshot = client.fetch_home(activity)
client.check_payment_access(activity, home=snapshot)
except (ValueError, WeiboError) as exc:
errors.append(WeiboImportIssue(line=line_no, message=str(exc)))
continue
if snapshot.uid in seen_uids:
skipped += 1
continue
seen_uids.add(snapshot.uid)
account = (
db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first()
)
if account is None:
account = WeiboAccount(uid=snapshot.uid, cookie=cookie)
db.add(account)
_apply_snapshot(account, snapshot, cookie)
if clean_tag:
account.tag = clean_tag
accounts.append(account)
if accounts:
db.commit()
for account in accounts:
db.refresh(account)
return accounts, errors, skipped
def refresh_weibo_account(
db: Session,
account: WeiboAccount,
*,
activity: str = "",
commit: bool = True,
) -> WeiboHomeSnapshot:
"""刷新一个微博账号;失败时保留旧资料并记录本次检查状态。"""
try:
client = WeiboClient(account.cookie or "")
snapshot = client.fetch_home(activity)
client.check_payment_access(activity, home=snapshot)
except (WeiboError, ValueError) as exc:
account.ck_check_status = _error_status(exc)
account.ck_check_message = str(exc)
account.ck_checked_at = _now()
if commit:
db.commit()
raise
_apply_snapshot(account, snapshot, account.cookie or "")
if commit:
db.commit()
db.refresh(account)
return snapshot
def update_weibo_account_metadata(
db: Session,
account: WeiboAccount,
*,
tag: str | None = None,
remark: str | None = None,
) -> dict[str, dict[str, str]]:
"""更新人工维护的账号标签与备注。"""
changes: dict[str, dict[str, str]] = {}
if tag is not None:
clean_tag = tag.strip()
if clean_tag != (account.tag or ""):
changes["tag"] = {"old": account.tag or "", "new": clean_tag}
account.tag = clean_tag
if remark is not None:
clean_remark = remark.strip()
if clean_remark != (account.remark or ""):
changes["remark"] = {
"old": account.remark or "",
"new": clean_remark,
}
account.remark = clean_remark
if changes:
db.commit()
db.refresh(account)
return changes
def disable_weibo_account(
db: Session,
account: WeiboAccount,
*,
reason: str = "",
) -> None:
"""停用账号;保留 CK 和资料,后续可重新启用。"""
if not account.is_enabled:
return
account.is_enabled = False
account.disabled_at = _now()
account.disabled_reason = reason.strip()[:256]
db.commit()
db.refresh(account)
def enable_weibo_account(db: Session, account: WeiboAccount) -> None:
"""启用已通过完整 CK 检测的账号。"""
if account.is_enabled:
return
account.is_enabled = True
account.disabled_at = None
account.disabled_reason = ""
db.commit()
db.refresh(account)
def generate_weibo_bind_schemes(
account: WeiboAccount,
bind_url: str,
) -> list[dict[str, str]]:
"""分别生成微信、QQ Scheme;单渠道失败不阻断另一渠道。"""
client = WeiboClient()
result: list[dict[str, str]] = []
for channel, label in (("wx", "微信"), ("qq", "QQ")):
try:
scheme = client.generate_bind_scheme(bind_url, channel)
result.append(
{"channel": channel, "label": label, "scheme": scheme, "error": ""}
)
except (WeiboError, ValueError) as exc:
result.append(
{
"channel": channel,
"label": label,
"scheme": "",
"error": str(exc),
}
)
return result
def generate_weibo_bind_qrcode(
account: WeiboAccount,
bind_url: str,
channel: str,
) -> dict[str, str]:
"""生成指定渠道的小程序码,并尽力附带可复制的 Scheme。"""
_ = account
label = {"wx": "微信", "qq": "QQ"}[channel]
client = WeiboClient()
try:
qrcode = client.generate_bind_qrcode(bind_url, channel)
except (WeiboError, ValueError) as exc:
return {
"channel": channel,
"label": label,
"scheme": "",
"qrcode_image": "",
"qrcode_token": "",
"error": str(exc),
}
scheme = ""
try:
scheme = client.generate_bind_scheme(bind_url, channel)
except (WeiboError, ValueError):
# 二维码图片是主结果,Scheme 仅用于复制,不影响扫码绑定。
pass
return {
"channel": channel,
"label": label,
"scheme": scheme,
"qrcode_image": qrcode.image_src,
"qrcode_token": qrcode.token,
"error": "",
}
def _effective_activity(account: WeiboAccount, fallback: str) -> str:
return account.activity or fallback
def _mark_manual_failure(
db: Session,
account: WeiboAccount,
exc: Exception,
) -> None:
"""只有明确登录态失效才改动 CK 状态,业务失败不能把有效 CK 标坏。"""
if isinstance(exc, WeiboAuthError):
account.ck_check_status = "invalid"
account.ck_check_message = str(exc)
account.ck_checked_at = _now()
db.commit()
def query_weibo_mall(
db: Session,
account: WeiboAccount,
*,
activity: str = "",
) -> dict:
try:
data = WeiboClient(account.cookie or "").list_mall(
_effective_activity(account, activity)
)
except (WeiboError, ValueError) as exc:
_mark_manual_failure(db, account, exc)
raise
if isinstance(data.get("weidou_balance"), int):
account.weidou_balance = data["weidou_balance"]
db.commit()
return data
def query_weibo_exchange_records(
db: Session,
account: WeiboAccount,
*,
page: int,
page_size: int,
activity: str = "",
) -> dict:
try:
return WeiboClient(account.cookie or "").list_exchange_records(
_effective_activity(account, activity),
page=page,
page_size=page_size,
)
except (WeiboError, ValueError) as exc:
_mark_manual_failure(db, account, exc)
raise
def exchange_weibo_goods(
db: Session,
account: WeiboAccount,
*,
goods_id: int,
expected_price: int,
activity: str = "",
) -> WeiboExchangeResult:
"""预检并执行一次微博微豆兑换,然后刷新账号余额。"""
try:
result = WeiboClient(account.cookie or "").exchange_goods(
goods_id,
expected_price=expected_price,
activity=_effective_activity(account, activity),
)
except (WeiboError, ValueError) as exc:
_mark_manual_failure(db, account, exc)
raise
if result.balance_after is not None:
account.weidou_balance = result.balance_after
db.commit()
return result
def query_weibo_point_history(
db: Session,
account: WeiboAccount,
*,
page: int,
page_size: int,
activity: str = "",
) -> dict:
try:
return WeiboClient(account.cookie or "").list_point_history(
_effective_activity(account, activity),
page=page,
page_size=page_size,
)
except (WeiboError, ValueError) as exc:
_mark_manual_failure(db, account, exc)
raise
def create_weibo_recharge_order(
db: Session,
account: WeiboAccount,
*,
amount_yuan: int,
activity: str = "",
) -> WeiboRechargeOrder:
try:
return WeiboClient(account.cookie or "").create_recharge_order(
amount_yuan=amount_yuan,
activity=_effective_activity(account, activity),
)
except (WeiboError, ValueError) as exc:
_mark_manual_failure(db, account, exc)
raise
def query_weibo_payment_status(
db: Session,
account: WeiboAccount,
*,
pay_id: str,
) -> WeiboPaymentStatus:
try:
return WeiboClient(account.cookie or "").get_payment_status(pay_id)
except Exception as exc:
_mark_manual_failure(db, account, exc)
raise
+4
View File
@@ -31,6 +31,8 @@ const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage'));
const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage'));
const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage'));
const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage'));
const WeiboCollabPage = lazy(() => import('./pages/WeiboCollabPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
@@ -105,6 +107,8 @@ function AppContent() {
<Route path="huya/elite" element={lazyRoute(<HuyaElitePage />)} />
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage />)} />
<Route path="weibo/collab" element={lazyRoute(<WeiboCollabPage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="automation" element={lazyRoute(<AutomationPage />)} />
+1 -1
View File
@@ -345,7 +345,7 @@ export const huyaApi = {
'/huya/external-orders/ck/export',
{ ids },
),
/** 单行复制 CK(仅超级管理员;后端再次校验角色) */
/** 单行复制 CK(与批量导出共用 huya:collab:ck_export 权限) */
copyCollabOrderCk: (id: number) =>
api.post<HuyaCollabCkExportResult, HuyaCollabCkExportResult>(
`/huya/external-orders/${id}/ck/copy`,
+25
View File
@@ -14,4 +14,29 @@ export { huyaApi } from './huya';
export { loginApi } from './login';
export { proxyApi } from './proxy';
export { userApi } from './users';
export { weiboApi } from './weibo';
export { weiboCollabApi } from './weiboCollab';
export type {
WeiboCollabAccountLookup,
WeiboCollabAccountOption,
WeiboCollabArchiveResult,
WeiboCollabArchiveScope,
WeiboCollabBalanceCheckResult,
WeiboCollabExchangeCheckResult,
WeiboCollabGoods,
WeiboCollabLiveGoods,
WeiboCollabMetric,
WeiboCollabOrder,
WeiboCollabOrderCreate,
WeiboCollabOrderList,
WeiboCollabOrderUpdate,
WeiboCollabOwner,
WeiboCollabShop,
WeiboCollabStatus,
WeiboCollabSummary,
WeiboCollabTimeline,
WeiboCollabUnarchiveResult,
WeiboCollabVerdict,
WeiboCollabVerifyBatchResult,
} from './weiboCollab';
export { yybApi } from './yyb';
+144
View File
@@ -634,6 +634,7 @@ export interface DouyuTaskAccountItem {
uid: string;
nickname: string;
tag: string;
activity: string;
ck_check_status: string;
ck_checked_at: string | null;
points: number | null;
@@ -745,6 +746,149 @@ export interface DouyuXpdGoodsItem extends DouyuGoodsItem {
goods_left: number | null;
}
// ==================== Weibo ====================
export interface WeiboAccountItem {
id: number;
uid: string;
nickname: string;
tag: string;
remark: string;
is_enabled: boolean;
disabled_at: string | null;
disabled_reason: string;
ck_check_status: string;
ck_check_message: string;
ck_checked_at: string | null;
is_bound: boolean | null;
game_role_name: string;
can_rebind: boolean | null;
rebind_remain_days: number | null;
weidou_balance: number | null;
cookie_updated_at: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface WeiboImportError {
line: number;
message: string;
}
export interface WeiboImportResult {
success: boolean;
message: string;
count: number;
skipped: number;
errors: WeiboImportError[];
accounts: WeiboAccountItem[];
}
export interface WeiboBindScheme {
channel: 'wx' | 'qq';
label: string;
scheme: string;
qrcode_image: string;
qrcode_token: string;
error: string;
}
export interface WeiboBindQrResult {
account_id: number;
is_bound: boolean;
can_rebind: boolean;
rebind_remain_days: number;
schemes: WeiboBindScheme[];
}
export interface WeiboAccountCkResult {
account_id: number;
uid: string;
ck_text: string;
}
export interface WeiboGoodsItem {
id: number;
goods_name: string;
goods_image: string;
price: number;
remain_count: number | null;
is_sold_out: boolean;
goods_desc: string;
}
export interface WeiboMallListResult {
account_id: number;
weidou_balance: number;
goods: WeiboGoodsItem[];
}
export interface WeiboExchangeRecordItem {
goods_name: string;
game_account: string;
cost_weidou: number | null;
exchange_time: string;
reward_status: number | null;
raw: Record<string, unknown>;
}
export interface WeiboExchangeRecordsResult {
account_id: number;
page: number;
page_size: number;
total: number;
items: WeiboExchangeRecordItem[];
}
export interface WeiboExchangeResult {
account_id: number;
goods_id: number;
goods_name: string;
price: number;
balance_before: number;
balance_after: number | null;
remain_before: number | null;
remain_after: number | null;
verified: boolean;
verify_error: string;
}
export interface WeiboPointRecordItem {
id: string;
source_type: number | null;
record_type: number | null;
source_id: string;
source_name: string;
point_num: number;
create_time: string;
expire_time: string;
}
export interface WeiboPointHistoryResult {
account_id: number;
page: number;
page_size: number;
total: number;
items: WeiboPointRecordItem[];
}
export interface WeiboRechargeOrderResult {
account_id: number;
order_id: string;
pay_id: string;
amount_yuan: number;
single_reward: number;
alipay_qr_code: string;
desktop_pay_url: string;
}
export interface WeiboPaymentStatusResult {
pay_id: string;
out_pay_id: string;
status: string;
sub_status: string;
}
// ==================== Huya ====================
export interface HuyaAccountItem {
+111
View File
@@ -0,0 +1,111 @@
import api from './client';
import type {
MessageResponse,
PaginatedResponse,
WeiboAccountItem,
WeiboAccountCkResult,
WeiboBindQrResult,
WeiboExchangeRecordsResult,
WeiboExchangeResult,
WeiboMallListResult,
WeiboPaymentStatusResult,
WeiboPointHistoryResult,
WeiboRechargeOrderResult,
WeiboImportResult,
} from './types';
export const weiboApi = {
listAccounts: (params: {
search?: string;
bind_status?: string;
enabled?: boolean;
page: number;
page_size: number;
}) =>
api.get<PaginatedResponse<WeiboAccountItem>, PaginatedResponse<WeiboAccountItem>>(
'/weibo/accounts',
{ params },
),
getAccount: (accountId: number) =>
api.get<WeiboAccountItem, WeiboAccountItem>(`/weibo/accounts/${accountId}`),
importCookies: (text: string, tag = '') =>
api.post<WeiboImportResult, WeiboImportResult>('/weibo/accounts/import-cookies', {
text,
tag,
}),
updateAccount: (
accountId: number,
data: { tag?: string; remark?: string },
) =>
api.put<WeiboAccountItem, WeiboAccountItem>(
`/weibo/accounts/${accountId}`,
data,
),
disableAccount: (accountId: number, reason = '') =>
api.post<WeiboAccountItem, WeiboAccountItem>(
`/weibo/accounts/${accountId}/disable`,
{ reason },
),
enableAccount: (accountId: number) =>
api.post<WeiboAccountItem, WeiboAccountItem>(
`/weibo/accounts/${accountId}/enable`,
undefined,
{ timeout: 120000 },
),
refreshAccount: (accountId: number) =>
api.post<WeiboAccountItem, WeiboAccountItem>(
`/weibo/accounts/${accountId}/refresh`,
),
copyAccountCk: (accountId: number) =>
api.post<WeiboAccountCkResult, WeiboAccountCkResult>(
`/weibo/accounts/${accountId}/ck/copy`,
),
bindQrcodes: (accountId: number, channel: 'wx' | 'qq') =>
api.post<WeiboBindQrResult, WeiboBindQrResult>(
`/weibo/accounts/${accountId}/bind-qrcodes`,
undefined,
{ params: { channel }, timeout: 60000 },
),
mallList: (accountId: number) =>
api.post<WeiboMallListResult, WeiboMallListResult>(
`/weibo/accounts/${accountId}/mall-list`,
undefined,
{ timeout: 60000 },
),
exchangeGoods: (accountId: number, goodsId: number, expectedPrice: number) =>
api.post<WeiboExchangeResult, WeiboExchangeResult>(
`/weibo/accounts/${accountId}/exchange`,
{
goods_id: goodsId,
expected_price: expectedPrice,
confirm: true,
},
{ timeout: 120000 },
),
exchangeRecords: (accountId: number, page = 1, pageSize = 20) =>
api.post<WeiboExchangeRecordsResult, WeiboExchangeRecordsResult>(
`/weibo/accounts/${accountId}/exchange-records`,
undefined,
{ params: { page, page_size: pageSize }, timeout: 60000 },
),
pointHistory: (accountId: number, page = 1, pageSize = 20) =>
api.post<WeiboPointHistoryResult, WeiboPointHistoryResult>(
`/weibo/accounts/${accountId}/point-history`,
undefined,
{ params: { page, page_size: pageSize }, timeout: 60000 },
),
createRechargeOrder: (accountId: number, amountYuan: number) =>
api.post<WeiboRechargeOrderResult, WeiboRechargeOrderResult>(
`/weibo/accounts/${accountId}/recharge/orders`,
{ amount_yuan: amountYuan },
{ timeout: 120000 },
),
rechargeStatus: (accountId: number, payId: string) =>
api.post<WeiboPaymentStatusResult, WeiboPaymentStatusResult>(
`/weibo/accounts/${accountId}/recharge/status`,
{ pay_id: payId },
{ timeout: 30000 },
),
deleteAccount: (accountId: number) =>
api.delete<MessageResponse, MessageResponse>(`/weibo/accounts/${accountId}`),
};
+408
View File
@@ -0,0 +1,408 @@
import api from './client';
export type WeiboCollabStatus =
| 'incomplete'
| 'to_recharge'
| 'recharged'
| 'exchange_pending'
| 'done'
| 'weidou_error'
| 'failed'
| 'void';
export type WeiboCollabArchiveScope = 'active' | 'archived' | 'all';
export type WeiboCollabVerdict = 'success' | 'failed' | 'unknown' | '';
export interface WeiboCollabShop {
id: number;
name: string;
sort_order: number;
is_active: boolean;
remark: string;
created_by: number | null;
updated_by: number | null;
created_at: string | null;
updated_at: string | null;
}
export interface WeiboCollabGoods {
id: number;
name: string;
price: number;
goods_id: number | null;
goods_image: string;
sort_order: number;
is_active: boolean;
remark: string;
created_by: number | null;
updated_by: number | null;
created_at: string | null;
updated_at: string | null;
}
export interface WeiboCollabLiveGoods {
id: number;
goods_name: string;
goods_image: string;
price: number;
remain_count: number | null;
is_sold_out: boolean;
}
export interface WeiboCollabAccountLookup {
ok: boolean;
message: string;
account_id: number;
uid: string;
nickname: string;
is_enabled: boolean;
ck_check_status: string;
ck_check_message: string;
is_bound: boolean | null;
game_role_name: string;
weidou_balance: number | null;
goods: WeiboCollabLiveGoods[];
orders_using_account: Array<{
id: number;
order_no: string;
status: WeiboCollabStatus;
status_label: string;
}>;
}
export interface WeiboCollabAccountOption {
id: number;
uid: string;
nickname: string;
game_role_name: string;
weidou_balance: number | null;
ck_check_status: string;
}
export interface WeiboCollabOrder {
id: number;
order_no: string;
shop: string;
owner_id: number | null;
owner_name: string;
account_id: number | null;
account_uid: string;
account_nickname: string;
account_exists: boolean;
account_enabled: boolean;
account_ck_status: string;
account_ck_message: string;
account_is_bound: boolean | null;
game_role_name: string;
bind_snapshot_at: string | null;
goods_id: number | null;
goods_name: string;
goods_image: string;
required_weidou: number | null;
home_images: string[];
remark: string;
status: WeiboCollabStatus;
status_label: string;
next_status: WeiboCollabStatus | null;
missing_fields: string[];
weidou_balance: number | null;
weidou_required_total: number | null;
weidou_surplus: number | null;
weidou_threshold: number | null;
weidou_checked_at: string | null;
weidou_check_reason: string;
weidou_check_message: string;
weidou_check_detail: Record<string, unknown> | null;
exchange_started_at: string | null;
exchange_checked_at: string | null;
exchange_record_key: string;
exchange_record_snapshot: Record<string, unknown> | null;
exchange_verdict: WeiboCollabVerdict;
exchange_message: string;
recharged_at: string | null;
done_at: string | null;
void_reason: string;
is_archived: boolean;
archived_at: string | null;
archived_by: number | null;
archived_by_name: string;
archive_reason: string;
created_by: number | null;
created_by_name: string;
updated_by: number | null;
updated_by_name: string;
version: number;
created_at: string | null;
updated_at: string | null;
account_cluster: {
total: number;
orders: Array<{
id: number;
order_no: string;
status: WeiboCollabStatus;
status_label: string;
goods_name: string;
}>;
} | null;
last_metric_at: string | null;
last_metric_message: string;
}
export interface WeiboCollabOrderList {
items: WeiboCollabOrder[];
total: number;
page: number;
page_size: number;
}
export interface WeiboCollabSummary {
total: number;
by_status: Record<string, number>;
account_missing: number;
account_disabled: number;
ck_invalid: number;
archived_total: number;
}
export interface WeiboCollabOwner {
id: number;
username: string;
}
export interface WeiboCollabMetric {
id: number;
order_id: number;
order_no: string;
account_uid: string;
weidou_balance: number | null;
weidou_required_total: number | null;
weidou_surplus: number | null;
records_total: number;
records_matched: number;
verdict: WeiboCollabVerdict;
verdict_label: string;
message: string;
source: string;
created_at: string | null;
}
export interface WeiboCollabTimeline {
id: number;
action: string;
username: string;
detail: string;
success: boolean | null;
created_at: string | null;
}
export interface WeiboCollabOrderCreate {
order_no: string;
shop?: string;
owner_id?: number | null;
account_id?: number | null;
goods_id?: number | null;
goods_name?: string;
goods_image?: string;
required_weidou?: number | null;
home_images?: string[];
remark?: string;
draft?: boolean;
require_verified?: boolean;
}
export interface WeiboCollabOrderUpdate {
order_no?: string;
shop?: string;
owner_id?: number | null;
goods_id?: number | null;
goods_name?: string;
goods_image?: string;
required_weidou?: number | null;
home_images?: string[];
remark?: string;
expected_version?: number;
}
export interface WeiboCollabBalanceCheckResult {
total: number;
updated: number;
threshold: number;
groups: Array<{
account_id: number;
uid: string;
status: string;
status_label: string;
balance: number | null;
required_total: number;
surplus: number | null;
threshold: number;
reason: string;
message: string;
order_ids: number[];
}>;
failures: Array<{ id: number; order_no: string; message: string }>;
skipped: Array<{ id: number; order_no: string; message: string }>;
}
export interface WeiboCollabExchangeCheckResult {
total: number;
success: number;
failed: number;
unknown: number;
results: Array<{
id: number;
order_no: string;
ok: boolean;
verdict: WeiboCollabVerdict;
verdict_label: string;
message: string;
records_total: number;
records_matched: number;
status: WeiboCollabStatus;
status_label: string;
metric_id: number;
}>;
failures: Array<{ id: number; order_no: string; message: string }>;
}
export interface WeiboCollabVerifyBatchResult {
success: boolean;
results: Array<{
id: number;
order_no: string;
success: boolean;
message: string;
}>;
}
export interface WeiboCollabArchiveResult {
total: number;
archived: number;
skipped: number;
results: Array<{ id: number; order_no: string; ok: boolean; message: string }>;
}
export interface WeiboCollabUnarchiveResult {
total: number;
unarchived: number;
skipped: number;
results: Array<{ id: number; order_no: string; ok: boolean; message: string }>;
}
export const weiboCollabApi = {
accounts: () =>
api.get<WeiboCollabAccountOption[], WeiboCollabAccountOption[]>(
'/weibo/external-orders/accounts/options',
),
summary: (params: { include_void?: boolean; archive_scope?: WeiboCollabArchiveScope }) =>
api.get<WeiboCollabSummary, WeiboCollabSummary>('/weibo/external-orders/summary', {
params,
}),
owners: () =>
api.get<WeiboCollabOwner[], WeiboCollabOwner[]>('/weibo/external-orders/owners'),
listOrders: (params: Record<string, unknown>) =>
api.get<WeiboCollabOrderList, WeiboCollabOrderList>('/weibo/external-orders', {
params,
}),
getOrder: (id: number) =>
api.get<WeiboCollabOrder, WeiboCollabOrder>(`/weibo/external-orders/${id}`),
createOrder: (data: WeiboCollabOrderCreate) =>
api.post<WeiboCollabOrder, WeiboCollabOrder>('/weibo/external-orders', data, {
timeout: 120000,
}),
updateOrder: (id: number, data: WeiboCollabOrderUpdate) =>
api.put<WeiboCollabOrder, WeiboCollabOrder>(`/weibo/external-orders/${id}`, data),
setAccount: (id: number, accountId: number, expectedVersion?: number) =>
api.post<WeiboCollabOrder, WeiboCollabOrder>(
`/weibo/external-orders/${id}/account`,
{ account_id: accountId, expected_version: expectedVersion },
{ timeout: 120000 },
),
verifyOrder: (id: number) =>
api.post<WeiboCollabOrder, WeiboCollabOrder>(
`/weibo/external-orders/${id}/verify`,
undefined,
{ timeout: 120000 },
),
updateStatus: (
id: number,
data: {
status: WeiboCollabStatus;
reason?: string;
rollback?: boolean;
expected_version?: number;
},
) =>
api.post<WeiboCollabOrder, WeiboCollabOrder>(
`/weibo/external-orders/${id}/status`,
data,
),
batchStatus: (data: {
ids: number[];
status: WeiboCollabStatus;
reason?: string;
rollback?: boolean;
}) => api.post('/weibo/external-orders/status/batch', data),
verifyBatch: (ids: number[]) =>
api.post<WeiboCollabVerifyBatchResult, WeiboCollabVerifyBatchResult>(
'/weibo/external-orders/verify/batch',
ids,
{ timeout: 120000 },
),
checkBalance: (ids: number[], threshold: number) =>
api.post<WeiboCollabBalanceCheckResult, WeiboCollabBalanceCheckResult>(
'/weibo/external-orders/balances/check',
{ ids, threshold },
{ timeout: 120000 },
),
checkExchangeRecords: (ids: number[]) =>
api.post<WeiboCollabExchangeCheckResult, WeiboCollabExchangeCheckResult>(
'/weibo/external-orders/exchange-records/check',
{ ids },
{ timeout: 120000 },
),
archive: (ids: number[], reason = '') =>
api.post<WeiboCollabArchiveResult, WeiboCollabArchiveResult>(
'/weibo/external-orders/archive',
{ ids, reason },
),
unarchive: (ids: number[]) =>
api.post<WeiboCollabUnarchiveResult, WeiboCollabUnarchiveResult>(
'/weibo/external-orders/unarchive',
ids,
),
deleteOrder: (id: number) => api.delete(`/weibo/external-orders/${id}`),
metrics: (id: number) =>
api.get<WeiboCollabMetric[], WeiboCollabMetric[]>(
`/weibo/external-orders/${id}/metrics`,
),
timeline: (id: number) =>
api.get<WeiboCollabTimeline[], WeiboCollabTimeline[]>(
`/weibo/external-orders/${id}/timeline`,
),
lookupAccount: (accountId: number, withGoods = true) =>
api.post<WeiboCollabAccountLookup, WeiboCollabAccountLookup>(
'/weibo/external-orders/accounts/lookup',
{ account_id: accountId, with_goods: withGoods },
{ timeout: 120000 },
),
listShops: () =>
api.get<WeiboCollabShop[], WeiboCollabShop[]>('/weibo/collab-shops'),
createShop: (data: { name: string; remark?: string }) =>
api.post<WeiboCollabShop, WeiboCollabShop>('/weibo/collab-shops', data),
updateShop: (id: number, data: Partial<Omit<WeiboCollabShop, 'id'>>) =>
api.put<WeiboCollabShop, WeiboCollabShop>(`/weibo/collab-shops/${id}`, data),
deleteShop: (id: number) => api.delete(`/weibo/collab-shops/${id}`),
listGoods: () =>
api.get<WeiboCollabGoods[], WeiboCollabGoods[]>('/weibo/collab-goods'),
createGoods: (data: {
name: string;
price: number;
goods_id?: number | null;
goods_image?: string;
remark?: string;
}) => api.post<WeiboCollabGoods, WeiboCollabGoods>('/weibo/collab-goods', data),
updateGoods: (id: number, data: Partial<Omit<WeiboCollabGoods, 'id'>>) =>
api.put<WeiboCollabGoods, WeiboCollabGoods>(`/weibo/collab-goods/${id}`, data),
deleteGoods: (id: number) => api.delete(`/weibo/collab-goods/${id}`),
};
+12
View File
@@ -8,6 +8,7 @@ import {
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined, FileExcelOutlined,
MessageOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -52,6 +53,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
const menuItems: MenuProps['items'] = [];
const douyuItems: NonNullable<MenuProps['items']> = [];
const huyaItems: NonNullable<MenuProps['items']> = [];
const weiboItems: NonNullable<MenuProps['items']> = [];
const systemItems: NonNullable<MenuProps['items']> = [];
// Dashboard - 所有人可见
@@ -124,12 +126,22 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
huyaItems.push({ key: '/huya/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
}
if (can('weibo:view')) {
weiboItems.push({ key: '/weibo/accounts', label: '账号与绑定', icon: <MessageOutlined /> });
}
if (can('weibo:collab:view')) {
weiboItems.push({ key: '/weibo/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
}
if (douyuItems.length > 0) {
menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems });
}
if (huyaItems.length > 0) {
menuItems.push({ key: 'group-huya', type: 'group', label: '虎牙', children: huyaItems });
}
if (weiboItems.length > 0) {
menuItems.push({ key: 'group-weibo', type: 'group', label: '微博', children: weiboItems });
}
// 系统
if (can('proxy:manage')) {
+4 -6
View File
@@ -501,10 +501,8 @@ export default function HuyaCollabPage() {
const { can, user } = usePermissions();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
// 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py)
// 单行复制与批量导出共用这个权限点(默认运营和客服都有)
const canExportCk = can('huya:collab:ck_export');
// 「复制 CK」是单行一键带走凭据,按用户要求只给超级管理员(后端也会再校验一次)
const isAdmin = user?.role === 'super_admin';
const [rows, setRows] = useState<HuyaCollabOrder[]>([]);
const [total, setTotal] = useState(0);
@@ -1399,7 +1397,7 @@ export default function HuyaCollabPage() {
}
};
/** 单行「复制 CK」:只有超级管理员能看到按钮,后端还会再校验一次角色。 */
/** 单行「复制 CK」与批量导出共用权限点,后端会再次校验。 */
const copyOneCk = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
@@ -2249,8 +2247,8 @@ export default function HuyaCollabPage() {
补资料
</Button>
)}
{isAdmin && !row.is_archived && (
<Tooltip title={row.has_cookie ? '复制这一行的 CK 明文(仅管理员)' : '这行还没贴 CK'}>
{canExportCk && !row.is_archived && (
<Tooltip title={row.has_cookie ? '复制这一行的 CK 明文' : '这行还没贴 CK'}>
<Button size="small" icon={<CopyOutlined />}
disabled={busy || !row.has_cookie}
onClick={() => void copyOneCk(row)}>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff