Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b4f72994 | ||
|
|
5b42577b0b | ||
|
|
7f7a8f6004 | ||
|
|
ed15701392 | ||
|
|
79cfd4a634 | ||
|
|
a0afa430ec | ||
|
|
dd3508d614 | ||
|
|
ffbe0f41f5 | ||
|
|
b7f34df04d | ||
|
|
16196332f2 |
@@ -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` |
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
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` 是否跨活动变化;实现时应从页面动态读取。
|
||||
@@ -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) |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
"""虎牙自动注册批次(持久化元数据)"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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。"""
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 />)} />
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user