server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物
This commit is contained in:
+8
-2
@@ -24,8 +24,11 @@ obj/
|
||||
*.blockmap
|
||||
*.zip
|
||||
builder-debug.yml
|
||||
client/dist/ # agent-core 编译产物(win/darwin)
|
||||
client/desktop/icons/ # 生成图标(gen-icon.js/gen-ico.js 可重生成)
|
||||
|
||||
# 编译产物目录(无行内注释)
|
||||
client/dist/
|
||||
client/desktop/icons/
|
||||
client/macos/release/
|
||||
|
||||
# 本地运行数据与密钥(绝不入库)
|
||||
server/data/
|
||||
@@ -34,3 +37,6 @@ client/core/data/
|
||||
client/desktop/release/
|
||||
client/desktop/win-unpacked/
|
||||
*.log
|
||||
|
||||
# 第三方参考仓库(非产品代码;含嵌套 git/密钥/236MB 二进制,不入库)
|
||||
references/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: run build test tidy infra-up infra-down health
|
||||
.PHONY: run build web-build web-test web-smoke test tidy infra-up infra-down health mac-core mac-client
|
||||
|
||||
run: ## 本地运行服务
|
||||
cd server && go run .
|
||||
@@ -6,8 +6,19 @@ run: ## 本地运行服务
|
||||
build: ## 编译二进制
|
||||
cd server && go build -o ../dist/everypublish-server .
|
||||
|
||||
web-build: ## 构建网页端
|
||||
cd apps/web && npm run build
|
||||
|
||||
web-test: ## 网页端类型检查
|
||||
cd apps/web && npx tsc --noEmit
|
||||
|
||||
web-smoke: ## 网页端 API/Browser WS smoke(需先启动 Web-only server)
|
||||
cd apps/web && npm test
|
||||
|
||||
test: ## 单测
|
||||
cd server && go test ./...
|
||||
cd server && go vet ./...
|
||||
cd apps/web && npx tsc --noEmit
|
||||
|
||||
tidy: ## 整理依赖
|
||||
cd server && go mod tidy
|
||||
@@ -20,3 +31,10 @@ infra-down: ## 停止 MySQL/Redis
|
||||
|
||||
health: ## 健康检查
|
||||
curl -s http://127.0.0.1:8090/health
|
||||
|
||||
mac-core: ## 构建 macOS arm64 agent-core
|
||||
@echo "macOS 客户端已暂停;该目标仅保留历史兼容,不属于 V1 验收"
|
||||
cd client/core && GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o ../dist/agent-core .
|
||||
|
||||
mac-client: ## 构建 macOS arm64 SwiftUI 原生 DMG
|
||||
@echo "macOS 客户端已暂停;该目标不属于 V1 验收"
|
||||
|
||||
@@ -1,59 +1,72 @@
|
||||
# EveryPublish 项目总文档(唯一入口)
|
||||
# EveryPublish
|
||||
|
||||
**一句话定位**:企业多平台官方账号的发布中台——网页控制台协作、审核、排期,客户端单点持号、自动发布。
|
||||
EveryPublish 是一个本机部署的多平台内容发布 Web 控制台:用户打开浏览器登录,在网页内完成账号、素材、任务、审核、挑战和结果管理。
|
||||
|
||||
> 当前状态:开发进行中(阶段一网页端 D1)。进度跟踪见 docs/progress.md。
|
||||
> 当前交付边界:Web-only V1。macOS/Windows 客户端、Agent 配对和设备管理暂停,不进入本阶段验收。
|
||||
|
||||
## 文档地图(全部文档一览)
|
||||
## 当前架构
|
||||
|
||||
| # | 文档 | 用途 | 主要读者 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | [docs/prd.md](docs/prd.md) | 产品需求:做什么、给谁用、体验与验收要点 | 全员 | v1.0 待评审 |
|
||||
| 2 | [docs/tech-stack.md](docs/tech-stack.md) | 技术栈定版(唯一权威来源) | 开发 | 定版 |
|
||||
| 3 | [docs/system-design.md](docs/system-design.md) | 系统设计:功能清单/API/鉴权/传输/数据模型 | 开发 | v1 |
|
||||
| 4 | [docs/logic-diagrams.md](docs/logic-diagrams.md) | 11 张逻辑图全集(架构/时序/状态机/排期) | 全员 | 完成 |
|
||||
| 5 | [docs/delivery-plan.md](docs/delivery-plan.md) | 日计划 D1-D13 + 每步验收标准 + 风险 | 开发/管理 | 完成 |
|
||||
| 6 | [docs/page-wireframes.md](docs/page-wireframes.md) | 全部 22 个页面的 ASCII 线框图 | 前端 | 完成 |
|
||||
| 7 | [docs/frontend-guide.md](docs/frontend-guide.md) | starter 组件清单/场景映射/过渡效果/套用规范 | 前端 | 完成 |
|
||||
| 8 | [docs/dev-schedule.md](docs/dev-schedule.md) | AI 加速排期(参考,已被 #5 取代) | 管理 | 归档 |
|
||||
| 9 | [docs/multi-platform-publish-plan.md](docs/multi-platform-publish-plan.md) | 方案总纲:调研/开源聚合/IP风控/协议考证 | 全员(背景) | 完成 |
|
||||
| 10 | [docs/progress.md](docs/progress.md) | 开发进度跟踪:逐日验收状态实时更新 | 全员 | 进行中 |
|
||||
| 12 | [docs/web-test-report.md](docs/web-test-report.md) | 网页端测试报告(检查点①) | 全员 | 已验收 ✅ |
|
||||
| 13 | [docs/client-test-checklist.md](docs/client-test-checklist.md) | Windows 客户端测试清单(检查点②) | 开发/测试 | 待验收 |
|
||||
| 11 | [docs/api.md](docs/api.md) | 服务端接口文档(认证/工作区/成员/账号/素材/任务/挑战/通知/审计) | 开发 | v1 |
|
||||
```text
|
||||
浏览器 React + TDesign
|
||||
│ REST / Browser WebSocket
|
||||
▼
|
||||
Go server :8090
|
||||
├─ MySQL / Redis
|
||||
├─ 本地素材 StorageDriver
|
||||
├─ WebExecutor(mock 联调)
|
||||
└─ PlatformAdapter(真实平台逐个接入)
|
||||
```
|
||||
|
||||
## 阅读路径
|
||||
服务器运行在当前设备时,发布执行所需的浏览器 profile、cookie 和素材均留在该设备;不需要安装客户端或输入配对码。
|
||||
|
||||
- **产品评审**:1 → 4 → 5
|
||||
- **开发开工**:2 → 3 → 5 → 7 → 6
|
||||
- **新人上手**:1 → 9 → 4 → 3
|
||||
- **向客户/上级汇报**:1(定位与范围)+ 5(排期)
|
||||
## 本地启动
|
||||
|
||||
## 关键决策速览
|
||||
```bash
|
||||
make infra-up
|
||||
make run
|
||||
cd apps/web && npm run dev -- --port 3013
|
||||
```
|
||||
|
||||
| 项 | 决策 |
|
||||
本地联调默认使用 `EXECUTOR_MODE=mock`,不会访问真实平台。要联调已接入的 B 站 adapter,启动服务前设置 `EXECUTOR_MODE=web`;二维码扫码或 Cookie 导入成功后,任务才会走真实平台链路。没有人工扫码时,使用 adapter HTTP stub 和 mock 账号完成自动化验证,不把 mock URL 当成真实发布。
|
||||
|
||||
- Go API/Browser WS:`http://127.0.0.1:8090`
|
||||
- Vite Web:`http://127.0.0.1:3013`
|
||||
- 生产构建:`cd apps/web && npm run build`,设置 `STATIC_DIR=apps/web/dist` 后由 Go server 托管
|
||||
|
||||
## 网页功能
|
||||
|
||||
- 工作台:任务统计、执行器状态、挑战和最近任务。
|
||||
- 账号管理:平台账号、扫码/Cookie 登录、健康检查、重新登录。
|
||||
- 素材库:上传进度、SHA-256 去重、预览、分组和删除。
|
||||
- 发布管理:新建、草稿、审核、排期、任务详情、重试和取消。
|
||||
- 挑战/通知/审计:二维码、验证码、实时状态、历史追踪。
|
||||
|
||||
网页端不需要客户端安装、设备配对或 Agent WSS。客户端代码仅作为历史归档保留。
|
||||
|
||||
## 文档入口
|
||||
|
||||
| 文档 | 说明 |
|
||||
|---|---|
|
||||
| 技术栈 | React+TDesign(页面)/ Go(网页端服务层+客户端核心)/ WinUI 3(客户端壳)/ MySQL 8 + Redis(数据) |
|
||||
| MVP 范围 | 网页端全部 + Windows 客户端 + 抖音/快手/小红书/B站;素材本地盘直链 |
|
||||
| 二期 | 视频号、官方API通道(X/IG/YouTube/TikTok)、OSS、托管Agent |
|
||||
| 连接方式 | WSS 主通道(Agent 出站,Ed25519 设备签名)+ HTTPS 降级;大文件直链不走 WSS |
|
||||
| 鉴权 | 用户 JWT+refresh;Agent 配对码+设备密钥;平台账号 cookie 仅存客户端本地 |
|
||||
| 职责边界 | 网站=分配任务+收状态(零平台凭据);客户端=持号+登录鉴权+发布执行(凭据仅本机) |
|
||||
| 红线 | 矩阵养号/搬运去重绕过/刷量/私信轰炸一律不做 |
|
||||
| [docs/web-only-v1-plan.md](docs/web-only-v1-plan.md) | 当前 V1 范围、页面、执行器和验收 |
|
||||
| [docs/web-only-logic-diagrams.md](docs/web-only-logic-diagrams.md) | Web-only 系统、登录、上传、执行、扫码逻辑图 |
|
||||
| [docs/reference-repositories-analysis.md](docs/reference-repositories-analysis.md) | 三个 MIT 参考仓库的拉取记录和借鉴边界 |
|
||||
| [docs/system-design.md](docs/system-design.md) | 当前系统设计 |
|
||||
| [docs/delivery-plan.md](docs/delivery-plan.md) | 当前交付排期 |
|
||||
| [docs/page-wireframes.md](docs/page-wireframes.md) | 网页 ASCII 线框 |
|
||||
| [docs/api.md](docs/api.md) | REST API |
|
||||
| [docs/progress.md](docs/progress.md) | 当前进度和剩余风险 |
|
||||
|
||||
## 目录结构
|
||||
## 参考项目
|
||||
|
||||
- apps/web —— 前端(TDesign starter,MIT)
|
||||
- server/ —— Go 服务器(REST + WSS 网关 + 队列;端口 8090)
|
||||
- shared/ —— 协议 Go module(server 与 agent-core 共用)
|
||||
- client/ —— Windows 客户端(ui WinUI3 + core agent-core,D8 起)
|
||||
- docs/ —— 全部文档(见上表)
|
||||
- diagrams/png —— 11 张逻辑图 PNG(2400px 宽,白底);diagrams/mmd —— mermaid 源文件
|
||||
- tools/ —— 联调诊断(diag-connection.mjs)
|
||||
- `references/social-auto-upload`(MIT,dreammis,CLI/uploader/QR 参考)
|
||||
- `references/social-auto-upload-web-ui`(MIT,DevilJie,Web/SSE/任务队列参考)
|
||||
- `references/MediaPublishPlatform`(MIT,funfan0517,批量上传/定时/发布记录参考)
|
||||
|
||||
## 开发进行中(阶段一 D1-D7)
|
||||
参考代码只读保存,新增实现必须保留 MIT 版权说明;当前工程不混入 Vue/Flask/SQLite 三套运行时。
|
||||
|
||||
- 排期:docs/delivery-plan.md(含检查点①网页端测试 / 检查点②客户端测试,均需用户确认)
|
||||
- 甘特图:diagrams/png/11-开发排期甘特.png
|
||||
- 本地启动:`make infra-up`(MySQL)→ `make run`(Go 后端 :8090)→ `cd apps/web && pnpm dev`(前端 :3003)
|
||||
- 部署态(单进程):`cd apps/web && pnpm build` 后直接 `make run`,Go 服务在 :8090 同时托管前端 + API + WSS(无需 vite)
|
||||
## 开发原则
|
||||
|
||||
- mock executor 只能证明业务状态机,不冒充真实平台成功。
|
||||
- 真实扫码无法执行时,状态必须停在“等待扫码”,不能自动标记 active。
|
||||
- 所有任务、挑战和素材错误必须能在页面和日志中定位。
|
||||
- 不做刷量、绕过平台风控、矩阵养号、私信轰炸等功能。
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
"build:site": "vite build --mode site",
|
||||
"site:preview": "npm run build && cp -r dist _site",
|
||||
"preview": "vite preview --mode test",
|
||||
"test": "echo \"no test specified,work in process\"",
|
||||
"test:coverage": "echo \"no test specified,work in process\"",
|
||||
"test": "node ../../tools/web-smoke.mjs",
|
||||
"test:coverage": "node ../../tools/web-smoke.mjs",
|
||||
"lint": "eslint ./src --ext ts,tsx",
|
||||
"lint:fix": "eslint ./src --ext ts,tsx --fix",
|
||||
"prepare": "husky install"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Badge, Dropdown, Space } from 'tdesign-react';
|
||||
import { Button, Badge, Dropdown, Space, Tag } from 'tdesign-react';
|
||||
import { Icon, MailIcon, PoweroffIcon, UserCircleIcon } from 'tdesign-icons-react';
|
||||
import { useAppDispatch, useAppSelector } from 'modules/store';
|
||||
import { logout, selectUser } from 'modules/user';
|
||||
import { disconnectWS, onWS } from 'utils/ws';
|
||||
import request from 'utils/request';
|
||||
import Style from './HeaderIcon.module.less';
|
||||
|
||||
const { DropdownMenu, DropdownItem } = Dropdown;
|
||||
@@ -13,6 +15,26 @@ export default memo(() => {
|
||||
const navigate = useNavigate();
|
||||
const userState = useAppSelector(selectUser);
|
||||
const nickname = userState.userInfo.nickname || userState.userInfo.email || '未登录';
|
||||
const [wsOnline, setWsOnline] = useState(false);
|
||||
const [unread, setUnread] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const offConnection = onWS('connection.status', (payload) => setWsOnline(Boolean(payload?.online)));
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
const res = await request.get('/notifications');
|
||||
setUnread(Number(res.data.unread || 0));
|
||||
} catch (e) {
|
||||
setUnread(0);
|
||||
}
|
||||
};
|
||||
loadNotifications();
|
||||
const offNotification = onWS('notification.new', loadNotifications);
|
||||
return () => {
|
||||
offConnection();
|
||||
offNotification();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clickHandler = (data: any) => {
|
||||
if (data.value === 1) {
|
||||
@@ -22,13 +44,17 @@ export default memo(() => {
|
||||
|
||||
const handleLogout = async () => {
|
||||
await dispatch(logout());
|
||||
disconnectWS();
|
||||
navigate('/login/index');
|
||||
};
|
||||
|
||||
return (
|
||||
<Space align='center'>
|
||||
<Badge className={Style.badge} count={0} dot={false} maxCount={99} shape='circle' showZero={false} size='medium'>
|
||||
<Button className={Style.menuIcon} shape='square' size='large' variant='text' icon={<MailIcon />} />
|
||||
<Tag theme={wsOnline ? 'success' : 'warning'} variant='light'>
|
||||
{wsOnline ? '实时连接' : '连接重试中'}
|
||||
</Tag>
|
||||
<Badge className={Style.badge} count={unread} dot={false} maxCount={99} shape='circle' showZero={false} size='medium'>
|
||||
<Button className={Style.menuIcon} shape='square' size='large' variant='text' icon={<MailIcon />} onClick={() => navigate('/notification/list')} />
|
||||
</Badge>
|
||||
<Dropdown trigger={'click'} onClick={clickHandler}>
|
||||
<Button variant='text' className={Style.dropdown}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Card, Dialog, Form, FormInstanceFunctions, Input, MessagePlugin, Popconfirm, Select, Space, Table, Tag } from 'tdesign-react';
|
||||
import { Button, Card, Dialog, Form, FormInstanceFunctions, Input, MessagePlugin, Popconfirm, QRCode, Select, Space, Table, Tag, Textarea } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { onWS } from 'utils/ws';
|
||||
import { PLATFORM_LABEL, PLATFORM_OPTIONS } from 'constants/platforms';
|
||||
import { useAppSelector } from 'modules/store';
|
||||
import { selectUser } from 'modules/user';
|
||||
|
||||
const { FormItem } = Form;
|
||||
|
||||
@@ -15,14 +17,11 @@ interface IAccount {
|
||||
avatarUrl: string;
|
||||
status: string;
|
||||
health: number;
|
||||
agentDeviceId: number;
|
||||
ipProfile: string;
|
||||
lastActiveAt?: string | null;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
interface IDevice { id: number; name: string; status: string; revoked: boolean; }
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
|
||||
unbound: { label: '未绑定', theme: 'default' },
|
||||
binding: { label: '绑定中', theme: 'warning' },
|
||||
@@ -32,12 +31,20 @@ const STATUS_MAP: Record<string, { label: string; theme: string }> = {
|
||||
};
|
||||
|
||||
export default function AccountList() {
|
||||
const userState = useAppSelector(selectUser);
|
||||
const canOperate = ['owner', 'admin', 'operator'].includes(userState.memberRole || '');
|
||||
const canAdmin = ['owner', 'admin'].includes(userState.memberRole || '');
|
||||
const [list, setList] = useState<IAccount[]>([]);
|
||||
const [devices, setDevices] = useState<IDevice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createVisible, setCreateVisible] = useState(false);
|
||||
const [bindAccount, setBindAccount] = useState<IAccount | null>(null);
|
||||
const [bindTip, setBindTip] = useState('');
|
||||
const [bindChallenge, setBindChallenge] = useState<{ id: number; qrUrl: string; qrToken: string; status: string; prompt: string } | null>(null);
|
||||
const [credentialsTarget, setCredentialsTarget] = useState<IAccount | null>(null);
|
||||
const [credentialsValue, setCredentialsValue] = useState('');
|
||||
const [credentialsSaving, setCredentialsSaving] = useState(false);
|
||||
const [checkingId, setCheckingId] = useState(0);
|
||||
const bindAccountRef = useRef<IAccount | null>(null);
|
||||
const formRef = useRef<FormInstanceFunctions>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -52,34 +59,31 @@ export default function AccountList() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
const res = await request.get('/agent/devices');
|
||||
setDevices(((res.data.list || []) as IDevice[]).filter((d) => d.status === 'online' && !d.revoked));
|
||||
} catch (e) {
|
||||
setDevices([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const off = onWS('challenge.status', (p) => {
|
||||
if (p?.status === 'solved') {
|
||||
const current = bindAccountRef.current;
|
||||
if (p?.accountId && current?.id === p.accountId) {
|
||||
setBindTip(p.prompt || (p.status === 'scanned' ? '已扫码,请在手机上确认登录' : p.status === 'pending' ? '等待扫码' : '登录状态已更新'));
|
||||
}
|
||||
if (p?.status === 'solved' && current?.id === p.accountId) {
|
||||
MessagePlugin.success('账号绑定成功');
|
||||
setBindAccount(null);
|
||||
bindAccountRef.current = null;
|
||||
setBindChallenge(null);
|
||||
load();
|
||||
}
|
||||
});
|
||||
return off;
|
||||
const offAccount = onWS('account.status', () => load());
|
||||
return () => { off(); offAccount(); };
|
||||
}, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
loadDevices();
|
||||
setCreateVisible(true);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
const value = (formRef.current?.getFieldsValue?.(true) || {}) as { platform: string; agentDeviceId: number; remark: string };
|
||||
const value = (formRef.current?.getFieldsValue?.(true) || {}) as { platform: string; remark: string };
|
||||
if (!value.platform) {
|
||||
MessagePlugin.warning('请选择平台');
|
||||
return;
|
||||
@@ -87,7 +91,6 @@ export default function AccountList() {
|
||||
try {
|
||||
await request.post('/accounts', {
|
||||
platform: value.platform,
|
||||
agentDeviceId: value.agentDeviceId || 0,
|
||||
remark: value.remark || '',
|
||||
});
|
||||
MessagePlugin.success('账号已添加');
|
||||
@@ -100,13 +103,66 @@ export default function AccountList() {
|
||||
|
||||
const bind = async (row: IAccount) => {
|
||||
setBindAccount(row);
|
||||
bindAccountRef.current = row;
|
||||
setBindTip('');
|
||||
try {
|
||||
const res = await request.post('/accounts/' + row.id + '/bind');
|
||||
setBindTip('已发起绑定,等待客户端扫码(挑战 #' + res.data.challengeId + ')...');
|
||||
setBindChallenge({
|
||||
id: res.data.challengeId,
|
||||
qrUrl: res.data.qrUrl || '',
|
||||
qrToken: res.data.qrToken || '',
|
||||
status: 'active',
|
||||
prompt: res.data.prompt || '等待扫码',
|
||||
});
|
||||
setBindTip(res.data.prompt || '等待扫码');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '发起绑定失败');
|
||||
setBindAccount(null);
|
||||
bindAccountRef.current = null;
|
||||
setBindChallenge(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unbind = async (row: IAccount) => {
|
||||
try {
|
||||
await request.post('/accounts/' + row.id + '/unbind');
|
||||
MessagePlugin.success('账号已解绑');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '解绑失败');
|
||||
}
|
||||
};
|
||||
|
||||
const importCredentials = async () => {
|
||||
if (!credentialsTarget || !credentialsValue.trim()) {
|
||||
MessagePlugin.warning('请输入平台 Cookie 凭据');
|
||||
return;
|
||||
}
|
||||
setCredentialsSaving(true);
|
||||
try {
|
||||
await request.post('/accounts/' + credentialsTarget.id + '/credentials', { cookies: credentialsValue.trim() });
|
||||
MessagePlugin.success('凭据已加密保存,账号已恢复可用');
|
||||
setCredentialsTarget(null);
|
||||
setCredentialsValue('');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '凭据导入失败');
|
||||
} finally {
|
||||
setCredentialsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAccount = async (row: IAccount) => {
|
||||
setCheckingId(row.id);
|
||||
try {
|
||||
await request.post('/accounts/' + row.id + '/check');
|
||||
MessagePlugin.success('账号凭据有效');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '账号检查失败');
|
||||
load();
|
||||
} finally {
|
||||
setCheckingId(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,8 +176,6 @@ export default function AccountList() {
|
||||
}
|
||||
};
|
||||
|
||||
const deviceName = (id: number) => devices.find((d) => d.id === id)?.name || ('设备 #' + id);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
colKey: 'platform',
|
||||
@@ -149,12 +203,6 @@ export default function AccountList() {
|
||||
return <Tag theme={st.theme as any}>{st.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
colKey: 'agentDeviceId',
|
||||
title: '执行设备',
|
||||
width: 140,
|
||||
cell: ({ row }: { row: IAccount }) => <span>{row.agentDeviceId ? deviceName(row.agentDeviceId) : '-'}</span>,
|
||||
},
|
||||
{
|
||||
colKey: 'lastActiveAt',
|
||||
title: '最后活跃',
|
||||
@@ -166,39 +214,49 @@ export default function AccountList() {
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 140,
|
||||
width: 230,
|
||||
cell: ({ row }: { row: IAccount }) => (
|
||||
<Space size={0}>
|
||||
{(row.status === 'unbound' || row.status === 'expired') && (
|
||||
{canOperate && (row.status === 'unbound' || row.status === 'expired' || row.status === 'active') && (
|
||||
<Button size='small' variant='text' theme='primary' onClick={() => bind(row)}>
|
||||
绑定
|
||||
{row.status === 'active' ? '重新登录' : '登录'}
|
||||
</Button>
|
||||
)}
|
||||
{row.status === 'unbound' && (
|
||||
{canOperate && (row.status === 'unbound' || row.status === 'expired') && (
|
||||
<Button size='small' variant='text' onClick={() => { setCredentialsTarget(row); setCredentialsValue(''); }}>
|
||||
导入凭据
|
||||
</Button>
|
||||
)}
|
||||
{canOperate && row.status === 'active' && (
|
||||
<Button size='small' variant='text' loading={checkingId === row.id} onClick={() => checkAccount(row)}>
|
||||
检查
|
||||
</Button>
|
||||
)}
|
||||
{canAdmin && row.status === 'unbound' && (
|
||||
<Popconfirm content='确认删除该账号?' onConfirm={() => remove(row)}>
|
||||
<Button size='small' variant='text' theme='danger'>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canAdmin && (row.status === 'active' || row.status === 'binding' || row.status === 'expired') && (
|
||||
<Popconfirm content='解绑后需要重新登录,确认?' onConfirm={() => unbind(row)}>
|
||||
<Button size='small' variant='text' theme='danger'>
|
||||
解绑
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const deviceOptions = [
|
||||
{ label: '暂不指定(稍后选择)', value: 0 },
|
||||
...devices.map((d) => ({ label: d.name + '(在线)', value: d.id })),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title='账号台账'
|
||||
actions={
|
||||
<Space>
|
||||
<Button theme='primary' onClick={openCreate}>
|
||||
添加账号
|
||||
</Button>
|
||||
{canOperate && <Button theme='primary' onClick={openCreate}>添加账号</Button>}
|
||||
<Button variant='outline' onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -206,7 +264,7 @@ export default function AccountList() {
|
||||
}
|
||||
>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>凭据只保存在客户端本机保险库,服务器零凭据;账号名称/头像由客户端绑定后自动获取。</div>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>当前为 Web-only 本地模式:登录挑战和平台浏览器 profile 在运行服务器的设备上完成,不需要客户端配对。</div>
|
||||
<Dialog
|
||||
header='添加平台账号'
|
||||
visible={createVisible}
|
||||
@@ -226,9 +284,6 @@ export default function AccountList() {
|
||||
<FormItem label='平台' name='platform' rules={[{ required: true, message: '请选择平台', type: 'error' }]}>
|
||||
<Select options={PLATFORM_OPTIONS} placeholder='选择平台' />
|
||||
</FormItem>
|
||||
<FormItem label='执行设备' name='agentDeviceId' initialData={0}>
|
||||
<Select options={deviceOptions} placeholder={devices.length ? '选择在线设备' : '无在线设备'} />
|
||||
</FormItem>
|
||||
<FormItem label='备注' name='remark'>
|
||||
<Input placeholder='选填,如「品牌主号」「投放账号」' />
|
||||
</FormItem>
|
||||
@@ -237,16 +292,49 @@ export default function AccountList() {
|
||||
<Dialog
|
||||
header={'绑定 ' + (bindAccount ? (bindAccount.remark || PLATFORM_LABEL[bindAccount.platform] || '') : '')}
|
||||
visible={!!bindAccount}
|
||||
onClose={() => setBindAccount(null)}
|
||||
onClose={() => { setBindAccount(null); bindAccountRef.current = null; setBindChallenge(null); }}
|
||||
footer={null}
|
||||
>
|
||||
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
||||
<div style={{ marginBottom: 12 }}>📱 {bindTip || '正在发起绑定...'}</div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 12 }}>请在客户端设备上完成扫码/验证,完成后本窗口自动关闭。</div>
|
||||
<Button variant='outline' onClick={() => setBindAccount(null)}>
|
||||
稍后处理(可在挑战中心继续)
|
||||
<div style={{ padding: '8px 0 16px', textAlign: 'center' }}>
|
||||
{bindChallenge?.qrUrl && !bindChallenge.qrUrl.startsWith('mock://') && (
|
||||
<QRCode value={bindChallenge.qrUrl} size={220} />
|
||||
)}
|
||||
<div style={{ margin: '14px 0 6px', fontWeight: 600 }}>{bindTip || '正在发起登录...'}</div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginBottom: 14 }}>
|
||||
二维码由当前服务器生成并轮询状态,不需要客户端配对码。
|
||||
</div>
|
||||
{bindChallenge?.qrUrl?.startsWith('mock://') && (
|
||||
<Button theme='primary' onClick={async () => {
|
||||
if (!bindChallenge) return;
|
||||
try {
|
||||
await request.post('/challenges/' + bindChallenge.id + '/solve', { value: 'web-mock-login' });
|
||||
MessagePlugin.success('本地模拟登录已完成');
|
||||
setBindAccount(null);
|
||||
bindAccountRef.current = null;
|
||||
setBindChallenge(null);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '模拟登录失败');
|
||||
}
|
||||
}}>完成本地模拟登录</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button variant='outline' onClick={() => { setBindAccount(null); bindAccountRef.current = null; setBindChallenge(null); }}>
|
||||
稍后处理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
header={'导入 ' + (credentialsTarget ? (credentialsTarget.remark || PLATFORM_LABEL[credentialsTarget.platform] || '') : '') + ' 凭据'}
|
||||
visible={!!credentialsTarget}
|
||||
onClose={() => { setCredentialsTarget(null); setCredentialsValue(''); }}
|
||||
confirmBtn={<Button theme='primary' loading={credentialsSaving} onClick={importCredentials}>加密保存</Button>}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 10 }}>
|
||||
仅支持已接入真实适配器的平台。凭据只写入当前服务器的加密目录,不会显示在列表、日志或响应中。
|
||||
</div>
|
||||
<Textarea value={credentialsValue} onChange={(v) => setCredentialsValue(v as string)} rows={5} placeholder='粘贴平台 Cookie 字符串' />
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, MessagePlugin, Popconfirm, Table, Tag } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
|
||||
interface IAgent {
|
||||
id: number;
|
||||
name: string;
|
||||
workspaceName: string;
|
||||
os: string;
|
||||
version: string;
|
||||
status: string;
|
||||
revoked: boolean;
|
||||
lastSeenAt?: string | null;
|
||||
}
|
||||
|
||||
export default function AdminAgent() {
|
||||
const [list, setList] = useState<IAgent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request.get('/admin/agents?page=' + page + '&size=20');
|
||||
setList((res.data.list || []) as IAgent[]);
|
||||
setTotal(res.data.total || 0);
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const revoke = async (a: IAgent) => {
|
||||
try {
|
||||
await request.put('/admin/agents/' + a.id + '/revoke');
|
||||
MessagePlugin.success('已强制吊销');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 70 },
|
||||
{ colKey: 'name', title: '设备名', ellipsis: true },
|
||||
{ colKey: 'workspaceName', title: '所属租户', ellipsis: true },
|
||||
{ colKey: 'os', title: '系统', width: 90 },
|
||||
{ colKey: 'version', title: '版本', width: 110 },
|
||||
{
|
||||
colKey: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cell: ({ row }: { row: IAgent }) => {
|
||||
if (row.revoked) return <Tag theme='danger'>已吊销</Tag>;
|
||||
return row.status === 'online' ? <Tag theme='success'>在线</Tag> : <Tag theme='default'>离线</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
colKey: 'lastSeenAt',
|
||||
title: '最后活跃',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: IAgent }) => (
|
||||
<span>{row.lastSeenAt ? dayjs(row.lastSeenAt).format('MM-DD HH:mm:ss') : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 110,
|
||||
cell: ({ row }: { row: IAgent }) =>
|
||||
row.revoked ? null : (
|
||||
<Popconfirm content='强制吊销后设备立即下线?' onConfirm={() => revoke(row)}>
|
||||
<Button size='small' variant='text' theme='danger'>
|
||||
强制吊销
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title='Agent 总览' subtitle='全局设备:在线/版本/强制吊销'>
|
||||
<Table
|
||||
data={list}
|
||||
columns={columns}
|
||||
rowKey='id'
|
||||
loading={loading}
|
||||
hover
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: (p) => setPage(p.current) }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -43,12 +43,13 @@ export const globalErrorCodes: ApiErrorCode[] = [
|
||||
{ code: 2003, desc: '刷新令牌无效或已失效' },
|
||||
{ code: 2004, desc: '已是该工作空间成员' },
|
||||
{ code: 2005, desc: '邀请链接无效或已过期' },
|
||||
{ code: 2006, desc: '配对码无效或已过期' },
|
||||
{ code: 3001, desc: '绑定中/已绑定账号不可删除' },
|
||||
{ code: 3002, desc: '账号已绑定' },
|
||||
{ code: 3003, desc: '挑战已结束(不可重发/挂起)' },
|
||||
{ code: 3004, desc: '下载链接已过期' },
|
||||
{ code: 3005, desc: '任务当前状态不允许该操作' },
|
||||
{ code: 3006, desc: '当前模式或平台不支持该操作' },
|
||||
{ code: 2007, desc: '平台适配器或外部服务失败' },
|
||||
{ code: 5000, desc: '服务内部错误' },
|
||||
];
|
||||
|
||||
@@ -102,6 +103,11 @@ export const apiGroups: ApiGroup[] = [
|
||||
params: [{ name: 'refreshToken', type: 'string', required: false, desc: '要吊销的 refreshToken' }],
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/logout -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"refreshToken":"<refresh-token>"}'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/auth/password', summary: '修改当前用户密码', auth: 'Bearer', status: '200 / 400 / 401',
|
||||
params: [{ name: 'currentPassword', type: 'string', required: true, desc: '当前密码' }, { name: 'newPassword', type: 'string', required: true, desc: '新密码,至少 8 位' }],
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/password -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"currentPassword":"old-pass","newPassword":"new-pass-2026"}'`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/auth/me',
|
||||
@@ -169,7 +175,7 @@ export const apiGroups: ApiGroup[] = [
|
||||
{
|
||||
key: 'account',
|
||||
title: '账号台账 /accounts',
|
||||
desc: '平台账号登记与绑定(凭据仅在客户端本机,服务器零凭据)',
|
||||
desc: '平台账号登记与网页本机登录;凭据和浏览器 profile 由当前服务器本机执行器管理',
|
||||
apis: [
|
||||
{
|
||||
method: 'GET', path: '/accounts', summary: '账号列表(platform/status 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
|
||||
@@ -180,7 +186,7 @@ export const apiGroups: ApiGroup[] = [
|
||||
method: 'POST', path: '/accounts', summary: '新增账号(初始 unbound)', auth: 'Bearer', status: '200 / 400 / 401',
|
||||
params: [
|
||||
{ name: 'platform', type: 'string', required: true, desc: 'douyin/kuaishou/xiaohongshu/bilibili' },
|
||||
{ name: 'accountName', type: 'string', required: true, desc: '账号名称' },
|
||||
{ name: 'accountName', type: 'string', required: false, desc: '账号名称,可在网页登录后同步' },
|
||||
{ name: 'avatarUrl', type: 'string', required: false, desc: '头像 URL' },
|
||||
{ name: 'ipProfile', type: 'string', required: false, desc: 'IP 画像标识(一账号固定 IP)' },
|
||||
],
|
||||
@@ -196,9 +202,22 @@ export const apiGroups: ApiGroup[] = [
|
||||
curl: `curl -X DELETE http://127.0.0.1:8090/api/v1/accounts/1 -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/accounts/:id/bind', summary: '发起绑定 → 创建挑战 → 推送给在线设备扫码', auth: 'Bearer', status: '200 / 400 / 401 / 404 / 409(3002)',
|
||||
method: 'POST', path: '/accounts/:id/bind', summary: '发起网页登录挑战(QR/验证码)', auth: 'Bearer', status: '200 / 400 / 401 / 404',
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/bind -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/accounts/:id/credentials', summary: '导入平台 Cookie(服务端加密保存)', auth: 'Bearer(owner/admin/operator)', status: '200 / 400 / 401 / 409(3006)',
|
||||
params: [{ name: 'cookies', type: 'string', required: true, desc: '平台 Cookie 字符串,不会回显' }],
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/credentials -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"cookies":"SESSDATA=...; bili_jct=...; DedeUserID=..."}'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/accounts/:id/check', summary: '调用平台适配器检查凭据', auth: 'Bearer(owner/admin/operator)', status: '200 / 409(3006) / 502(2007)',
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/check -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/accounts/:id/unbind', summary: '解绑并清理本机登录状态', auth: 'Bearer', status: '200 / 401 / 404',
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/unbind -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -246,7 +265,7 @@ export const apiGroups: ApiGroup[] = [
|
||||
{ name: 'tags', type: 'string[]', required: false, desc: '话题标签' },
|
||||
{ name: 'accountIds', type: 'number[]', required: true, desc: '账号 ID 数组(至少 1 个)' },
|
||||
{ name: 'materialIds', type: 'number[]', required: true, desc: '素材 ID 数组(至少 1 个)' },
|
||||
{ name: 'scheduleAt', type: 'number', required: false, desc: '定时发布时间 unix 毫秒' },
|
||||
{ name: 'scheduleAt', type: 'number', required: false, desc: '定时发布时间 unix 毫秒,必须晚于当前时间' },
|
||||
{ name: 'priority', type: 'number', required: false, desc: '优先级 1-10(默认 5)' },
|
||||
],
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"title":"新品发布","accountIds":[1],"materialIds":[1]}'`,
|
||||
@@ -268,7 +287,7 @@ export const apiGroups: ApiGroup[] = [
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/submit -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/tasks/:id/approve', summary: '审核通过(→queued,在线设备立即下发)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
|
||||
method: 'POST', path: '/tasks/:id/approve', summary: '审核通过(→queued,本机 Web 执行器立即处理)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/approve -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
@@ -288,6 +307,10 @@ export const apiGroups: ApiGroup[] = [
|
||||
method: 'POST', path: '/tasks/:id/cancel', summary: '取消任务', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/cancel -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/tasks/:id/events', summary: '任务状态时间线和执行日志', auth: 'Bearer', status: '200 / 401 / 404',
|
||||
curl: `curl http://127.0.0.1:8090/api/v1/tasks/1/events -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -336,7 +359,7 @@ export const apiGroups: ApiGroup[] = [
|
||||
{
|
||||
key: 'audit',
|
||||
title: '审计 /audit-logs',
|
||||
desc: '操作审计(登录/登出/增删改/审核/挑战/设备,自动埋点)',
|
||||
desc: '操作审计(登录/登出/增删改/审核/挑战,自动埋点)',
|
||||
apis: [
|
||||
{
|
||||
method: 'GET', path: '/audit-logs', summary: '审计日志(userId/action 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
|
||||
@@ -344,37 +367,13 @@ export const apiGroups: ApiGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
title: '设备 /agent',
|
||||
desc: '客户端设备配对与吊销(Ed25519 公钥注册)',
|
||||
apis: [
|
||||
{
|
||||
method: 'POST', path: '/agent/pair-code', summary: '生成配对码(6 位,5 分钟一次性)', auth: 'Bearer', status: '200 / 401',
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/agent/pair-code -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/agent/pair', summary: '设备配对(凭码注册公钥)', auth: '无(凭配对码)', status: '200 / 400 / 410(2006)',
|
||||
params: [{ name: 'code', type: 'string', required: true, desc: '配对码' }, { name: 'deviceName', type: 'string', required: true, desc: '设备名' }, { name: 'publicKey', type: 'string', required: true, desc: 'Ed25519 公钥 base64' }],
|
||||
curl: `curl -X POST http://127.0.0.1:8090/api/v1/agent/pair -H 'Content-Type: application/json' -d '{"code":"AB12CD","deviceName":"PC-01","publicKey":"<ed25519-pub-b64>"}'`,
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/agent/devices', summary: '设备列表(在线状态)', auth: 'Bearer', status: '200 / 401',
|
||||
curl: `curl http://127.0.0.1:8090/api/v1/agent/devices -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'PUT', path: '/agent/devices/:id/revoke', summary: '吊销设备并强制下线', auth: 'Bearer(owner/admin)', status: '200 / 401 / 403 / 404',
|
||||
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/agent/devices/1/revoke -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
title: '平台管理 /admin',
|
||||
desc: '仅平台管理员(role=admin)可用',
|
||||
apis: [
|
||||
{
|
||||
method: 'GET', path: '/admin/tenants', summary: '租户列表(owner 邮箱/成员数/设备数)', auth: 'Bearer(admin)', status: '200 / 401 / 403(1003)',
|
||||
method: 'GET', path: '/admin/tenants', summary: '租户列表(owner 邮箱/成员数)', auth: 'Bearer(admin)', status: '200 / 401 / 403(1003)',
|
||||
curl: `curl 'http://127.0.0.1:8090/api/v1/admin/tenants' -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
@@ -382,14 +381,6 @@ export const apiGroups: ApiGroup[] = [
|
||||
params: [{ name: 'status', type: 'string', required: true, desc: 'active/suspended' }],
|
||||
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/admin/tenants/1/status -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"status":"suspended"}'`,
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/admin/agents', summary: '全局 Agent 总览(含所属租户)', auth: 'Bearer(admin)', status: '200 / 401 / 403',
|
||||
curl: `curl http://127.0.0.1:8090/api/v1/admin/agents -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
{
|
||||
method: 'PUT', path: '/admin/agents/:id/revoke', summary: '全局强制吊销设备', auth: 'Bearer(admin)', status: '200 / 401 / 403 / 404',
|
||||
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/admin/agents/1/revoke -H 'Authorization: Bearer <accessToken>'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -404,22 +395,22 @@ export const apiGroups: ApiGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ws',
|
||||
title: 'WSS 双通道 /ws',
|
||||
desc: '实时通道:Agent 设备签名 / 浏览器 JWT(WebSocket)',
|
||||
apis: [
|
||||
{
|
||||
method: 'GET', path: '/ws/agent', summary: 'Agent 通道(Ed25519 hello 验签;消息 hello/heartbeat/task.push/task.ack/task.result/challenge)', auth: 'Ed25519 设备签名', status: '101 升级 / 4408 拒绝',
|
||||
curl: `# 需客户端代码连接(fake-agent / client-core);首条消息 hello:{deviceId,nonce,ts,sig=ed25519(nonce|ts)}`,
|
||||
note: '任务下发延迟实测约 28ms(硬指标 <300ms)',
|
||||
key: 'runtime',
|
||||
title: '运行状态 /runtime/status',
|
||||
desc: '当前执行模式、已注册适配器、数据库和 Redis 状态',
|
||||
apis: [{ method: 'GET', path: '/runtime/status', summary: '读取当前网页执行器状态', auth: 'Bearer', status: '200 / 401', curl: `curl http://127.0.0.1:8090/api/v1/runtime/status -H 'Authorization: Bearer <accessToken>'` }],
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/ws/browser', summary: '浏览器通道(?token=JWT,只读订阅 task.status/agent.status/challenge.status)', auth: 'JWT(query token)', status: '101 升级 / 401',
|
||||
curl: `# 前端 utils/ws.ts 自动连接;地址 ws://127.0.0.1:3003/ws/browser?token=<JWT>`,
|
||||
key: 'ws',
|
||||
title: '浏览器实时通道 /ws',
|
||||
desc: '网页端 Browser WebSocket,断开后 REST 兜底',
|
||||
apis: [
|
||||
{
|
||||
method: 'GET', path: '/ws/browser', summary: '浏览器通道(?token=JWT,订阅 task.status/challenge.status/notification.new)', auth: 'JWT(query token)', status: '101 升级 / 401',
|
||||
curl: `# 前端 utils/ws.ts 自动连接;地址 ws://127.0.0.1:3013/ws/browser?token=<JWT>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default apiGroups;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ interface ITenant {
|
||||
plan: string;
|
||||
status: string;
|
||||
memberCount: number;
|
||||
deviceCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -53,7 +52,6 @@ export default function AdminTenant() {
|
||||
{ colKey: 'ownerEmail', title: 'Owner 邮箱', ellipsis: true },
|
||||
{ colKey: 'plan', title: '套餐', width: 90 },
|
||||
{ colKey: 'memberCount', title: '成员', width: 80 },
|
||||
{ colKey: 'deviceCount', title: '设备', width: 80 },
|
||||
{
|
||||
colKey: 'status',
|
||||
title: '状态',
|
||||
|
||||
@@ -71,7 +71,6 @@ export default function AuditList() {
|
||||
{ label: '账号绑定', value: 'account.bind' },
|
||||
{ label: '素材', value: 'material.upload' },
|
||||
{ label: '成员', value: 'member.invite' },
|
||||
{ label: '设备', value: 'agent.revoke' },
|
||||
]}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ interface IChallenge {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { qr: '扫码', sms: '短信验证码', confirm: 'APP 确认', captcha: '图形验证码', pending: '等待设备' };
|
||||
const KIND_LABEL: Record<string, string> = { qr: '扫码', sms: '短信验证码', confirm: '应用确认', captcha: '图形验证码', pending: '等待验证' };
|
||||
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
|
||||
active: { label: '待处理', theme: 'warning' },
|
||||
solved: { label: '已完成', theme: 'success' },
|
||||
@@ -92,9 +92,16 @@ export default function ChallengeList() {
|
||||
cell: ({ row }: { row: IChallenge }) => (
|
||||
<Space size={0}>
|
||||
{row.kind === 'qr' && row.status === 'active' && (
|
||||
<>
|
||||
<Button size='small' variant='text' theme='primary' onClick={() => setQrTarget(row)}>
|
||||
显示二维码
|
||||
</Button>
|
||||
{row.qrUrl?.startsWith('mock://') && (
|
||||
<Button size='small' variant='text' onClick={() => act(row, 'solve', { value: 'web-mock-login' })}>
|
||||
模拟完成
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(row.kind === 'sms' || row.kind === 'captcha') && row.status === 'active' && (
|
||||
<Button size='small' variant='text' theme='primary' onClick={() => setSolveTarget(row)}>
|
||||
@@ -107,7 +114,7 @@ export default function ChallengeList() {
|
||||
</Button>
|
||||
)}
|
||||
{row.status === 'active' && (
|
||||
<Popconfirm content='挂起后客户端停止等待?' onConfirm={() => act(row, 'suspend')}>
|
||||
<Popconfirm content='挂起后将停止等待本次登录,确认?' onConfirm={() => act(row, 'suspend')}>
|
||||
<Button size='small' variant='text' theme='warning'>
|
||||
挂起
|
||||
</Button>
|
||||
@@ -119,7 +126,7 @@ export default function ChallengeList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title='挑战中心' subtitle='二次验证兜底:扫码/验证码/APP 确认;超时自动挂起,可一键重发'>
|
||||
<Card title='挑战中心' subtitle='二次验证兜底:扫码/验证码/应用确认;超时自动挂起,可一键重发'>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<Dialog
|
||||
header='扫码验证(本地渲染,不截图传输)'
|
||||
@@ -128,8 +135,9 @@ export default function ChallengeList() {
|
||||
footer={null}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<QRCode value={qrTarget?.qrToken || qrTarget?.qrUrl || 'pending'} size={220} />
|
||||
<QRCode value={qrTarget?.qrUrl?.startsWith('mock://') ? qrTarget.qrUrl : (qrTarget?.qrUrl || qrTarget?.qrToken || 'pending')} size={220} />
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>{qrTarget?.prompt}</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 6 }}>二维码只需在此页面扫码;客户端配对码不参与平台登录。</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
|
||||
@@ -18,8 +18,8 @@ const STATUS_MAP: Record<string, { label: string; theme: string }> = {
|
||||
};
|
||||
|
||||
interface ITask { id: number; title: string; status: string; scheduleAt?: string | null; createdAt: string; publishedAt?: string | null; }
|
||||
interface IDevice { id: number; name: string; status: string; version: string; revoked: boolean; lastSeenAt?: string | null; }
|
||||
interface IChallenge { id: number; status: string; }
|
||||
interface IRuntime { executorMode: string; adapters: string[]; db: boolean; redis: boolean; }
|
||||
|
||||
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
||||
return (
|
||||
@@ -32,17 +32,11 @@ function StatCard({ label, value, hint }: { label: string; value: string; hint?:
|
||||
}
|
||||
|
||||
export default function DashboardBase() {
|
||||
const [devices, setDevices] = useState<IDevice[]>([]);
|
||||
const [tasks, setTasks] = useState<ITask[]>([]);
|
||||
const [challenges, setChallenges] = useState<IChallenge[]>([]);
|
||||
const [runtime, setRuntime] = useState<IRuntime | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await request.get('/agent/devices');
|
||||
setDevices((d.data.list || []) as IDevice[]);
|
||||
} catch (e) {
|
||||
setDevices([]);
|
||||
}
|
||||
try {
|
||||
const t = await request.get('/tasks?size=100');
|
||||
setTasks((t.data.list || []) as ITask[]);
|
||||
@@ -55,24 +49,27 @@ export default function DashboardBase() {
|
||||
} catch (e) {
|
||||
setChallenges([]);
|
||||
}
|
||||
try {
|
||||
const r = await request.get('/runtime/status');
|
||||
setRuntime(r.data as IRuntime);
|
||||
} catch (e) {
|
||||
setRuntime(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const offTask = onWS('task.status', () => load());
|
||||
const offAgent = onWS('agent.status', () => load());
|
||||
const offChallenge = onWS('challenge.status', () => load());
|
||||
return () => {
|
||||
offTask();
|
||||
offAgent();
|
||||
offChallenge();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const onlineDevices = devices.filter((d) => d.status === 'online' && !d.revoked).length;
|
||||
const activeChallenges = challenges.filter((c) => c.status === 'active').length;
|
||||
const pendingReview = tasks.filter((t) => t.status === 'pending_review').length;
|
||||
const todaySuccess = tasks.filter((t) => t.status === 'success' && t.publishedAt && dayjs(t.publishedAt).isSame(dayjs(), 'day')).length;
|
||||
const runningTasks = tasks.filter((t) => t.status === 'running' || t.status === 'dispatched' || t.status === 'queued').length;
|
||||
const recentTasks = tasks.slice(0, 8);
|
||||
|
||||
const taskColumns = [
|
||||
@@ -101,33 +98,15 @@ export default function DashboardBase() {
|
||||
},
|
||||
];
|
||||
|
||||
const deviceColumns = [
|
||||
{ colKey: 'name', title: '设备名', ellipsis: true },
|
||||
{
|
||||
colKey: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cell: ({ row }: { row: IDevice }) => {
|
||||
if (row.revoked) return <Tag theme='danger'>已吊销</Tag>;
|
||||
return row.status === 'online' ? <Tag theme='success'>在线</Tag> : <Tag theme='default'>离线</Tag>;
|
||||
},
|
||||
},
|
||||
{ colKey: 'version', title: '版本', width: 120 },
|
||||
{
|
||||
colKey: 'lastSeenAt',
|
||||
title: '最后活跃',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: IDevice }) => (
|
||||
<span>{row.lastSeenAt ? dayjs(row.lastSeenAt).format('MM-DD HH:mm:ss') : '-'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6} xl={3}>
|
||||
<StatCard label='Agent 在线' value={onlineDevices + ' / ' + devices.length} hint='在线设备 / 全部设备' />
|
||||
<StatCard
|
||||
label='本机执行器'
|
||||
value={runtime?.executorMode === 'web' ? 'Web 真实模式' : 'Mock 联调模式'}
|
||||
hint={runtime ? `适配器:${runtime.adapters.join('、') || '无'}` : '状态读取中'}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} xl={3}>
|
||||
<StatCard label='待处理挑战' value={String(activeChallenges)} hint='扫码/验证码等二次验证' />
|
||||
@@ -136,14 +115,16 @@ export default function DashboardBase() {
|
||||
<StatCard label='待审核任务' value={String(pendingReview)} hint='审核中心待处理' />
|
||||
</Col>
|
||||
<Col xs={12} sm={6} xl={3}>
|
||||
<StatCard label='今日发布成功' value={String(todaySuccess)} hint='今日已完成发布' />
|
||||
<StatCard label='执行中任务' value={String(runningTasks)} hint='排队/运行中的任务' />
|
||||
</Col>
|
||||
</Row>
|
||||
<Card title='最近任务' subtitle='实时状态推送' style={{ marginTop: 16 }}>
|
||||
<Table data={recentTasks} columns={taskColumns} rowKey='id' hover />
|
||||
</Card>
|
||||
<Card title='Agent 设备' style={{ marginTop: 16 }}>
|
||||
<Table data={devices} columns={deviceColumns} rowKey='id' hover />
|
||||
<Card title='执行器说明' style={{ marginTop: 16 }}>
|
||||
<div style={{ color: 'var(--td-text-color-secondary)', lineHeight: 1.8 }}>
|
||||
当前为 Web-only 本地模式:任务由服务器进程排队和执行,不需要客户端配对。{runtime?.executorMode === 'web' ? '已注册平台会走真实适配器。' : '当前为自动化联调模式,结果不会代表真实平台发布。'}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BrowseIcon, BrowseOffIcon, LockOnIcon, MailIcon } from 'tdesign-icons-r
|
||||
import classnames from 'classnames';
|
||||
import { useAppDispatch } from 'modules/store';
|
||||
import { login } from 'modules/user';
|
||||
import { connectWS } from 'utils/ws';
|
||||
|
||||
import Style from './index.module.less';
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function Login() {
|
||||
try {
|
||||
const formValue = (formRef.current?.getFieldsValue?.(true) || {}) as { email: string; password: string };
|
||||
await dispatch(login(formValue)).unwrap();
|
||||
connectWS();
|
||||
MessagePlugin.success('登录成功');
|
||||
navigate('/dashboard/base');
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LockOnIcon, MailIcon, BrowseOffIcon, BrowseIcon, UserIcon } from 'tdesi
|
||||
import classnames from 'classnames';
|
||||
import { useAppDispatch } from 'modules/store';
|
||||
import { register } from 'modules/user';
|
||||
import { connectWS } from 'utils/ws';
|
||||
|
||||
import Style from './index.module.less';
|
||||
|
||||
@@ -31,6 +32,7 @@ export default function Register() {
|
||||
try {
|
||||
const nickname = formValue.nickname || formValue.email.split('@')[0];
|
||||
await dispatch(register({ email: formValue.email, password: formValue.password, nickname })).unwrap();
|
||||
connectWS();
|
||||
MessagePlugin.success('注册成功,已自动登录');
|
||||
navigate('/dashboard/base');
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function MaterialList() {
|
||||
const [kind, setKind] = useState('');
|
||||
const [group, setGroup] = useState('');
|
||||
const [urlDialog, setUrlDialog] = useState<{ visible: boolean; url: string }>({ visible: false, url: '' });
|
||||
const [preview, setPreview] = useState<{ visible: boolean; url: string; kind: string; name: string }>({ visible: false, url: '', kind: '', name: '' });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -76,6 +77,15 @@ export default function MaterialList() {
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = async (m: IMaterial) => {
|
||||
try {
|
||||
const res = await request.get('/materials/' + m.id + '/url');
|
||||
setPreview({ visible: true, url: (res.data.url || '') + '?inline=1', kind: m.kind, name: m.name });
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '预览链接生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyURL = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlDialog.url);
|
||||
@@ -124,9 +134,12 @@ export default function MaterialList() {
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 160,
|
||||
width: 220,
|
||||
cell: ({ row }: { row: IMaterial }) => (
|
||||
<Space>
|
||||
<Button size='small' variant='text' onClick={() => openPreview(row)}>
|
||||
预览
|
||||
</Button>
|
||||
<Button size='small' variant='text' theme='primary' onClick={() => getURL(row)}>
|
||||
获取直链
|
||||
</Button>
|
||||
@@ -181,6 +194,21 @@ export default function MaterialList() {
|
||||
</Button>
|
||||
</Space>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
header={'预览 · ' + preview.name}
|
||||
visible={preview.visible}
|
||||
onClose={() => setPreview({ visible: false, url: '', kind: '', name: '' })}
|
||||
footer={null}
|
||||
>
|
||||
<div style={{ textAlign: 'center', minHeight: 120 }}>
|
||||
{preview.kind === 'video' ? (
|
||||
<video controls src={preview.url} style={{ maxWidth: '100%', maxHeight: 420 }} />
|
||||
) : (
|
||||
<img src={preview.url} alt={preview.name} style={{ maxWidth: '100%', maxHeight: 420, objectFit: 'contain' }} />
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>预览链接为一次性链接,关闭后需重新生成。</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,12 @@ export default function NotificationList() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const off = onWS('task.status', () => load());
|
||||
return off;
|
||||
const offTask = onWS('task.status', () => load());
|
||||
const offNotification = onWS('notification.new', () => load());
|
||||
return () => {
|
||||
offTask();
|
||||
offNotification();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const markRead = async (n: INotification) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, Calendar, MessagePlugin, Tag } from 'tdesign-react';
|
||||
import { Button, Card, Calendar, DatePicker, Drawer, MessagePlugin, Space, Tag } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
|
||||
@@ -7,6 +7,10 @@ interface ITask {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
content?: string;
|
||||
tags?: string;
|
||||
accountIds?: string;
|
||||
materialIds?: string;
|
||||
scheduleAt?: string | null;
|
||||
}
|
||||
|
||||
@@ -15,6 +19,9 @@ const THEME: Record<string, string> = { success: 'success', failed: 'danger', ca
|
||||
export default function CalendarPage() {
|
||||
const [tasks, setTasks] = useState<ITask[]>([]);
|
||||
const [map, setMap] = useState<Record<string, ITask[]>>({});
|
||||
const [selected, setSelected] = useState<ITask | null>(null);
|
||||
const [scheduleAt, setScheduleAt] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -38,6 +45,55 @@ export default function CalendarPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const openTask = async (task: ITask) => {
|
||||
try {
|
||||
const res = await request.get('/tasks/' + task.id);
|
||||
const row = res.data as ITask;
|
||||
setSelected(row);
|
||||
setScheduleAt(row.scheduleAt || '');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '任务详情加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveSchedule = async (nextSchedule = scheduleAt) => {
|
||||
if (!selected || !['draft', 'rejected'].includes(selected.status)) return;
|
||||
const parseIDs = (raw?: string): number[] => {
|
||||
try {
|
||||
const value = JSON.parse(raw || '[]');
|
||||
return Array.isArray(value) ? value.map(Number).filter((id) => Number.isFinite(id)) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
try {
|
||||
const value = JSON.parse(raw || '[]');
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
await request.put('/tasks/' + selected.id, {
|
||||
title: selected.title,
|
||||
content: selected.content || '',
|
||||
tags: parseTags(selected.tags),
|
||||
accountIds: parseIDs(selected.accountIds),
|
||||
materialIds: parseIDs(selected.materialIds),
|
||||
scheduleAt: nextSchedule ? dayjs(nextSchedule).valueOf() : null,
|
||||
});
|
||||
MessagePlugin.success(nextSchedule ? '定时已更新' : '已改为立即发布');
|
||||
setSelected(null);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '定时更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title='排期日历' subtitle='按计划发布时间查看任务'>
|
||||
<Calendar
|
||||
@@ -50,7 +106,7 @@ export default function CalendarPage() {
|
||||
<div>{dayNum}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{list.slice(0, 3).map((t) => (
|
||||
<Tag key={t.id} size='small' theme={(THEME[t.status] || 'primary') as any} variant='light'>
|
||||
<Tag key={t.id} size='small' theme={(THEME[t.status] || 'primary') as any} variant='light' onClick={() => openTask(t)}>
|
||||
{t.title.slice(0, 6)}
|
||||
</Tag>
|
||||
))}
|
||||
@@ -61,6 +117,26 @@ export default function CalendarPage() {
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>共 {tasks.filter((t) => t.scheduleAt).length} 个定时任务;立即发布的任务不在此展示。</div>
|
||||
<Drawer header={selected ? `任务 #${selected.id} 排期` : '任务排期'} visible={!!selected} onClose={() => setSelected(null)} size='420px'>
|
||||
{selected && (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div><b>标题</b>:{selected.title}</div>
|
||||
<div><b>状态</b>:{selected.status}</div>
|
||||
{['draft', 'rejected'].includes(selected.status) ? (
|
||||
<>
|
||||
<div style={{ marginTop: 12, marginBottom: 6 }}>计划发布时间</div>
|
||||
<DatePicker enableTimePicker value={scheduleAt} onChange={(v) => setScheduleAt(v as string)} clearable style={{ width: '100%' }} />
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button theme='primary' loading={saving} onClick={() => saveSchedule()}>保存排期</Button>
|
||||
<Button variant='outline' loading={saving} onClick={() => { setScheduleAt(''); saveSchedule(''); }}>改为立即发布</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginTop: 12, color: '#888' }}>只有草稿或驳回任务可以编辑排期。</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button, Card, DatePicker, Form, Input, MessagePlugin, Radio, Space, Steps, Table, Tag, TagInput, Textarea } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
@@ -12,6 +12,8 @@ interface IAccount { id: number; platform: string; accountName: string; remark:
|
||||
|
||||
export default function PublishNew() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const editingID = searchParams.get('taskId');
|
||||
const [step, setStep] = useState(0);
|
||||
const [materials, setMaterials] = useState<IMaterial[]>([]);
|
||||
const [accounts, setAccounts] = useState<IAccount[]>([]);
|
||||
@@ -39,6 +41,28 @@ export default function PublishNew() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingID) return;
|
||||
const parse = <T,>(raw: string | undefined, fallback: T[]): T[] => {
|
||||
try {
|
||||
const value = JSON.parse(raw || '[]');
|
||||
return Array.isArray(value) ? value as T[] : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
request.get('/tasks/' + editingID).then((res) => {
|
||||
const task = res.data;
|
||||
setTitle(task.title || '');
|
||||
setContent(task.content || '');
|
||||
setTags(parse<string>(task.tags, []));
|
||||
setSelAccs(parse<number>(task.accountIds, []));
|
||||
setSelMats(parse<number>(task.materialIds, []));
|
||||
setScheduleMode(task.scheduleAt ? 'scheduled' : 'now');
|
||||
setScheduleAt(task.scheduleAt || '');
|
||||
}).catch((e: any) => MessagePlugin.error(e?.response?.data?.message || '草稿加载失败'));
|
||||
}, [editingID]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim()) {
|
||||
MessagePlugin.warning('请填写标题');
|
||||
@@ -46,7 +70,7 @@ export default function PublishNew() {
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request.post('/tasks', {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
content,
|
||||
tags,
|
||||
@@ -54,8 +78,14 @@ export default function PublishNew() {
|
||||
materialIds: selMats.map(Number),
|
||||
scheduleAt: scheduleMode === 'scheduled' && scheduleAt ? dayjs(scheduleAt).valueOf() : null,
|
||||
priority: 5,
|
||||
});
|
||||
};
|
||||
if (editingID) {
|
||||
await request.put('/tasks/' + editingID, payload);
|
||||
MessagePlugin.success('草稿已更新,可在任务列表提交审核');
|
||||
} else {
|
||||
await request.post('/tasks', payload);
|
||||
MessagePlugin.success('草稿已创建,可在任务列表提交审核');
|
||||
}
|
||||
navigate('/publish/tasks');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '创建失败');
|
||||
@@ -159,7 +189,7 @@ export default function PublishNew() {
|
||||
<div><b>标题</b>:{title}</div>
|
||||
<div><b>标签</b>:{tags.join('、') || '-'}</div>
|
||||
<div><b>时间</b>:{scheduleMode === 'scheduled' && scheduleAt ? dayjs(scheduleAt).format('YYYY-MM-DD HH:mm') : '审核通过后立即'}</div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>提交后为草稿状态,需在任务列表提交审核;审核通过后自动下发客户端执行。</div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>提交后为草稿状态,需在任务列表提交审核;审核通过后由本机 Web 执行器排队处理。</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button, Card, Dialog, Drawer, Input, MessagePlugin, Select, Space, Tabl
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { onWS } from 'utils/ws';
|
||||
import { useAppSelector } from 'modules/store';
|
||||
import { selectUser } from 'modules/user';
|
||||
|
||||
interface ITask {
|
||||
id: number;
|
||||
@@ -22,6 +24,14 @@ interface ITask {
|
||||
tags: string;
|
||||
}
|
||||
|
||||
interface ITaskEvent {
|
||||
id: number;
|
||||
type: string;
|
||||
status: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
|
||||
draft: { label: '草稿', theme: 'default' },
|
||||
pending_review: { label: '待审核', theme: 'warning' },
|
||||
@@ -37,6 +47,9 @@ const STATUS_MAP: Record<string, { label: string; theme: string }> = {
|
||||
|
||||
export default function Tasks() {
|
||||
const navigate = useNavigate();
|
||||
const userState = useAppSelector(selectUser);
|
||||
const canOperate = ['owner', 'admin', 'operator'].includes(userState.memberRole || '');
|
||||
const canReview = ['owner', 'admin', 'reviewer'].includes(userState.memberRole || '');
|
||||
const [list, setList] = useState<ITask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
@@ -44,6 +57,7 @@ export default function Tasks() {
|
||||
const [rejectTarget, setRejectTarget] = useState<ITask | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [events, setEvents] = useState<ITaskEvent[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -84,9 +98,25 @@ export default function Tasks() {
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (task: ITask) => {
|
||||
setDetail(task);
|
||||
try {
|
||||
const res = await request.get('/tasks/' + task.id + '/events');
|
||||
setEvents((res.data.list || []) as ITaskEvent[]);
|
||||
} catch (e) {
|
||||
setEvents([]);
|
||||
MessagePlugin.error('任务时间线加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const renderActions = (row: ITask) => {
|
||||
const btns: React.ReactNode[] = [];
|
||||
if (row.status === 'draft') {
|
||||
if (canOperate && row.status === 'draft') {
|
||||
btns.push(
|
||||
<Button key='edit' size='small' variant='text' theme='primary' onClick={() => navigate('/publish/new?taskId=' + row.id)}>
|
||||
编辑
|
||||
</Button>,
|
||||
);
|
||||
btns.push(
|
||||
<Button key='submit' size='small' variant='text' theme='primary' onClick={() => act(row, 'submit')}>
|
||||
提交审核
|
||||
@@ -98,7 +128,7 @@ export default function Tasks() {
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (row.status === 'pending_review') {
|
||||
if (canReview && row.status === 'pending_review') {
|
||||
btns.push(
|
||||
<Button key='approve' size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>
|
||||
通过
|
||||
@@ -110,21 +140,26 @@ export default function Tasks() {
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (row.status === 'rejected') {
|
||||
if (canOperate && row.status === 'rejected') {
|
||||
btns.push(
|
||||
<Button key='edit-rejected' size='small' variant='text' theme='primary' onClick={() => navigate('/publish/new?taskId=' + row.id)}>
|
||||
编辑
|
||||
</Button>,
|
||||
);
|
||||
btns.push(
|
||||
<Button key='resubmit' size='small' variant='text' theme='primary' onClick={() => act(row, 'resubmit')}>
|
||||
重新提交
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (row.status === 'failed') {
|
||||
if (canOperate && row.status === 'failed') {
|
||||
btns.push(
|
||||
<Button key='retry' size='small' variant='text' theme='primary' onClick={() => act(row, 'retry')}>
|
||||
重试
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (['queued', 'dispatched', 'running', 'suspended', 'pending_review', 'draft', 'rejected'].includes(row.status)) {
|
||||
if (canOperate && ['queued', 'dispatched', 'running', 'suspended', 'pending_review', 'draft', 'rejected'].includes(row.status)) {
|
||||
btns.push(
|
||||
<Button key='cancel' size='small' variant='text' onClick={() => act(row, 'cancel')}>
|
||||
取消
|
||||
@@ -132,7 +167,7 @@ export default function Tasks() {
|
||||
);
|
||||
}
|
||||
btns.push(
|
||||
<Button key='detail' size='small' variant='text' onClick={() => setDetail(row)}>
|
||||
<Button key='detail' size='small' variant='text' onClick={() => openDetail(row)}>
|
||||
详情
|
||||
</Button>,
|
||||
);
|
||||
@@ -192,7 +227,7 @@ export default function Tasks() {
|
||||
<Drawer
|
||||
header={'任务 #' + (detail?.id || '') + ' 详情'}
|
||||
visible={!!detail}
|
||||
onClose={() => setDetail(null)}
|
||||
onClose={() => { setDetail(null); setEvents([]); }}
|
||||
size='420px'
|
||||
>
|
||||
{detail && (
|
||||
@@ -206,6 +241,15 @@ export default function Tasks() {
|
||||
<div><b>驳回批注</b>:{detail.reviewNote || '-'}</div>
|
||||
<div><b>发布链接</b>:{detail.publishedUrls === 'null' || !detail.publishedUrls ? '-' : detail.publishedUrls}</div>
|
||||
<div><b>创建时间</b>:{dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm')}</div>
|
||||
<div style={{ marginTop: 16, fontWeight: 600 }}>状态时间线</div>
|
||||
{events.length === 0 ? (
|
||||
<div style={{ color: '#888' }}>暂无事件</div>
|
||||
) : events.map((event) => (
|
||||
<div key={event.id} style={{ borderLeft: '2px solid var(--td-brand-color)', paddingLeft: 10, marginTop: 8 }}>
|
||||
<div>{event.message}</div>
|
||||
<div style={{ color: '#888', fontSize: 12 }}>{dayjs(event.createdAt).format('MM-DD HH:mm:ss')} · {event.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Button, Card, Dialog, Drawer, MessagePlugin, Space, Table, Tag, Textare
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { onWS } from 'utils/ws';
|
||||
import { useAppSelector } from 'modules/store';
|
||||
import { selectUser } from 'modules/user';
|
||||
|
||||
interface ITask {
|
||||
id: number;
|
||||
@@ -16,12 +18,23 @@ interface ITask {
|
||||
}
|
||||
|
||||
export default function ReviewList() {
|
||||
const userState = useAppSelector(selectUser);
|
||||
const canReview = ['owner', 'admin', 'reviewer'].includes(userState.memberRole || '');
|
||||
const [list, setList] = useState<ITask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preview, setPreview] = useState<ITask | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<ITask | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const countIDs = (raw: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -68,19 +81,15 @@ export default function ReviewList() {
|
||||
<Button size='small' variant='text' onClick={() => setPreview(row)}>
|
||||
预览
|
||||
</Button>
|
||||
<Button size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>
|
||||
通过
|
||||
</Button>
|
||||
<Button size='small' variant='text' theme='danger' onClick={() => setRejectTarget(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
{canReview && <Button size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>通过</Button>}
|
||||
{canReview && <Button size='small' variant='text' theme='danger' onClick={() => setRejectTarget(row)}>驳回</Button>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title='审核中心' subtitle='待审核任务(通过后自动下发客户端执行)'>
|
||||
<Card title='审核中心' subtitle='待审核任务(通过后由本机 Web 执行器处理)'>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<Drawer header={'任务 #' + (preview?.id || '') + ' 预览'} visible={!!preview} onClose={() => setPreview(null)} size='420px'>
|
||||
{preview && (
|
||||
@@ -88,8 +97,8 @@ export default function ReviewList() {
|
||||
<div><b>标题</b>:{preview.title}</div>
|
||||
<div><b>内容</b>:{preview.content || '-'}</div>
|
||||
<div><b>标签</b>:{preview.tags || '-'}</div>
|
||||
<div><b>账号数</b>:{(preview.accountIds || '[]').length > 2 ? JSON.parse(preview.accountIds).length : 0}</div>
|
||||
<div><b>素材数</b>:{(preview.materialIds || '[]').length > 2 ? JSON.parse(preview.materialIds).length : 0}</div>
|
||||
<div><b>账号数</b>:{countIDs(preview.accountIds)}</div>
|
||||
<div><b>素材数</b>:{countIDs(preview.materialIds)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, Switch, Tag } from 'tdesign-react';
|
||||
|
||||
const CHANNELS = [
|
||||
{ name: '企业微信机器人', desc: '任务失败/挑战待处理时推送到企业微信群' },
|
||||
{ name: '钉钉机器人', desc: '任务失败/挑战待处理时推送到钉钉群' },
|
||||
{ name: '邮件通知', desc: '审核结果/任务结果发送到成员邮箱' },
|
||||
];
|
||||
|
||||
export default function SettingChannel() {
|
||||
return (
|
||||
<Card
|
||||
title='通知渠道'
|
||||
actions={<Tag theme='warning' variant='light'>二期接入</Tag>}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{CHANNELS.map((ch) => (
|
||||
<div key={ch.name} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Switch disabled value={false} />
|
||||
<div>
|
||||
<div style={{ fontSize: 14 }}>{ch.name}</div>
|
||||
<div style={{ fontSize: 13, color: '#888' }}>{ch.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 13, color: '#888' }}>一期站内通知已全量可用;企微/钉钉/邮件渠道在二期接入。</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, MessagePlugin, Popconfirm, Table, Tag } from 'tdesign-react';
|
||||
import dayjs from 'dayjs';
|
||||
import request from 'utils/request';
|
||||
import { onWS } from 'utils/ws';
|
||||
|
||||
interface IDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
os: string;
|
||||
version: string;
|
||||
status: string;
|
||||
ip: string;
|
||||
revoked: boolean;
|
||||
lastSeenAt?: string | null;
|
||||
pairedAt?: string;
|
||||
}
|
||||
|
||||
export default function SettingDevice() {
|
||||
const [list, setList] = useState<IDevice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request.get('/agent/devices');
|
||||
setList((res.data.list || []) as IDevice[]);
|
||||
} catch (e) {
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const off = onWS('agent.status', () => load());
|
||||
return off;
|
||||
}, [load]);
|
||||
|
||||
const revoke = async (d: IDevice) => {
|
||||
try {
|
||||
await request.put('/agent/devices/' + d.id + '/revoke');
|
||||
MessagePlugin.success('已吊销,设备将被强制下线');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '吊销失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'name', title: '设备名', ellipsis: true },
|
||||
{
|
||||
colKey: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cell: ({ row }: { row: IDevice }) => {
|
||||
if (row.revoked) return <Tag theme='danger'>已吊销</Tag>;
|
||||
return row.status === 'online' ? <Tag theme='success'>在线</Tag> : <Tag theme='default'>离线</Tag>;
|
||||
},
|
||||
},
|
||||
{ colKey: 'os', title: '系统', width: 100 },
|
||||
{ colKey: 'version', title: '版本', width: 100 },
|
||||
{ colKey: 'ip', title: 'IP', width: 140 },
|
||||
{
|
||||
colKey: 'pairedAt',
|
||||
title: '配对时间',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: IDevice }) => <span>{row.pairedAt ? dayjs(row.pairedAt).format('YYYY-MM-DD HH:mm') : '-'}</span>,
|
||||
},
|
||||
{
|
||||
colKey: 'lastSeenAt',
|
||||
title: '最后活跃',
|
||||
width: 160,
|
||||
cell: ({ row }: { row: IDevice }) => (
|
||||
<span>{row.lastSeenAt ? dayjs(row.lastSeenAt).format('MM-DD HH:mm:ss') : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: 'op',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
cell: ({ row }: { row: IDevice }) =>
|
||||
row.revoked ? null : (
|
||||
<Popconfirm content='吊销后设备立即下线且无法再连接,确认?' onConfirm={() => revoke(row)}>
|
||||
<Button size='small' variant='text' theme='danger'>
|
||||
吊销
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title='Agent 设备'
|
||||
actions={
|
||||
<Button variant='outline' onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>配对:客户端安装后生成配对码(设置页留位,D9 客户端 UI 提供),在此页可吊销设备。</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default function SettingProfile() {
|
||||
const [workspaces, setWorkspaces] = useState<IWorkspace[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [switching, setSwitching] = useState(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -52,6 +53,21 @@ export default function SettingProfile() {
|
||||
}
|
||||
};
|
||||
|
||||
const switchWorkspace = async (workspaceId: number) => {
|
||||
if (workspaceId === userState.workspaceId) return;
|
||||
setSwitching(workspaceId);
|
||||
try {
|
||||
const res = await request.post('/auth/switch-workspace/' + workspaceId);
|
||||
localStorage.setItem('ep_token', res.data.accessToken);
|
||||
localStorage.setItem('ep_refresh', res.data.refreshToken);
|
||||
window.location.reload();
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '切换工作空间失败');
|
||||
} finally {
|
||||
setSwitching(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction='vertical' style={{ width: '100%' }} size={16}>
|
||||
<Card title='工作区资料'>
|
||||
@@ -75,9 +91,14 @@ export default function SettingProfile() {
|
||||
<Card title='我的工作空间'>
|
||||
<div style={{ fontSize: 14, lineHeight: 2 }}>
|
||||
{workspaces.map((w) => (
|
||||
<div key={w.id}>
|
||||
{w.name}({w.plan}){w.id === userState.workspaceId ? ' ← 当前' : ''}
|
||||
</div>
|
||||
<Space key={w.id}>
|
||||
<span>{w.name}({w.plan}){w.id === userState.workspaceId ? ' ← 当前' : ''}</span>
|
||||
{w.id !== userState.workspaceId && (
|
||||
<Button size='small' variant='text' loading={switching === w.id} onClick={() => switchWorkspace(w.id)}>
|
||||
切换
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Card, Input, Switch, Tag } from 'tdesign-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Input, MessagePlugin, Space, Switch, Tag } from 'tdesign-react';
|
||||
import request from 'utils/request';
|
||||
|
||||
export default function SettingSecurity() {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const changePassword = async () => {
|
||||
if (newPassword.length < 8) {
|
||||
MessagePlugin.warning('新密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await request.post('/auth/password', { currentPassword, newPassword });
|
||||
MessagePlugin.success('密码已更新,请重新登录');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
} catch (e: any) {
|
||||
MessagePlugin.error(e?.response?.data?.message || '密码更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Space direction='vertical' style={{ width: '100%' }} size={16}>
|
||||
<Card title='两步验证(2FA)'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 14 }}>
|
||||
<Switch disabled value={false} />
|
||||
@@ -16,15 +37,11 @@ export default function SettingSecurity() {
|
||||
</Card>
|
||||
<Card title='修改密码'>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 420 }}>
|
||||
<Input type='password' placeholder='当前密码' disabled />
|
||||
<Input type='password' placeholder='新密码(至少 8 位)' disabled />
|
||||
<div>
|
||||
<Tag theme='warning' variant='light'>
|
||||
二期开放
|
||||
</Tag>
|
||||
</div>
|
||||
<Input type='password' value={currentPassword} onChange={(v) => setCurrentPassword(v as string)} placeholder='当前密码' />
|
||||
<Input type='password' value={newPassword} onChange={(v) => setNewPassword(v as string)} placeholder='新密码(至少 8 位)' />
|
||||
<Button theme='primary' loading={saving} onClick={changePassword}>更新密码</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
import { ShopIcon, DesktopIcon, RootListIcon } from 'tdesign-icons-react';
|
||||
import { ShopIcon } from 'tdesign-icons-react';
|
||||
import { IRouter } from '../index';
|
||||
|
||||
const admin: IRouter[] = [
|
||||
@@ -12,11 +12,6 @@ const admin: IRouter[] = [
|
||||
Component: lazy(() => import('pages/Admin/Tenant')),
|
||||
meta: { title: '租户管理' },
|
||||
},
|
||||
{
|
||||
path: 'agent',
|
||||
Component: lazy(() => import('pages/Admin/Agent')),
|
||||
meta: { title: 'Agent 总览', Icon: DesktopIcon },
|
||||
},
|
||||
{
|
||||
path: 'docs',
|
||||
Component: lazy(() => import('pages/Admin/Docs')),
|
||||
|
||||
@@ -126,16 +126,6 @@ const workspace: IRouter[] = [
|
||||
Component: lazy(() => import('pages/Setting/Security')),
|
||||
meta: { title: '安全' },
|
||||
},
|
||||
{
|
||||
path: 'channel',
|
||||
Component: lazy(() => import('pages/Setting/Channel')),
|
||||
meta: { title: '通知渠道' },
|
||||
},
|
||||
{
|
||||
path: 'device',
|
||||
Component: lazy(() => import('pages/Setting/Device')),
|
||||
meta: { title: 'Agent 设备' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,7 +54,9 @@ instance.interceptors.response.use(
|
||||
if (data.code === SUCCESS_CODE) {
|
||||
return data;
|
||||
}
|
||||
return Promise.reject(data);
|
||||
const businessError = new Error(data.message || '请求失败') as Error & { response?: { data: unknown } };
|
||||
businessError.response = { data };
|
||||
return Promise.reject(businessError);
|
||||
}
|
||||
return Promise.reject(response?.data);
|
||||
},
|
||||
|
||||
@@ -5,10 +5,16 @@ let stopped = false;
|
||||
type Listener = (payload: any) => void;
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
function emit(type: string, payload: any) {
|
||||
listeners.get(type)?.forEach((fn) => fn(payload));
|
||||
}
|
||||
|
||||
export function connectWS() {
|
||||
stopped = false;
|
||||
if (stopped) return;
|
||||
const token = localStorage.getItem('ep_token');
|
||||
if (!token) return;
|
||||
if (sock && (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING)) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = proto + '://' + window.location.host + '/ws/browser?token=' + encodeURIComponent(token);
|
||||
try {
|
||||
@@ -17,6 +23,7 @@ export function connectWS() {
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
sock.onopen = () => emit('connection.status', { online: true });
|
||||
sock.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
@@ -28,6 +35,7 @@ export function connectWS() {
|
||||
};
|
||||
sock.onclose = () => {
|
||||
sock = null;
|
||||
emit('connection.status', { online: false });
|
||||
if (localStorage.getItem('ep_token')) scheduleRetry();
|
||||
};
|
||||
sock.onerror = () => {
|
||||
@@ -49,3 +57,13 @@ export function onWS(type: string, fn: Listener): () => void {
|
||||
set.add(fn);
|
||||
return () => set.delete(fn);
|
||||
}
|
||||
|
||||
export function disconnectWS() {
|
||||
stopped = true;
|
||||
if (retryTimer) {
|
||||
window.clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
sock?.close();
|
||||
sock = null;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default (params) => {
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3003,
|
||||
port: 3013,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8090',
|
||||
|
||||
@@ -135,6 +135,11 @@ func (s *Server) handleSolve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -143,11 +148,18 @@ func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "请求格式不合法"})
|
||||
return
|
||||
}
|
||||
if req.Code == "" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对码必填"})
|
||||
return
|
||||
}
|
||||
if s.pair == nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对服务未启用"})
|
||||
return
|
||||
}
|
||||
if err := s.pair(req.Code, req.Name); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
|
||||
return
|
||||
|
||||
@@ -2,11 +2,14 @@ package pairing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -69,11 +72,27 @@ func AutoPair(server, email, password, deviceName, pubKeyB64 string) (uint64, er
|
||||
|
||||
// PairWithCode 凭配对码注册设备,返回服务器分配的设备 ID
|
||||
func PairWithCode(server, pairCode, deviceName, osName, version, pubKeyB64 string) (uint64, error) {
|
||||
pairCode = strings.ToUpper(strings.TrimSpace(pairCode))
|
||||
deviceName = strings.TrimSpace(deviceName)
|
||||
if deviceName == "" {
|
||||
deviceName = "macOS Agent"
|
||||
}
|
||||
pubKeyB64 = strings.TrimSpace(pubKeyB64)
|
||||
if pubKeyB64 == "" {
|
||||
return 0, fmt.Errorf("本机设备公钥为空,请重启客户端后重试")
|
||||
}
|
||||
pubKey, keyErr := base64.StdEncoding.DecodeString(pubKeyB64)
|
||||
if keyErr != nil || len(pubKey) != ed25519.PublicKeySize {
|
||||
return 0, fmt.Errorf("本机设备公钥格式错误,请重启客户端后重试")
|
||||
}
|
||||
code, raw, err := httpJSON("POST", server+"/api/v1/agent/pair", "", ginH{
|
||||
"code": pairCode, "deviceName": deviceName, "os": osName, "version": version, "publicKey": pubKeyB64,
|
||||
})
|
||||
if err != nil || code != 200 {
|
||||
return 0, fmt.Errorf("pair failed: %s", string(raw))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("配对请求失败:%w", err)
|
||||
}
|
||||
if code != http.StatusOK {
|
||||
return 0, fmt.Errorf("%s", responseMessage(raw, "配对失败"))
|
||||
}
|
||||
id, _ := strconv.ParseUint(field(raw, "deviceId"), 10, 64)
|
||||
if id == 0 {
|
||||
@@ -81,3 +100,17 @@ func PairWithCode(server, pairCode, deviceName, osName, version, pubKeyB64 strin
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func responseMessage(raw []byte, fallback string) string {
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil && strings.TrimSpace(envelope.Message) != "" {
|
||||
return envelope.Message
|
||||
}
|
||||
if text := strings.TrimSpace(string(raw)); text != "" {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package pairing
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPairWithCodeSendsNormalizedPayload(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/agent/pair" || r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Code != "AB23CD" || body.DeviceName != "macOS Agent" {
|
||||
t.Fatalf("payload was not normalized: %+v", body)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(body.PublicKey)
|
||||
if err != nil || len(decoded) != ed25519.PublicKeySize {
|
||||
t.Fatalf("invalid public key: %q", body.PublicKey)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"ok","data":{"deviceId":42}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
id, err := PairWithCode(server.URL, " ab23cd ", "", "core", "0.3.0", base64.StdEncoding.EncodeToString(pub))
|
||||
if err != nil || id != 42 {
|
||||
t.Fatalf("PairWithCode() = %d, %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairWithCodeRejectsMissingPublicKey(t *testing.T) {
|
||||
_, err := PairWithCode("http://127.0.0.1:1", "AB23CD", "Mac", "core", "0.3.0", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "公钥") {
|
||||
t.Fatalf("expected actionable public key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairWithCodePreservesServerMessage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":1001,"data":null,"message":"设备公钥格式不合法"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
_, err := PairWithCode(server.URL, "AB23CD", "Mac", "core", "0.3.0", base64.StdEncoding.EncodeToString(pub))
|
||||
if err == nil || !strings.Contains(err.Error(), "设备公钥格式不合法") {
|
||||
t.Fatalf("expected server message, got %v", err)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -9,8 +9,9 @@ import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
@@ -102,6 +103,16 @@ func main() {
|
||||
if id.DeviceID > 0 {
|
||||
return errors.New("设备已配对")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = cfg.Name
|
||||
}
|
||||
if name == "" {
|
||||
name, _ = os.Hostname()
|
||||
}
|
||||
if name == "" {
|
||||
name = "macOS Agent"
|
||||
}
|
||||
did, err := pairing.PairWithCode(cfg.Server, code, name, "core", "0.2.0", id.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 生成 icons/app.png(256x256 纯蓝方块图标)
|
||||
// 生成 icons/app.png(512x512 纯蓝方块图标,兼容 macOS DMG)
|
||||
const zlib = require("zlib");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const W = 256, H = 256;
|
||||
const W = 512, H = 512;
|
||||
const row = Buffer.alloc(1 + W * 4);
|
||||
row[0] = 0;
|
||||
for (let x = 0; x < W; x++) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 938 B |
Binary file not shown.
|
Before Width: | Height: | Size: 916 B |
+30
-7
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require('electron');
|
||||
const { app, BrowserWindow, Tray, Menu, Notification, ipcMain, shell, nativeImage } = require('electron');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
@@ -17,6 +17,7 @@ let localJsonTimer = null;
|
||||
let quitting = false;
|
||||
|
||||
const dataDir = path.join(app.getPath('userData'), 'data');
|
||||
const localJsonPath = path.join(dataDir, 'local.json');
|
||||
const coreBin = isWin ? 'agent-core.exe' : 'agent-core';
|
||||
const coreExe = app.isPackaged
|
||||
? path.join(process.resourcesPath, coreBin)
|
||||
@@ -30,7 +31,14 @@ function startCore() {
|
||||
}
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
try { coreProc?.kill(); } catch (e) { /* ignore */ }
|
||||
corePort = 0;
|
||||
coreToken = '';
|
||||
try { fs.rmSync(localJsonPath, { force: true }); } catch (e) { /* ignore */ }
|
||||
coreProc = spawn(coreExe, ['-dir', dataDir], { windowsHide: true });
|
||||
coreProc.on('error', (err) => {
|
||||
console.error('[core] process error: ' + err.message);
|
||||
win?.webContents.send('core-error', '核心程序启动失败:' + err.message);
|
||||
});
|
||||
coreProc.on('exit', (code) => {
|
||||
coreProc = null;
|
||||
win?.webContents.send('core-exit', code);
|
||||
@@ -42,7 +50,7 @@ function pollLocalJson() {
|
||||
if (localJsonTimer) clearInterval(localJsonTimer);
|
||||
localJsonTimer = setInterval(() => {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(path.join(dataDir, 'local.json'), 'utf8'));
|
||||
const info = JSON.parse(fs.readFileSync(localJsonPath, 'utf8'));
|
||||
if (info.port && info.token) {
|
||||
corePort = info.port;
|
||||
coreToken = info.token;
|
||||
@@ -91,20 +99,31 @@ ipcMain.handle('core:save-config', (e, cfg) => { writeConfig(cfg); startCore();
|
||||
ipcMain.handle('core:restart', () => { startCore(); return true; });
|
||||
ipcMain.handle('app:open-data-dir', () => shell.openPath(dataDir));
|
||||
ipcMain.handle('app:open-web', (e, url) => shell.openExternal(url));
|
||||
ipcMain.handle('app:notify', (e, title, body) => {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title: String(title || 'EveryPublish'), body: String(body || '') }).show();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle('app:set-autostart', (e, on) => {
|
||||
app.setLoginItemSettings({ openAtLogin: !!on });
|
||||
app.setLoginItemSettings({ openAtLogin: !!on, openAsHidden: true, args: ['--minimized'] });
|
||||
return app.getLoginItemSettings().openAtLogin;
|
||||
});
|
||||
ipcMain.handle('app:get-autostart', () => app.getLoginItemSettings().openAtLogin);
|
||||
ipcMain.handle('app:quit', () => { quitting = true; app.quit(); });
|
||||
|
||||
function readConfig() {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')); }
|
||||
catch (e) { return { server: '', fakeAll: false }; }
|
||||
const defaults = {
|
||||
server: 'http://127.0.0.1:8090',
|
||||
webUrl: 'http://127.0.0.1:3003',
|
||||
fakeAll: false,
|
||||
};
|
||||
try { return { ...defaults, ...JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')) }; }
|
||||
catch (e) { return defaults; }
|
||||
}
|
||||
function writeConfig(cfg) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(cfg, null, 2));
|
||||
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify({ ...readConfig(), ...cfg }, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
@@ -172,6 +191,10 @@ async function runSmoke() {
|
||||
}
|
||||
}
|
||||
|
||||
app.on('before-quit', () => { quitting = true; try { coreProc?.kill(); } catch (e) { /* ignore */ } });
|
||||
app.on('before-quit', () => {
|
||||
quitting = true;
|
||||
if (localJsonTimer) clearInterval(localJsonTimer);
|
||||
try { coreProc?.kill(); } catch (e) { /* ignore */ }
|
||||
});
|
||||
app.on('window-all-closed', () => { /* 驻留托盘不退出 */ });
|
||||
app.on('activate', showWin);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "everypublish-client",
|
||||
"version": "0.2.0",
|
||||
"description": "EveryPublish Windows 客户端(Electron 壳 + Go agent-core)",
|
||||
"description": "EveryPublish 历史 Electron 调试壳(非 macOS 正式交付)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
@@ -17,12 +17,12 @@
|
||||
"productName": "EveryPublish",
|
||||
"directories": { "output": "release" },
|
||||
"files": ["main.js", "preload.js", "renderer/**/*", "icons/**/*"],
|
||||
"extraResources": [
|
||||
{ "from": "../dist/agent-core.exe", "to": "agent-core.exe" }
|
||||
],
|
||||
"win": {
|
||||
"target": [{ "target": "nsis", "arch": ["x64"] }],
|
||||
"icon": "icons/app.ico",
|
||||
"extraResources": [
|
||||
{ "from": "../dist/agent-core.exe", "to": "agent-core.exe" }
|
||||
],
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
|
||||
@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
restart: () => ipcRenderer.invoke('core:restart'),
|
||||
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
|
||||
openWeb: (url) => ipcRenderer.invoke('app:open-web', url),
|
||||
notify: (title, body) => ipcRenderer.invoke('app:notify', title, body),
|
||||
setAutoStart: (on) => ipcRenderer.invoke('app:set-autostart', on),
|
||||
getAutoStart: () => ipcRenderer.invoke('app:get-autostart'),
|
||||
quit: () => ipcRenderer.invoke('app:quit'),
|
||||
|
||||
@@ -13,6 +13,7 @@ document.querySelectorAll('.tab').forEach((tab) => {
|
||||
|
||||
// ---- 状态页 ----
|
||||
let currentServer = '';
|
||||
let currentWeb = 'http://127.0.0.1:3003';
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const s = await window.api.status();
|
||||
@@ -33,7 +34,7 @@ setInterval(refreshStatus, 3000);
|
||||
refreshStatus();
|
||||
|
||||
$('btn-open-web').addEventListener('click', () => {
|
||||
const url = currentServer || 'http://127.0.0.1:8090';
|
||||
const url = currentWeb || currentServer || 'http://127.0.0.1:3003';
|
||||
window.api.openWeb(url);
|
||||
});
|
||||
$('btn-open-data').addEventListener('click', () => window.api.openDataDir());
|
||||
@@ -66,35 +67,42 @@ $('btn-pair').addEventListener('click', async () => {
|
||||
// ---- 挑战页 ----
|
||||
let selectedChallenge = null;
|
||||
let pollTimer = null;
|
||||
let knownChallengeIds = new Set();
|
||||
const KIND_LABEL = { qr: '扫码', sms: '短信验证码', confirm: 'APP 确认', captcha: '图形验证码', pending: '等待设备' };
|
||||
|
||||
async function refreshChallenges() {
|
||||
try {
|
||||
const r = await window.api.challenges();
|
||||
const list = (r && r.list) || [];
|
||||
const fresh = list.filter((ch) => ch.challengeId && !knownChallengeIds.has(ch.challengeId));
|
||||
if (fresh.length && knownChallengeIds.size > 0 && window.api.notify) {
|
||||
window.api.notify('EveryPublish 需要处理挑战', fresh.map((ch) => ch.platform || '平台').join('、') + ' 有新的扫码或验证码任务');
|
||||
}
|
||||
knownChallengeIds = new Set(list.map((ch) => ch.challengeId).filter(Boolean));
|
||||
const badge = $('ch-badge');
|
||||
if (list.length > 0) { badge.textContent = list.length; badge.classList.remove('hidden'); }
|
||||
else badge.classList.add('hidden');
|
||||
const box = $('ch-list');
|
||||
box.innerHTML = '';
|
||||
box.replaceChildren();
|
||||
list.forEach((ch) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item' + (selectedChallenge && selectedChallenge.challengeId === ch.challengeId ? ' selected' : '');
|
||||
const inner = [
|
||||
'<span><b>',
|
||||
ch.platform || '?',
|
||||
'</b> · ',
|
||||
KIND_LABEL[ch.kind] || ch.kind,
|
||||
'</span><span class="p">',
|
||||
ch.prompt || '',
|
||||
'</span>',
|
||||
].join('');
|
||||
div.innerHTML = inner;
|
||||
const label = document.createElement('span');
|
||||
const platform = document.createElement('b');
|
||||
platform.textContent = ch.platform || '?';
|
||||
label.append(platform, document.createTextNode(' · ' + (KIND_LABEL[ch.kind] || ch.kind)));
|
||||
const prompt = document.createElement('span');
|
||||
prompt.className = 'p';
|
||||
prompt.textContent = ch.prompt || '';
|
||||
div.append(label, prompt);
|
||||
div.addEventListener('click', () => selectChallenge(ch));
|
||||
box.appendChild(div);
|
||||
});
|
||||
if (!list.length) {
|
||||
box.innerHTML = '<p class="hint">暂无待处理挑战</p>';
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'hint';
|
||||
empty.textContent = '暂无待处理挑战';
|
||||
box.appendChild(empty);
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
@@ -149,7 +157,9 @@ startPolling();
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const cfg = await window.api.getConfig();
|
||||
$('set-server').value = (cfg && cfg.server) || '';
|
||||
$('set-server').value = (cfg && cfg.server) || 'http://127.0.0.1:8090';
|
||||
$('set-web').value = (cfg && cfg.webUrl) || 'http://127.0.0.1:3003';
|
||||
currentWeb = $('set-web').value;
|
||||
$('set-fake').checked = !!(cfg && cfg.fakeAll);
|
||||
$('set-autostart').checked = await window.api.getAutoStart();
|
||||
} catch (e) { /* 忽略 */ }
|
||||
@@ -158,12 +168,15 @@ loadSettings();
|
||||
|
||||
$('btn-save').addEventListener('click', async () => {
|
||||
const server = $('set-server').value.trim();
|
||||
const webUrl = $('set-web').value.trim();
|
||||
const res = $('set-result');
|
||||
if (!server) { res.textContent = '请填写服务器地址(内网部署填 http://服务器IP:8090)'; res.className = 'msg err'; return; }
|
||||
const cfg = { server: server, fakeAll: $('set-fake').checked };
|
||||
if (!webUrl) { res.textContent = '请填写网站地址'; res.className = 'msg err'; return; }
|
||||
const cfg = { server: server, webUrl: webUrl, fakeAll: $('set-fake').checked };
|
||||
await window.api.saveConfig(cfg);
|
||||
await window.api.setAutoStart($('set-autostart').checked);
|
||||
currentServer = server;
|
||||
currentWeb = webUrl;
|
||||
res.textContent = '已保存,核心进程已重启';
|
||||
res.className = 'msg ok';
|
||||
});
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
<h2>设置</h2>
|
||||
<label>服务器地址(内网部署时填 http://服务器IP:8090)</label>
|
||||
<input id="set-server" placeholder="http://192.168.x.x:8090" />
|
||||
<label>网站地址(本地开发默认 http://127.0.0.1:3003)</label>
|
||||
<input id="set-web" placeholder="http://127.0.0.1:3003" />
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" id="set-fake" />
|
||||
<span>模拟执行(无真实账号的安装联调)</span>
|
||||
|
||||
Vendored
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
|
||||
CORE_DIR="$ROOT_DIR/client/core"
|
||||
DIST_DIR="$ROOT_DIR/client/dist"
|
||||
RELEASE_DIR="$ROOT_DIR/client/macos/release"
|
||||
APP_DIR="$RELEASE_DIR/EveryPublish.app"
|
||||
ARCH=${MAC_ARCH:-arm64}
|
||||
SIGN_IDENTITY=${MAC_SIGN_IDENTITY:--}
|
||||
|
||||
case "$ARCH" in
|
||||
arm64) GOARCH=arm64; SWIFT_ARCH=arm64 ;;
|
||||
x64) GOARCH=amd64; SWIFT_ARCH=x86_64 ;;
|
||||
*) echo "Unsupported MAC_ARCH: $ARCH (use arm64 or x64)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
mkdir -p "$DIST_DIR" "$RELEASE_DIR"
|
||||
(cd "$CORE_DIR" && GOOS=darwin GOARCH="$GOARCH" CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o "$DIST_DIR/agent-core" .)
|
||||
chmod 755 "$DIST_DIR/agent-core"
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
|
||||
swiftc "$SCRIPT_DIR/EveryPublishMacOSApp.swift" \
|
||||
-target "$SWIFT_ARCH-apple-macosx13.0" \
|
||||
-O \
|
||||
-parse-as-library \
|
||||
-framework SwiftUI \
|
||||
-framework AppKit \
|
||||
-framework UserNotifications \
|
||||
-o "$APP_DIR/Contents/MacOS/EveryPublish"
|
||||
cp "$DIST_DIR/agent-core" "$APP_DIR/Contents/Resources/agent-core"
|
||||
chmod 755 "$APP_DIR/Contents/MacOS/EveryPublish" "$APP_DIR/Contents/Resources/agent-core"
|
||||
|
||||
cat > "$APP_DIR/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key><string>EveryPublish</string>
|
||||
<key>CFBundleExecutable</key><string>EveryPublish</string>
|
||||
<key>CFBundleIdentifier</key><string>com.everypublish.macos</string>
|
||||
<key>CFBundleName</key><string>EveryPublish</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.3.0</string>
|
||||
<key>CFBundleVersion</key><string>0.3.0</string>
|
||||
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
||||
<key>LSUIElement</key><false/>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
if [ "$SIGN_IDENTITY" = "-" ]; then
|
||||
DMG_SUFFIX="-local"
|
||||
echo "Signing app ad hoc for local testing (set MAC_SIGN_IDENTITY for distribution)"
|
||||
codesign --force --deep --sign - "$APP_DIR"
|
||||
else
|
||||
DMG_SUFFIX=""
|
||||
echo "Signing app with $SIGN_IDENTITY"
|
||||
codesign --force --deep --options runtime --timestamp --sign "$SIGN_IDENTITY" "$APP_DIR"
|
||||
fi
|
||||
codesign --verify --deep --strict "$APP_DIR"
|
||||
|
||||
if command -v hdiutil >/dev/null 2>&1; then
|
||||
STAGE_DIR=$(mktemp -d "$RELEASE_DIR/dmg-stage.XXXXXX")
|
||||
trap 'rm -rf "$STAGE_DIR"' EXIT
|
||||
cp -R "$APP_DIR" "$STAGE_DIR/EveryPublish.app"
|
||||
ln -s /Applications "$STAGE_DIR/Applications"
|
||||
rm -f "$RELEASE_DIR/EveryPublish-0.3.0-$ARCH.dmg" "$RELEASE_DIR/EveryPublish-0.3.0-$ARCH-local.dmg"
|
||||
hdiutil create -volname EveryPublish -srcfolder "$STAGE_DIR" -ov -format UDZO "$RELEASE_DIR/EveryPublish-0.3.0-$ARCH$DMG_SUFFIX.dmg" >/dev/null
|
||||
fi
|
||||
|
||||
file "$APP_DIR/Contents/MacOS/EveryPublish" "$APP_DIR/Contents/Resources/agent-core"
|
||||
echo "Built $APP_DIR"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
APP_SOURCE="$SCRIPT_DIR/release/EveryPublish.app"
|
||||
APP_DEST="${HOME}/Applications/EveryPublish.app"
|
||||
|
||||
if [ ! -d "$APP_SOURCE" ]; then
|
||||
echo "Build the app first: ./build-macos-app.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${HOME}/Applications"
|
||||
rm -rf "$APP_DEST"
|
||||
cp -R "$APP_SOURCE" "$APP_DEST"
|
||||
xattr -dr com.apple.quarantine "$APP_DEST" 2>/dev/null || true
|
||||
open "$APP_DEST"
|
||||
echo "Installed local test app at $APP_DEST"
|
||||
Vendored
+5
@@ -9,5 +9,10 @@ BASE_URL=http://<服务器内网IP>:8090
|
||||
# 浏览器侧链接(邀请链接等),同样填内网地址:
|
||||
PUBLIC_BASE_URL=http://<服务器内网IP>:8090
|
||||
STORAGE_DIR=./data/materials
|
||||
CREDENTIAL_DIR=./data/credentials
|
||||
EXECUTOR_MODE=mock
|
||||
BILIBILI_MEMBER_BASE=https://member.bilibili.com
|
||||
BILIBILI_PASSPORT_BASE=https://passport.bilibili.com
|
||||
BILIBILI_UPOS_SCHEME=https
|
||||
# 部署包自带 web/ 前端目录,按部署包路径即可:
|
||||
STATIC_DIR=./web
|
||||
|
||||
+34
-140
@@ -1,161 +1,55 @@
|
||||
# EveryPublish 服务端接口文档(v1)
|
||||
# EveryPublish Web API(Web-only V1)
|
||||
|
||||
> 基地址:`/api/v1` · 统一响应:`{"code":0,"message":"ok","data":{...}}`,code!=0 为业务错误。
|
||||
> 认证:`Authorization: Bearer <accessToken>`(15min,前端自动用 refresh 轮换)。
|
||||
Base URL:`http://127.0.0.1:8090/api/v1`。成功响应为 `{code:0,message:"ok",data:...}`,失败响应包含业务 `code/message`。
|
||||
|
||||
## 业务错误码
|
||||
|
||||
| code | 含义 |
|
||||
|---|---|
|
||||
| 1001 | 参数不合法 |
|
||||
| 1002 | 未登录/令牌无效 |
|
||||
| 1003 | 无权限 |
|
||||
| 1004 | 资源不存在 |
|
||||
| 2001 | 邮箱已注册 |
|
||||
| 2002 | 邮箱或密码错误 |
|
||||
| 2003 | 刷新令牌无效 |
|
||||
| 2004 | 已是成员 |
|
||||
| 2005 | 邀请链接无效或过期 |
|
||||
| 1006 | 需要两步验证(2FA,默认关) |
|
||||
| 3001-3005 | 业务冲突(账号不可删/已绑定/挑战已结束/链接过期/任务状态不允许) |
|
||||
|
||||
## 认证 /auth
|
||||
## 认证
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | /auth/register | 注册 {email,password,nickname} → 返回令牌对+用户+默认工作空间 |
|
||||
| POST | /auth/login | 登录 {email,password} → 令牌对+用户 |
|
||||
| POST | /auth/refresh | {refreshToken} → 轮换新令牌对(旧 refresh 立即吊销) |
|
||||
| POST | /auth/logout | 登出(吊销 refresh) |
|
||||
| GET | /auth/me | 当前用户+工作空间+角色 |
|
||||
| POST | `/auth/register` | 注册并返回 token |
|
||||
| POST | `/auth/login` | 登录并返回 access/refresh |
|
||||
| POST | `/auth/refresh` | refresh 轮换 |
|
||||
| POST | `/auth/logout` | 登出 |
|
||||
| POST | `/auth/password` | 修改当前用户密码 |
|
||||
| GET | `/auth/me` | 当前用户 |
|
||||
| POST | `/auth/switch-workspace/:id` | 切换到本人所属工作空间并签发新 token |
|
||||
|
||||
## 工作空间 /workspaces(登录)
|
||||
## 核心业务
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| 模块 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | /workspaces | 创建 {name}(创建者为 owner) |
|
||||
| GET | /workspaces | 我所在的工作空间列表 |
|
||||
| PUT | /workspaces/:id | 改名(owner/admin) |
|
||||
| 工作区/成员 | `/workspaces`、`/members` | 工作区与角色权限 |
|
||||
| 账号 | `/accounts`、`/accounts/:id/bind`、`/accounts/:id/credentials`、`/accounts/:id/check`、`/accounts/:id/unbind` | 网页本机账号、扫码登录、凭据导入和登录检查 |
|
||||
| 素材 | `/materials`、`/materials/:id/url`、`/files/:token` | multipart、SHA-256 去重、一次性下载直链和 `?inline=1` 预览 |
|
||||
| 任务 | `/tasks`、`/tasks/:id/events`、`submit`、`approve`、`reject`、`retry`、`cancel` | 状态机、时间线和审核 |
|
||||
| 挑战 | `/challenges`、`/challenges/:id/solve`、`resend`、`suspend` | QR/验证码/APP 确认 |
|
||||
| 通知 | `/notifications`、`/notifications/:id/read`、`read-all` | 站内通知 |
|
||||
| 审计 | `/audit-logs` | 业务操作记录 |
|
||||
|
||||
## 成员 /members(登录)
|
||||
### 账号绑定返回值
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /members | 成员列表(含 email/nickname) |
|
||||
| POST | /members/invite | 邀请 {email,role} → {token,link}(owner/admin;role: admin/operator/reviewer/viewer) |
|
||||
| POST | /members/join | 接受邀请 {token}(须与登录邮箱一致) |
|
||||
| PUT | /members/:id/role | 改角色 {role}(不可改 owner) |
|
||||
| DELETE | /members/:id | 移除(不可移除 owner) |
|
||||
`POST /accounts/:id/bind` 对同一账号的已有 active challenge 幂等,返回 `challengeId、qrUrl、qrToken、prompt、expiresAt`。网页显示 `qrUrl`,服务端后台轮询平台状态;用户使用目标平台手机客户端扫码,不生成 EveryPublish 客户端配对码。真实二维码由适配器确认,不能调用 `/challenges/:id/solve` 伪造完成;只有显式 `mock://` 联调挑战允许使用 `value` 解决。
|
||||
|
||||
## 账号台账 /accounts(登录)
|
||||
`POST /accounts/:id/credentials` 接收 `{ "cookies": "..." }`,仅在 `EXECUTOR_MODE=web` 且平台适配器已注册时可用。成功后只返回账号状态;Cookie 不进入响应、审计详情或 WebSocket payload。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /accounts?platform=&status=&page=&size= | 台账列表(状态:unbound/binding/active/suspended/expired) |
|
||||
| POST | /accounts | 新增 {platform(douyin/kuaishou/xiaohongshu/bilibili),accountName,avatarUrl?,ipProfile?} |
|
||||
| PUT | /accounts/:id | 改资料(名称/头像/IP 画像) |
|
||||
| DELETE | /accounts/:id | 删除(仅 unbound) |
|
||||
| POST | /accounts/:id/bind | 发起绑定 → 创建挑战记录(status→binding)→ {challengeId} |
|
||||
`POST /accounts/:id/check` 调用平台适配器的凭据检查;有效时更新 `active/lastActiveAt`,失效时更新 `expired/lastError`,不会返回 Cookie。
|
||||
|
||||
## 素材 /materials(登录)
|
||||
写操作按 workspace member role 限制:账号资料、素材和任务由 owner/admin/operator 处理;解绑、删除和工作区成员管理需要 owner/admin;审核由 reviewer/admin 处理;viewer 只读。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /materials?kind=&group=&page=&size= | 素材列表 |
|
||||
| POST | /materials | multipart 上传:file 字段 + 可选 group/tags;流式 sha256,同工作区去重(dedup:true 返回已有素材) |
|
||||
| GET | /materials/:id/url | 生成签名直链(10 分钟、一次性)→ {url,expiresAt} |
|
||||
| DELETE | /materials/:id | 删除素材及文件 |
|
||||
| GET | /files/:token | 凭 token 下载文件(无鉴权,token 即凭证;一次性) |
|
||||
## Browser WebSocket
|
||||
|
||||
## 任务 /tasks(登录;状态机)
|
||||
`GET /ws/browser?token=<accessToken>`,浏览器连接后接收:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D[draft] -->|submit| P[pending_review]
|
||||
P -->|approve| Q[queued] -->|dispatch| DP[dispatched] -->|start| R[running]
|
||||
R -->|success| S[success]
|
||||
R -->|fail| F[failed] -->|retry| Q
|
||||
R -->|suspend| SU[suspended] -->|resume| Q
|
||||
P -->|reject| RJ[rejected] -->|resubmit| P
|
||||
D -->|cancel| C[cancelled]
|
||||
Q -->|cancel| C
|
||||
DP -->|cancel| C
|
||||
R -->|cancel| C
|
||||
SU -->|cancel| C
|
||||
```
|
||||
- `task.status`:任务状态/结果/错误。
|
||||
- `challenge.status`:挑战创建、解决、过期。
|
||||
- `notification.new`:新通知。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /tasks?status=&schedule=&page=&size= | 任务列表 |
|
||||
| POST | /tasks | 新建草稿 {title,content?,tags?,accountIds[],materialIds[],scheduleAt?(unix ms),priority?(1-10)} |
|
||||
| GET | /tasks/:id | 详情 |
|
||||
| PUT | /tasks/:id | 编辑(仅 draft/rejected) |
|
||||
| DELETE | /tasks/:id | 删除(仅 draft) |
|
||||
| POST | /tasks/:id/submit | 提交审核 |
|
||||
| POST | /tasks/:id/approve | 审核通过 → queued(通知创建人) |
|
||||
| POST | /tasks/:id/reject | 驳回 {note}(通知创建人) |
|
||||
| POST | /tasks/:id/resubmit | 驳回后重提 |
|
||||
| POST | /tasks/:id/retry | 失败重试 → queued |
|
||||
| POST | /tasks/:id/cancel | 取消 |
|
||||
WebSocket 不是首屏数据源;页面必须先 REST 拉取,断线时通过 REST 轮询兜底。
|
||||
|
||||
## 挑战 /challenges(登录)
|
||||
## 本地 mock 执行
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /challenges?status=&accountId= | 挑战列表(过期自动置 expired) |
|
||||
| POST | /challenges/:id/solve | 人工完成 {value?}(验证码/APP 确认;扫码由 Agent 完成) |
|
||||
| POST | /challenges/:id/resend | 一键重发(expired/suspended → active,重置 30min) |
|
||||
| POST | /challenges/:id/suspend | 挂起 |
|
||||
设置 `EXECUTOR_MODE=mock`(默认)后,审核通过的任务由 Go server 本机执行,状态依次为 `queued → dispatched → running → success`,结果 URL 使用 `mock://` 协议,仅用于自动化联调。
|
||||
|
||||
## 通知 /notifications(登录)
|
||||
设置 `EXECUTOR_MODE=web` 后,已注册的平台适配器在 Go server 所在设备执行。当前已接入 B 站:二维码轮询、workspace/account 隔离加密凭据、UPOS 分片上传和投稿;其它未接入平台保留明确的 mock fallback,不能把 mock URL 当作真实平台结果。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /notifications?kind= | 我的通知 + unread 计数 |
|
||||
| POST | /notifications/:id/read | 标记已读 |
|
||||
| POST | /notifications/read-all | 全部已读 |
|
||||
|
||||
## 审计 /audit-logs(登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /audit-logs?userId=&action=&page=&size= | 审计日志(中间件自动埋点:登录/登出/增删改/审核/挑战等) |
|
||||
|
||||
## 设备配对 /agent
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| POST | /agent/pair-code | Bearer | 生成配对码(6 位,5 分钟一次性) |
|
||||
| POST | /agent/pair | 无(凭码) | 配对 {code,deviceName,os,version,publicKey(Ed25519 base64)} → {deviceId} |
|
||||
| GET | /agent/devices | Bearer | 设备列表(在线状态) |
|
||||
|
||||
## WSS 双通道
|
||||
|
||||
### Agent 通道 `/ws/agent`(Ed25519 设备签名,无 JWT)
|
||||
|
||||
| 方向 | 消息 | 说明 |
|
||||
|---|---|---|
|
||||
| C→S | `hello` | {deviceId,nonce,ts,sig(base64, 签名原文 nonce+`\|`+ts),version} |
|
||||
| S→C | `hello.ack` | {serverNonce,sessionId,serverTs,sig} |
|
||||
| C→S | `heartbeat`(10-30s) / S→C `heartbeat.ack` | 保活 |
|
||||
| S→C | `task.push` | 任务下发 {taskId,platform,accountId,title,content,tags,materialUrls(一次性签名直链),priority} |
|
||||
| C→S | `task.ack` | {taskId,accept,reason?}(accept→running) |
|
||||
| C→S | `task.result` | {taskId,status(success/failed),publishedUrl,receipts,error,finishedAt} |
|
||||
| S→C | `challenge.new` | 绑定扫码挑战 {challengeId,accountId,platform,kind,qrToken,qrUrl,prompt,expiresAt} |
|
||||
| C→S | `challenge.ack` / `challenge.solve` | 应答 / 完成(solve 后账号→active) |
|
||||
|
||||
### 浏览器通道 `/ws/browser?token=<JWT>`(只读订阅)
|
||||
|
||||
| 事件 | 说明 |
|
||||
|---|---|
|
||||
| `task.status` | {taskId,status,errorMessage,publishedUrls}(下发/执行/成功/失败实时推送) |
|
||||
| `agent.status` | {deviceId,online} |
|
||||
| `challenge.status` | {challengeId,status,accountId} |
|
||||
|
||||
> 实测:任务下发延迟(approve→task.push)≈ 28.7ms(本机,硬指标 <300ms)。
|
||||
|
||||
## 健康检查
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /health | 服务健康(db/redis 状态) |
|
||||
B 站 adapter 的 `BILIBILI_MEMBER_BASE`、`BILIBILI_PASSPORT_BASE` 和 `BILIBILI_UPOS_SCHEME` 可在本地 stub 联调时覆盖;生产环境保持官方地址并遵守平台规则。
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
# EveryPublish Windows 客户端 · 安装与使用说明
|
||||
# 客户端安装说明(暂停)
|
||||
|
||||
> 产物:`client/desktop/release/EveryPublish Setup 0.2.0.exe`(NSIS 安装向导,双击安装,可选安装目录、创建桌面快捷方式)。
|
||||
当前 Web-only V1 不需要安装 macOS/Windows 客户端。请按 [`README.md`](../README.md) 启动 Go server 和 React Web,浏览器访问本机控制台。
|
||||
|
||||
## 一、安装后它能做什么
|
||||
|
||||
- **常驻后台执行**:安装后即注册开机自启(可在设置页关),关闭窗口最小化到托盘不退出。
|
||||
- **接收并执行发布任务**:网页控制台审核通过的任务自动下发到本机,自动登录平台(B站无浏览器投稿;抖音/快手/小红书驱动浏览器)并发布,结果与截图回执回传控制台。
|
||||
- **完成账号扫码绑定**:网页端发起绑定后,客户端弹挑战,展示二维码/接收验证码。
|
||||
- **凭据本地加密保存**:平台账号 cookie 只存在本机(AES-GCM 保险库),服务器零凭据。
|
||||
|
||||
## 二、需要填写什么
|
||||
|
||||
| 位置 | 填什么 | 何时填 |
|
||||
|---|---|---|
|
||||
| 设置页 → 服务器地址 | `http://<服务器内网IP>:8090`(不是 localhost!) | 首次启动 |
|
||||
| 配对页 → 配对码 | 网页控制台「设置→Agent 设备」生成的 6 位码 | 首次绑定 |
|
||||
| 配对页 → 设备名 | 任意(选填) | 首次绑定 |
|
||||
| 挑战页 → 验证码 | 平台发送的短信/图形验证码 | 绑定账号时 |
|
||||
| 设置页 → 模拟执行开关 | 无真实账号联调时打开 | 可选 |
|
||||
|
||||
## 三、连接方式(内网场景)
|
||||
|
||||
1. 服务器部署在内网某台机器(Linux/Windows),监听 8090(部署说明见 dist/deploy/deploy-README.md)。
|
||||
2. 客户端与服务器**同一内网可达**:客户端填服务器内网 IP。
|
||||
3. 客户端经 WSS(Agent 出站长连接,断线自动重连)与服务器通信——**无需服务器主动连客户端**,防火墙只需放行服务器 8090。
|
||||
4. 测试建议:设置页先开「模拟执行」,走通 配对→绑定→任务→发布成功 后再接真实平台。
|
||||
|
||||
## 四、卸载/数据
|
||||
|
||||
- 卸载:控制面板卸载 EveryPublish。
|
||||
- 数据目录(凭据/配置/截图):`%APPDATA%\EveryPublish`,卸载后如需彻底清除请手动删除。
|
||||
旧客户端代码保留在 `client/` 目录,只作为历史资料,不进入当前构建、启动和验收流程。
|
||||
|
||||
@@ -1,51 +1,3 @@
|
||||
# EveryPublish Windows 客户端测试清单(检查点②)
|
||||
# 客户端测试清单(暂停)
|
||||
|
||||
> 版本 0.2.0 · 交付形态:`dist/EveryPublish-Client-0.2.0-win-x64.zip`(WinUI 壳 + agent-core.exe 同目录)。
|
||||
> 前置:网页端已部署(http://<服务器>:8090 单进程),客户端与服务器网络可达。
|
||||
|
||||
## 一、构建/获取安装包
|
||||
|
||||
- 方式 A(推荐):Windows 机器上执行 `client/ui/Packaging/build-installer.ps1`(自动生成图标 → Go 交叉编译 agent-core.exe → dotnet publish → 打包 zip)。
|
||||
- 方式 B:直接用 `client/dist/agent-core.exe` + 手工编译 WinUI 壳(见 client/ui/README.md)。
|
||||
|
||||
## 二、第一轮:安装与假任务闭环(无需真实账号)
|
||||
|
||||
1. 解压 zip → 双击 `EveryPublish.Client.exe`:窗口出现「未配对」,右下角托盘出现图标。
|
||||
2. 设置页:**打开「模拟执行」开关** → 保存并重启核心(此模式下所有平台走假执行器,不碰真实平台)。
|
||||
3. 网页端(管理员/owner 登录)→「设置 → Agent 设备」→ 生成配对码。
|
||||
4. 客户端「配对」页输入配对码 → 配对成功 → 「状态」页显示**在线**(服务器/设备号)。
|
||||
5. 网页端:账号管理 → 添加账号(任选平台)→ 指定刚配对的设备 → 绑定 → 客户端「挑战」页出现挑战 → 点选提交「我已确认」→ 网页端账号变**已绑定**。
|
||||
6. 网页端:素材库上传一个视频 → 新建发布(选素材+账号)→ 任务列表「提交审核」→ 审核中心「通过」→ 任务自动下发 → **变绿「发布成功」**(假执行器秒回)。
|
||||
7. 系统行为:关闭窗口 → 最小化到托盘(进程仍在);托盘右键「退出」才退出;设置页开「开机自启」→ 注销重登自动运行;任务管理器手动结束 agent-core.exe → 5 秒内看门狗自动拉起(3 次失败弹窗提示)。
|
||||
|
||||
## 三、第二轮:B站真实发布(无浏览器投稿)
|
||||
|
||||
1. 设置页**关闭「模拟执行」**并保存(重启核心)。
|
||||
2. 网页端添加 B站账号 → 指定设备 → 绑定 → 客户端「挑战」页**显示 B站登录二维码**(API 生成)→ 手机 B站 App 扫码确认 → 网页端账号变已绑定(cookie 加密存入本机保险库,服务器零凭据)。
|
||||
3. 新建发布(选该 B站账号)→ 审核通过 → 客户端自动执行:preupload → 分片上传 → 投稿 → 网页端任务变成功,发布链接为 `https://www.bilibili.com/video/BV...`。
|
||||
4. 到 B站「创作中心 → 内容管理」核对稿件已上线;客户端数据目录 `receipts/` 有截图回执。
|
||||
|
||||
## 四、第三轮:抖音/快手/小红书真实发布(CDP)
|
||||
|
||||
1. 分别添加对应平台账号 → 绑定 → 客户端「挑战」页显示该平台登录页二维码(CDP 截图)→ 手机 App 扫码。
|
||||
2. 新建发布 → 审核通过 → 客户端拉起浏览器(每账号独立档案,首次会下载 Chromium 或使用系统 Chrome)→ 自动上传/填表/发布 → 任务成功 + 截图回执。
|
||||
3. **选择器容错**:平台改版可能导致找不到元素——失败时查看客户端日志(数据目录下)与 `receipts/` 失败截图,把截图发我调整选择器(架构上只需改配置,无需改流程)。
|
||||
|
||||
## 五、验收标准(与九项最终验收第 6-7 项对应)
|
||||
|
||||
| # | 项 | 通过条件 |
|
||||
|---|---|---|
|
||||
| 1 | 安装 | 解压即用,双击启动 |
|
||||
| 2 | 配对 | 配对码绑定成功,状态页在线 |
|
||||
| 3 | 假任务闭环 | 第一轮步骤 5-6 全通过 |
|
||||
| 4 | B站真实发布 | 稿件真实上线 |
|
||||
| 5 | 抖音/快手/小红书真实发布 | 至少 1 个平台真实上线(可先交付可跑子集) |
|
||||
| 6 | 凭据安全 | 平台凭据仅存本机 `%APPDATA%\EveryPublish`(加密 vault),服务器零凭据 |
|
||||
| 7 | 常驻行为 | 托盘/自启/watchdog 均正常 |
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
- **核心进程未就绪**:安装目录缺少 agent-core.exe,或看门狗 3 次失败弹窗——检查打包脚本是否复制了 core。
|
||||
- **配对码无效**:5 分钟过期,网页端重新生成。
|
||||
- **任务一直排队**:账号绑定的设备离线(IP 隔离设计),确认客户端状态页在线。
|
||||
- **日志位置**:数据目录(设置页可打开):`%APPDATA%\EveryPublish`(core 日志经看门狗输出,可用「打开数据目录」查看 config.json/local.json;进程日志在 Windows 事件查看器或直接命令行运行 `agent-core.exe -dir <目录>` 观察)。
|
||||
当前 Web-only V1 不执行客户端安装、托盘、配对或 Agent WSS 测试。网页验收以 [`docs/web-test-report.md`](web-test-report.md) 和 [`docs/web-only-v1-plan.md`](web-only-v1-plan.md) 为准。
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# 代码 Review 记录(2026-08-20,推送 git.awaioi.com 前)
|
||||
# 代码 Review 记录(2026-08-20,历史客户端线)
|
||||
|
||||
> 本文中的 Agent/设备问题属于已暂停的历史客户端线,不是当前 Web-only V1 验收范围。网页端验收以 `docs/web-test-report.md` 为准。
|
||||
|
||||
> 范围:server/(Go)、client/core/(Go)、apps/web(React)、client/ui+desktop(WinUI3/Electron,静态)。
|
||||
> 结论:基线全绿(go build/vet/test -race、tsc --noEmit、pnpm build、desktop JS 语法)。本次修复 7 处,均在提交前落地。
|
||||
|
||||
+27
-145
@@ -1,153 +1,35 @@
|
||||
# EveryPublish 交付文档(技术栈定版 · 页面构成 · 日计划)
|
||||
# EveryPublish Web-only V1 交付计划
|
||||
|
||||
> 2026-08-20 · 开发顺序:网站端(含API) → 客户端(Windows) → 联调交付(含两个用户确认检查点)。基础仓库已拉取:apps/web(Tencent/tdesign-react-starter,MIT)。技术栈定版见 docs/tech-stack.md(MySQL + 前后端 Go 统一)。
|
||||
> 当前唯一有效排期。macOS/Windows 客户端线暂停;不以客户端安装、配对或 Agent WSS 作为 V1 门槛。
|
||||
|
||||
## 1. 技术栈定版
|
||||
## 1. 交付目标
|
||||
|
||||
| 层 | 选型 | 依据 |
|
||||
在本机启动 Go server 和 React Web,用户通过浏览器完成登录、账号登录/扫码、素材上传、发布任务、审核、挑战、日志和结果查看。真实平台按 adapter 逐个验收,mock executor 先保证业务全链路可重复测试。
|
||||
|
||||
## 2. 排期
|
||||
|
||||
| 阶段 | 内容 | 验收证据 |
|
||||
|---|---|---|
|
||||
| 网页端 | React 18 + TDesign React(Tencent starter,MIT) | 已拉取预览:Vite + TS + Router6 + Redux Toolkit + axios;自带后台页面与 mock,无需装饰 |
|
||||
| 网页端后端(服务器) | **Go**(gin + coder/websocket + asynq + GORM) | 见 Rust/Go 对比;与 agent-core 同语言共享协议模块;页面层仍为 React+TDesign |
|
||||
| 客户端 UI | **WinUI 3(C#/.NET 8)原生** | 系统原生控件,无需自写 UI;QR 用 QRCoder 本地渲染 token |
|
||||
| 客户端核心 | **Go**(WSS 客户端 + go-rod CDP 自动化 + 凭据保险库) | 单二进制、无运行时、可二期抽 Windows 服务/跑 VPS |
|
||||
| 数据库/队列 | **MySQL 8.x(GORM)+ Redis(asynq)** | 任务队列/排期靠 Redis;MySQL 存业务数据 |
|
||||
| 素材存储 | 一期服务器本地盘(签名直链);二期 OSS | StorageDriver 抽象预留 |
|
||||
| W1 | 清理客户端/Agent 网页依赖、确认 Web-only 路由/数据模型、参考仓库分析 | 客户端/设备页面不在菜单;Agent 路由返回 404;逻辑图与 README 一致 |
|
||||
| W2 | 认证、工作区、成员、权限、审计加固 | Go API 集成测试 + 浏览器登录回归 |
|
||||
| W3 | 素材库流式上传、去重、预览、删除、直链 | 真实 multipart + 重复 SHA 测试 |
|
||||
| W4 | 账号台账、Cookie 导入、网页扫码挑战、账号健康状态 | mock QR/挑战状态机、Cookie 导入契约、挂起/重发/空确认值和 B站轮询 stub 已测试;真实扫码由用户验收 |
|
||||
| W5 | 新建发布、任务状态机、审核、排期、取消/重试、数据库事件 | mock/web smoke、真实 adapter 逐账号执行、context cancel、成功/驳回/重提/取消时间线已测试 |
|
||||
| W6 | 挑战中心、通知、实时 Browser WS、断线 REST 兜底 | WebSocket 注入断开 + 页面状态恢复 |
|
||||
| W7 | 平台 adapter:当前先完成 B站,再逐个平台接入 | B站 HTTP stub、错误分类、凭据隔离和上传链路通过;真实扫码/投稿必须人工验收,未接入平台不冒充真实成功 |
|
||||
| W8 | 全页面视觉整理、部署脚本、文档审校和发布验收 | API/TypeScript/build/smoke 已通过;浏览器控制服务恢复后逐页验收,用户随后手动验收真实发布 |
|
||||
|
||||
### 1.1 Rust 还是 Go:结论选 Go
|
||||
## 3. 每阶段停止条件
|
||||
|
||||
| 维度 | Go | Rust |
|
||||
|---|---|---|
|
||||
| 本项目规模 | 性能绰绰有余(WS 网关 goroutine-per-conn,千级连接轻松) | 性能过剩,无收益 |
|
||||
| 开发与 AI 协作 | AI 生成质量高、迭代快、编译秒级 | borrow checker 调试成本高、编译慢 |
|
||||
| 单二进制部署 | 静态编译、零依赖(Windows 端尤其省心) | 更小,但构建链复杂 |
|
||||
| 浏览器自动化 | go-rod(CDP)成熟可用 | 生态弱 |
|
||||
| Windows 常驻/服务 | kardianos/service 简单 | 复杂度高 |
|
||||
| 生态(队列/WS/DB) | asynq / coder-websocket / GORM(MySQL) 齐全 | 可选少 |
|
||||
- 任一阶段接口测试不通过,不进入下阶段。
|
||||
- 真实扫码无法执行时,只能标记“QR 已生成、等待用户扫码”,不能标记账号 active 或发布成功。
|
||||
- mock executor 只能证明任务状态机,不证明目标平台发布。
|
||||
- 失败重试必须有错误分类和日志,不允许吞掉异常。
|
||||
|
||||
**性能保障**:①WSS 网关 goroutine-per-conn,单机万级并发无压力;②GORM(MySQL 连接池)+ Redis 承载队列;③大文件绝不走 WSS(签名直链),网关只传 <1KB 消息;④服务无状态可水平扩展;⑤验收硬指标:任务下发 <300ms(tools/diag-connection.mjs 实测基线)。
|
||||
## 4. V1 交付物
|
||||
|
||||
## 2. 样式方案
|
||||
|
||||
- **网页端**:TDesign starter 默认主题(企业后台风格,深浅色可切换),全部页面用 TDesign 组件,不写自定义样式;示例页清理后按菜单骨架填充。
|
||||
- **客户端**:WinUI 3 Fluent 原生风格(与 Windows 11 一致),托盘用 H.NotifyIcon.WinUI(或 Win32 interop),扫码/挑战用对话框页。
|
||||
|
||||
## 3. 网页端页面构成
|
||||
|
||||
### 3.1 用户端(客户工作台)
|
||||
|
||||
| 菜单 | 页面 | 关键功能 | 角色 |
|
||||
|---|---|---|---|
|
||||
| 工作台 | 仪表盘 | 今日发布统计、待处理挑战数、Agent 在线状态、最近任务 | 全部 |
|
||||
| 发布管理 | 任务列表 / 新建发布 / 排期日历 / 任务详情 | 状态筛选、批量重试/取消;选素材→平台账号→标题话题→定时/立即→提交审核;月/周视图;事件时间线+回执截图+失败原因 | 运营/审核/管理员 |
|
||||
| 素材库 | 素材列表 / 上传 / 预览 | 拖拽上传(multipart+进度)、分组标签、sha256、删除 | 运营/审核 |
|
||||
| 账号管理 | 账号台账 / 绑定向导 / 挑战弹窗 | 平台/绑定状态/健康度/代理/最后活跃;发起扫码→QR 弹窗;重新扫码/解绑 | 管理员 |
|
||||
| 审核中心 | 待审列表 | 分平台预览、通过/驳回+批注(可配置跳过) | 审核/管理员 |
|
||||
| 通知与挑战 | 挑战中心 / 通知列表 | QR 从 token 本地渲染、验证码输入、APP确认指引、超时挂起/一键重发 | 相关角色 |
|
||||
| 成员与权限 | 成员列表 / 邀请 | 邀请链接、角色分配 | 管理员 |
|
||||
| 设置 | 工作区资料 / 安全 / 通知渠道 / Agent 设备 | 2FA 开关(默认关)、企微/钉钉/邮件、设备列表与吊销 | 管理员 |
|
||||
| 审计日志 | 日志列表 | 谁在何时做了什么、筛选 | 管理员 |
|
||||
|
||||
### 3.2 平台端(admin 角色,同部署加一个角色即可)
|
||||
|
||||
| 菜单 | 页面 | 功能 |
|
||||
|---|---|---|
|
||||
| 租户管理 | 客户列表/详情 | 状态、配额(简单)、停用 |
|
||||
| Agent 总览 | 设备列表 | 在线/版本/强制吊销/远程指令 |
|
||||
| 渠道状态(后置) | 适配器列表 | 平台模块状态、灰度 |
|
||||
| 风控台账(后置) | 事件列表 | 风控/冷却记录 |
|
||||
|
||||
## 4. 调用方式设计
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph WEB["网页端 React+TDesign"]
|
||||
U["用户端页面 / 平台端admin"]
|
||||
end
|
||||
subgraph SRV["Go 服务器"]
|
||||
API["REST /api/*"]
|
||||
WS["WSS 网关"]
|
||||
end
|
||||
subgraph CLIENT["Windows 客户端"]
|
||||
UI["WinUI 3 壳 C#"] -->|"localhost HTTP+随机token"| CORE["agent-core Go"]
|
||||
end
|
||||
U -->|"REST(axios) + 实时WS(JWT)"| API
|
||||
U -->|"实时状态推送"| WS
|
||||
CORE -->|"WSS Ed25519设备签名"| WS
|
||||
CORE -->|"素材直链下载(签名token)"| API
|
||||
CORE -->|"CDP 驱动系统Chrome/Edge"| CH["浏览器"]
|
||||
```
|
||||
|
||||
- ① 网页端 → 服务器:REST(axios,JWT 15min + refresh 轮换)+ 原生 WebSocket(实时状态推送)。
|
||||
- ② 客户端 WinUI 壳 → agent-core:localhost HTTP/WS + 随机 token(仅回环,配对时写入本地配置)。
|
||||
- ③ agent-core → 服务器:WSS + Ed25519 设备签名(配对码注册公钥)。
|
||||
- ④ 素材:multipart 上传 + 签名直链下载(大文件不走 WSS)。
|
||||
|
||||
## 5. 后台常驻方案(Windows)
|
||||
|
||||
- **开机自启**:安装时写注册表 HKCU Run。
|
||||
- **最小化到托盘**:H.NotifyIcon.WinUI / Win32 Shell_NotifyIcon。
|
||||
- **进程模型**:WinUI 主进程(UI)spawn agent-core 子进程(随机端口+token);主进程做 watchdog——心跳检测、崩溃自拉起、3 次失败弹窗提示。
|
||||
- **断线自愈**:core 内 WSS 指数退避重连(1s/5s/15s/60s)。
|
||||
- **二期**:core 抽离为 Windows 服务(kardianos/service),支持无 UI 部署与客户 VPS 形态。
|
||||
|
||||
## 6. 开发日计划(1 人 + AI,顺序开发,D1-D13 + 2 天缓冲)
|
||||
|
||||
### 阶段一 网站端(D1-D7)
|
||||
|
||||
| 日 | 任务 | 验收标准 |
|
||||
|---|---|---|
|
||||
| D1 | 预览与工程化:starter 跑通 → 依赖升级评估(Vite2→5/TS4.8→5) → 清理示例页与 mock → 按页面清单搭菜单/路由空壳;Go 后端初始化(health/配置/DB/Redis) | dev 无报错、菜单空壳可点、/health 200、DB 连上 |
|
||||
| D2 | 认证与工作区 API:Argon2 登录/refresh 轮换/登出(2FA 代码留位默认关)、成员/邀请/角色、审计埋点 | 单测过;前端登录页真实登录 |
|
||||
| D3 | 业务 API:账号台账(bind→challenge)、素材(multipart+签名直链+sha256)、任务 CRUD+状态机+排期、审核动作、通知 | 单测+接口文档;curl 全接口可跑 |
|
||||
| D4 | WSS 网关(hello 验签/心跳/断线、task.push/ack/result、challenge 双向) + 假执行器脚本 + 前端实时推送 | localhost 假执行器闭环:任务→下发→执行→回传→页面变绿,<300ms |
|
||||
| D5 | 页面①:登录、仪表盘、成员、设置 | 页面与真实 API 联通 |
|
||||
| D6 | 页面②:素材库、账号台账+绑定挑战弹窗、任务列表+新建+排期日历 | 全流程可点通(与假执行器) |
|
||||
| D7 | 页面③:审核中心、挑战中心(QR 本地渲染+验证码)、通知、审计 + admin(租户列表/Agent 总览) | 角色权限正确、admin 入口可用 |
|
||||
|
||||
> **检查点①(暂停)**:D7 完成后暂停目标 → 执行网页端手动测试(§7 第 1-5 项 + 全页面冒烟)→ 输出测试报告 → 等待用户确认通过后再进入阶段二。
|
||||
|
||||
### 阶段二 客户端(D8-D11)
|
||||
|
||||
| 日 | 任务 | 验收标准 |
|
||||
|---|---|---|
|
||||
| D8 | agent-core(Go):WSS 客户端(重连/幂等)、凭据保险库(加密)、执行器框架、代理配置、挑战处理 + 假平台执行器 | core 单测过;与服务器假执行闭环 |
|
||||
| D9 | WinUI 3 壳:托盘、配对引导、扫码/挑战窗口、状态页;localhost 调 core;开机自启+watchdog;打包脚本 | Windows 实机:安装→配对→假任务→回传 |
|
||||
| D10 | 真实平台:B站(投稿协议,无浏览器)先行 | B站真实发布成功 |
|
||||
| D11 | 抖音/快手/小红书(go-rod CDP:系统 Chrome、独立档案、指纹、代理绑定;参考 social-auto-upload) | 三平台真实发布+截图回执(可先交付可跑子集) |
|
||||
|
||||
> **检查点②(暂停)**:D11 完成后暂停目标 → 打包并交付用户测试(附测试清单)→ 等待用户确认测试通过后再进入阶段三联调。
|
||||
|
||||
### 阶段三 联调与交付(D12-D13)
|
||||
|
||||
| 日 | 任务 | 验收标准 |
|
||||
|---|---|---|
|
||||
| D12 | 内网联调+异常注入:断线重连/重复任务/登录过期挑战/失败退避;延迟基线 | 验收清单全过 |
|
||||
| D13 | 打包(安装器+自动更新)、内部试用、修复、部署手册 | 新机安装走通、交付物齐 |
|
||||
| D14-15 | 缓冲(主要给平台逆向) | — |
|
||||
|
||||
## 7. 最终验收清单
|
||||
|
||||
1. 登录/刷新/登出/角色权限正常(2FA 默认关)。
|
||||
2. 任务全生命周期:草稿→审核→排期→队列→执行→成功/失败→重试。
|
||||
3. 任务下发 <300ms;断线重连 <10s 恢复;同任务不重复执行(幂等)。
|
||||
4. 挑战全流程:扫码/验证码/APP确认/超时挂起/一键重发。
|
||||
5. 素材:上传、签名直链下载、sha256 校验。
|
||||
6. 四平台真实发布成功 + 回执截图入库。
|
||||
7. Windows 新机:安装→配对→绑定账号→真实发布。
|
||||
8. 凭据不出本机;审计日志完整。
|
||||
9. 安装包 + 自动更新可用。
|
||||
|
||||
## 8. 交付物清单
|
||||
|
||||
- 代码:web/(网页端)、server/(Go 服务器)、client/(WinUI+agent-core)。
|
||||
- 文档:docs/prd.md(需求)、docs/tech-stack.md(技术栈)、docs/system-design.md(设计)、docs/logic-diagrams.md(逻辑图)、docs/page-wireframes.md(页面线框图)、docs/frontend-guide.md(前端指南)、本交付文档(排期)、部署手册、验收报告。
|
||||
- 工具:tools/diag-connection.mjs 联调诊断。
|
||||
|
||||
## 9. 风险与应对
|
||||
|
||||
| 风险 | 应对 |
|
||||
|---|---|
|
||||
| starter 依赖过旧(Vite2/TS4.8) | D1 升级评估,必要则升 Vite5+TS5,锁定 LTS Node |
|
||||
| 平台逆向不可预测 | D14-15 缓冲;B站先行;卡住降级为可跑子集 |
|
||||
| WinUI 托盘无官方控件 | H.NotifyIcon.WinUI 或 Win32 interop(D9 前验证) |
|
||||
| 系统 Chrome 版本兼容 | go-rod 用 CDP,绑定系统 Edge 兜底 |
|
||||
- `apps/web`:React + TDesign 网页控制台。
|
||||
- `server`:认证、业务 API、Browser WS、WebExecutor/PlatformAdapter、MySQL/Redis。
|
||||
- `docs/web-only-v1-plan.md`、`docs/web-only-logic-diagrams.md`、`docs/reference-repositories-analysis.md`。
|
||||
- `references/`:三个 MIT 参考仓库的只读副本和许可证。
|
||||
- 自动化测试与浏览器验收报告。
|
||||
|
||||
+13
-96
@@ -1,101 +1,18 @@
|
||||
# EveryPublish 开发排期 v2(AI 加速 · 日级)
|
||||
# EveryPublish Web-only 开发排期
|
||||
|
||||
> 2026-08-20 更新:技术栈定版后的顺序开发计划(网站端→客户端→联调)见 docs/delivery-plan.md,以新文档为准。
|
||||
> 旧的客户端优先排期已停止。详细范围见 [web-only-v1-plan.md](web-only-v1-plan.md)。
|
||||
|
||||
> 2026-08-20 · 取代 logic-diagrams.md 图11 的传统 10 周排期。前提:1 人 + AI 编码代理并发推进。
|
||||
|
||||
## 1. 排期总览
|
||||
|
||||
**内核 6 个工作日 + 2 天风险缓冲**:D1 协议定版+假执行器闭环 → D2 网页端全部功能 → D3 客户端 Windows → D4 四平台真实接入 → D5 内网联调加固 → D6 打包试用。
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title MVP 闭环 AI加速排期(1人+AI并发)
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %m-%d
|
||||
section 协议与骨架
|
||||
协议定版+单测 :D1a, 2026-08-21, 1d
|
||||
工程骨架+假执行器闭环 :D1b, 2026-08-21, 1d
|
||||
section 网页端
|
||||
网页端全部功能+WSS网关 :D2, 2026-08-22, 1d
|
||||
section 客户端
|
||||
Agent框架+Electron+打包 :D3, 2026-08-23, 1d
|
||||
section 平台接入(高风险)
|
||||
四平台真实发布 :D4, 2026-08-24, 2d
|
||||
section 联调收尾
|
||||
内网联调+异常加固 :D5, 2026-08-26, 1d
|
||||
打包试用+修复 :D6, 2026-08-27, 1d
|
||||
section 缓冲
|
||||
D4风险缓冲 :buf, 2026-08-28, 2d
|
||||
```
|
||||
|
||||
## 2. 加速逻辑(为什么能从 10 周压到 6 天内核)
|
||||
|
||||
| 传统排期的串行等待 | AI 加速后的处理 |
|
||||
|---|---|
|
||||
| 协议讨论来回 | 本文档 §7 消息协议已定版,D1 上午直接产出 TS 协议包+单测 |
|
||||
| 两端互相等接口 | 协议包是唯一耦合点:D1 定死后,网页端/客户端/测试三线完全并行 |
|
||||
| 脚手架与样板代码 | AI 代理直接生成(monorepo、页面 CRUD、Electron 壳、队列 worker) |
|
||||
| 平台适配逐个人工摸索 | 每平台一个 AI 子代理,参考 social-auto-upload(MIT 1.4万星)并行逆向 |
|
||||
| 联调来回排期 | 假执行器先行:D1 起每天都是可联调状态 |
|
||||
|
||||
## 3. 日计划
|
||||
|
||||
### D1 协议定版 + 工程骨架(验收:本机假执行器闭环)
|
||||
|
||||
| 时段 | 任务 | 并行方式 |
|
||||
| 阶段 | 当前状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 上午 | packages/protocol:zod schema(hello/heartbeat/task.push/ack/result/challenge.push/resolve/agent.cmd)+ 幂等工具 + 单测 | 人+AI 共同定版 |
|
||||
| 下午 | monorepo(pnpm+Turborepo)+ Drizzle 建表 + Redis/BullMQ + 假执行器脚本 + 网页最小页(任务列表/状态) | 协议定版后三线并行:web 骨架 / agent 骨架 / mock+测试 |
|
||||
| W1 Web-only 收敛/文档/路由清理 | 已完成 | 客户端菜单、Agent 路由和设备字段不进入网页流程 |
|
||||
| W2 认证/工作区/权限/审计 | 已完成 | JWT、refresh、workspace 隔离和角色写权限有 API 回归 |
|
||||
| W3 素材上传/预览/去重 | 已联调 | 流式上传、SHA-256 去重、类型筛选、一次性直链、删除和接口 smoke 已覆盖 |
|
||||
| W4 账号登录/QR/挑战状态 | 已联调(真实扫码待验收) | mock QR、挂起/重发/空值、B站轮询 stub、Cookie 导入契约已覆盖 |
|
||||
| W5 发布向导/审核/任务队列 | 已联调 | 成功、驳回/重提/取消、逐账号 adapter、context cancel 和任务时间线已覆盖 |
|
||||
| W6 通知/Browser WS/REST 兜底 | 已联调 | Browser WS、通知读全、审计列表和 REST 首屏数据已覆盖 |
|
||||
| W7 真实平台 adapters | B站代码完成,人工验收待完成 | B站 HTTP stub/错误分类通过;真实扫码和真实投稿必须由用户手动验收 |
|
||||
| W8 全量浏览器 UI/部署验收 | API/构建完成,视觉验收待浏览器服务 | TypeScript/build/smoke 通过;当前浏览器控制服务不可用,不能声称逐页视觉完成 |
|
||||
|
||||
**验收**:localhost 闭环——创建任务 → WSS 下发 → 假执行 → 回传 → 页面变绿;延迟 <300ms。
|
||||
当前每次提交的最低门槛:`go test ./...`、`go vet ./...`、`npx tsc --noEmit`、`npm run build`、`npm test`、`git diff --check`。
|
||||
|
||||
### D2 网页端全部功能(验收:全页面可用)
|
||||
|
||||
- auth(无 2FA UI)/ 工作区成员角色 / 账号台账 / 素材(本地盘 multipart + 签名直链)/ 任务+排期日历 / 审核流 / 挑战中心(token 本地渲染二维码 + 验证码输入)/ 审计 / 通知。
|
||||
- WSS 网关:hello Ed25519 验签、心跳 10s/25s 超时、挑战消息双向。
|
||||
**验收**:与假执行器全流程;二维码能从 token 本地渲染并可被手机识别。
|
||||
|
||||
### D3 客户端 Windows(验收:安装→配对→假任务→回传)
|
||||
|
||||
- agent-core:WSS 客户端(断线重连/幂等 ack)+ 凭据保险库(加密)+ 执行器框架 + 挑战处理器 + 代理配置。
|
||||
- Electron 壳:托盘、配对引导、扫码/挑战窗口、连接状态、日志。
|
||||
- electron-builder Windows 打包(CI,不用本机交叉编译)。
|
||||
**验收**:Windows 实机安装 → 配对 → 接假任务 → 回传结果。
|
||||
|
||||
### D4 四平台真实接入(验收:真实发布+截图回执)★最高风险
|
||||
|
||||
- 顺序:B站(投稿协议,无浏览器)先行 → 抖音/快手/小红书(Playwright,参考 social-auto-upload)并行。
|
||||
- 登录:B站 API 取码;三平台浏览器取码 + 只传 token。指纹:真实 Chrome + 每账号独立档案。
|
||||
**验收**:四平台真实发布成功,回执截图入库。
|
||||
|
||||
### D5 内网联调 + 异常加固(验收:清单全过)
|
||||
|
||||
- Mac 服务器 + Windows 客户端局域网全链路;异常注入:断网重连 / 重复任务 / 登录过期挑战 / 失败退避;延迟基线 <300ms。
|
||||
|
||||
### D6 打包试用 + 修复
|
||||
|
||||
- Windows 安装包 + 自动更新 + 内部试用 + 修复。
|
||||
|
||||
## 4. AI 并发工作法
|
||||
|
||||
1. **依赖序**:协议(D1 上午)→ 三线并行(D1 下午起):A=网页端功能、B=Agent 核心+壳、C=平台 uploader(每平台一个)、D=测试/联调脚本。
|
||||
2. **人只做五件事**:协议定版、代码审查、跑验收、平台逆向卡点介入、外部资源准备(账号/代理/扫码手机)。
|
||||
3. **子代理 prompt 三件套**:协议包路径 + 对应文档章节 + 验收标准(文档已齐,直接引用)。
|
||||
4. **每日节奏**:早定目标 → AI 并行 → 晚验收;验收不过的条目次日优先。
|
||||
5. **单测+验收清单兜底 AI 代码质量**:不写测试的产出不收。
|
||||
|
||||
## 5. 风险与缓冲
|
||||
|
||||
| 风险 | 影响 | 缓冲策略 |
|
||||
|---|---|---|
|
||||
| D4 平台逆向不可预测 | 最可能拖期 | 预留 2 天缓冲;先 B站(确定性最高)后三平台;卡住即降级为「少一个平台」交付 |
|
||||
| Windows 打包/签名坑 | D3 可能溢出 | 第一天就起 CI;不用本机交叉编译 |
|
||||
| 平台风控触发 | 账号受限 | 独立档案+真实扫码+人级频率,不硬刚 |
|
||||
| AI 生成代码质量 | 返工 | 单测+验收清单;关键路径(鉴权/幂等)人工复审 |
|
||||
|
||||
## 6. 结论
|
||||
|
||||
- **1 人 + AI 并发:内核 6 个工作日,含缓冲 8 个日历日**;「一天跑通 MVP 闭环」= D1 假执行器闭环,完全现实(协议与验收标准已齐)。
|
||||
- **真实平台闭环的关键在 D4**:逆向工作无法被 AI 完全消除,现实预期 2 天 + 缓冲,先交付 B站/抖音/小红书/快手中的可跑子集。
|
||||
- 视频号、官方 API 通道、OSS 仍为二期(接口已预留)。
|
||||
每周都必须留下 API 测试结果、浏览器冒烟结果和未完成项;没有人工扫码时,真实登录只能标记为待用户验收。
|
||||
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
# 前端开发指南(TDesign starter 深度说明)
|
||||
# 前端开发指南(Web-only V1)
|
||||
|
||||
> 2026-08-20 · 基于 apps/web 实际代码(starter 0.3.1)· 配套 docs/page-wireframes.md 使用。
|
||||
> 2026-08-21 · 基于 apps/web 实际代码。客户端/Agent 页面不属于当前菜单和验收。
|
||||
|
||||
## 1. 模板事实清单(实测)
|
||||
|
||||
@@ -17,27 +17,27 @@
|
||||
|
||||
- **路由即菜单**:src/router/modules/ 每个 .ts 一个顶级菜单;meta{title, Icon, hidden, single};children 即子菜单;isFullPage=true 为全屏页(登录页)。
|
||||
- **布局**:src/layouts/components/ AppLayout(整体) + Header + Menu + Footer + Page;新页面只需加路由+页面文件。
|
||||
- **接口**:src/services 为 axios 封装示例;把 src/configs/host.ts 的 development.API 指向 Go 服务器(如 http://localhost:8080)。
|
||||
- **状态**:Redux Toolkit(modules/user 存登录态);实时状态用原生 WebSocket(/ws,JWT 鉴权),事件 task.*/challenge.*/notification.*。
|
||||
- **接口**:src/services 为 axios 封装示例;开发期使用 Vite `/api` 代理到 Go `127.0.0.1:8090`,网页默认访问 `127.0.0.1:3013`。
|
||||
- **状态**:Redux Toolkit(modules/user 存登录态);实时状态用原生 WebSocket(/ws/browser,JWT 鉴权),事件 task.*/challenge.*/notification.*。
|
||||
- **mock 移除**:删除 mock 目录与 vite-plugin-mock 使用,全部走真实 API。
|
||||
|
||||
## 3. 组件清单与场景映射
|
||||
|
||||
| TDesign 组件 | 适用场景 | 本项目页面 |
|
||||
|---|---|---|
|
||||
| Table + Tag + Pagination + Popconfirm | 数据列表/状态/二次确认 | 任务列表/素材/账号台账/成员/审计/租户/Agent总览 |
|
||||
| Table + Tag + Pagination + Popconfirm | 数据列表/状态/二次确认 | 任务列表/素材/账号台账/成员/审计/租户 |
|
||||
| Form + Input + Textarea + Select + Checkbox + Radio + Switch | 表单 | 登录/新建发布/设置/成员邀请 |
|
||||
| Steps | 分步向导 | 新建发布四步 |
|
||||
| DatePicker / DateRangePicker | 时间选择 | 定时发布/筛选 |
|
||||
| Calendar | 月历 | 排期日历(若 1.15 无此组件则用卡片网格自绘,需 D6 前验证) |
|
||||
| Upload + Progress + ImageViewer | 上传/预览 | 素材库 |
|
||||
| Dialog | 模态弹窗 | 绑定扫码/验证码输入/邀请 |
|
||||
| Dialog | 模态弹窗 | 网页扫码/验证码输入/邀请 |
|
||||
| Drawer | 侧滑详情 | 任务详情/审核预览 |
|
||||
| Timeline | 时间线 | 任务事件流 |
|
||||
| Badge + Tag | 状态角标 | 绑定状态/任务状态/通知 |
|
||||
| Message + Notification + Loading + Skeleton | 全局反馈 | 全站 |
|
||||
| Tabs | 分栏 | 审核分平台预览/设置子页 |
|
||||
| 二维码 | TDesign 无此组件 | 唯一新增依赖:qrcode.react(token 本地渲染) |
|
||||
| 二维码 | TDesign QRCode | QR URL/token 本地渲染;真实扫码状态由 Browser WS/REST 更新 |
|
||||
|
||||
## 4. 过渡效果说明(结论:本期不做页面动画)
|
||||
|
||||
@@ -56,17 +56,17 @@
|
||||
| 新建发布 | pages/Form/Step | 四步:素材→平台账号→内容→定时提交 |
|
||||
| 任务详情 | pages/Detail/Base → Drawer 化 | Timeline 事件流 + 回执截图 |
|
||||
| 审核中心 | pages/List/Base + Drawer | 预览用 Tabs 分平台 |
|
||||
| 设置 | pages/Form/Base + Tabs | 资料/安全/通知渠道/Agent设备 |
|
||||
| admin 两页 | pages/List/Base | /admin 路由域 + 角色守卫 |
|
||||
| 设置 | pages/Form/Base + Tabs | 资料/安全(密码修改)/站内通知 |
|
||||
| admin | pages/List/Base | /admin 路由域 + 角色守卫 |
|
||||
|
||||
## 6. 菜单与路由改造清单(D1 完成)
|
||||
|
||||
- router/modules 改为:dashboard(工作台)、publish(发布管理: 任务列表/新建发布/排期日历)、material(素材库)、account(账号管理)、review(审核中心)、challenge(通知与挑战)、member(成员与权限)、setting(设置)、audit(审计日志)、admin(平台管理域)。
|
||||
- router/modules 改为:dashboard(工作台)、publish(发布管理: 任务列表/新建发布/排期日历)、material(素材库)、account(账号管理)、review(审核中心)、challenge(通知与挑战)、notification(站内通知)、member(成员与权限)、setting(工作区资料/安全)、audit(审计日志)、admin(平台管理域)。
|
||||
- 任务详情为 hidden 路由(不进菜单)。
|
||||
- 权限:utils 增 auth.ts(角色→菜单过滤 + 路由守卫),角色:admin/reviewer/operator/platform_admin。
|
||||
|
||||
## 7. 前后端联调约定
|
||||
|
||||
- REST:/api/*,Header Authorization: Bearer <JWT>;401 自动 refresh 重试一次。
|
||||
- WS:原生 WebSocket,连接 /ws?token=<JWT>;心跳由服务端协议统一(10s/25s)。
|
||||
- REST:/api/v1/*,Header Authorization: Bearer <JWT>;401 自动 refresh 重试一次。
|
||||
- WS:原生 Browser WebSocket,连接 /ws/browser?token=<JWT>;断线时 REST 轮询兜底。
|
||||
- 分页约定:{ list, total, page, pageSize };错误码约定:业务码 + message,前端 Message 提示。
|
||||
|
||||
+18
-291
@@ -1,299 +1,26 @@
|
||||
# EveryPublish 逻辑图全集(最小闭环版)
|
||||
# EveryPublish V1 逻辑图
|
||||
|
||||
> 2026-08-20 · 汇总自 docs/multi-platform-publish-plan.md 与 docs/system-design.md,MVP 范围已标注。
|
||||
> 当前以 [web-only-logic-diagrams.md](web-only-logic-diagrams.md) 为准;客户端/Agent 图已移出 V1。
|
||||
|
||||
## 图索引
|
||||
## 网页闭环
|
||||
|
||||
| # | 图 | 回答的问题 | 来源 | PNG |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 总体架构(双通道) | 系统由哪些部分组成、如何协作 | plan §3 | [PNG](../diagrams/png/01-总体架构.png) |
|
||||
| 2 | 控制面/数据面分离 | 为什么我方服务器不会被封 | plan §3.1 | [PNG](../diagrams/png/02-控制面-数据面分离.png) |
|
||||
| 3 | MVP 部署拓扑(内网联调) | 一期怎么跑起来(Mac+Windows) | 新增 | [PNG](../diagrams/png/03-MVP-部署拓扑.png) |
|
||||
| 4 | IP 路由逻辑 | 账号与 IP 如何绑定 | plan §8 | [PNG](../diagrams/png/04-IP-路由逻辑.png) |
|
||||
| 5 | 重试与降级状态机 | 失败/挑战/风控如何流转 | plan §21.2 | [PNG](../diagrams/png/05-重试与降级状态机.png) |
|
||||
| 6 | 全链路时序(含异常分支) | 用户→端→平台→用户完整路径 | plan §21.1 | [PNG](../diagrams/png/06-全链路时序.png) |
|
||||
| 7 | 二次验证挑战兜底流程 | 扫码/验证码/APP确认怎么处理 | plan §21.3 | [PNG](../diagrams/png/07-二次验证挑战兜底流程.png) |
|
||||
| 8 | 登录链路优化时序 | 登录为何能从 10s 级压到 2s 级 | plan §22 | [PNG](../diagrams/png/08-登录链路优化时序.png) |
|
||||
| 9 | Agent 配对时序 | 客户端如何安全接入 | system-design §8.2 | [PNG](../diagrams/png/09-Agent-配对时序.png) |
|
||||
| 10 | 素材传输链路 | 文件怎么上传/下发/回传 | system-design §12 | [PNG](../diagrams/png/10-素材传输链路.png) |
|
||||
| 11 | 开发排期甘特 | 何时完成最小闭环 | 新增 | [PNG](../diagrams/png/11-开发排期甘特.png) |
|
||||
|
||||
## 图1 总体架构(双通道)
|
||||
|
||||
**MVP 范围**:一期只做通道B(Agent 自动化)+ 国内四平台(抖音/快手/小红书/B站);通道A(官方API)与视频号为二期。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph WEB["① 控制端 Web 多租户SaaS"]
|
||||
U["运营 / 审核 / 管理员"]
|
||||
W["任务编排 · 素材库 · 账号台账 · 审计"]
|
||||
U --- W
|
||||
end
|
||||
subgraph CORE["② 调度中心 服务器"]
|
||||
Q["任务队列 Redis(asynq) · 状态机 · 排期器"]
|
||||
API["通道A 官方API执行器 OAuth"]
|
||||
AGW["通道B Agent网关 WSS注册/心跳/路由"]
|
||||
end
|
||||
subgraph AGENT["③ 执行端 Agent 客户侧安装"]
|
||||
WS["WSS长连接 主动出网回连"]
|
||||
VAULT["凭据保险库 cookie本地加密"]
|
||||
ROUTER["代理路由器 一账号一IP"]
|
||||
PA["浏览器执行器 go-rod CDP 独立档案"]
|
||||
QR["扫码登录面板"]
|
||||
end
|
||||
WEB -->|"任务下发"| Q
|
||||
Q -->|"通道A 官方API"| API
|
||||
Q -->|"通道B 自动化"| AGW
|
||||
AGW --> WS --> ROUTER --> PA
|
||||
VAULT --- PA
|
||||
QR --- VAULT
|
||||
API --> INT["国际 X/IG/YouTube/TikTok"]
|
||||
PA --> DOM["国内 抖音/快手/小红书/视频号/B站"]
|
||||
PA --> INT
|
||||
```text
|
||||
登录 → 工作台 → 上传素材 → 添加/登录平台账号 → 新建发布
|
||||
▲ │
|
||||
│ ▼
|
||||
└─ 任务/通知/审计 ◄─ worker 执行 ◄─ 审核通过/排期
|
||||
│
|
||||
├─ challenge → 网页 QR/验证码 → resume
|
||||
├─ success → URL/receipt
|
||||
└─ failed → 错误分类 → retry
|
||||
```
|
||||
|
||||
## 图2 控制面/数据面分离
|
||||
## 连接降级
|
||||
|
||||
我方服务器只流转任务元数据(<1KB),绝不触达平台;全部平台流量从客户 Agent 发出。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph CP["控制面 我方服务器 无平台流量"]
|
||||
WEB["控制台/API"] --- Q["队列/排期/状态机"]
|
||||
Q --- GW["Agent网关 WSS"]
|
||||
end
|
||||
subgraph DP["数据面 客户Agent 全部平台流量"]
|
||||
WS["WSS客户端"] --- EX["任务执行器"]
|
||||
EX --- V["凭据保险库"]
|
||||
EX --- PR["代理路由"]
|
||||
end
|
||||
CP -->|"任务元数据 WSS <1KB"| DP
|
||||
DP -->|"结果/挑战 WSS"| CP
|
||||
DP -->|"上传/发布 平台流量"| P["目标平台"]
|
||||
CP -.->|"绝不触达"| P
|
||||
```text
|
||||
REST 首屏数据 + Browser WS 增量
|
||||
├─ WS online:实时更新
|
||||
└─ WS offline:顶部告警 + REST 轮询 + 操作仍可重试
|
||||
```
|
||||
|
||||
## 图3 MVP 部署拓扑(内网联调)
|
||||
|
||||
Mac 跑网页端+服务器,Windows 装客户端,同局域网联调。注意服务器绑定 0.0.0.0、客户端用局域网 IP、防火墙放行、关 AP 隔离。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph M["Mac 开发机 内网测试环境"]
|
||||
WEB["网页端 dev"] --> S["服务器 API + WSS网关"]
|
||||
end
|
||||
subgraph W["Windows 客户机"]
|
||||
APP["客户端App Agent<br/>WinUI壳+Go执行核心"]
|
||||
end
|
||||
W -->|"WSS ws://局域网IP:端口"| S
|
||||
S --> DB[("MySQL + Redis")]
|
||||
S --> FS[("素材 服务器本地盘")]
|
||||
APP -->|"直链下载素材 带签名token"| FS
|
||||
```
|
||||
|
||||
## 图4 IP 路由逻辑
|
||||
|
||||
一账号一固定 IP,国内账号=国内住宅 IP,国外账号=国外住宅 IP,浏览器 Context 级注入。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ACC["账号台账"] --> REG{"账号归属地?"}
|
||||
REG -->|国内平台| POOL1["国内住宅IP池<br/>芝麻/快代理/922S5等"]
|
||||
REG -->|国外平台| POOL2["海外住宅IP池<br/>BrightData/IPRoyal等"]
|
||||
POOL1 --> BIND["一账号绑定一IP<br/>长期固定不轮换"]
|
||||
POOL2 --> BIND
|
||||
BIND --> CTX["浏览器Context级注入<br/>非全局TUN 防串账号"]
|
||||
CTX --> EXEC["执行发布"]
|
||||
```
|
||||
|
||||
## 图5 重试与降级状态机
|
||||
|
||||
失败按原因分流:网络→指数退避重试;登录态→挑战(人工扫码);风控→账号冷却+人工队列。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 队列中
|
||||
队列中 --> 执行中: Agent认领
|
||||
执行中 --> 已发布: 平台回执成功
|
||||
执行中 --> 网络重试: 网络/超时错误
|
||||
网络重试 --> 执行中: 退避1m/5m/15m/1h
|
||||
网络重试 --> 失败终态: 超上限N次
|
||||
执行中 --> 挑战中: 需扫码/验证码
|
||||
挑战中 --> 执行中: 用户完成挑战
|
||||
挑战中 --> 挂起: 挑战超时
|
||||
挂起 --> 执行中: 用户稍后处理
|
||||
执行中 --> 账号冷却: 风控拦截
|
||||
账号冷却 --> 执行中: 冷却结束+人工确认
|
||||
账号冷却 --> 失败终态: 人工放弃
|
||||
失败终态 --> 队列中: 人工一键重发
|
||||
已发布 --> [*]
|
||||
失败终态 --> [*]
|
||||
```
|
||||
|
||||
## 图6 全链路时序(含异常分支)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant OP as 运营
|
||||
participant S as 服务器
|
||||
participant A as Agent
|
||||
participant PL as 平台
|
||||
participant IT as 账号负责人
|
||||
Note over A,S: 单条WSS长连接复用(实测RTT 0.07ms)
|
||||
OP->>S: 提交任务(素材/平台/排期)
|
||||
S->>S: 校验→审核→入队→排期触发
|
||||
S->>A: task.push(taskId,加密载荷)
|
||||
A-->>S: ack(taskId) 幂等确认
|
||||
A->>A: 选账号→注入代理→协议级登录态校验(不启浏览器)
|
||||
alt 登录态正常
|
||||
A->>PL: 冷启动浏览器→上传素材→提交发布
|
||||
PL-->>A: 发布回执+截图
|
||||
A->>S: task.result(success)
|
||||
S-->>OP: 实时状态+链接
|
||||
else 平台触发二次验证
|
||||
PL-->>A: 挑战(扫码/短信/APP确认/滑块)
|
||||
A->>S: challenge.push(类型,凭证)
|
||||
S-->>IT: 控制台+企微提醒
|
||||
IT->>S: 扫码或输入验证码
|
||||
S->>A: challenge.resolve(结果)
|
||||
A->>PL: 继续发布流程
|
||||
else 网络类失败
|
||||
A->>A: 指数退避 1m/5m/15m/1h
|
||||
A->>PL: 自动重试上传
|
||||
else 登录态失效
|
||||
A->>S: challenge.push(qr)
|
||||
S-->>IT: 推送扫码提醒
|
||||
IT->>S: 扫码成功
|
||||
S->>A: challenge.resolve
|
||||
A->>PL: 恢复执行
|
||||
else 风控拦截
|
||||
A->>S: task.result(fail,原因)
|
||||
S->>S: 账号冷却+人工队列
|
||||
S-->>OP: 告警通知
|
||||
end
|
||||
```
|
||||
|
||||
## 图7 二次验证挑战兜底流程
|
||||
|
||||
验证码类挑战人工兜底为主、Agent 硬解为辅;超时挂起→人工队列→一键重发。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
TR["平台触发二次验证"] --> DET{"Agent检测挑战类型"}
|
||||
DET -->|二维码登录| QR["取码→推控制台+企微<br/>码过期自动刷新"]
|
||||
DET -->|APP确认| CF["推送手机确认提醒<br/>Agent轮询登录态"]
|
||||
DET -->|短信/邮箱| SMS["控制台弹输入框<br/>用户输入→回传回填"]
|
||||
DET -->|滑块/点选| CAP["默认截图推人工<br/>低风控平台本地尝试"]
|
||||
QR --> OK{"完成?"}
|
||||
CF --> OK
|
||||
SMS --> OK
|
||||
CAP --> OK
|
||||
OK -->|是| CONT["Agent继续发布流程"]
|
||||
OK -->|否/超时| PEND["任务挂起→人工队列<br/>一键重发/放弃"]
|
||||
PEND --> CONT
|
||||
```
|
||||
|
||||
## 图8 登录链路优化时序
|
||||
|
||||
API 取码免浏览器、只传 token 不传截图、短轮询检测、扫码与检测并行。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户(手机)
|
||||
participant C as 控制台/企微
|
||||
participant S as 服务器
|
||||
participant A as Agent
|
||||
participant P as 平台
|
||||
Note over A,P: 首选API直取(无浏览器, B站已验证)
|
||||
A->>P: HTTP取码 0.1-0.5s 得token+图片URL
|
||||
A->>S: challenge.push(token) 实测<50ms
|
||||
S->>C: WS推码 并行推企微/托盘
|
||||
C->>C: 本地重渲染二维码(非截图)
|
||||
par 用户扫码 3-10s
|
||||
U->>U: 手机扫码确认
|
||||
and Agent短轮询检测
|
||||
loop 300-500ms
|
||||
A->>P: 轮询扫码状态(或网络钩子)
|
||||
end
|
||||
end
|
||||
A->>P: API换取cookie 0.2-0.5s
|
||||
A->>A: 存入凭据保险库
|
||||
A->>S: challenge.resolve(已登录)
|
||||
S-->>C: 状态变绿
|
||||
```
|
||||
|
||||
## 图9 Agent 配对时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant W as 网页端
|
||||
participant S as 服务器
|
||||
participant A as App(Agent)
|
||||
U->>W: 登录(密码+TOTP 2FA可选)
|
||||
W->>S: POST /api/auth/login
|
||||
S-->>W: JWT(15min) + refresh cookie
|
||||
U->>W: 工作区→设备→添加设备
|
||||
W->>S: POST /api/agents/pairing
|
||||
S-->>W: 配对码(6位,5分钟有效)
|
||||
U->>A: 输入配对码
|
||||
A->>A: 生成Ed25519密钥对+deviceId
|
||||
A->>S: WSS auth(deviceId,配对码,公钥,签名)
|
||||
S->>S: 验证→注册公钥→签发agentToken(可吊销)
|
||||
S-->>A: 绑定成功
|
||||
A->>A: 私钥入本地保险库(DPAPI/SQLCipher)
|
||||
```
|
||||
|
||||
## 图10 素材传输链路(一期本地盘 / 二期OSS)
|
||||
|
||||
大文件绝不走 WSS;一期存服务器本地盘、直链带签名 token;二期切 OSS 预签名(StorageDriver 抽象保证零改动)。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
OP["运营(网页端)"] -->|"① multipart直传(一期服务器本地盘)"| FS["素材存储 一期本地盘/二期OSS"]
|
||||
OP -->|"② 元数据 API(sha256)"| S["服务器"]
|
||||
S -->|"③ WSS task.push + 短时效下载URL"| A["Agent"]
|
||||
A -->|"④ 直链下载(带签名token)"| FS
|
||||
A -->|"⑤ 上传发布"| P["目标平台"]
|
||||
P -->|"⑥ 回执+截图"| A
|
||||
A -->|"⑦ WSS task.result(URL+状态)"| S
|
||||
S -->|"⑧ WSS 实时状态推送"| OP
|
||||
```
|
||||
|
||||
## 图11 开发排期甘特(2 人,至最小闭环)
|
||||
|
||||
> 2026-08-20 更新:AI 加速版日级排期见 docs/dev-schedule.md,以新文档为准。
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title EveryPublish 最小闭环排期(2人)
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %m-%d
|
||||
section 奠基
|
||||
协议包+工程骨架 :M0, 2026-08-24, 7d
|
||||
section 网页端
|
||||
服务器最小闭环 :M1, 2026-08-31, 14d
|
||||
网页端全部功能 :M2, 2026-08-31, 28d
|
||||
section 客户端Windows
|
||||
Agent框架+WinUI壳 :M3, 2026-08-31, 21d
|
||||
真实平台接入4平台 :M4, 2026-09-21, 21d
|
||||
section 联调与收尾
|
||||
内网联调+异常加固 :M5, 2026-10-12, 14d
|
||||
打包+内部试用 :M6, 2026-10-26, 7d
|
||||
section 二期
|
||||
视频号+官方API通道 :M7, after M6, 21d
|
||||
```
|
||||
|
||||
## 最小闭环验收清单
|
||||
|
||||
| 里程碑 | 验收标准 | 周次 |
|
||||
|---|---|---|
|
||||
| M0 协议+骨架 | 协议包单测通过,两端共享类型 | W1 |
|
||||
| M1 服务器最小闭环 | 假执行器:任务→下发→执行→回传→显示全通 | W2-3 |
|
||||
| M2 网页端全部功能 | 账号/素材/任务/排期/审核/挑战/审计/通知全页面可用 | W2-6 |
|
||||
| M3 Windows 客户端框架 | 安装→配对→接假任务→回传结果 | W2-4 |
|
||||
| M4 真实平台接入 | 抖音/快手/小红书/B站真实发布成功+截图回执 | W5-7 |
|
||||
| M5 内网联调+加固 | 断线/重试/挑战/延迟<300ms 验收全过 | W8-9 |
|
||||
| M6 打包+试用 | Windows 安装包+自动更新+内部试用 | W10 |
|
||||
|
||||
**人力**:2 人(网页/服务端 1 + 客户端 1)10 周闭环;单人 16-18 周。闭环后持续成本:平台适配每周 0.5-1 天。
|
||||
旧 Agent WSS 逻辑保存在 git 历史和 `docs/macos-client-*`,不属于 V1 验收。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# macOS 客户端安装说明(暂停)
|
||||
|
||||
当前 Web-only V1 不需要安装 macOS 客户端。请按 [`README.md`](../README.md) 启动 Go server 和 React Web,浏览器访问本机控制台。
|
||||
|
||||
旧客户端代码和安装脚本保留在 `client/` 目录,仅作历史资料,不进入当前构建、启动和验收流程。
|
||||
@@ -0,0 +1,7 @@
|
||||
# macOS 客户端历史归档
|
||||
|
||||
当前任务只交付 Web-only V1。macOS/Windows 客户端、Agent WSS、设备配对和设备管理全部暂停,不在网页菜单、默认路由、开发排期或验收清单中。
|
||||
|
||||
`client/macos/`、`client/desktop/` 和 `client/core/` 仅保留为历史实验代码,当前不得继续开发,也不能作为网页端功能完成的依据。
|
||||
|
||||
网页端必须在当前服务器设备上独立完成:登录、账号挑战、素材上传、发布任务、审核、队列、结果、通知和审计。真实平台扫码需要用户后续手动验收;在没有扫码时只允许验证二维码生成、轮询等待、过期、重发和 mock 联调。
|
||||
@@ -0,0 +1,3 @@
|
||||
# macOS 客户端线框历史归档
|
||||
|
||||
当前不开发客户端,因此不保留可执行的客户端页面线框。Web-only V1 的网页 ASCII 线框以 [`docs/page-wireframes.md`](page-wireframes.md) 为准。
|
||||
@@ -1,600 +1,60 @@
|
||||
# EveryPublish 多平台聚合发布方案(调研与架构设计)
|
||||
# EveryPublish 多平台发布策略(Web-only V1)
|
||||
|
||||
> 调研日期:2026-08-20 | 数据来源:GitHub API 实测(星标/许可/活跃度)+ 官方文档抽查 + 工程实践估计。
|
||||
> 说明:本会话 web_search 不可用,个别官方接口细节(X 定价、国内开放平台审核范围)以官方最新文档为准,文中已标注置信度。
|
||||
> 2026-08-21。当前优先网页本机执行;客户端/托管 Agent 方案归档,不能作为 V1 完成条件。
|
||||
|
||||
## 0. TL;DR 结论速览
|
||||
## 1. 现实边界
|
||||
|
||||
1. **你的方向是对的**:「网站控制端 + 客户侧安装的执行端」是覆盖国内平台且支持多人协作的唯一高效解。行业成熟商业产品(易媒助手、融媒宝、蚁小二等)均采用同构方案,开源侧 `social-auto-upload` 的 Web-UI 衍生版也是该形态。
|
||||
2. **但不要一刀切,用双通道混合架构**:国际平台(X/IG/YouTube/TikTok)走**官方 API**,服务器直连、无需 Agent、完全合规;国内平台(抖音/快手/小红书/视频号/B站)走 **Agent 浏览器自动化**。合规面最大、Agent 复杂度最小。
|
||||
3. **唯一硬骨头是视频号**:微信官方无内容发布 API,只有「视频号助手」网页自动化一条路,会话短(小时级)、风控极高且牵连微信账号——建议单独迭代、默认关闭、客户主动开通并书面确认。
|
||||
4. **IP 风控的核心是一账号一固定 IP**:国内账号绑国内住宅 IP,国外账号绑国外住宅 IP,在 Agent 的浏览器 Context 级注入,永不跨区、永不轮换。
|
||||
5. **现成可复用**:`dreammis/social-auto-upload`(MIT,14.4k★,覆盖抖音/快手/小红书/视频号/微博/B站/TikTok/YouTube)是国内通道的最佳地基;`inovector/mixpost`(MIT)可整体消化国际侧;`postiz-app`(AGPL)只宜自托管参考。
|
||||
6. **最快验证路径**:第 1 周自托管 Mixpost 跑通国际平台并开始验证客户价值,同时并行开发 Agent 通道。
|
||||
不同平台的登录、上传和风控页面会持续变化。我们可以把账号、素材、任务、审核、挑战和执行器统一到一个 Web 控制台,但不能在没有用户扫码和真实账号的情况下声称“所有平台已发布成功”。
|
||||
|
||||
## 1. 需求与约束拆解
|
||||
```text
|
||||
网页控制台 → Go API/任务状态机 → 本机 PlatformAdapter → 目标平台
|
||||
│
|
||||
└→ mock executor(无真实账号时自动化验收)
|
||||
```
|
||||
|
||||
| 客户诉求 | 技术含义 |
|
||||
|---|---|
|
||||
| 越简单越好 | 客户三步上手:装 Agent → 扫码绑定 → 网站排期,零配置 |
|
||||
| 发布越快 | WSS 任务推送到执行 <1s;跨平台并行;失败自动重试 |
|
||||
| 账号越稳 | 登录态单点持有 + 固定设备指纹 + 固定 IP + 人级频率 |
|
||||
| 覆盖全部发布功能 | 图文/视频/定时/多平台/多账号,统一任务模型 |
|
||||
| 不违反官方规则 | 官方 API 优先;自动化部分明确告知灰色性质并分级开通 |
|
||||
| 多运营协作一个官方账号 | 控制台角色权限 + 审核流;账号凭据不落运营手中 |
|
||||
| IP 风控 | 账号-IP 绑定路由,按归属地分流(见 §8) |
|
||||
## 2. V1 平台优先级
|
||||
|
||||
## 2. 三种实现形态对比
|
||||
|
||||
| 维度 | Web控制端+桌面Agent(推荐) | 浏览器插件 | 纯云端官方API SaaS |
|
||||
| 优先级 | 平台 | V1 目标 | 证据门槛 |
|
||||
|---|---|---|---|
|
||||
| 多用户协作单账号 | **优**:凭据单点持有,角色审批,全程审计 | 差:多人登录同一账号易触发平台多设备风控 | 优:但仅覆盖有 API 的平台 |
|
||||
| 账号凭据安全 | 优:客户本地加密,服务器零凭据 | 差:凭据散落各浏览器 | 优:OAuth,无密码 |
|
||||
| IP 控制 | 优:Agent 按账号绑定代理 | 优:用户真实 IP(最自然) | 中:API 对 IP 不敏感 |
|
||||
| 7×24 无人值守 | 优 | 差:需客户电脑开机 + 浏览器常驻 | 优 |
|
||||
| 平台覆盖率 | **最广**:自动化兜底全部平台 | 中:受插件权限限制 | 仅覆盖有官方 API 的平台(抖音/快手/小红书/视频号内容发布均无公开 API) |
|
||||
| 合规度 | 灰+白混合(分级开通) | 灰 | 白 |
|
||||
| 开发成本 | 中高(可大幅复用开源) | 低 | 低(现成开源) |
|
||||
| P0 | B站 | 登录挑战、视频上传、任务结果 | HTTP stub + 用户扫码 + 平台页面核对 + 回执 |
|
||||
| P1 | 抖音、快手、小红书 | 登录挑战、视频/图文适配 | 独立 adapter 测试 + 用户扫码;当前未启用 |
|
||||
| P2 | 视频号、百家号、微博、国际平台 | 先保留接口/研究,不标记已支持 | 官方能力或人工验收后再开启 |
|
||||
|
||||
**结论**:主架构 = Web 控制端 + Agent;国际平台叠加官方 API 通道;浏览器插件仅作扫码辅助或单人轻量版。
|
||||
## 3. 统一 PlatformAdapter 契约
|
||||
|
||||
## 3. 总体架构(逻辑图)
|
||||
每个平台必须实现以下能力,不允许 API handler 直接写选择器:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph WEB["① 控制端 Web(多租户 SaaS)"]
|
||||
U["运营 / 审核 / 管理员"]
|
||||
W["任务编排 · 素材库OSS · 账号台账 · 审计"]
|
||||
U --- W
|
||||
end
|
||||
subgraph CORE["② 调度中心(服务器)"]
|
||||
Q["任务队列 Redis · 状态机 · 排期器"]
|
||||
API["通道A 官方API执行器(OAuth直连)"]
|
||||
AGW["通道B Agent网关(WSS注册/心跳/路由)"]
|
||||
end
|
||||
subgraph AGENT["③ 执行端 Agent(客户侧安装)"]
|
||||
WS["WSS长连接 · 主动出网回连"]
|
||||
VAULT["凭据保险库(cookie本地加密)"]
|
||||
ROUTER["代理路由器(一账号一IP)"]
|
||||
PA["Playwright 执行器(每账号独立档案)"]
|
||||
QR["扫码登录面板(本地页面/托盘)"]
|
||||
end
|
||||
WEB -->|"任务下发"| Q
|
||||
Q -->|"通道A 官方API"| API
|
||||
Q -->|"通道B 自动化"| AGW
|
||||
AGW --> WS --> ROUTER --> PA
|
||||
VAULT --- PA
|
||||
QR --- VAULT
|
||||
API --> INT["国际:X / IG / YouTube / TikTok"]
|
||||
PA --> DOM["国内:抖音/快手/小红书/视频号/B站/微博"]
|
||||
PA --> INT
|
||||
```text
|
||||
metadata() 平台名称、内容类型、限制
|
||||
login(challenge) 生成 QR/等待登录/保存本机 profile
|
||||
check(profile) 检查登录态并返回 active/expired
|
||||
publish(task) 上传素材、填充内容、定时/立即、返回 URL/receipt
|
||||
cancel(task) 尝试取消或返回不可取消原因
|
||||
```
|
||||
|
||||
**要点**:
|
||||
- 控制端:角色(管理员/审核/运营)、素材库(OSS+CDN)、账号台账、审计日志、通知(企微/钉钉/邮件)。
|
||||
- Agent:轻量常驻进程(早期草案为 Tauri/Electron 壳;最终定版:WinUI 3 壳 + Go 核心,见 docs/tech-stack.md)。四大组件:WSS 客户端(主动出网,免内网穿透)、凭据保险库(SQLCipher,服务器零凭据)、代理路由器、Playwright 执行器。
|
||||
- 素材流:控制端预签名直传 OSS → Agent 按任务拉取 → 发布后回调。
|
||||
- 响应速度:WSS 下发 <1s;总耗时 ≈ 平台上传+审核,架构不构成瓶颈。
|
||||
- **控制面/数据面分离**:我方服务器只处理任务元数据,全部平台流量从客户侧 Agent 发出,我方 IP 不接触平台(详见 §3.1)。
|
||||
平台 adapter 的错误必须分类为:`auth_required`、`challenge_required`、`validation`、`rate_limited`、`network`、`platform_changed`、`unknown`。原始响应写脱敏日志,验证码/cookie 不写日志。
|
||||
|
||||
## 3.1 控制面 / 数据面分离(关键澄清:我方服务器不会被封)
|
||||
## 4. 参考代码借鉴
|
||||
|
||||
你提出的这一点是方案成立的前提,明确写入设计原则:
|
||||
- `dreammis/social-auto-upload`(MIT):借鉴 uploader/base_video 的扩展名/定时校验、QR 工具和各平台上传参数。其当前主线是 CLI,README 明确 Web 非主线。
|
||||
- `DevilJie/social-auto-upload-web-ui`(MIT):借鉴 Flask Registry、BasePlatform、SSE 登录、串行 worker、素材中心和批量发布的业务拆分。
|
||||
- 参考仓库放在 `references/` 只读保存;具体文件、版本、许可证和风险见 `docs/reference-repositories-analysis.md`。
|
||||
|
||||
- **控制面(我方 SaaS 服务器)**:只流转任务元数据——队列、排期、审核、审计、素材 OSS 中转、通知。**从不直接访问任何平台**,平台看不到我方任何基础设施,因而不存在「我方服务器 IP 被封」的可能。
|
||||
- **数据面(客户侧 Agent)**:登录态、代理路由、浏览器自动化、上传与发布,全部从客户自己的机器发出,使用客户自己的 IP(企业宽带/住宅)或客户绑定的住宅代理。**客户与客户之间天然隔离**,杜绝「大量用户共用同一 IP」的矩阵风控问题。
|
||||
- **素材中转**:素材存我方 OSS,但上传动作由客户 Agent 发起,平台只看到 Agent 的 IP,我方 OSS 不暴露给平台。
|
||||
- **唯一例外——官方 API 通道**:X/IG/YouTube/TikTok 走客户自己的应用凭据 + OAuth,平台识别的是客户的应用与账号,IP 不敏感,可安全地放在我方服务器执行;如客户仍希望「数据不出我方」,也可整体下沉到 Agent。
|
||||
## 5. 风控和合规边界
|
||||
|
||||
**Agent 部署三形态**:
|
||||
- 只支持用户自己授权的账号和官方登录/扫码流程。
|
||||
- 不做刷量、绕过验证码、批量注册、矩阵养号、私信轰炸或规避平台风控。
|
||||
- 账号 cookie/profile 只保存在运行服务器的本机受保护目录;浏览器页面不回传明文凭据。
|
||||
- 真实平台 selector、限流和风控由用户验收;mock 结果只使用 `mock://`,不能伪装成平台 URL。
|
||||
|
||||
| 形态 | 7×24 | IP 真实度 | 成本 | 适用 |
|
||||
|---|---|---|---|---|
|
||||
| 客户办公电脑(默认) | 需常开 | 企业真实 IP,最优 | 零 | 默认形态,符合「越简单越好」 |
|
||||
| 客户自有 VPS + 住宅代理 | ✅ | 住宅代理,较好 | 代理月费 | 需要深夜定时发布/无人值守的客户 |
|
||||
| 我方托管 Agent(增值) | ✅ | 独享住宅代理 + 独享设备档案 | 高(我方承担风险与运维) | 大客户定制,绝不与其他客户共享 IP |
|
||||
## 6. 交付顺序
|
||||
|
||||
多人协作逻辑不变:控制面照常支撑多运营/审核/排期,数据面由客户 Agent 单点执行——一个官方账号始终只在一台机器、一个 IP、一个设备档案上登录。
|
||||
|
||||
## 4. 任务状态机(逻辑图)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 草稿
|
||||
草稿 --> 待审核: 运营提交
|
||||
待审核 --> 已排期: 审核通过
|
||||
待审核 --> 草稿: 驳回
|
||||
已排期 --> 队列中: 到点入队
|
||||
队列中 --> 执行中: Agent认领
|
||||
执行中 --> 已发布: 平台回执成功
|
||||
执行中 --> 失败重试: 可重试错误
|
||||
失败重试 --> 队列中: 指数退避重入
|
||||
执行中 --> 失败终态: 超过重试上限
|
||||
失败终态 --> [*]
|
||||
已发布 --> [*]
|
||||
```text
|
||||
统一 Web 业务和 mock 闭环
|
||||
→ B站 adapter(当前唯一真实实现)
|
||||
→ 快手/小红书 adapter
|
||||
→ 用户真实扫码验收
|
||||
→ 再评估视频号/国际官方 API
|
||||
```
|
||||
|
||||
## 5. 任务执行时序(逻辑图)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant OP as 运营(Web)
|
||||
participant S as 服务器
|
||||
participant A as Agent(客户机器)
|
||||
participant P as 目标平台
|
||||
A->>S: WSS注册(设备+账号清单)
|
||||
OP->>S: 提交任务(素材/标题/平台/时间)
|
||||
S->>S: 校验 → 审核 → 排期
|
||||
S->>A: 推送任务(加密载荷)
|
||||
A->>S: ACK 认领(幂等)
|
||||
A->>A: 选账号 → 注入代理 → 校验登录态
|
||||
A->>P: 上传素材 + 提交发布
|
||||
P-->>A: 发布回执
|
||||
A->>S: 回传结果 + 截图留证
|
||||
S-->>OP: 实时状态 + 失败通知
|
||||
```
|
||||
|
||||
## 6. 通信与安全设计
|
||||
|
||||
- **WSS 长连接**:Agent → Server 出站连接(天然穿透 NAT/防火墙),心跳 30s,断线指数退避重连;任务带 ID + ACK 幂等,防重复执行。
|
||||
- **加密**:任务载荷 AES-256-GCM,密钥经 KMS 信封加密;素材下载 URL 短时效签名。
|
||||
- **凭据**:cookie/token 仅存 Agent 本地(SQLCipher),服务器只存「账号是否在线、最后活跃」等元信息。
|
||||
- **审计**:谁在何时提交/审核/执行了哪个账号的什么内容,全链路留痕。
|
||||
|
||||
## 7. 平台接入矩阵
|
||||
|
||||
| 平台 | 官方发布API | 推荐通道 | 登录方式 | 会话稳定性 | 难度 | 合规 | 风控敏感度 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 抖音 | 开放平台有(企业审核) | 自动化(默认)/API(客户有资质时) | 扫码 | 中(约1-4周) | ★★★ | 灰/白 | 高 |
|
||||
| 快手 | 开放平台有(需审核) | 自动化 | 扫码 | 中 | ★★★ | 灰/白 | 中高 |
|
||||
| 小红书 | 专业号API(以电商为主) | 自动化 | 扫码 | 中短 | ★★★ | 灰/白 | 高 |
|
||||
| 视频号 | **无**(仅视频号助手) | 自动化(唯一路径) | 微信扫码 | **短(小时级)** | ★★★★★ | 灰 | **极高(牵连微信)** |
|
||||
| B站 | 投稿协议成熟(biliup) | 协议库直连(非浏览器) | 扫码/cookie | 长(月级) | ★★ | 半白 | 低 |
|
||||
| 微博 | 开放平台(需审核) | API / 自动化 | 扫码 | 中 | ★★ | 白 | 低 |
|
||||
| X | v2官方API(付费) | 服务器直连 API | OAuth | 长 | ★★ | 白 | 低 |
|
||||
| Instagram | Graph API(仅商业号) | 服务器直连 API | OAuth | 长 | ★★ | 白 | 低 |
|
||||
| YouTube | Data API v3 | 服务器直连 API | OAuth | 长 | ★★ | 白 | 低 |
|
||||
| TikTok | Content Posting API(需审核) | 服务器直连 API | OAuth | 长 | ★★★ | 白 | 低 |
|
||||
| WhatsApp | Cloud API(模板消息) | Cloud API(通知)/私协议(群发,灰) | 扫码/凭证 | 中 | ★★★ | 白/灰 | 中高 |
|
||||
|
||||
> 注:自动化目标站点——抖音 `creator.douyin.com`、快手 `cp.kuaishou.com`、小红书 `creator.xiaohongshu.com`、视频号 `channels.weixin.qq.com`、B站 `member.bilibili.com`。
|
||||
> WhatsApp 特殊性:无信息流「发布」概念;Cloud API 走模板消息(适合通知/营销,需审核模板),状态/群发属私协议灰区。
|
||||
> 置信度说明:抖音/快手/小红书开放平台能力存在但审核门槛与覆盖范围需以官方文档核实(中置信);视频号无公开 API 为高置信(官方仅有视频号助手)。
|
||||
|
||||
## 8. IP 风控方案(逻辑图 + 策略)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ACC["账号台账"] --> REG{"账号归属地?"}
|
||||
REG -->|国内平台| POOL1["国内住宅IP池<br/>芝麻/快代理/922S5等"]
|
||||
REG -->|国外平台| POOL2["海外住宅IP池<br/>BrightData/IPRoyal等"]
|
||||
POOL1 --> BIND["一账号绑定一IP<br/>长期固定不轮换"]
|
||||
POOL2 --> BIND
|
||||
BIND --> CTX["浏览器Context级注入<br/>(非全局TUN,防串账号)"]
|
||||
CTX --> EXEC["执行发布"]
|
||||
```
|
||||
|
||||
**策略**:
|
||||
- 一账号一 IP,长期固定;**严禁轮换**(轮换=异地登录信号,触发风控)。
|
||||
- 住宅 IP 优先,IDC 机房 IP 风险最高;新 IP 先「养」再发。
|
||||
- 官方 API 通道不依赖 IP 属地(服务器任选);但 YouTube 自动化必须显式代理(`social-auto-upload` 的 `YT_PROXY` 已实证此坑:Chromium 不吃系统代理)。
|
||||
- 代理池定时健康检查(可用性 + 归属地),异常自动摘除;同平台多账号放不同 IP + 不同设备档案。
|
||||
|
||||
## 9. 账号稳定策略
|
||||
|
||||
- **每账号独立持久化浏览器档案**(user_data_dir):固定 UA/分辨率/时区/字体,不跨机器迁移。
|
||||
- **登录一律官方扫码**(APP 扫码天然绑定设备),禁止代登密码。
|
||||
- **Cookie 保鲜**:每日低频心跳访问 + 失效检测,失效即推送「重新扫码」到控制台与企业微信。
|
||||
- **发布节奏**:单账号人级频率上限,失败指数退避,避开平台风控高峰(大促/晚高峰更敏感)。
|
||||
- **视频指纹**:ffmpeg 重编码去除元数据,多平台分发避免同文件哈希直传(防搬运识别关联)。
|
||||
- 平台风控核心信号是「设备+IP+行为」三要素,三者全固定最稳——这也是 Agent 方案优于插件方案的根本原因。
|
||||
|
||||
## 10. 开源项目聚合与复用建议
|
||||
|
||||
| 项目 | 星标 | 许可 | 覆盖平台 | 复用方式 |
|
||||
|---|---|---|---|---|
|
||||
| dreammis/social-auto-upload | 14.4k | MIT | 抖音/快手/小红书/视频号/微博/B站/百家号/支付宝生活号/虎扑/TikTok/YouTube | **国内通道地基**:fork 后把本地脚本改造成 Agent 服务,uploader 层几乎不动 |
|
||||
| inovector/mixpost | 3.5k | MIT | X/FB/IG/LinkedIn/Mastodon/Pinterest/TikTok/YouTube | MIT 可商用,国际侧可直接魔改 |
|
||||
| gitroomhq/postiz-app | 34.9k | AGPL-3.0 | X/IG/TikTok/YouTube/Threads/Bluesky/Pinterest/LinkedIn/Reddit 等 | 自托管可用;闭源 SaaS 注意 AGPL 传染,仅作参考 |
|
||||
| biliup/biliup | 5.4k | MIT | B站 | 投稿库 `bili_webup` 直接集成 |
|
||||
| DevilJie/social-auto-upload-web-ui | 210 | MIT | 同上(Web 化) | Web+本地服务分层先例,前端可参考 |
|
||||
| dorisoy/ShortVideo.AutoPublisher | 167 | MIT | 抖音/视频号/小红书/百家号/头条 | 视频号实现参考(.NET+Playwright),桌面 Agent 先例 |
|
||||
| ddean2009/MoneyPrinterPlus | 6.8k | GPL-3.0 | 抖音/快手/小红书/视频号 | 视频号上传流程参考;GPL 注意 |
|
||||
| white0dew/XiaohongshuSkills | 3.3k | MIT | 小红书 | 小红书发布流程参考(Agent 生态) |
|
||||
| NanmiCoder/MediaCrawler | 63.1k | 自定义 | 小红书/抖音/快手/B站/微博(爬) | 登录态/签名逆向参考;发布在付费 Pro 版 |
|
||||
| ReaJason/xhs | 2.2k | MIT | 小红书 | Web 协议封装,发布能力有限需自研上传 |
|
||||
| tweepy | 11.2k | MIT | X | X 官方 API 客户端 |
|
||||
| WhiskeySockets/Baileys | 10.8k | MIT | WhatsApp(私协议) | 备用;优先 Cloud API |
|
||||
| davidteather/TikTok-Api | 6.6k | MIT | TikTok(私协议) | 备用;优先官方 API |
|
||||
| subzeroid/instagrapi | 6.7k | 自定义 | IG(私协议) | 不推荐主线(封号风险高) |
|
||||
| lich0821/WeChatFerry | 6.8k | MIT | 微信(已归档) | 仅参考 |
|
||||
|
||||
**复用策略**:国内地基 `social-auto-upload`(MIT)→ 国际侧 Mixpost(MIT)→ B站 biliup → 视频号参考 tencent_uploader + ShortVideo.AutoPublisher。私协议库(instagrapi/Baileys/TikTok-Api)不作主线,仅官方 API 未覆盖时的知情备选。
|
||||
|
||||
## 11. 实现难度与可信度评估
|
||||
|
||||
| 模块 | 难度 | 可信度 | 说明 |
|
||||
|---|---|---|---|
|
||||
| Web 控制台 + 任务队列 | ★★ | ★★★★★ | 成熟技术栈,无风险 |
|
||||
| 官方 API 通道(国际) | ★★ | ★★★★★ | OAuth + 官方 SDK,完全合规 |
|
||||
| Agent 框架(WSS+凭据+代理) | ★★★ | ★★★★ | 工程量大但完全可控 |
|
||||
| 抖音/快手/小红书自动化 | ★★★ | ★★★☆ | 有 MIT 开源可 fork;持续风控适配 |
|
||||
| B站投稿 | ★★ | ★★★★★ | biliup 协议成熟稳定 |
|
||||
| **视频号** | ★★★★★ | ★★ | 无 API、会话短、牵连微信,唯一硬骨头 |
|
||||
| WhatsApp 群发/状态 | ★★★★ | ★★☆ | Cloud API 限模板;私协议有封号风险 |
|
||||
| IP 代理管理 | ★★★ | ★★★★ | 采购 + 绑定 + 健康检查 |
|
||||
| 多租户/权限/审计 | ★★★ | ★★★★★ | 常规业务功能 |
|
||||
|
||||
**总评**:MVP(官方 API 通道 + 抖音/快手/小红书/B站)2-3 人 × 4-6 周;全量(含视频号)3-4 人 × 2-3 个月 + 持续风控维护(平台前端改版适配是长期成本,每周巡检)。
|
||||
|
||||
## 12. 分阶段实施路线
|
||||
|
||||
| 阶段 | 内容 | 周期 | 产出 |
|
||||
|---|---|---|---|
|
||||
| P0 验证 | 自托管 Mixpost 跑通 X/IG/YouTube | 1 周 | 验证国际侧价值与合规通道 |
|
||||
| P1 MVP | 控制台 + 任务队列 + Agent 框架 + 抖音/快手/小红书/B站 | 3-4 周 | 可交付的第一版 |
|
||||
| P2 协作 | 角色权限/审核流/审计/通知 | 1-2 周 | 多人协作闭环 |
|
||||
| P3 攻坚 | 视频号(独立迭代)+ 微博/百家号 | 2-3 周 | 全平台覆盖 |
|
||||
| P4 白通道 | 抖音开放平台/小红书专业号 API 接入(客户有资质时) | 并行 | 合规面扩大 |
|
||||
| P5 商业化 | 多租户/计费/渠道 | 持续 | SaaS 化 |
|
||||
|
||||
## 13. 合规边界(务必读)
|
||||
|
||||
- **官方 API = 完全合规**(平台授权);**浏览器自动化 = 技术上违反多数平台「禁止自动化访问」条款**,实际执行风险是限流/封号。产品必须对客户**知情告知**,不可隐瞒。
|
||||
- **分级开通**:白名单通道(官方 API 平台)默认开启;灰通道(自动化)客户主动开通 + 签署风险告知书。
|
||||
- **红线不碰**:大规模矩阵养号、搬运去重绕过、刷量、私信轰炸——触碰即封号且可能产生法律风险。
|
||||
- **内容合规前置**:平台内容审核由平台执行,我方提供发布前自检提示,不代替审核。
|
||||
- **数据合规**:凭据加密最小化,素材仅在客户授权空间流转(个保法/数安法)。
|
||||
- **视频号特别告知**:微信账号是客户核心资产,封禁损失远大于其他平台——默认关闭、独立审批开通、建议专用微信号。
|
||||
|
||||
## 14. 风险清单与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|---|---|---|
|
||||
| 平台前端改版/选择器失效 | 自动化断链 | 模块化选择器 + 灰度监控 + 每周巡检(最大持续成本) |
|
||||
| 登录态失效 | 发布失败 | 保鲜心跳 + 失效推送 + 一键重扫 |
|
||||
| 账号风控 | 限流/封号 | 固定设备+IP+人级频率+养号策略 |
|
||||
| 代理失效 | 整批失败 | 池健康检查 + 同区域自动切换 |
|
||||
| API 涨价/收紧 | 成本/断供 | X 从 Basic 起步,预留自动化兜底 |
|
||||
| 视频号牵连微信 | 重大损失 | 默认关闭 + 专用微信号 + 客户书面确认 |
|
||||
| AGPL 传染 | 法律/商业 | 商用选 MIT 项目或自托管,律师审阅 |
|
||||
|
||||
## 15. 「越简单越好」的产品体验设计
|
||||
|
||||
- **客户三步**:装 Agent(一条命令/安装包)→ 打开本地扫码面板绑定各平台 → 网站排期一键发布。
|
||||
- **控制台极简视图**:今天要发什么 / 发了没 / 失败原因 / 一键重试。
|
||||
- **发布回执**:每平台截图 + 链接留证,运营可查可复核。
|
||||
- **登录过期**:红点 + 企微提醒 + 扫码面板自动弹出。
|
||||
- **单人版与团队版同一套**:单人 = 跳过审核流,零学习成本。
|
||||
|
||||
## 16. 用户端 / 管理端体验走查(Q&A 补充)
|
||||
|
||||
### 16.1 三种形态的「安装」真相
|
||||
|
||||
同一套 Agent 软件,只差运行地点:
|
||||
|
||||
| 形态 | 装什么 | 谁来装 | 怎么装 | 扫码在哪出现 |
|
||||
|---|---|---|---|---|
|
||||
| 客户办公电脑(默认) | 桌面应用(系统托盘) | 客户 IT / 账号负责人 | 下载安装包,双击即用 | 应用窗口 / 托盘弹窗 |
|
||||
| 客户自有 VPS | Docker 容器(无界面) | 客户 IT | 一条命令 / 一键脚本 | 控制台统一弹码(经 WSS 回传二维码图片,推荐) |
|
||||
| 我方托管(增值) | **无需安装** | 我方 | 我方部署 | 客户控制台页面直接弹码 |
|
||||
|
||||
- 无论哪种形态,客户要做的「账号动作」**只有扫码登录**(官方 APP 扫码,天然绑定设备);账号密码/验证码永不经过我方系统。
|
||||
- 住宅代理:办公电脑走企业宽带时**零配置**;VPS / 托管形态需购买住宅代理并粘贴代理账号密码(托管形态可由我方代采购)。
|
||||
|
||||
### 16.2 用户端体验(装 Agent 的那一端)
|
||||
|
||||
- **首次 5 分钟**:装好 → 打开 → 登录企业工作区 → 输入配对码绑定控制台 → 逐个平台点「绑定」弹码 → 手机扫码 → 绿色对勾。此后常驻托盘,无感运行。
|
||||
- **日常几乎零操作**:托盘显示在线状态与待发任务数;登录过期时弹窗 + 企微/短信提醒「请重新扫码」;部分平台约每周需扫一次码。
|
||||
- **关键分工**:装机的人是企业 IT 或账号负责人,不是日常运营——运营全程只碰网站。
|
||||
|
||||
### 16.3 管理端体验(Web 控制台)
|
||||
|
||||
- **运营**:素材库上传 → 编辑标题/话题 → 勾选平台与账号 → 排期日历选时间 → 提交。发布中实时进度(上传中/审核中/已发布 + 链接截图);失败显示原因 + 一键重试。
|
||||
- **审核**:待审列表 → 分平台预览 → 通过/驳回(附批注)。
|
||||
- **管理员**:账号台账(绑定状态/最后活跃/代理绑定)、成员与角色、审计日志、通知渠道(企微/钉钉/邮件)。
|
||||
- **单人版**:同一套界面自动隐藏审核流,运营即管理员,零学习成本。
|
||||
|
||||
### 16.4 平台方管理端(我方 SaaS 后台)
|
||||
|
||||
- 租户/计费、Agent 在线监控与版本推送、渠道适配器健康巡检(平台改版监控)、代理池与风控事件台账(托管形态)、客户支持。
|
||||
|
||||
## 17. 工程实施:技术栈、联调环境与排期(Q&A 补充)
|
||||
|
||||
### 17.1 可行性结论
|
||||
|
||||
- **能实现**。先例:dreammis/social-auto-upload(MIT)已跑通国内全平台上传流程,我们只把它从「本地脚本」改为「Agent 服务 + 控制台」;商业产品(易媒/融媒宝)同构。真正的不确定性只在平台风控对抗,不在架构。
|
||||
- 「软件与网站连接很慢,即使本地调用也很慢」是**工程问题**:按 17.2 对照排查 + 17.3 分段计时,通常 5 分钟可定位。
|
||||
|
||||
### 17.2 连接慢的十大嫌疑
|
||||
|
||||
| 症状 | 根因与修复 |
|
||||
|---|---|
|
||||
| 每条消息都有固定 100-500ms 延迟 | 每次请求重新鉴权(bcrypt 重算)→ 改会话 Token/JWT 校验 |
|
||||
| 本机访问 localhost 也慢几秒/偶发超时 | IPv6 陷阱:Node17+ 把 localhost 解析到 ::1,服务端只监听 127.0.0.1 → 显式用 127.0.0.1、--dns-result-order=ipv4first,或双栈监听 |
|
||||
| 开了代理软件后本地调用变慢 | HTTP(S)_PROXY 环境变量劫持 localhost → NO_PROXY 加入 localhost,127.0.0.1 |
|
||||
| 客户端执行发布时心跳/消息全卡住 | 单线程串行:Playwright 等重活阻塞消息循环 → 执行器独立 worker,消息与执行分离 |
|
||||
| 状态更新「慢半拍」 | HTTP 轮询代替长连接 → 改 WebSocket/SSE 推送 |
|
||||
| 每次任务重新建连 | 连接未复用 / TLS 每次握手 → 持久连接 + 会话复用 |
|
||||
| 局域网跨机慢 | 路由器 AP 隔离 / mDNS 主机名解析慢 → 用 IP 直连、关 AP 隔离 |
|
||||
| Windows 上整体偏慢 | Defender 实时扫描客户端进程(Go core/浏览器引擎)→ 加排除项,先用裸 curl 测基线 |
|
||||
|
||||
### 17.3 分段计时定位法(本仓库 `tools/diag-connection.mjs`)
|
||||
|
||||
| 环节 | 局域网正常值 | 偏大指向 |
|
||||
|---|---|---|
|
||||
| TCP connect | <2ms | 防火墙 / IPv6 / 代理 |
|
||||
| WS 握手 open | <10ms | 服务端 WS 框架 / 鉴权 |
|
||||
| 首条消息 RTT | <20ms | 串行往返过多 / 服务端处理 |
|
||||
| 心跳间隔抖动 | 稳定 ±1s | 客户端单线程阻塞 |
|
||||
| 服务端处理耗时 | 与接口总延迟之差=网络 | DB 慢查询 / N+1 / 同步 IO |
|
||||
|
||||
### 17.4 技术栈
|
||||
|
||||
> 2026-08-20 注:本节为早期 JS 栈草案,已被定版取代——技术栈以 docs/tech-stack.md 与 docs/delivery-plan.md 为准(MySQL + 前后端 Go 统一,页面层 React+TDesign)。
|
||||
|
||||
| 模块 | 选型 | 理由 |
|
||||
|---|---|---|
|
||||
| 仓库组织 | pnpm monorepo + Turborepo | apps/web、apps/agent-core、apps/agent-desktop 共享协议包 |
|
||||
| 网站 | Next.js 15 + TS + Tailwind + shadcn/ui | 全栈一体,API Routes 即后端 |
|
||||
| 数据 | PostgreSQL + Drizzle + Redis + BullMQ | 任务队列与排期靠 Redis |
|
||||
| 实时 | Socket.IO(WebSocket) | 心跳/幂等/重连开箱即用 |
|
||||
| 客户端核心 | Node + Playwright + ws + better-sqlite3 | 凭据本地加密存储;执行器独立子进程 |
|
||||
| 客户端壳 | Electron + electron-builder | 托盘 + 扫码窗口;CI 出 Windows 包 |
|
||||
| 素材 | 一期:服务器本地盘(StorageDriver 抽象);二期:MinIO/OSS | 直链下载(带短时效签名 token) |
|
||||
| 测试 | vitest + Playwright + MockUploader 假执行器 | 先打通链路再上真实平台 |
|
||||
|
||||
### 17.5 内网联调环境(Mac=服务端,Windows=客户端)
|
||||
|
||||
完全可行,标准做法:
|
||||
|
||||
1. Mac:web 与 WS 服务绑定 0.0.0.0(`next dev -H 0.0.0.0`);查 Mac 局域网 IP:`ipconfig getifaddr en0`。
|
||||
2. Windows:Agent 配置 `EP_WS=ws://<Mac局域网IP>:<端口>`(用 IP,不用主机名)。
|
||||
3. 防火墙:macOS 放行 node 入站;Windows 出站默认放行。
|
||||
4. 路由器:关闭 AP 隔离(访客网络常见坑)。
|
||||
5. 先用 curl/wscat 从 Windows 测通 Mac 端口,再启动 Agent。
|
||||
6. 公司网络隔离时:ZeroTier / Tailscale 组虚拟局域网。
|
||||
7. 内网阶段用 ws:// 即可;生产才上 wss:// + 证书。
|
||||
|
||||
### 17.6 开发顺序与周期
|
||||
|
||||
| 阶段 | 内容 | 周期 |
|
||||
|---|---|---|
|
||||
| 0 协议定版+骨架 | WS 协议 JSON Schema、假执行器打通全链路、延迟基线 <300ms | 1 周 |
|
||||
| 1 网站功能 | 租户/角色/账号台账/素材/排期/审核/通知 | 2-3 周 |
|
||||
| 2 Agent 真实平台 | 抖音/快手/小红书/B站 uploader(参考 social-auto-upload) | 3-4 周 |
|
||||
| 3 联调测试 | 异常注入:断网/掉线/登录过期/重复任务 | 1-2 周 |
|
||||
| 4 视频号攻坚 | 独立迭代,默认关闭 | 2 周 |
|
||||
| 5 打包分发 | Windows 安装包 + 自动更新 | 1 周 |
|
||||
|
||||
- **单人 10-13 周,双人并行 6-8 周**。
|
||||
- 管理预期:网站 CRUD 与客户端框架确实不长(各 2-3 周),但**时间黑洞是平台 uploader 的适配与风控对抗**,不是网站也不是客户端框架;此后每周约 0.5-1 天持续维护。
|
||||
|
||||
## 18. 协议层考证:局域网慢的根因与实测基线
|
||||
|
||||
### 18.1 实测基线(2026-08 本机实测,Apple Silicon)
|
||||
|
||||
| 环节 | 实测 | 结论 |
|
||||
|---|---|---|
|
||||
| TCP 连接 127.0.0.1 x100 | avg 0.14ms(p99 1.17ms) | 协议开销可忽略 |
|
||||
| TCP 连接 localhost(走 IPv6 解析) | avg 0.49ms(p99 4.33ms) | **IPv6 路径慢 3.5 倍**;若防火墙丢弃 ::1 包将变成秒级 |
|
||||
| WS 握手(新建连接)x30 | avg 0.58ms | 建连便宜,但别每条消息都建 |
|
||||
| 单连接消息 RTT x2000 | **avg 0.07ms(p99 0.14ms)** | 协议真实下界:亚毫秒 |
|
||||
| 每消息新建连接 x30 | avg 0.75ms | 是复用连接的 **10 倍** |
|
||||
| 单次 bcrypt 级哈希(pbkdf2 100k) | 15.9ms | 每次请求重算鉴权 = 每条消息 +16ms(Windows 更慢 50-150ms) |
|
||||
| 串行 10 次往返 | 0.91ms | 本机便宜;跨机时 = 10×RTT + 处理 |
|
||||
|
||||
**考证结论**:协议层(TCP+WS 复用连接)本机只有 0.07ms。局域网环境「慢」必然是下列上层之一叠加:
|
||||
|
||||
### 18.2 局域网环境下慢的候选层(延迟预算表)
|
||||
|
||||
| 层 | 典型增量 | 判断方法 |
|
||||
|---|---|---|
|
||||
| 协议 RTT(复用连接) | <1ms | 基准 |
|
||||
| WiFi 抖动 / 客户端节能 | +1~20ms | 换有线对比 |
|
||||
| 丢包重传(TCP RTO) | **+200ms 起/包** | ping 查丢包率 |
|
||||
| 每请求重算鉴权(bcrypt 级) | +16~150ms | 与免鉴权接口对比压测 |
|
||||
| 每消息新建连接 / TLS 握手 | 本地 +0.5ms,跨机 +数 RTT | 对比复用与不复用 |
|
||||
| IPv6 陷阱 | 本地 +0.35ms,丢包场景秒级 | 对比 127.0.0.1 与 localhost |
|
||||
| HTTP 轮询 | 秒级(等于轮询间隔) | 协议审计 |
|
||||
| Socket.IO 降级为长轮询 | 秒级 | 检查实际 transport |
|
||||
| 服务端阻塞 / 慢 DB / N+1 | 不定 | 全链路埋点 |
|
||||
|
||||
### 18.3 协议层五条铁律(让「慢」结构上不可能发生)
|
||||
|
||||
1. **一条 WSS 连接用到底**:Agent→Server 单连接复用,不每任务建连;开启 TCP_NODELAY。
|
||||
2. **鉴权只做一次**:连接建立时 auth 一次,后续走连接态 + 消息级 HMAC 校验(微秒级),杜绝每请求重算密码哈希。
|
||||
3. **执行与消息分离**:Playwright/上传放独立 worker 进程,绝不阻塞消息循环(心跳卡死是「假慢」主因)。
|
||||
4. **消息合并与节流**:ack 合并、进度回报节流 1 次/s,避免 chatty 串行往返。
|
||||
5. **全链路埋点**:每条消息带 ts、ack 带 ackTs,服务端聚合各段 delay 指标并告警——「慢在哪」永远用数据说话,不再靠猜。
|
||||
|
||||
## 19. 浏览器调用最小化与指纹模拟
|
||||
|
||||
### 19.1 浏览器调用清单(原则:协议能办的事绝不开浏览器)
|
||||
|
||||
| 操作 | 触发浏览器? | 替代方案 |
|
||||
|---|---|---|
|
||||
| 登录取码 | 否 | 平台二维码 API(**仅视频号必须在浏览器内取码**) |
|
||||
| 登录态校验 / 保鲜 | 否 | 轻量 HTTP API 心跳(首页/用户信息接口),空闲时浏览器保持关闭 |
|
||||
| B站上传发布 | 否 | 投稿协议纯 HTTP(参考 biliup / bili_webup) |
|
||||
| 抖音/快手/小红书上传 | **是(仅此一步)** | 冷启动 → 会话复用 → 用完即关 |
|
||||
| 定时发布设置 | 部分可 API | 能用 API 就不开浏览器 |
|
||||
| 视频号全流程 | 是 | 无替代(唯一通道) |
|
||||
| 二次验证 | 视类型 | QR/短信/APP确认推送人工;滑块低风控平台本地尝试 |
|
||||
|
||||
### 19.2 浏览器生命周期
|
||||
|
||||
- **空闲**:浏览器保持关闭,登录态靠协议心跳保鲜 → 心跳与消息零浏览器开销。
|
||||
- **执行**:仅「上传发布」阶段冷启动;同平台同账号任务排队复用同一实例(会话复用)。
|
||||
- **结束**:任务完成即关,释放内存与句柄。
|
||||
|
||||
### 19.3 指纹模拟要点
|
||||
|
||||
- **用客户机器上真实 Chrome**(playwright channel:`chrome`),不用捆绑 Chromium——JA3/UA/指纹与真实浏览器的差异本身就是风控信号。
|
||||
- 每账号独立 user_data_dir:固定 UA/分辨率/时区/语言/字体/hardwareConcurrency/deviceMemory。
|
||||
- playwright-extra + stealth 插件;抹除 navigator.webdriver 与 CDP 自动化特征。
|
||||
- 网络层:固定代理、WebRTC 防泄漏、时区与代理归属地一致。
|
||||
- 行为层:可选「先浏览主页再发布」养号动作,操作间隔随机化(人级)。
|
||||
- **铁律:一档案 = 一平台一账号一IP,永不交叉复用。**
|
||||
|
||||
## 20. 二次验证场景与兜底设计
|
||||
|
||||
### 20.1 统一挑战模型(Challenge)
|
||||
|
||||
- 消息:challenge.push(Agent→Server:类型+凭证+平台+账号)/ challenge.resolve(Server→Agent:用户输入结果)。
|
||||
- 类型:qr(二维码)/ confirm(APP确认)/ sms(验证码)/ captcha(滑块点选)/ pending(人工处理)。
|
||||
- 状态:挑战中 → 已解决 / 超时挂起 / 放弃。
|
||||
|
||||
### 20.2 五类挑战与兜底
|
||||
|
||||
| 类型 | 常见平台 | 检测 | 兜底流程 | 超时处理 |
|
||||
|---|---|---|---|---|
|
||||
| 二维码登录 | 抖音/快手/小红书/B站常态 | 登录页出现 QR | 推控制台+企微,码过期自动刷新 | 5min → 任务挂起 |
|
||||
| APP确认「是本人」 | 抖音/小红书异地登录 | 页面提示确认 | 推送手机确认,Agent 轮询登录态 | 5min → 挂起 |
|
||||
| 短信/邮箱验证码 | 各平台敏感操作 | 输入框出现 | 控制台弹输入框(带尾号提示)→ 回填,错可重输 2 次 | 3min → 挂起 |
|
||||
| 滑块/点选 | 上传前风控 | 验证组件出现 | 默认截图推人工拖拽;低风控平台本地自动尝试,失败转人工 | 3min → 挂起 |
|
||||
| 人机识别失败 | TikTok 等国际平台 | 登录被拒 | 推送人工登录指引 | 转人工队列 |
|
||||
|
||||
### 20.3 原则
|
||||
|
||||
- **账号稳优先**:验证码类挑战人工兜底为主、Agent 硬解为辅,绝不为速度牺牲账号。
|
||||
- 挑战走控制面通道(不与平台流量混在一起)。
|
||||
- 挂起任务可一键重发/放弃;所有挑战过程留审计。
|
||||
|
||||
## 21. 详细端到端逻辑图(重绘)
|
||||
|
||||
### 21.1 全链路时序(用户→端→平台→用户 + 重试/挑战)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant OP as 运营
|
||||
participant S as 服务器
|
||||
participant A as Agent
|
||||
participant PL as 平台
|
||||
participant IT as 账号负责人
|
||||
Note over A,S: 单条WSS长连接复用(实测RTT 0.07ms)
|
||||
OP->>S: 提交任务(素材/平台/排期)
|
||||
S->>S: 校验→审核→入队→排期触发
|
||||
S->>A: task.push(taskId,加密载荷)
|
||||
A-->>S: ack(taskId) 幂等确认
|
||||
A->>A: 选账号→注入代理→协议级登录态校验(不启浏览器)
|
||||
alt 登录态正常
|
||||
A->>PL: 冷启动浏览器→上传素材→提交发布
|
||||
PL-->>A: 发布回执+截图
|
||||
A->>S: task.result(success)
|
||||
S-->>OP: 实时状态+链接
|
||||
else 平台触发二次验证
|
||||
PL-->>A: 挑战(扫码/短信/APP确认/滑块)
|
||||
A->>S: challenge.push(类型,凭证)
|
||||
S-->>IT: 控制台+企微提醒
|
||||
IT->>S: 扫码或输入验证码
|
||||
S->>A: challenge.resolve(结果)
|
||||
A->>PL: 继续发布流程
|
||||
else 网络类失败
|
||||
A->>A: 指数退避 1m/5m/15m/1h
|
||||
A->>PL: 自动重试上传
|
||||
else 登录态失效
|
||||
A->>S: challenge.push(qr)
|
||||
S-->>IT: 推送扫码提醒
|
||||
IT->>S: 扫码成功
|
||||
S->>A: challenge.resolve
|
||||
A->>PL: 恢复执行
|
||||
else 风控拦截
|
||||
A->>S: task.result(fail,原因)
|
||||
S->>S: 账号冷却+人工队列
|
||||
S-->>OP: 告警通知
|
||||
end
|
||||
```
|
||||
|
||||
### 21.2 重试与降级状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 队列中
|
||||
队列中 --> 执行中: Agent认领
|
||||
执行中 --> 已发布: 平台回执成功
|
||||
执行中 --> 网络重试: 网络/超时错误
|
||||
网络重试 --> 执行中: 退避1m/5m/15m/1h
|
||||
网络重试 --> 失败终态: 超上限N次
|
||||
执行中 --> 挑战中: 需扫码/验证码
|
||||
挑战中 --> 执行中: 用户完成挑战
|
||||
挑战中 --> 挂起: 挑战超时
|
||||
挂起 --> 执行中: 用户稍后处理
|
||||
执行中 --> 账号冷却: 风控拦截
|
||||
账号冷却 --> 执行中: 冷却结束+人工确认
|
||||
账号冷却 --> 失败终态: 人工放弃
|
||||
失败终态 --> 队列中: 人工一键重发
|
||||
已发布 --> [*]
|
||||
失败终态 --> [*]
|
||||
```
|
||||
|
||||
### 21.3 二次验证挑战兜底流程
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
TR["平台触发二次验证"] --> DET{"Agent检测挑战类型"}
|
||||
DET -->|二维码登录| QR["取码→推控制台+企微<br/>码过期自动刷新"]
|
||||
DET -->|APP确认| CF["推送手机确认提醒<br/>Agent轮询登录态"]
|
||||
DET -->|短信/邮箱| SMS["控制台弹输入框<br/>用户输入→回传回填<br/>错可重输2次"]
|
||||
DET -->|滑块/点选| CAP["默认截图推人工<br/>低风控平台本地尝试"]
|
||||
QR --> OK{"完成?"}
|
||||
CF --> OK
|
||||
SMS --> OK
|
||||
CAP --> OK
|
||||
OK -->|是| CONT["Agent继续发布流程"]
|
||||
OK -->|否/超时| PEND["任务挂起→人工队列<br/>一键重发/放弃"]
|
||||
PEND --> CONT
|
||||
```
|
||||
|
||||
## 22. 登录链路优化:把 10 秒级登录压到 2 秒级
|
||||
|
||||
### 22.1 拆解:登录到底慢在哪
|
||||
|
||||
朴素实现(浏览器全流程)逐环节耗时:启动浏览器 → 加载登录页 → 找/截二维码 → 传图 → 等扫码 → 轮询检测 → 换取登录态。其中「浏览器 + 页面 + 轮询」是机器耗时大头,人的扫码时间(3-10s)无法压缩但可以重叠。
|
||||
|
||||
| 环节 | 朴素实现 | 优化后 | 手段 |
|
||||
|---|---|---|---|
|
||||
| 启动浏览器 | 1-3s(冷启动) | **0s** | API 取码免浏览器(B站已验证,biliup 直接出 qrcode.png);视频号用常驻温实例 |
|
||||
| 页面加载渲染 | 2-5s | ~1s(仅视频号) | 直达登录页 + 路由拦截广告/统计/字体资源 |
|
||||
| 抽取二维码 | 0.5-2s(截图/找元素) | **<0.1s** | DOM 取二维码图片 URL 或 API 直接返回 token(开源项目 utils/login_qrcode 即此模式) |
|
||||
| 码跨层传递 | 0.2-1s(传截图) | **<50ms** | 只传 token/图片URL,控制台本地重渲染——更快且**更清晰**(截图压缩会降低扫码成功率) |
|
||||
| 检测扫码成功 | 轮询 1-3s/次 | 0.3-0.5s | 短轮询 300-500ms;或拦截页面自身状态 XHR 即时检测 |
|
||||
| 换取登录态 | 1-2s(页面跳转等待) | 0.2-0.5s | API 换取 cookie/token |
|
||||
| **机器合计** | **约 10-16s** | **约 1-2s**(视频号场景 3-4s) | 用户扫码 3-10s 不变,但成为唯一等待 |
|
||||
|
||||
### 22.2 五条铁律
|
||||
|
||||
1. **让登录少发生**:cookie 保鲜 + refresh 接口 + 过期预测(登录是低频事件,最好的登录优化是「不登录」)。
|
||||
2. **能 API 不浏览器**:B站纯 API 已验证;抖音/小红书/快手优先尝试其 Web 登录 API,失败再回退浏览器。
|
||||
3. **只传 token 不传截图**:所有层传递二维码内容/图片 URL,渲染端本地重绘。
|
||||
4. **检测即时化**:300-500ms 短轮询 + 页面网络钩子双通道。
|
||||
5. **等待重叠**:多平台并行取码、预计过期前预热、控制台+企微+托盘同时推送。
|
||||
|
||||
### 22.3 视频号例外(唯一必须浏览器的登录)
|
||||
|
||||
- 温浏览器常驻:登录需求出现前预启动,不冷启动。
|
||||
- 直达登录页 + 资源拦截:只加载必要资源,页面 5s→1s。
|
||||
- 取码:DOM 抓二维码图片 URL,不截图。
|
||||
- 检测:注入 MutationObserver 监听登录态跳转 + 拦截页面状态 XHR。
|
||||
- 提示:抖音/快手现可选用 patchright(补丁版 Playwright,开源项目已采用),指纹绕过更稳。
|
||||
|
||||
## 23. 系统设计文档(网页端 + 客户端 App)
|
||||
|
||||
完整系统设计(两端功能清单、连接方案评估、四层鉴权、REST/WS 协议、配对与登录流程、边界问题)见独立文档:docs/system-design.md。
|
||||
|
||||
## 附录:参考链接
|
||||
|
||||
- https://github.com/dreammis/social-auto-upload
|
||||
- https://github.com/DevilJie/social-auto-upload-web-ui
|
||||
- https://github.com/dorisoy/ShortVideo.AutoPublisher
|
||||
- https://github.com/gitroomhq/postiz-app
|
||||
- https://github.com/inovector/mixpost
|
||||
- https://github.com/biliup/biliup
|
||||
- https://github.com/SocialSisterYi/bilibili-API-collect (已归档)
|
||||
- https://github.com/NanmiCoder/MediaCrawler
|
||||
- https://github.com/ReaJason/xhs
|
||||
- https://github.com/tweepy/tweepy
|
||||
- https://github.com/WhiskeySockets/Baileys
|
||||
- https://github.com/davidteather/TikTok-Api
|
||||
- https://github.com/subzeroid/instagrapi
|
||||
- 官方文档:developers.facebook.com(IG)、developers.google.com/youtube、developers.tiktok.com、developer.x.com、open.kuaishou.com、developer.open-douyin.com、open.xiaohongshu.com、channels.weixin.qq.com
|
||||
任何平台 adapter 未通过独立登录、上传、失败、重试和回执测试,都只能显示“实验性/未启用”。
|
||||
|
||||
+53
-334
@@ -1,334 +1,53 @@
|
||||
# EveryPublish 页面线框图全集(ASCII)
|
||||
|
||||
> 2026-08-20 · 与 docs/delivery-plan.md 日计划对应(D5-D7 页面开发按本图实现)。标注行=推荐使用的 TDesign 组件与 starter 模板。
|
||||
|
||||
## 0. 通用布局骨架(除登录页外所有网页端页面共用)
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| Logo EveryPublish 🔔通知(角标) 头像 ▾ |
|
||||
+-------------+----------------------------------------------------+
|
||||
| 侧边菜单 | 面包屑: 首页 / 发布管理 / 任务列表 |
|
||||
| 工作台 | +------------------------------------------------+|
|
||||
| 发布管理 ▾ | | ||
|
||||
| 任务列表 | | 页面内容区(各页不同) ||
|
||||
| 新建发布 | | ||
|
||||
| 排期日历 | | ||
|
||||
| 素材库 | | ||
|
||||
| 账号管理 | | ||
|
||||
| 审核中心 | | ||
|
||||
| 通知与挑战 | | ||
|
||||
| 成员与权限 | | ||
|
||||
| 设置 ▾ | +------------------------------------------------+|
|
||||
| 审计日志 | |
|
||||
+-------------+----------------------------------------------------+
|
||||
|
||||
组件: Layout + Menu(侧边) + Breadcrumb + Dropdown(头像) + Badge(通知角标)
|
||||
说明: admin 角色在头像下拉出现「平台管理」入口,跳 /admin 域(同骨架,菜单替换)。
|
||||
|
||||
## 1. 登录页(isFullPage 全屏,无侧边菜单)
|
||||
|
||||
+-----------------------------------------------------------+
|
||||
| |
|
||||
| [Logo EveryPublish] |
|
||||
| +---------------------------------+ |
|
||||
| | 账号 [___________________] | |
|
||||
| | 密码 [___________________] | |
|
||||
| | [x] 记住我 忘记密码? | |
|
||||
| | [ 登 录 ] | |
|
||||
| +---------------------------------+ |
|
||||
| |
|
||||
+-----------------------------------------------------------+
|
||||
|
||||
组件: Form + Input + Checkbox + Button; 模板: pages/Login 改造(去第三方登录)
|
||||
验收: 登录成功进入工作台; 失败提示; refresh 自动续期。
|
||||
|
||||
## 2. 仪表盘(工作台)
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| [今日发布:12] [成功:10] [失败:2] [待处理挑战:1] |
|
||||
| Agent 在线状态: ● 在线(1台) 最后心跳 3s 前 [查看设备] |
|
||||
| 最近任务: |
|
||||
| | 素材 | 标题 | 平台 | 状态 | 时间 | 操作 | |
|
||||
| | 图 | xxx | 抖音 | 已发布 | 10:32 | 查看 | |
|
||||
| | 图 | yyy | 快手 | 失败 | 10:30 | 重试 | |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Card + Statistic(可用 echarts 现成卡片或纯文本) + Table + Tag + Badge
|
||||
模板: pages/Dashboard/Base 改造
|
||||
验收: 数字与后端统计一致; Agent 状态实时。
|
||||
|
||||
## 3. 任务列表(发布管理)
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| 筛选: [状态▾全部] [平台▾全部] [日期范围] [搜索标题____] [查询][重置] |
|
||||
| [ + 新建发布 ] |
|
||||
| | 素材 | 标题 | 平台 | 账号 | 排期时间 | 状态 | 操作 | |
|
||||
| | 图 | xxx | 抖音 | 官号 | 10:30 | 已发布 | 查看/重试 | |
|
||||
| | 图 | yyy | 小红书 | 官号 | 明天09:00| 待审核 | 查看/取消 | |
|
||||
| ... |
|
||||
| < 1 2 3 ... 10 > 共 123 条 |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Table + Select + DateRangePicker + Input + Button + Tag + Pagination + Popconfirm(取消)
|
||||
模板: pages/List/Base
|
||||
验收: 筛选/分页正确; 重试与取消二次确认; 状态实时刷新。
|
||||
|
||||
## 4. 新建发布(分步向导)
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| 步骤条: ① 选择素材 → ② 平台与账号 → ③ 编辑内容 → ④ 定时与提交 |
|
||||
| |
|
||||
| 步骤①: 素材卡片网格(点选) [从素材库选择][上传] |
|
||||
| +------+ +------+ +------+ |
|
||||
| | 缩略图 | | 缩略图 | | 缩略图 | (选中高亮✓) |
|
||||
| | 名称 | | 名称 | | 名称 | |
|
||||
| +------+ +------+ +------+ |
|
||||
| 步骤②: [x]抖音 [x]快手 [x]小红书 [x]B站 每平台: [账号▾官方号] |
|
||||
| 步骤③: 标题[________________] 话题[#____] 描述[多行textarea] |
|
||||
| 右侧: 各平台发布预览卡片 |
|
||||
| 步骤④: (•)立即发布 ( )定时 [日期时间选择器] [提交审核] |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Steps + Grid(Card选择) + Upload + Checkbox + Select + Input + Textarea + Radio + DatePicker
|
||||
模板: pages/Form/Step
|
||||
验收: 必填校验; 提交后进入待审核/直接排期(视配置)。
|
||||
|
||||
## 5. 排期日历
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| [◀ 2026年8月 ▶] [今天] 图例: ●待发 ●已发 ●失败 |
|
||||
| 一 二 三 四 五 六 日 |
|
||||
| 1 2 3 4 5 6 |
|
||||
| 7 8 9 [10●2] 11 12 13 (右侧/下方列出当日任务) |
|
||||
| 14 15 16 17 18 19 20 | 09:00 xxx 抖音 待审核 | |
|
||||
| ... | 14:00 yyy B站 已发布 | |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Calendar(TDesign 1.x, 若无则以卡片网格自绘) + List + Tag
|
||||
模板: 新建页面
|
||||
验收: 月视图任务点正确; 点击日期过滤当日任务。
|
||||
|
||||
## 6. 任务详情(Drawer 侧滑)
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| X 任务详情 #1024 [重试][关闭] |
|
||||
| 基本信息: 标题 / 平台 / 账号 / 创建人 / 排期时间 |
|
||||
| 事件时间线: |
|
||||
| ● 10:29:30 任务下发 Agent |
|
||||
| ● 10:30:01 开始上传素材(进度 100%) |
|
||||
| ● 10:32:10 平台审核通过, 已发布 |
|
||||
| ● 10:32:11 回执截图 [缩略图][查看大图] 链接: https://... |
|
||||
| 失败场景: 失败原因红色标注 + [重试] |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Drawer + Descriptions + Timeline + ImageViewer + Tag + Button
|
||||
模板: pages/Detail/Base 改造
|
||||
验收: 事件流完整; 截图可放大; 失败原因可见。
|
||||
|
||||
## 7. 素材库
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| [上传素材] 分组:[全部▾] 搜索:[____] 视图:[卡片][列表] |
|
||||
| +--------+ +--------+ +--------+ |
|
||||
| | 缩略图 | | 缩略图 | | 缩略图 | |
|
||||
| | 名称.mp4| | 名称.jpg| | ... | |
|
||||
| | 128MB | | 2.1MB | | | |
|
||||
| | [预览][删除] | [预览][删除] | | | |
|
||||
| +--------+ +--------+ +--------+ |
|
||||
| 上传弹窗: [拖拽文件到此处] 进度条: ▓▓▓▓▓▓░░ 80% |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Upload(dragger) + Progress + Grid + ImageViewer + Select + Input + Popconfirm
|
||||
模板: pages/List/Card
|
||||
验收: 大文件上传进度; sha256 入库; 预览/删除正常。
|
||||
|
||||
## 8. 账号台账 + 绑定挑战弹窗
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| [ + 绑定账号 ] |
|
||||
| | 平台 | 账号昵称 | 状态 | 健康度 | 代理 | 最后活跃 | 操作 | |
|
||||
| | 抖音 | xxx官方 | 已绑定 | ●良好 | 无 | 2小时前 | 重扫/解绑 | |
|
||||
| | 快手 | yyy | 登录过期 | ●差 | 无 | 5天前 | 重新扫码 | |
|
||||
| +--------------------------------------------------------------+ |
|
||||
| 绑定弹窗: 选择平台[▾] → 二维码展示区 → 状态: 等待扫码... |
|
||||
| [刷新二维码] (二维码由 token 本地渲染, 非截图) |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Table + Dialog + Select + Button + Tag + Badge + qrcode.react(唯一新增依赖)
|
||||
模板: pages/List/Base + Dialog
|
||||
验收: 发起绑定→Agent 取码→弹窗显示→手机扫码→台账变绿。
|
||||
|
||||
## 9. 审核中心
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| 待审列表: |
|
||||
| | 素材 | 标题 | 平台数 | 提交人 | 提交时间 | 操作 | |
|
||||
| | 图 | xxx | 3 | 运营A | 10:00 | [预览][通过][驳回] | |
|
||||
| 预览 Drawer: 左侧各平台 Tab 预览效果; 右侧批注框 |
|
||||
| [通过] 或 [驳回 + 批注原因______] |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Table + Drawer + Tabs + Textarea + Button
|
||||
模板: pages/List/Base + Drawer
|
||||
验收: 通过→排期; 驳回→回草稿+通知提交人。
|
||||
|
||||
## 10. 挑战中心(通知与挑战)
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| 待处理: [类型] 二维码登录 [平台]抖音 [账号]xxx [时间]2分钟前 [处理] |
|
||||
| 已完成: 短信验证码 小红书 ... 1小时前 ✓已解决 |
|
||||
| QR 弹窗: 二维码展示(自动刷新) 提示: 请用抖音APP扫码 |
|
||||
| 验证码弹窗: 已发送至手机尾号 1234 [输入框______] [提交] (可重输2次) |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: List/Table + Dialog + Input + Button + Tag + qrcode.react
|
||||
验收: 五类挑战均可人工完成; 超时挂起可一键重发。
|
||||
|
||||
## 11. 通知
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| [全部已读] |
|
||||
| ● 10:32 任务#1024 抖音 发布失败: 风控拦截 [查看] 已读 |
|
||||
| ○ 10:00 抖音官方号 登录即将过期, 请重新扫码 [去扫码] 未读 |
|
||||
| ○ 09:30 成员 运营B 提交了待审核任务 [去审核] 未读 |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: List + Badge + Button
|
||||
验收: 事件产生即推送; 已读状态持久。
|
||||
|
||||
## 12. 成员与权限
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| [ + 邀请成员 ] |
|
||||
| | 姓名 | 手机 | 角色 | 状态 | 加入时间 | 操作 | |
|
||||
| | 张A | 138.. | 管理员 | 正常 | 08-01 | 改角色/停用 | |
|
||||
| | 李B | 139.. | 运营 | 正常 | 08-10 | 改角色/停用 | |
|
||||
| 邀请弹窗: 角色[▾运营] → 生成邀请链接 [复制] |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Table + Dialog + Select + Button + Message(复制成功)
|
||||
模板: pages/User
|
||||
验收: 角色权限隔离生效; 停用成员无法登录。
|
||||
|
||||
## 13. 设置(四个子页)
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| Tab: [工作区资料] [安全] [通知渠道] [Agent设备] |
|
||||
| 资料: 名称[____] Logo[上传] 简介[____] [保存] |
|
||||
| 安全: 2FA 开关 [○关(默认)] 登录设备: |Win Chrome| 杭州 | [吊销] | |
|
||||
| 通知渠道: 企微[○开 配置] 钉钉[○关] 邮件[○关] |
|
||||
| Agent设备: | 设备名 | 状态●在线 | 版本 | 最后心跳 | [吊销][指令] | |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Tabs + Form + Switch + Upload + Table + Popconfirm
|
||||
验收: 2FA 默认关; 吊销设备后客户端失联。
|
||||
|
||||
## 14. 审计日志
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| 筛选: [操作人____] [动作▾] [时间范围] [查询] |
|
||||
| | 时间 | 操作人 | 动作 | 对象 | 详情 | |
|
||||
| | 10:30| 张A | 通过 | 任务#1024 | 审核通过 | |
|
||||
| | 10:00| 李B | 提交 | 任务#1024 | 提交待审核 | |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Table + Select + DateRangePicker + Input
|
||||
验收: 全操作可查。
|
||||
|
||||
## 15. admin · 租户管理
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| | 租户名称 | 套餐 | 成员数 | Agent数 | 状态 | 创建时间 | 操作 | |
|
||||
| | 某某科技 | 试用 | 5 | 1 | 正常 | 08-01 | [停用]| |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Table + Popconfirm
|
||||
验收: 停用后租户不可登录。
|
||||
|
||||
## 16. admin · Agent 总览
|
||||
|
||||
+------------------------------------------------------------------+
|
||||
| | 租户 | 设备名 | 版本 | 在线 | 最后心跳 | 操作 | |
|
||||
| | 某某 | PC-01 | 0.1 | ●在线 | 3s前 | [吊销][远程指令] | |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
组件: Table + Badge + Popconfirm
|
||||
验收: 在线状态实时; 吊销即时生效。
|
||||
|
||||
## 17. 客户端 · 配对引导(首次启动)
|
||||
|
||||
+----------------------------------------------------------+
|
||||
| EveryPublish 发布助手 [—][□][X] |
|
||||
| |
|
||||
| 第一步: 在网页端 设置→Agent设备 获取配对码 |
|
||||
| 配对码: [______] |
|
||||
| [ 绑 定 ] |
|
||||
| 状态: 绑定成功 ✓ / 失败原因(红字) |
|
||||
+----------------------------------------------------------+
|
||||
|
||||
组件: WinUI 原生 (StackPanel/TextBox/Button/InfoBar)
|
||||
验收: 5 分钟完成配对; 失败有明确原因。
|
||||
|
||||
## 18. 客户端 · 托盘菜单
|
||||
|
||||
+--------------------+
|
||||
| ● 已连接(延迟 12ms)|
|
||||
| 待发任务: 3 |
|
||||
| 打开面板 |
|
||||
| 重新扫码 |
|
||||
| 查看日志 |
|
||||
| 退出 |
|
||||
+--------------------+
|
||||
|
||||
组件: H.NotifyIcon 托盘菜单(Win32 interop)
|
||||
验收: 最小化到托盘; 状态随连接实时变化。
|
||||
|
||||
## 19. 客户端 · 主面板
|
||||
|
||||
+----------------------------------------------------------+
|
||||
| 服务器: ● 已连接 延迟 12ms 版本 0.1.0 |
|
||||
| 账号: |
|
||||
| | 平台 | 状态 | 健康度 | 操作 | |
|
||||
| | 抖音 | 已绑定 | ●良好 | [重新扫码] | |
|
||||
| | 快手 | 过期 | ●差 | [扫码修复] | |
|
||||
| 待发任务: 3 今日已发: 5 |
|
||||
| [打开日志] [设置] |
|
||||
+----------------------------------------------------------+
|
||||
|
||||
组件: WinUI 原生 (ListView/GridView/ProgressRing)
|
||||
验收: 状态与服务器一致; 扫码入口直达挑战窗口。
|
||||
|
||||
## 20. 客户端 · 扫码挑战窗口
|
||||
|
||||
+----------------------------------------------------------+
|
||||
| 请使用 抖音APP 扫描二维码登录 |
|
||||
| ▓▓▓▓▓▓▓▓ (二维码, token 本地渲染) ▓▓▓▓▓▓▓▓ |
|
||||
| 状态: 等待扫码... → 已扫码,请在手机确认 → 登录成功 |
|
||||
| [刷新二维码] [取消] |
|
||||
+----------------------------------------------------------+
|
||||
|
||||
组件: WinUI + QRCoder(C#)
|
||||
验收: 码过期自动刷新; 状态实时。
|
||||
|
||||
## 21. 客户端 · 验证码输入窗口
|
||||
|
||||
+----------------------------------------------------------+
|
||||
| 小红书 需要短信验证码 |
|
||||
| 已发送至 138****1234 |
|
||||
| [______] [提交] (剩余重试 2 次) |
|
||||
+----------------------------------------------------------+
|
||||
|
||||
组件: WinUI TextBox/Button
|
||||
验收: 回传服务器→Agent 回填; 错误可重输。
|
||||
|
||||
## 22. 客户端 · 设置(代理与日志)
|
||||
|
||||
+----------------------------------------------------------+
|
||||
| Tab: [代理] [日志] [关于] |
|
||||
| 代理: 每账号代理配置: 抖音官号 → [socks5://____] [测试] |
|
||||
| 日志: 文本区(滚动) + [导出] [清空] |
|
||||
| 关于: 版本 0.1.0 [检查更新] |
|
||||
+----------------------------------------------------------+
|
||||
|
||||
组件: WinUI Pivot + TextBox + Button
|
||||
验收: 代理测试连通性; 日志可导出。
|
||||
# EveryPublish Web-only 页面线框(ASCII)
|
||||
|
||||
## 通用布局
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ EveryPublish 实时连接 ● 通知 用户 ▾ │
|
||||
├───────────────┬────────────────────────────────────────────┤
|
||||
│ 工作台 │ 面包屑 / 页面标题 │
|
||||
│ 发布管理 │ │
|
||||
│ 素材库 │ 页面内容 │
|
||||
│ 账号管理 │ │
|
||||
│ 审核中心 │ │
|
||||
│ 挑战中心 │ │
|
||||
│ 通知 │ │
|
||||
│ 成员与设置 │ │
|
||||
│ 审计日志 │ │
|
||||
└───────────────┴────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 发布中心
|
||||
|
||||
```text
|
||||
┌─新建发布────────────────────────────────────────────────────┐
|
||||
│ ①素材 [选择/上传] ②账号 [多选] ③内容 [标题/正文/标签] │
|
||||
│ ④时间 [立即/定时] ⑤预览 [平台卡片] │
|
||||
│ [保存草稿] [提交审核] │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 账号登录/挑战
|
||||
|
||||
```text
|
||||
┌─账号管理────────────────────────────────────────────────────┐
|
||||
│ 平台 | 账号名 | 状态 | 健康 | 最后检查 | [登录] [检查] │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ 登录弹窗:二维码图片 / 刷新倒计时 / “等待手机扫码” │
|
||||
│ 结果:已登录 / 已过期 / 失败原因 / [重新登录] │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 任务中心
|
||||
|
||||
```text
|
||||
┌─任务筛选 [状态] [平台] [日期] [搜索]─────────────────────────┐
|
||||
│ 标题 | 平台 | 账号 | 状态 | 进度 | 时间 | 操作 │
|
||||
│ demo | 抖音 | 本地号 | 运行中 62% | 12:10 | 取消/详情 │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ 详情:状态时间线、adapter 日志、失败原因、发布链接、回执 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
客户端页面和 Agent 设备页面不在 V1 菜单。
|
||||
|
||||
+24
-162
@@ -1,173 +1,35 @@
|
||||
# EveryPublish PRD 产品需求文档
|
||||
# EveryPublish V1 PRD(网页端优先)
|
||||
|
||||
> 版本 v1.0 · 2026-08-20 · 状态:待评审 · 技术栈以 docs/tech-stack.md 为准
|
||||
## 用户目标
|
||||
|
||||
## 1. 文档信息与文档体系
|
||||
单机部署后,用户只打开浏览器即可管理多平台内容发布,不需要安装或配对客户端。用户可以添加平台账号、上传素材、创建任务、审核、处理挑战、查看结果和审计。
|
||||
|
||||
### 1.1 版本记录
|
||||
## 必须完成
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| v1.0 | 2026-08-20 | 初稿,覆盖一期 MVP 全部功能需求 |
|
||||
- 邮箱密码登录、刷新、退出、工作区隔离。
|
||||
- 视频/图片素材上传、预览、去重、删除。
|
||||
- 平台账号登记、网页登录/扫码挑战、Cookie 状态和重新登录;当前真实 adapter 以 B 站为首个验收平台,其他平台必须独立接入后再开放。
|
||||
- 多账号发布向导、草稿、审核、定时、任务队列、重试、取消;未接入真实平台时只允许显式 mock 联调。
|
||||
- 挑战中心(QR、验证码、APP 确认、过期、重发)。
|
||||
- 通知、审计、Browser WS 实时更新和 REST 兜底。
|
||||
- mock executor 全流程自动化验收。
|
||||
|
||||
### 1.2 文档体系位置(PRD 与其他文档的关系)
|
||||
## 明确不做
|
||||
|
||||
| 层级 | 文档 | 回答的问题 | 本项目对应 |
|
||||
|---|---|---|---|
|
||||
| 商业/市场 | MRD / BRD | 为什么做、市场与商业 | 并入本文档 §2,暂不单列 |
|
||||
| **产品需求** | **PRD(本文档)** | **做什么、给谁用、什么体验** | docs/prd.md |
|
||||
| 技术需求/规格 | TRD / SRS | 怎么做、接口与协议 | docs/system-design.md + docs/tech-stack.md |
|
||||
| 流程与进度 | 逻辑图 / 排期 | 流程怎么走、何时交付 | docs/logic-diagrams.md + docs/delivery-plan.md |
|
||||
- Windows/macOS 客户端、安装包、托盘、设备配对和 Agent 管理。
|
||||
- 未经用户扫码的真实平台账号登录。
|
||||
- 用 fake URL 冒充真实平台发布成功。
|
||||
- 一次性把 19 个平台注册为“已支持”但没有独立 adapter 测试。
|
||||
|
||||
> 注:SPDC / MDC 非行业标准缩写。若指「技术方案类」(SPDC≈方案/规格)与「模块开发上下文」(MDC≈模块设计),在本文档体系中分别由 system-design/tech-stack 与 logic-diagrams 承接;待确认团队定义后对齐(见 §12 开放问题)。
|
||||
## 角色
|
||||
|
||||
## 2. 产品概述
|
||||
|
||||
### 2.1 背景与问题(痛点)
|
||||
|
||||
- 企业通常只有一个官方账号、多名运营协作;现有开源发布工具均为「单人本地扫码发布」,多人共用账号混乱、无审核、无审计。
|
||||
- 国内平台(抖音/快手/小红书/视频号)无公开内容发布 API,只能浏览器自动化;IP 风控要求国内账号用国内 IP、国外账号用国外 IP。
|
||||
- 客户诉求:越简单越好、发布越快、账号越稳、覆盖全部平台、不违反平台官方规则。
|
||||
|
||||
### 2.2 产品定位(一句话)
|
||||
|
||||
**企业多平台官方账号的发布中台:网页控制台协作、审核、排期,客户端单点持号、自动发布。**
|
||||
|
||||
### 2.3 目标用户
|
||||
|
||||
| 用户 | 场景 | 优先级 |
|
||||
|---|---|---|
|
||||
| 企业新媒体运营团队 | 单官方账号 + 多运营协作,需审核与审计 | 主 |
|
||||
| 代运营 / MCN | 多客户多账号,需隔离与批量 | 次 |
|
||||
| 单人创作者 | 自用简化版(隐藏审核流) | 次 |
|
||||
|
||||
### 2.4 核心价值主张
|
||||
|
||||
- **简单**:装客户端 → 扫码绑定 → 网页排期,三步上手,零配置。
|
||||
- **快**:任务下发 <300ms,多平台并行发布,失败一键重试。
|
||||
- **稳**:凭据单机加密持有,设备+IP+行为三固定,账号风控最小化。
|
||||
- **覆盖**:一期国内四平台(抖音/快手/小红书/B站),二期视频号+国际平台官方 API 通道。
|
||||
- **合规**:官方 API 优先;自动化通道分级开通、知情告知;明确不做矩阵养号等灰产。
|
||||
|
||||
### 2.5 职责边界(网站 vs 客户端,两类账号)
|
||||
|
||||
**一句话**:网站 = 任务分配中枢 + 状态看板;客户端 = 持号 + 登录鉴权 + 发布执行。发布流量永不经过网站服务器。
|
||||
|
||||
| 账号类型 | 谁负责登录鉴权 | 网站/服务器的角色 |
|
||||
|---|---|---|
|
||||
| 控制台用户账号(运营/管理员登录后台,如 demo@everypublish.io) | 网站(注册/登录/JWT) | 管理面板本体,分配任务的人 |
|
||||
| 平台账号(抖音/快手/小红书/B站/视频号/海外,即发布目标账号) | 客户端 Agent(扫码/验证/本机保存凭据) | 只下发任务 + 接收状态,**零凭据** |
|
||||
|
||||
- 网站对平台账号**不负责登录鉴权**,只做两件事:**下发/分配发布任务**(<1KB 元数据经 WSS 推给客户端)与**接收执行状态回传**。
|
||||
- 平台账号的登录、扫码验证、凭据保存、实际发布全部在客户端(客户自己的电脑)完成;服务器账号表只存 `平台 / 备注 / 状态 / 绑定设备` 等元数据,**不含任何 cookie / 密码 / token**。
|
||||
- 绑定流程示例:网站生成「挑战」记录并推给客户端 → 客户端拉起扫码 → 客户端回传「已解决」→ 网站把账号标记为「已绑定」。网站全程不接触、不存储平台凭据。
|
||||
|
||||
## 3. 范围
|
||||
|
||||
### 3.1 一期(MVP)
|
||||
|
||||
- 网页端全部功能:用户端(客户工作台)+ 平台端(admin 角色)。
|
||||
- 客户端 Windows 版:安装配对、托盘常驻、扫码挑战、自动更新。
|
||||
- 平台:抖音 / 快手 / 小红书 / B站(视频号二期)。
|
||||
- 素材:服务器本地盘 + 签名直链下载。
|
||||
- 2FA:能力预留,默认关闭。
|
||||
|
||||
### 3.2 二期(不在本期验收)
|
||||
|
||||
- 视频号、官方 API 通道(X/IG/YouTube/TikTok)、OSS 双桶与断点续传、托管 Agent、代理采购入口、数据报表增强、Windows 服务化(支持客户 VPS 形态)。
|
||||
|
||||
### 3.3 明确不做(红线)
|
||||
|
||||
- 矩阵养号、批量注册、搬运去重绕过、刷量、私信轰炸;违反平台规则的功能一律不做。
|
||||
|
||||
## 4. 用户角色与权限
|
||||
|
||||
| 角色 | 端 | 核心权限 |
|
||||
|---|---|---|
|
||||
| 管理员 | 网页端 | 成员/角色、账号台账、绑定/解绑、Agent 设备、审计、设置 |
|
||||
| 审核员 | 网页端 | 审核通过/驳回、查看素材与任务 |
|
||||
| 运营 | 网页端 | 素材上传、创建/排期/重试任务、查看结果 |
|
||||
| 账号负责人(装机人) | 客户端 | 安装配对、扫码绑定、处理登录过期 |
|
||||
| 平台 admin | 网页端 | 租户管理、Agent 总览、渠道状态(后置) |
|
||||
|
||||
## 5. 功能需求
|
||||
|
||||
> 优先级:P0 = 一期必须;P1 = 一期尽量;P2 = 二期。
|
||||
|
||||
### 5.1 网页端 · 用户端(客户工作台)
|
||||
|
||||
| 模块 | 功能 | 优先级 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| 认证与账户 | 账号密码登录、refresh 轮换、登出、登录设备管理;2FA 默认关 | P0 | 登录成功/登出后会话失效;refresh 自动续期 |
|
||||
| 工作区与成员 | 成员邀请、角色分配、停用 | P0 | 角色权限隔离正确 |
|
||||
| 账号台账 | 平台账号列表(绑定状态/健康度/代理/最后活跃)、发起绑定(生成扫码挑战)、重新扫码、解绑 | P0 | 绑定→扫码→台账变绿全流程 |
|
||||
| 素材库 | 上传(multipart+进度)、列表/分组/标签、预览、删除 | P0 | 大文件上传成功;sha256 校验入库 |
|
||||
| 发布任务 | 新建(选素材→平台账号→标题话题→定时/立即→提交审核)、任务列表(状态筛选)、排期日历、任务详情(事件时间线+回执截图+失败原因)、重试、取消 | P0 | 任务全生命周期流转正确 |
|
||||
| 审核流 | 待审队列、分平台预览、通过/驳回+批注、可配置跳过 | P0 | 驳回回退草稿;跳过时直接排期 |
|
||||
| 挑战中心 | QR 从 token 本地渲染、验证码输入、APP确认指引、超时挂起、一键重发 | P0 | 五类挑战均可人工完成 |
|
||||
| 通知 | 站内通知(挑战/失败/登录过期)、已读 | P0 | 事件产生即推送 |
|
||||
| 审计 | 全操作日志查询 | P0 | 谁在何时做了什么可查 |
|
||||
| 设置 | 工作区资料、2FA 开关(默认关)、通知渠道(企微/钉钉/邮件)、Agent 设备列表与吊销 | P0/P1 | 设备吊销后客户端失联 |
|
||||
|
||||
### 5.2 网页端 · 平台端(admin)
|
||||
|
||||
| 模块 | 功能 | 优先级 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| 租户管理 | 客户列表、状态、配额(简单)、停用 | P0 | 停用后租户不可登录 |
|
||||
| Agent 总览 | 设备列表:在线/版本/强制吊销/远程指令 | P0 | 在线状态实时 |
|
||||
| 渠道状态 | 平台适配器状态、灰度开关 | P2 | — |
|
||||
| 风控台账 | 风控事件、账号冷却记录 | P2 | — |
|
||||
|
||||
### 5.3 客户端(Windows)
|
||||
|
||||
| 模块 | 功能 | 优先级 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| 安装与配对 | 安装包、配对码绑定工作区、首次引导 | P0 | 新机 5 分钟内完成配对 |
|
||||
| 托盘常驻 | 开机自启、最小化托盘、在线状态显示 | P0 | 重启后自动恢复连接 |
|
||||
| 扫码与挑战 | QR 展示/自动刷新、验证码回填、APP确认轮询 | P0 | 与网页端挑战中心联动 |
|
||||
| 任务执行 | 接收/幂等去重/执行/回传;浏览器档案与指纹 | P0 | 重复任务不重复执行 |
|
||||
| 运维 | 本地日志、自动更新、连接诊断 | P0/P1 | 断线指数退避自愈 |
|
||||
|
||||
## 6. 关键流程(引用 docs/logic-diagrams.md)
|
||||
|
||||
- 账号绑定(图8 登录链路优化):API 取码 → 只传 token → 本地重渲染 → 短轮询检测。
|
||||
- 发布全链路(图6):提交 → 审核 → 排期 → 下发 → 执行 → 回传 → 实时状态;异常分流(网络重试/挑战/风控冷却)。
|
||||
- 二次验证兜底(图7):五类挑战人工兜底,超时挂起、一键重发。
|
||||
- 重试状态机(图5):网络退避 1m/5m/15m/1h;登录态转挑战;风控转人工。
|
||||
|
||||
## 7. 非功能需求
|
||||
|
||||
| 类别 | 指标 |
|
||||
| 角色 | 权限 |
|
||||
|---|---|
|
||||
| 性能 | 任务下发 <300ms;网页页面 P95 <2s;WSS 心跳 10s/离线判定 25s |
|
||||
| 可靠性 | 断线重连 <10s 恢复;任务幂等(同任务不重复);挑战超时策略完整 |
|
||||
| 安全 | 四层鉴权;凭据不出本机(服务器零凭据);日志脱敏;审计完整 |
|
||||
| 易用性 | 三步上手;登录过期提醒+一键重扫;失败原因可见+一键重试 |
|
||||
| 合规 | 自动化通道知情告知并签署;内容发布前自检提示;红线功能拒绝 |
|
||||
| 可维护 | 协议版本化(zod/Go 侧校验);平台适配器独立可灰度 |
|
||||
| owner/admin | 工作区、账号、成员、审核、任务、设置 |
|
||||
| operator | 素材、账号登录、创建/提交任务、查看自己任务 |
|
||||
| reviewer | 查看任务、审核、查看挑战和结果 |
|
||||
| viewer | 只读工作台/任务/素材 |
|
||||
|
||||
## 8. 数据需求(一期最小)
|
||||
## 成功标准
|
||||
|
||||
- 任务事件流(状态/时间/原因/截图);账号健康度(登录态预测过期);发布统计(成功数/失败原因分布);审计与通知记录。
|
||||
|
||||
## 9. 约束与依赖
|
||||
|
||||
- 平台风控与前端改版(最大持续风险);IP/代理采购(客户侧);Windows 10/11 环境;扫码需客户手机(微信/抖音等 APP)。
|
||||
- 一期素材存服务器本地盘:需磁盘配额与清理策略。
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
见 docs/delivery-plan.md §7 最终验收清单(登录与权限 / 任务全生命周期 / <300ms 与幂等 / 挑战全流程 / 素材校验 / 四平台真实发布 / Windows 新机全流程 / 凭据不出本机 / 安装包与自动更新)。
|
||||
|
||||
## 11. 里程碑
|
||||
|
||||
见 docs/delivery-plan.md §6 日计划(D1-D13 + 缓冲):D1-D7 网站端,D8-D11 客户端,D12-D13 联调交付。
|
||||
|
||||
## 12. 开放问题(待评审确认)
|
||||
|
||||
1. SPDC / MDC 的确切定义——请提供全称,以对齐文档体系。
|
||||
2. 定价与计费模式(订阅/按账号数/按平台数)。
|
||||
3. 通知渠道一期范围(企微/钉钉/邮件是否全做)。
|
||||
4. 代理采购入口是否一期内置(默认客户自带)。
|
||||
5. 「手机版本」此前表述——指首版还是未来手机端 App,需确认。
|
||||
6. 单人版是否一期提供(隐藏审核流)。
|
||||
mock 任务从创建到成功可在本机重复运行;真实平台流程在用户完成一次手动扫码后可观察、可恢复、可追责。
|
||||
|
||||
+30
-30
@@ -1,37 +1,37 @@
|
||||
# EveryPublish 开发进度跟踪
|
||||
# EveryPublish 当前进度(Web-only V1)
|
||||
|
||||
> 状态:进行中 · 阶段一(网页端 D1-D7)。目标:goal-8c8e81e4-d2ed-4ba9-a4f7-d413c4f9e00b。
|
||||
> 更新规则:每天结束更新本表;检查点①/②需用户确认后才进入下一阶段。
|
||||
> 最后审校:2026-08-21。当前只验收网页端,客户端线暂停。
|
||||
|
||||
## 总览
|
||||
|
||||
| 阶段 | 状态 | 说明 |
|
||||
| 模块 | 状态 | 证据/说明 |
|
||||
|---|---|---|
|
||||
| 阶段一 网页端 D1-D7 | 🟡 进行中 | D1-D7 全部完成,进入检查点①网页端手动测试 |
|
||||
| 检查点① 网页端手动测试 | ✅ 用户已验收确认 | 用户确认进入阶段二 |
|
||||
| 阶段二 客户端 D8-D11 | 🟡 进行中 | D8-D11 代码全部交付,进入检查点②交付用户测试 |
|
||||
| 检查点② 交付用户测试 | ✅ 已交付 | 最终产物切换为 Electron 壳(client/desktop,正规安装包+正常 UI 四页签)替代 WinUI3 源码交付;NSIS 安装包 `EveryPublish Setup 0.2.0.exe` 构建成功(图标 app.ico 修复后 EB_EXIT=0,内嵌 agent-core.exe);服务器部署包 dist/deploy(linux-amd64/windows-amd64+web+docker-compose+.env 样例)齐备;内网连接/填写说明(docs/client-install-guide.md + dist/deploy/deploy-README.md + 测试账号方案)交付;模拟 E2E 第 N 轮实测:登录→设备在线(winui-fakeall/core-d8)→账号 active→素材 ready→任务→submit→approve→假执行→status success;等用户在内网 192.x 实机验收 |
|
||||
| 阶段三 联调交付 D12-D13 | ⚪ 未开始 | |
|
||||
| React/TDesign 网页骨架 | 已有 | `apps/web` 可构建,路由覆盖工作台/素材/账号/任务/审核/挑战/通知 |
|
||||
| Go REST/JWT/工作区 | 已加固 | workspace 切换、workspace 行级隔离、owner/admin/operator/reviewer 写权限已有 API 回归 |
|
||||
| 素材上传/去重/直链 | 已联调 | server handler + React Upload;覆盖 multipart、SHA-256 去重查询、类型筛选、一次性下载直链、inline 预览和删除 |
|
||||
| 任务状态机/审核 | 已联调 | draft/review/queued/dispatched/running/success/failed;原子领取、防重复事件、取消 context 和本机执行已测试 |
|
||||
| 排期编辑/清除 | 已联调 | 排期日历可打开草稿/驳回任务,设置或清除 `scheduleAt`,已执行任务只读 |
|
||||
| 运行状态/安全设置 | 已联调 | `/runtime/status` 展示执行模式/适配器/DB/Redis;密码修改 API 与页面已接通 |
|
||||
| macOS/Windows 客户端 | 暂停 | 旧代码保留为历史资料,不进入当前构建和验收 |
|
||||
| Web-only 执行器 | 已联调 | mock 与 web smoke 均通过;已注册真实 adapter 使用可取消 context,未接入平台不会伪装成真实成功 |
|
||||
| 账号网页扫码/Cookie | B站适配器已接入 | 服务端生成 B站二维码、后台轮询 86101/86090/0、确认后写入 workspace/account 隔离 AES-256-GCM 凭据;支持 web 模式 Cookie 导入和登录检查;真实扫码仍需人工验收 |
|
||||
| B站真实发布 | 代码与 HTTP stub 已完成 | UPOS preupload、分片真实 ETag、merge、投稿、CSRF 编码和错误分类已实现;必须使用真实账号/素材手动验收 |
|
||||
| 参考仓库分析 | 已完成 | 三个 MIT 仓库已拉取到 `references/` 并有分析文档 |
|
||||
| 自动化网页测试 | 已扩展 | `apps/web npm test` 覆盖认证/工作区/成员/运行状态/权限、账号更新/幂等绑定、挑战挂起/重发/解决、素材筛选/直链/预览/删除、审核驳回/重提/取消、排期设置/清除、密码修改、通知、审计、WS 和成功时间线;真实 UI 仍需浏览器服务/人工验收 |
|
||||
|
||||
## 逐日验收
|
||||
## 当前已验证
|
||||
|
||||
| 日 | 任务 | 验收标准 | 状态 | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 工程化与骨架 | dev 无报错、菜单空壳可点、/health 200、DB 连上 | ✅ 通过 | 后端:/health 200(db:true,redis:true)、12 表 AutoMigrate、go build/vet/run 通过(:8090);前端:示例页+mock 清理、17 个页面空壳+2 组菜单路由、vite dev :3003 启动 200、代理到后端 404(gin) 正常、tsc --noEmit 0 错误 |
|
||||
| D2 | 认证与工作区 API | 单测过;前端登录页真实登录 | ✅ 通过 | 后端:Argon2id 口令、JWT access 15min/refresh 7d 轮换(Redis jti 吊销)、register/login/refresh/logout/me、workspaces CRUD、members 邀请(JWT)/加入/改角色/移除、审计中间件落库;go test 单测 4 项+集成测试 2 场景全过(真 MySQL everypublish_test + 真 Redis);前端:登录/注册页接真实 API、401 自动刷新重试(单飞)、路由守卫、启动拉取用户信息、Header 显示昵称+登出;tsc 0 错误;经 vite 代理 curl 验证登录 200/错误密码 401 |
|
||||
| D3 | 业务 API | 单测+接口文档;curl 全接口可跑 | ✅ 通过 | 账号台账 CRUD+bind→challenge(平台白名单/绑定中禁删);素材 multipart 流式 sha256+同工作区去重+签名直链(10min 一次性 200→404 已验证);任务状态机(纯函数 4 项单测:happy path/失败重试/挂起恢复/非法转移) + CRUD/submit/approve/reject/resubmit/retry/cancel+驳回/通过自动通知;挑战 solve/resend/suspend+过期软置;通知列表/已读/全部已读;go test 全过;curl E2E 账号/素材/直链/任务/通知全链路 200;接口文档 docs/api.md(含状态机图) |
|
||||
| D4 | WSS 网关+假执行器 | 假执行器闭环 <300ms | ✅ 通过 | 双通道 WSS:/ws/agent(Ed25519 hello 验签,坏签名拒绝已验证)+ /ws/browser(JWT 订阅);心跳/hello.ack 签名;task.push→ack→result 全链路;challenge.new→solve→账号激活;设备配对码(5min 一次性);下发器(审批即时触发+1s 轮询兜底,先落库后推送防竞态);假执行器 server/cmd/fake-agent;集成测试全链路 PASS,下发延迟实测 28.7ms(<300ms);前端 utils/ws.ts 自动重连 + 工作台真实页面(设备在线状态+最近任务实时变绿),经浏览器通道收到 3 条 task.status 事件(末条 success) |
|
||||
| D5 | 页面① | 页面与真实 API 联通 | ✅ 通过 | 登录/仪表盘(D2/D4 已真实化)+ 成员页(列表/邀请弹窗生成链接+复制/行内改角色/移除,owner 保护)+ 设置 4 页(工作区资料可改名保存、我的账号、2FA 默认关留位、通知渠道二期留位、设备列表+吊销)+ 邀请接受页 /invite(登录态判断/邮箱校验/成功引导);后端补 PUT /agent/devices/:id/revoke(吊销+强制下线)与 PUBLIC_BASE_URL(浏览器链接与 Agent 直链分离);tsc 0 错误;6 页面全 200;邀请链接实测指向 3003 前端;吊销实测生效 |
|
||||
| D6 | 页面② | 全流程可点通 | ✅ 通过 | 素材库(Upload 组件接真实上传、类型/分组筛选、sha256/大小/签名直链弹窗+复制、删除);账号台账(平台标签/状态/健康度/IP 画像、添加弹窗、绑定弹窗监听 challenge.status WS 自动关闭);任务列表(状态筛选、按状态渲染操作:提交/通过/驳回(批注弹窗)/重提/重试/取消/删除、详情 Drawer、WS 实时刷新);新建发布四步向导(选素材→选账号(仅 active)→填内容(TagInput/定时 DatePicker)→确认提交);排期日历(Calendar 组件按日展示任务 Tag);tsc 0 错误;5 页面全 200;全流程模拟:素材经前端代理上传→新账号→bind→假执行器扫码→active→向导 payload 建任务→提交→审核→自动下发→status:success |
|
||||
| D7 | 页面③+admin | 角色权限正确、admin 入口可用 | ✅ 通过 | 审核中心(待审列表+预览抽屉+通过/驳回批注);挑战中心(QR 本地渲染 TDesign QRCode+验证码输入+挂起/一键重发,WS 实时刷新);通知(未读角标+已读/全部已读);审计(动作筛选+分页);admin 两页(租户列表/停用启用、Agent 总览/强制吊销);菜单按角色过滤(meta.admin 仅 admin 可见);后端 /admin 组 AdminRequired 守卫;实测:普通用户 admin API=403、admin 账号=200;tsc 0 错误;6 页面全 200 |
|
||||
| D8 | agent-core | core 单测过;与服务器假执行闭环 | ✅ 通过 | client/core(Go):设备身份(Ed25519 密钥 0600 持久化)、凭据保险库(AES-256-GCM,主密钥独立文件,防篡改单测)、执行器框架(Registry+兜底+ChallengeSolver)、假执行器、WSS 客户端(1s/5s/15s/60s 指数退避重连+握手重置、15s 心跳、写锁防并发写、任务幂等:已执行任务重放缓存结果不重复执行)、配对(AutoPair/PairWithCode)、配置持久化;单测 4 包全过(含 stub 服务器握手验签/任务往返/幂等重放/断线重连);Windows 交叉编译 agent-core.exe 9.0M 成功;真服务器闭环:core 设备指定绑定→挑战自动解决→账号 active→任务下发→执行→success(core 日志全程确认) |
|
||||
| D9 | WinUI3 壳 | Windows 实机:安装→配对→假任务→回传 | ✅ 代码交付(实机验收并入检查点②) | Go 侧:core 新增 localhost 控制服务(随机端口+token 写 local.json,/status 含 server/deviceId/name、/challenges、/challenges/:id 提交、/pair 配对回调,401 鉴权)+ 挑战队列/人工解决/在线状态接口,单测 5 包全绿,curl 实测 401/状态/配对错误传播全通过;WinUI 壳:csproj(net8.0-windows10.0.19041.0+WindowsAppSDK 1.5 自包含+H.NotifyIcon.WinUI)+MainWindow(顶部导航4页/关闭最小化托盘/托盘右键打开退出)+状态页(3s轮询)/配对页/挑战页(2s轮询+验证码提交)/设置页(服务器地址+开机自启+重启核心)+Watchdog(5s心跳/崩溃自拉起/3次失败弹窗)+AutoStart(HKCU Run)+打包脚本 build-installer.ps1(生成图标→Go 交叉编译→dotnet publish→组装→zip);Mac 无法编译 C#/WinUI,Windows 构建步骤与验证清单见 client/ui/README.md |
|
||||
| D10 | B站真实发布 | B站真实发布成功 | ✅ 代码交付(真实账号发布验证并入检查点②) | client/core/internal/exec/bilibili:无浏览器投稿全链路(preupload→upos/bda2 分片上传→分片合并→x/vu/web/add 投稿,协议依据 biliup MIT 源码)+ 扫码登录(passport web qrcode generate→poll 轮询 86101/86090/0→跨域 URL 提取 SESSDATA/bili_jct/DedeUserID→加密保存保险库);ChallengeSolver 接口扩展 onQR 回调(二维码推壳展示);执行器接入 main(B站走真实链路,其它平台假兜底);单测 3 项全过(stub 全链路:分片参数/合并 parts 数/csrf/投稿体校验;QR 登录二次轮询成功+凭据落库;未扫超时);go vet 干净;agent-core.exe 交叉编译成功 |
|
||||
| D11 | 抖音/快手/小红书 | 三平台真实发布+截图回执 | ✅ 代码交付(真实账号发布验证并入检查点②) | client/core/internal/exec/{cdp,cdnbase,douyin,kuaishou,xiaohongshu}:CDP 基础设施(rod-launcher 启动、每账号独立档案 UserDataDir、proxy-server 代理绑定、go-rod/stealth 指纹反自动化、系统 Chrome/Edge 自动探测、cookie 注入/导出、扫码登录通用流程:登录页→QR 截图落盘 file://→onQR 推壳→轮询 URL 离开登录页→cookie 加密入库);通用发布流程(上传页→注 cookie→传视频→等表单 180s→填标题/描述话题→点发布→等成功跳转→截图回执 PNG);三平台选择器按 social-auto-upload 2026-06 版配置(抖音 creator-micro/content/manage 成功特征、快手 article/manage/video、小红书 publish/success);main 注册三平台真实链路;单测 4 项(JSON 转义往返/素材下载/HTTP 错误/Chrome 路径探测);WinUI 挑战页补 Image 二维码显示(http/file:// 均可);go vet 干净;agent-core.exe 交叉编译 14M 成功 |
|
||||
| D12 | 联调+异常注入 | 验收清单全过 | ⚪ | |
|
||||
| D13 | 打包+内部试用 | 新机安装走通、交付物齐 | 🟡 打包完成待用户试用 | Electron 壳(四页签 UI/托盘/开机自启/fakeAll)+NSIS 安装包构建成功(见检查点②);部署包+安装指南+测试账号说明齐;用户内网实机安装与联调由 D12-D13 继续 |
|
||||
- Go server 全量单测和 vet 通过,包含 B站 adapter HTTP stub。
|
||||
- `apps/web` 的 TypeScript 和 production build 通过。
|
||||
- 本地 MySQL、Redis、Go server、Vite 可启动。
|
||||
|
||||
## 服务端口约定
|
||||
## 当前阻塞/风险
|
||||
|
||||
- 本机开发:Go 后端 :8090(8080 被 nginx 占用);MySQL 3306(Docker everypublish-mysql);Redis 6379(本机)。
|
||||
- 前端 dev:Vite :3002(starter 默认),/api 走 vite proxy 到 :8090。
|
||||
- 没有人工扫码,真实平台登录和真实发布不能标记为完成。
|
||||
- 真实平台选择器和风控行为需要逐平台人工验收。
|
||||
- 当前只接入 B站真实 PlatformAdapter;其它平台在 `EXECUTOR_MODE=web` 下单独任务仍保留本机 mock 联调,混合真实/未接入平台任务会明确失败,不能当作真实发布。
|
||||
- B站真实登录/发布没有人工扫码时不能标记为完成;网络、风控、验证码和平台接口变化仍需手动验收。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 用户在账号管理中选择 B站,扫码并确认账号从“等待扫码”变为“已绑定”;或导入真实 Cookie 验证加密保存和账号状态。
|
||||
2. 选择真实 B站账号和视频素材发布一条测试内容,核对平台页面、链接、回执和任务日志。
|
||||
3. 根据人工验收结果补充 B站验证码/过期/限流处理,再逐个平台新增 adapter。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 参考仓库分析记录
|
||||
|
||||
## 仓库一:dreammis/social-auto-upload
|
||||
|
||||
- 地址:<https://github.com/dreammis/social-auto-upload>
|
||||
- 本地:`references/social-auto-upload`
|
||||
- HEAD:`1c66b7d`
|
||||
- 许可证:MIT(`LICENSE`)
|
||||
- 形态:Python CLI + Playwright/Patchright uploader,Web 不是当前主线。
|
||||
- 可借鉴:文件/时间校验、平台参数契约、二维码处理、profile/cookie/upload 抽象。
|
||||
- 不能直接照搬:依赖版本、账号路径、全局配置和平台选择器必须经过 workspace/account/task 隔离。
|
||||
|
||||
## 仓库二:DevilJie/social-auto-upload-web-ui
|
||||
|
||||
- 地址:<https://github.com/DevilJie/social-auto-upload-web-ui>
|
||||
- 本地:`references/social-auto-upload-web-ui`
|
||||
- HEAD:`85e783c`
|
||||
- 许可证:MIT(`LICENSE`)
|
||||
- 形态:Vue 3 + Element Plus + Flask + SQLite + 本地任务 worker + SSE;19 平台 Registry。
|
||||
- 可借鉴:统一平台接口、Registry、`/login` SSE、Cookie 导入四步状态机、异步 `/postVideo`、任务状态轮询、草稿批量发布和分片上传。
|
||||
- 关键调用链:`backend/app.py:672-708` 登录 SSE;`backend/app.py:719-847` Cookie 导入与 stream;`backend/impl/base_platform.py:188-355` 解析→临时文件→sync_profile→写账号;`backend/app.py:961-1154` 视频入队/状态查询;`backend/services/publish_executor.py:1-170` 单 worker;`backend/ext_api/task_queue.py:28-55,161-330` v2 多 worker;`backend/blueprints/uploads_bp.py:100-430` 可续传分片;`backend/ext_api/__init__.py:831-975,1219-1375` 草稿与批量发布。
|
||||
- 风险:单 worker 状态有内存 TTL,v2 是另一套队列;二维码消息格式不统一;图文接口同步执行且缺资源可能占位成功;素材删除的 storage backend 选择不一致;TaskCenter 的中文状态过滤与英文后端状态可能不匹配。EveryPublish 只借鉴协议/状态思想,统一到 MySQL Task/TaskEvent 和 Go dispatcher。
|
||||
|
||||
## 仓库三:funfan0517/MediaPublishPlatform
|
||||
|
||||
- 地址:<https://github.com/funfan0517/MediaPublishPlatform>
|
||||
- 本地:`references/MediaPublishPlatform`
|
||||
- HEAD:`0813236`
|
||||
- 许可证:MIT(`LICENSE`,版权归 funfan0517)
|
||||
- 形态:Flask + SQLite + Playwright + Vue 3/Element Plus + Pinia;这是用户描述的“Media/Medium Publish Platform”候选仓库。
|
||||
- 后端入口:`sau_backend/sau_backend.py`。文件 `/uploadSave`,素材 `/getFiles`,账号 `/getAccounts`/`/getValidAccounts`,登录 `/login`(SSE),发布 `/postVideo`、`/postVideosToMultiplePlatforms`,记录 `/getPublishTaskRecords`,重试 `/retryPublishTask`,取消 `/cancelPublishTask`。
|
||||
- 上传核心:`newFileUpload/baseFileUploader.py` 统一处理 profile/cookie、文件类型、标题/正文/标签/封面/地点/定时;`multiFileUploader.py` 按文件→平台→账号串行尝试,账号失败后切换下一个账号。
|
||||
- 平台配置:`newFileUpload/platform_configs.py` 以字典保存 URL、选择器和功能开关;新增平台主要改配置,但选择器仍是平台强耦合代码。
|
||||
- 登录:`myUtils/login.py` 启动可视 Chrome,等待 URL 离开 login 页面后保存 `storage_state`;线程 + `Queue` 通过 SSE 推送状态。
|
||||
- 数据模型:`db/createTable.py` 只有 `user_info`、`file_records`、`publish_task_records`,没有 workspace、角色、幂等键、状态事件或凭据加密层。
|
||||
- 不能直接复制:全局 CORS、SQLite 并发限制、cookie 明文文件、错误 `code` 不统一、简单路径检查、发布记录与执行状态分离。EveryPublish 只借鉴页面信息架构、参数契约、profile、SSE 状态思想和平台配置表。
|
||||
|
||||
## 真实缺陷清单(参考代码审计)
|
||||
|
||||
| 项目 | 缺陷 | 当前迁移策略 |
|
||||
|---|---|---|
|
||||
| MediaPublishPlatform | 无鉴权、CORS 全开、Cookie/文件接口可越权 | JWT + workspace 条件 + 加密凭据,绝不返回 Cookie |
|
||||
| MediaPublishPlatform | URL 离开 login 即判登录成功、SSE 无可靠断开 | adapter 明确状态码/响应 schema,context 可取消,挑战持久化 |
|
||||
| MediaPublishPlatform | 同步 Playwright、retry/cancel 只改数据库、批次粗粒度成功 | Dispatcher context + TaskEvent + 逐账号结果 |
|
||||
| 两个参考项目 | 定时参数类型/位置错位、平台 schedule feature 常关闭 | 当前任务使用毫秒时间戳,adapter 明确支持/拒绝 |
|
||||
| social-auto-upload-web-ui | 单线程内存队列和 v2 队列并存 | EveryPublish 只保留一个持久化任务状态机 |
|
||||
| social-auto-upload-web-ui | 纯字符串/JSON 混合 SSE、图文缺资源仍可能成功 | Browser WS envelope + 非空结果/错误分类硬校验 |
|
||||
|
||||
## 三个参考系统的关系
|
||||
|
||||
```text
|
||||
dreammis/social-auto-upload
|
||||
└─ 平台 uploader、Playwright、cookie/profile、CLI 参数
|
||||
├─ DevilJie/social-auto-upload-web-ui
|
||||
│ └─ Flask Registry、SSE 登录、异步发布任务和 Web 页面
|
||||
└─ funfan0517/MediaPublishPlatform
|
||||
└─ 统一 BaseFileUploader、批量账号轮换、定时发布、发布记录
|
||||
|
||||
EveryPublish Web-only V1
|
||||
└─ Go REST/JWT/GORM + MySQL/Redis + Browser WS
|
||||
├─ 统一平台参数和浏览器 profile 思路
|
||||
├─ SSE 状态转换为 DB Challenge + Browser WS
|
||||
├─ SQLite 任务记录转换为 Task + TaskEvent 状态机
|
||||
├─ 批量账号轮换/定时参数进入任务请求和调度器
|
||||
└─ 每个平台 adapter 独立实现和测试
|
||||
```
|
||||
|
||||
## 合并策略
|
||||
|
||||
```text
|
||||
参考仓库(只读)
|
||||
├─ 平台参数/登录/上传思路 ──> PlatformAdapter 接口
|
||||
├─ SSE/任务队列思路 ────────> Browser WS + DB 状态
|
||||
├─ 批量账号轮换/定时参数 ───> 当前任务请求和调度器
|
||||
└─ 页面信息架构 ────────────> 当前 React + TDesign 页面
|
||||
```
|
||||
|
||||
不会把 Vue/Flask/SQLite 整套替换当前工程;这会造成三套认证、数据模型和部署入口。当前工程只吸收可验证的领域逻辑,并在每个 adapter 的文件头标注来源和 MIT 许可。真实平台选择器必须经过用户授权和人工扫码验收,不能用 mock 结果代替真实成功。
|
||||
@@ -0,0 +1,99 @@
|
||||
# 参考系统逻辑图
|
||||
|
||||
本文只描述两个参考仓库的实际代码路径,不把它们的缺陷当作 EveryPublish 设计。许可证:两个仓库均为 MIT;本文件和当前实现只借鉴领域逻辑,不复制无鉴权路由、明文 Cookie 或平台选择器。
|
||||
|
||||
## MediaPublishPlatform
|
||||
|
||||
```text
|
||||
Vue AccountManagement
|
||||
│ GET /login?type&id
|
||||
▼
|
||||
Flask sau_backend.py
|
||||
│ Queue + daemon thread
|
||||
▼
|
||||
login.py / unified_login_cookie_gen
|
||||
│ Playwright headed Chrome
|
||||
│ frame URL 离开 login 即判成功
|
||||
▼
|
||||
cookiesFile/<platform>_cookie_<id>.json
|
||||
│ DB user_info(status=1)
|
||||
└─ SSE: Cookie已保存 -> 登录成功 -> 前端关闭
|
||||
|
||||
Vue MaterialManagement ── POST /uploadSave ──> videoFile + file_records
|
||||
│
|
||||
└─ POST /postVideo
|
||||
│ task_id
|
||||
├─ 为 file × account 写 publish_task_records(发布中)
|
||||
├─ synchronous post_file
|
||||
│ └─ file → account → Playwright BaseFileUploader
|
||||
└─ 统一把整个 task_id 改成功/失败
|
||||
|
||||
Vue 多平台发布 ── POST /postVideosToMultiplePlatforms
|
||||
│
|
||||
└─ file × platform × account 串行尝试
|
||||
├─ 某账号成功后切换下一文件
|
||||
└─ 按 platform success>0 粗粒度更新整批记录
|
||||
```
|
||||
|
||||
关键原文位置:登录 `sau_backend.py:360-413`、`myUtils/login.py:31-160`;素材 `sau_backend.py:93-192`;单平台发布 `sau_backend.py:1155-1315`;批量发布 `sau_backend.py:1318-1504`;统一上传 `newFileUpload/baseFileUploader.py:145-280`、`multiFileUploader.py:7-74,154-239`;状态表 `db/createTable.py:37-51`。
|
||||
|
||||
## social-auto-upload-web-ui
|
||||
|
||||
```text
|
||||
Vue AccountManagement
|
||||
│ GET /login
|
||||
▼
|
||||
Flask app.sse_stream
|
||||
│ Queue + platform.login
|
||||
├─ QR/文本消息(不同平台格式不统一)
|
||||
├─ storage_state / Cookie 保存
|
||||
└─ 终态 200/500/error 后关闭 SSE
|
||||
|
||||
Vue AccountManagement ── POST /importAccount
|
||||
▼
|
||||
BasePlatform.import_cookie
|
||||
├─ step1 解析 Cookie
|
||||
├─ step2 写临时 Cookie 文件
|
||||
├─ step3 sync_profile / stats
|
||||
└─ step4 写 user_info(status=1) + done
|
||||
|
||||
Vue PublishCenter ── POST /postVideo
|
||||
│ 参数校验、素材 probe、平台配置合并
|
||||
▼
|
||||
publish_executor 单 worker
|
||||
├─ queued
|
||||
├─ running -> platform.publish
|
||||
└─ success/failed(内存 TTL 2h/200 条)
|
||||
│ GET /postVideo/status/<task_id>
|
||||
└─ SQLite publish_batches/publish_details 保存历史
|
||||
|
||||
Vue TaskCenter ── /api/v2/tasks
|
||||
▼
|
||||
TaskQueue(默认 2 workers)
|
||||
pending -> queued -> running -> success/failed/cancelled
|
||||
└─ /api/v2/tasks/stream SSE + heartbeat
|
||||
|
||||
Vue Material/Draft
|
||||
├─ /api/materials/upload(1MB 流式写盘 + 异步 probe)
|
||||
├─ /api/uploads/init -> chunk -> merge(可续传)
|
||||
└─ /api/v2/drafts/batch-publish(每 draft × account 入队)
|
||||
```
|
||||
|
||||
关键原文位置:SSE 登录 `backend/app.py:83-105,672-708`;Cookie 导入 `backend/app.py:719-847`、`backend/impl/base_platform.py:188-355`;视频异步队列 `backend/app.py:961-1154`、`backend/services/publish_executor.py:1-170`;v2 队列 `backend/ext_api/__init__.py:170-348`、`backend/ext_api/task_queue.py:28-55,161-330`;素材和分片 `backend/blueprints/materials_bp.py:155-497`、`backend/blueprints/uploads_bp.py:100-430`。
|
||||
|
||||
## 迁移到 EveryPublish
|
||||
|
||||
```text
|
||||
参考平台 Registry / BaseUploader
|
||||
-> Go platform.Adapter + Registry + 每平台 HTTP/Playwright 实现
|
||||
参考 Queue/SSE
|
||||
-> MySQL Task/TaskEvent + Browser WS + REST 刷新
|
||||
参考 Cookie storage_state
|
||||
-> workspace/account AES-256-GCM credentials.Store
|
||||
参考 file × account 明细
|
||||
-> Task accountIds + 逐 adapter 结果聚合,部分成功明确失败原因
|
||||
参考分片上传
|
||||
-> B站 adapter UPOS;素材源仍使用一次性签名直链
|
||||
```
|
||||
|
||||
保留的差异:EveryPublish 要求 JWT、workspace 隔离、角色权限、审计、取消 context 和持久化状态;真实扫码不能用 URL 变化或 mock URL 直接判定成功。
|
||||
+46
-216
@@ -1,234 +1,64 @@
|
||||
# EveryPublish 系统设计(网页端 + 客户端 App)
|
||||
# EveryPublish Web-only 系统设计
|
||||
|
||||
> 版本 v1 · 2026-08-20 · 与 docs/multi-platform-publish-plan.md 配套(架构原则见其 §3/§3.1/§17-22)
|
||||
> V1 版本 · 2026-08-21
|
||||
|
||||
## 1. 总体形态
|
||||
|
||||
- **网页端(我方统一部署)**:客户工作台 + 内部后台,同一应用、角色与租户隔离;客户与我们内部共用一套代码。
|
||||
- **客户端 App(客户部署)**:Agent 常驻应用(系统托盘/后台服务),承载数据面。
|
||||
- **连接**:WSS 双向长连接为主通道 + HTTPS 长轮询降级(评估见 §4)。
|
||||
|
||||
> **术语澄清**:本文「Agent」= 客户端发布执行器(本地常驻程序,系统托盘/后台服务形态),与 AI 大模型 Agent 无关——不跑模型、不调 AI 接口、不需要任何模型配置。用户侧零配置:下载安装包 → 扫码绑定 → 完成。运行时与浏览器驱动全部内置进安装包。对外可称「发布助手」。
|
||||
|
||||
## 2. 网页端功能清单
|
||||
|
||||
### 2.1 客户工作台(按角色)
|
||||
|
||||
| 模块 | 功能点 | 可见角色 |
|
||||
|---|---|---|
|
||||
| 认证与账户 | 登录(密码+TOTP 2FA)、找回密码、登录设备管理 | 全部 |
|
||||
| 工作区与成员 | 成员邀请、角色(管理员/审核/运营)、权限 | 管理员 |
|
||||
| 平台账号台账 | 绑定/解绑、登录态健康度(过期预测)、代理绑定查看、发起扫码 | 管理员 |
|
||||
| 素材库 | 上传(OSS 预签名直传)、分组/标签、检索、预览、元信息 | 运营/审核 |
|
||||
| 发布任务 | 创建(选素材/平台/账号/标题话题/定时)、排期日历、列表筛选、实时状态、失败重试、取消 | 运营 |
|
||||
| 审核流 | 待审队列、通过/驳回+批注(可配置跳过) | 审核/管理员 |
|
||||
| 通知与挑战 | 挑战处理中心(扫码/验证码/APP确认)、失败告警、登录过期提醒 | 管理员/运营 |
|
||||
| 审计 | 全操作日志查询 | 管理员 |
|
||||
| 数据报表(可选) | 发布量/成功率/失败原因分布 | 管理员 |
|
||||
|
||||
### 2.2 内部后台(平台角色,与工作台同部署)
|
||||
|
||||
| 模块 | 功能点 |
|
||||
|---|---|
|
||||
| 租户管理 | 客户列表、套餐/计费、配额、状态 |
|
||||
| Agent 管理 | 设备在线监控、版本推送、远程指令(重启/升级)、强制下线/吊销 |
|
||||
| 渠道适配器 | 平台模块状态、改版巡检、灰度开关 |
|
||||
| 风控台账 | 风控事件、账号冷却记录 |
|
||||
| 系统配置 | 通知模板、代理池(托管形态)、审计查询 |
|
||||
|
||||
## 3. 客户端 App 功能清单
|
||||
|
||||
| 模块 | 功能点 |
|
||||
|---|---|
|
||||
| 安装与配对 | 安装包、配对码绑定工作区、首次引导 |
|
||||
| 连接管理 | WSS 连接状态、心跳、自动重连、延迟诊断 |
|
||||
| 凭据保险库 | 本地加密存储(SQLCipher)、账号列表与健康度 |
|
||||
| 扫码与挑战 | 二维码展示/自动刷新、验证码回填、APP确认轮询 |
|
||||
| 代理管理 | 代理配置、健康检查、按账号绑定 |
|
||||
| 任务执行 | 接收/幂等去重/执行/回传;浏览器档案管理;指纹配置 |
|
||||
| 运维 | 本地日志、自动更新、远程指令(重启/升级/日志上传) |
|
||||
|
||||
## 4. 网页端与 App 的连接方案评估
|
||||
|
||||
| 方案 | 延迟 | 双向 | 穿透 | 安全 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| REST 轮询 | 秒级 | 否 | 是 | 中 | 仅作降级通道 |
|
||||
| 服务器回调 Agent(Agent 开端口) | 低 | 是 | 否(需端口映射/UPnP) | 差(暴露面) | 排除 |
|
||||
| SSE 单向推送 | 低 | 半 | 是 | 中 | 可选补充 |
|
||||
| MQTT | 低 | 是 | 是(出站) | 高 | 进阶可选(暂不引入) |
|
||||
| **WSS(Agent 出站)** | **<50ms** | **是** | **是(免内网穿透)** | **高** | **主通道** |
|
||||
| gRPC 双向流 | 低 | 是 | 是 | 高 | 暂不需要 |
|
||||
|
||||
**安全依据(为什么 WSS 出站最安全)**:
|
||||
|
||||
1. **零入站暴露面**:Agent 不监听任何端口,攻击者无处下手。
|
||||
2. **设备级身份**:安装时生成 Ed25519 密钥对,配对时注册公钥;连接与关键消息均签名,不可伪造。
|
||||
3. **单一信任锚**:Agent 只信任我方服务器;所有指令经服务器签名下发,杜绝中间人。
|
||||
4. **可降级**:企业防火墙拦截 WSS 时自动降级 HTTPS 长轮询(同样 TLS+签名),可用性不降级。
|
||||
5. **证书固定(可选)**:TLS 1.3 + 证书指纹固定,防内网 DNS 劫持。
|
||||
|
||||
## 5. 鉴权体系(四层)
|
||||
|
||||
| 对象 | 方式 | 凭证 | 存储 | 吊销 |
|
||||
|---|---|---|---|---|
|
||||
| 网页用户 | 密码(Argon2id)+ TOTP 2FA(可选,默认关闭,管理员可开启) | Access JWT 15min + Refresh Cookie 7d(轮换) | 服务器 | 会话管理/登出全部设备 |
|
||||
| Agent 设备 | 配对码 + Ed25519 签名 | agentToken(长期可吊销) | 服务器存公钥;App 本地存私钥(DPAPI/SQLCipher) | 控制台吊销设备 |
|
||||
| 平台官方 API(X/IG/YouTube) | OAuth2 + PKCE | refresh token | 服务器 KMS 信封加密 | OAuth 应用撤销 |
|
||||
| 国内平台账号 | 官方扫码登录 | cookie/token | 仅 Agent 本地保险库,服务器零凭据 | 远程失效→重新扫码 |
|
||||
| 挑战输入(验证码等) | WSS 加密通道 | 不适用 | 不落日志、不持久化明文 | 会话结束即弃 |
|
||||
|
||||
## 6. REST API 清单(网页端 → 服务器)
|
||||
|
||||
| 模块 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| 认证 | POST /api/auth/login、/refresh、/logout、/2fa/verify | 登录/刷新/登出/两步验证 |
|
||||
| 工作区 | GET/PUT /api/workspace、POST /api/members/invite、PATCH /api/members/:id | 工作区与成员 |
|
||||
| 账号台账 | GET /api/accounts、POST /api/accounts/bind、DELETE /api/accounts/:id、GET /api/accounts/:id/health | 绑定发起即生成挑战 |
|
||||
| 素材 | GET/POST /api/materials、POST /api/materials/presign、DELETE /api/materials/:id | 直传签名与元数据 |
|
||||
| 任务 | GET/POST /api/tasks、POST /api/tasks/:id/submit/approve/reject/retry/cancel、GET /api/tasks/:id/events | 任务生命周期 |
|
||||
| 挑战 | GET /api/challenges、POST /api/challenges/:id/resolve | resolve 载荷:验证码/确认结果 |
|
||||
| 审计 | GET /api/audit-logs | 全操作日志 |
|
||||
| 通知 | GET /api/notifications、POST /api/notifications/read | 通知中心 |
|
||||
| Agent 管理 | POST /api/agents/pairing、GET /api/agents、POST /api/agents/:id/revoke、POST /api/agents/:id/cmd | 配对码/吊销/远程指令 |
|
||||
| 内部后台 | GET /api/admin/tenants、/agents、/adapters、/risk-events | 平台角色专属,租户域隔离 |
|
||||
|
||||
## 7. WebSocket 消息协议
|
||||
|
||||
| 方向 | 消息 | 载荷要点 |
|
||||
|---|---|---|
|
||||
| A→S | hello | deviceId + 签名 + 协议版本(连接即鉴权,只此一次) |
|
||||
| A→S | heartbeat | 10s 间隔;服务器 25s 未收判定离线 |
|
||||
| S→A | task.push | taskId + 加密载荷(素材URL/标题/平台/账号) |
|
||||
| A→S | task.ack / task.result | 幂等确认 / 结果+截图URL+失败原因分类 |
|
||||
| A→S | challenge.push | 类型(qr/confirm/sms/captcha)+ 凭证 |
|
||||
| S→A | challenge.resolve | 用户输入结果(验证码等,端到端加密) |
|
||||
| S→A | agent.cmd / config.update | 重启/升级/配置下发(签名) |
|
||||
| 浏览器↔S | 原生 WebSocket(JSON 协议) | task / challenge / notification 订阅推送(JWT 鉴权) |
|
||||
|
||||
## 8. 关键流程
|
||||
|
||||
### 8.1 网页用户登录(标准)
|
||||
|
||||
账号密码 → 签发 Access JWT(15min) + Refresh Cookie(7d 轮换) → 进入工作区。TOTP 2FA 为可选能力,**默认关闭**,管理员可对工作区开启;异常登录(新设备/异地)触发告警通知。
|
||||
|
||||
### 8.2 Agent 配对(一次性)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant W as 网页端
|
||||
participant S as 服务器
|
||||
participant A as App(Agent)
|
||||
U->>W: 登录(密码+TOTP 2FA)
|
||||
W->>S: POST /api/auth/login
|
||||
S-->>W: JWT(15min) + refresh cookie
|
||||
U->>W: 工作区→设备→添加设备
|
||||
W->>S: POST /api/agents/pairing
|
||||
S-->>W: 配对码(6位,5分钟有效)
|
||||
U->>A: 输入配对码
|
||||
A->>A: 生成Ed25519密钥对+deviceId
|
||||
A->>S: WSS auth(deviceId,配对码,公钥,签名)
|
||||
S->>S: 验证→注册公钥→签发agentToken(可吊销)
|
||||
S-->>A: 绑定成功
|
||||
A->>A: 私钥入本地保险库(DPAPI/SQLCipher)
|
||||
```text
|
||||
浏览器 React/TDesign
|
||||
│ REST + Browser WebSocket
|
||||
▼
|
||||
Go server
|
||||
├─ JWT/工作区/权限/审计
|
||||
├─ 素材存储(本地盘,StorageDriver)
|
||||
├─ 任务状态机 + Dispatcher
|
||||
├─ Challenge/Notification
|
||||
└─ WebExecutor / PlatformAdapter(本机执行)
|
||||
│
|
||||
├─ mock(自动化联调默认)
|
||||
└─ Playwright/Patchright profile + cookie vault(真实平台)
|
||||
```
|
||||
|
||||
### 8.3 平台账号绑定(沿用 §22 优化登录)
|
||||
V1 不需要客户端 App、设备配对、Agent WSS、Ed25519 设备身份或 Agent 设备管理。浏览器连接服务器即可完成管理;服务器进程运行在用户当前设备上时,平台浏览器和 cookie 也留在该设备。
|
||||
|
||||
控制台发起绑定 → 服务器生成挑战 → Agent API 取码(B站等)或温浏览器取码(视频号)→ 只传 token/URL → 控制台重渲染 → 用户扫码 → Agent 短轮询检测 → 换取 cookie 入保险库 → 台账变绿。
|
||||
## 2. 模块职责
|
||||
|
||||
### 8.4 发布任务全链路(沿用 §21 全链路时序)
|
||||
|
||||
|
||||
## 9. 边界问题与解决方案
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| 模块 | 责任 |
|
||||
|---|---|
|
||||
| 企业防火墙拦截 WSS | 443 端口 + wss;仍失败则自动降级 HTTPS 长轮询(同签名) |
|
||||
| 服务器重启/网络抖动 | Redis 持久队列(asynq)+ 重投 + Agent 断线重连 + taskId 幂等 ack |
|
||||
| 重复执行 | taskId 全局唯一,Agent 本地状态表去重 |
|
||||
| 大文件挤占消息通道 | 素材走 OSS 预签名直传,WSS 只传元数据 + SHA-256;断点续传 |
|
||||
| 两端时钟不同步(签名 nonce) | 配对时同步服务器时间 + nonce 宽限窗口 |
|
||||
| 并发挑战冲突 | 账号级互斥锁:一账号同一时刻仅一个挑战 |
|
||||
| 版本不兼容 | 协议 version 字段 + zod schema 校验,旧版 App 只读降级 |
|
||||
| 验证码泄露 | 挑战消息端到端加密、日志脱敏、不持久化明文 |
|
||||
| 排期时区错乱 | 统一 UTC 存储,控制台按本地时区渲染 |
|
||||
| 托管形态代理失效 | 池健康检查自动摘除 + 同区域切换 + 告警 |
|
||||
| `apps/web` | 页面、表单、上传进度、Browser WS 状态、错误提示 |
|
||||
| `server/internal/api` | REST 鉴权、参数校验、工作区隔离、审计 |
|
||||
| `server/internal/task` | 可验证的任务状态转移 |
|
||||
| `server/internal/ws` | Browser WS 广播;不再把 Agent 当 V1 必需依赖 |
|
||||
| `WebExecutor` | 统一提交/进度/结果/挑战接口 |
|
||||
| `TaskEvent` | 持久化每次状态转移、执行结果和错误信息 |
|
||||
| `PlatformAdapter` | 每个平台登录、Cookie 检查、视频/图文发布 |
|
||||
| `StorageDriver` | 本地素材存储;后续可换 OSS |
|
||||
|
||||
## 10. 核心数据模型
|
||||
## 3. 数据与凭据
|
||||
|
||||
> 数据库引擎:MySQL 8.x(GORM);Redis 承载队列与缓存(asynq)。
|
||||
- 业务表保留 workspace_id,所有查询必须带当前 workspace。
|
||||
- 素材按流式 SHA-256 写入本地盘,服务端限制最大上传体积。
|
||||
- 本地平台 cookie 仅存当前设备的受保护数据目录(AES-256-GCM,workspace/account 分隔),不写入网页响应和审计日志。
|
||||
- 挑战输入不写明文日志;QR URL/token 只在挑战生命周期内可见。
|
||||
|
||||
| 表 | 关键字段 | 说明 |
|
||||
|---|---|---|
|
||||
| tenant | id, name, plan, status | 租户(客户/内部) |
|
||||
| user | id, tenant_id, phone/email, password_hash, totp_secret, role | 平台角色独立域 |
|
||||
| platform_account | id, tenant_id, platform, status, health, proxy_binding | 台账(无凭据字段) |
|
||||
| agent_device | id, tenant_id, device_id, public_key, token_hash, status, version | 设备(公钥,无私钥) |
|
||||
| material | id, tenant_id, oss_key, sha256, meta | 素材 |
|
||||
| task | id, tenant_id, material_id, targets, schedule_at, status, retry_count | 发布任务 |
|
||||
| task_event | id, task_id, type, payload, ts | 状态事件流 |
|
||||
| challenge | id, account_id, type, status, expires_at | 挑战(凭证加密) |
|
||||
| audit_log | id, tenant_id, actor, action, target, ts | 审计 |
|
||||
| notification | id, tenant_id, user_id, channel, content, read_at | 通知 |
|
||||
## 4. API/实时协议
|
||||
|
||||
## 11. 安全清单
|
||||
REST 使用 `/api/v1`;浏览器 WS 使用 `/ws/browser?token=<JWT>`。页面首次加载总是 REST 拉取,WS 只负责增量事件;WS 断开时显示状态并启动有限轮询。
|
||||
|
||||
- 全链路 TLS 1.2+(HTTPS/WSS);密码 Argon2id;登录限速;异常登录告警;TOTP 2FA 可选(默认关闭)。
|
||||
- 租户数据行级隔离(tenant_id 强制注入);内部后台独立角色域。
|
||||
- 敏感字段 KMS 信封加密;日志脱敏;挑战内容不落日志。
|
||||
- Agent 指令签名校验;设备可远程吊销。
|
||||
- 依赖扫描 + 常规安全审计(SAST/依赖 CVE)。
|
||||
## 12. 传输体系设计
|
||||
|
||||
### 12.0 分期策略(一期本地,二期接桶)
|
||||
|
||||
- **一期(首版)**:不接入存储桶。素材存服务器本地磁盘;运营 multipart 直传服务器;Agent 用**带短时效签名 token 的直链下载**(HTTP 直链,不做分片续传)。大文件同样不走 WSS。
|
||||
- **二期**:接 OSS 双桶(国内/海外)、预签名 URL、Range 分片断点续传、本地缓存 LRU(即 §12.1-12.5 全部能力)。
|
||||
- **工程要求**:存储层必须抽象为 StorageDriver 接口(一期 local 实现 / 二期 oss 实现),上传、下载、元数据三处调用全部经接口——二期切换零业务改动。
|
||||
|
||||
### 12.1 三链路分离(控制面轻、数据面直)
|
||||
|
||||
| 链路 | 内容 | 通道 | 典型大小 | 续传 | 加密 |
|
||||
|---|---|---|---|---|---|
|
||||
| 控制面 | 任务/状态/挑战/心跳 | WSS | <1KB | 幂等重发 | TLS + 签名 |
|
||||
| 数据面·上传 | 运营浏览器 → 素材桶 | HTTPS 直传(预签名) | 10MB ~ 数GB | 分片续传 | TLS + 私有桶签名 |
|
||||
| 数据面·下发 | 素材桶 → Agent | HTTPS Range 下载 | 10MB ~ 数GB | Range 续传 | TLS + 短时效签名 |
|
||||
| 数据面·回传 | 截图 → 素材桶 | HTTPS 直传(预签名) | 100-500KB | 无需 | TLS + 签名 |
|
||||
|
||||
**铁律:大文件绝不走 WSS**(避免阻塞消息通道);WSS 只传元数据与短时效下载 URL。一期由服务器本地盘存素材字节(单机磁盘即够);二期切换 OSS 后服务器只存元数据(oss_key/sha256/大小)。
|
||||
|
||||
### 12.2 全链路传输流程
|
||||
|
||||
> 注:图中「素材桶 OSS」为二期形态;一期将 OSS 替换为服务器本地盘(见 §12.0 分期策略),其余链路不变。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
OP["运营(网页端)"] -->|"① 分片直传(预签名,断点续传)"| OSS["素材桶 OSS"]
|
||||
OP -->|"② 元数据 API(oss_key/sha256)"| S["服务器"]
|
||||
S -->|"③ WSS task.push + 短时效下载URL"| A["Agent"]
|
||||
A -->|"④ Range分片下载+SHA256校验+本地缓存"| OSS
|
||||
A -->|"⑤ 上传发布"| P["目标平台"]
|
||||
P -->|"⑥ 回执+截图"| A
|
||||
A -->|"⑦ 截图直传OSS(预签名)"| OSS
|
||||
A -->|"⑧ WSS task.result(URL+状态)"| S
|
||||
S -->|"⑨ WSS 实时状态推送"| OP
|
||||
```text
|
||||
task.status {taskId,status,errorMessage,publishedUrls,progress}
|
||||
challenge.status {challengeId,accountId,status,prompt,expiresAt}
|
||||
notification.new {id,kind,title,content}
|
||||
executor.health {mode,online,queueDepth,lastError}
|
||||
```
|
||||
|
||||
### 12.3 断点续传与完整性
|
||||
## 5. 执行与失败语义
|
||||
|
||||
- 浏览器上传:OSS 分片上传(SDK 原生),失败自动重传分片。
|
||||
- Agent 下载:HTTP Range 分段 + 本地 .part 文件,完成后整体 SHA-256 校验(与 DB 元数据比对),失败重下。
|
||||
- 预签名 URL 过期:Agent 向服务器换新 URL(协议预留 refresh 消息)。
|
||||
```text
|
||||
queued → dispatched → running → success
|
||||
└→ failed → retry → queued
|
||||
running/queued → cancelled
|
||||
running → waiting_challenge → running/failed
|
||||
```
|
||||
|
||||
### 12.4 区域与成本
|
||||
|
||||
- **双桶架构**:国内桶(阿里云 OSS/腾讯 COS)+ 海外桶(Cloudflare R2 或 S3),按任务目标平台就近分配——国内平台任务用国内桶、国际平台任务用海外桶,避免跨境传输慢。
|
||||
- **本地缓存 + LRU**:重复任务/重试不重复下载,降低 OSS 流量成本。
|
||||
- 进阶:大客户可绑定自有存储(数据不出门)。
|
||||
|
||||
### 12.5 安全
|
||||
|
||||
- 私有桶 + 预签名 URL(上传 15min、下载 2h 短时效);URL 只经加密通道下发,不落日志。
|
||||
- 上传白名单:仅视频/图片 MIME + 大小上限。
|
||||
- 素材本身最终要公开到平台,HTTPS 即够;凭据/挑战类数据才端到端加密。
|
||||
每次执行都记录 adapter、账号、开始/结束时间、错误分类、目标平台响应摘要和回执 URL。`example.com/fake` 只能出现在显式 mock 模式,不能作为真实平台成功结果。
|
||||
|
||||
+13
-43
@@ -1,47 +1,17 @@
|
||||
# EveryPublish 技术栈定版(唯一权威来源)
|
||||
# EveryPublish Web-only 技术栈
|
||||
|
||||
> 2026-08-20 · 其他文档中与本文件冲突之处,以本文件为准。
|
||||
|
||||
## 1. 总览
|
||||
|
||||
| 端 | 层 | 技术 |
|
||||
| 层 | V1 选型 | 说明 |
|
||||
|---|---|---|
|
||||
| 网页端 | 页面(UI) | React 18 + TypeScript + TDesign React(Tencent starter,MIT,已拉取 apps/web) |
|
||||
| 网页端 | 服务端/API | **Go**:gin + coder/websocket + asynq + GORM + argon2 |
|
||||
| 客户端 | UI 壳 | WinUI 3(C# / .NET 8) |
|
||||
| 客户端 | 核心 | **Go**:WSS 客户端 + go-rod(CDP)+ 凭据保险库 |
|
||||
| 数据 | 数据库 | **MySQL 8.x**(GORM + go-sql-driver/mysql;迁移 golang-migrate) |
|
||||
| 数据 | 队列/缓存 | Redis 7(asynq) |
|
||||
| 存储 | 一期 | 服务器本地盘(签名直链下载) |
|
||||
| 存储 | 二期 | OSS 双桶(StorageDriver 抽象预留) |
|
||||
| 前端 | React 18 + TypeScript + TDesign React + Vite | 当前工程保留,统一表单/表格/上传/弹窗 |
|
||||
| API | Go + Gin | JWT、工作区隔离、素材、任务、挑战 |
|
||||
| 实时 | Browser WebSocket + REST 兜底 | 不依赖 Agent WSS |
|
||||
| 数据库 | MySQL 8 + GORM | 任务、账号、素材、挑战、审计持久化 |
|
||||
| 队列 | Redis + 进程内 dispatcher(V1) | V1 先保证本机可重复,后续可换持久队列 |
|
||||
| 执行器 | `WebExecutor` + `PlatformAdapter` | mock 先行,真实平台逐个接入 |
|
||||
| 浏览器自动化 | Patchright/Playwright adapter | 借鉴三个 MIT 参考仓库,隔离 profile/cookie |
|
||||
| 存储 | 本地 StorageDriver | 保留 OSS 接口,不让大文件走 WebSocket |
|
||||
| 测试 | Go test/vet + TypeScript + Playwright 浏览器冒烟 | 前端不能再以空 test 脚本代替测试 |
|
||||
|
||||
## 2. 「网页端与后端都用 Go」的说明
|
||||
## 暂停技术
|
||||
|
||||
- **页面层仍是 React + TDesign**(starter 已拉取预览,MIT 可商用),不写自定义样式。
|
||||
- 「用 Go」指两处:**网页端的服务端/API 层** 与 **客户端 agent-core**,统一 Go 语言。
|
||||
- 二者共享同一协议 Go module(server 与 agent-core 引用同一 shared/proto 包),消息 Schema 只写一次。
|
||||
|
||||
## 3. Go 选型明细
|
||||
|
||||
- Web 框架:gin
|
||||
- WebSocket:github.com/coder/websocket(原生 JSON 协议,不用 Socket.IO)
|
||||
- 任务队列:asynq(Redis)
|
||||
- ORM/DB:GORM + go-sql-driver/mysql(MySQL 8.x);迁移 golang-migrate
|
||||
- 鉴权:golang-jwt + x/crypto/argon2(用户);Ed25519 设备签名(Agent)
|
||||
- 浏览器自动化(客户端):go-rod + rod-stealth,驱动系统 Chrome/Edge
|
||||
- 常驻服务(二期):kardianos/service
|
||||
|
||||
## 4. 目录结构(monorepo)
|
||||
|
||||
- web/ —— React 前端(现有 apps/web 的 starter)
|
||||
- server/ —— Go 服务器(REST + WSS 网关 + 队列 worker)
|
||||
- client/ui —— WinUI 3 壳(C#)
|
||||
- client/core —— agent-core(Go)
|
||||
- shared/ —— 协议 Go module(server 与 core 共用)
|
||||
- tools/ —— 联调诊断(diag-connection.mjs 等)
|
||||
|
||||
## 5. 版本与工具
|
||||
|
||||
- Go 1.23+;Node 20 LTS(前端,starter 依赖升级见交付文档 D1);.NET 8;MySQL 8.x;Redis 7
|
||||
- 包管理:pnpm(前端)、go mod(后端)
|
||||
- CI:GitHub Actions(前端构建 / Go 单测 / WinUI 打包)
|
||||
SwiftUI/AppKit、WinUI/Electron、Agent core、Agent WSS、设备 Ed25519 配对和客户端安装包全部移出 V1 验收。旧代码保留但不再由 `make build/test` 默认构建。
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Web-only 客户端页面规格
|
||||
|
||||
当前版本只交付浏览器控制台。macOS/Windows Agent、客户端配对码、设备列表和 Agent 日志不进入菜单,也不参与验收。浏览器与本机 Go 服务之间只有 REST 和 Browser WebSocket 两条链路。
|
||||
|
||||
## 视觉与交互基线
|
||||
|
||||
- 形态:左侧固定导航 + 顶部工作区/实时连接状态 + 右侧内容区;桌面宽度下优先扫描效率,窄屏下导航折叠为抽屉。
|
||||
- 颜色:中性灰白背景,蓝色只用于主操作和进行中状态,绿色表示成功,橙色表示等待,红色表示失败/危险;不使用渐变、装饰性大卡片或模拟客户端窗口。
|
||||
- 页面结构:一个页面一个主任务;统计使用紧凑指标条,列表使用表格,详情使用抽屉,确认操作使用弹窗,长流程使用分步表单。
|
||||
- 状态:所有异步操作都显示 loading、成功、失败和可重试入口;WebSocket 断开时顶部显示“实时连接断开”,页面继续用 REST 刷新。
|
||||
- 日志:任务详情必须展示时间、状态、动作、平台错误分类和可公开的回执;不展示 cookie、二维码 token 或原始响应体。
|
||||
|
||||
## 页面地图
|
||||
|
||||
```text
|
||||
EveryPublish Web Console
|
||||
├─ 工作台
|
||||
│ ├─ 今日任务 / 成功率 / 待处理挑战 / 执行器健康
|
||||
│ └─ 最近任务与需要处理的异常
|
||||
├─ 发布管理
|
||||
│ ├─ 新建发布:素材 → 账号 → 内容 → 时间 → 预览 → 保存/提交
|
||||
│ ├─ 任务列表:筛选、审核、重试、取消、详情
|
||||
│ └─ 排期日历:查看和编辑草稿/定时任务
|
||||
├─ 素材库:上传进度、去重、预览、分组、删除、一次性直链
|
||||
├─ 账号管理:平台账号、登录二维码、登录状态、重新登录、解绑
|
||||
├─ 审核中心:待审、通过、驳回批注
|
||||
├─ 挑战中心:二维码/验证码、等待扫码、已扫码、确认、过期、重发
|
||||
├─ 通知:任务失败、登录过期、挑战到达、审核结果
|
||||
├─ 成员管理:成员、角色、工作区切换
|
||||
├─ 设置:工作区资料、安全(密码修改)、站内通知
|
||||
└─ 审计日志:登录、账号、素材、任务、挑战和设置操作
|
||||
```
|
||||
|
||||
## 关键页面 ASCII
|
||||
|
||||
### 1. 工作台
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ EveryPublish 工作区 ▾ 实时连接 ● 用户 ▾ │
|
||||
├───────────────┬────────────────────────────────────────────────────────────┤
|
||||
│ 工作台 │ 今日概览 │
|
||||
│ 发布管理 │ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────────────┐ │
|
||||
│ 素材库 │ │待审核 │ │执行中 │ │已成功 │ │挑战/异常 │ │
|
||||
│ 账号管理 │ │ 12 │ │ 3 │ │ 86 │ │ 需要处理 2 │ │
|
||||
│ 审核中心 │ └────────┘ └────────┘ └────────┘ └──────────────────────┘ │
|
||||
│ 挑战中心 │ 最近任务 │
|
||||
│ 通知 │ 标题 平台 状态 时间 操作 │
|
||||
│ 成员管理 │ 春季活动主片 B站 执行中 12:10 详情 │
|
||||
│ 设置 │ 产品短视频 抖音 失败 11:54 重试 │
|
||||
│ 审计日志 │ [新建发布] [查看全部任务] │
|
||||
└───────────────┴────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 账号登录(单一二维码状态机)
|
||||
|
||||
```text
|
||||
┌─账号管理─────────────────────────────────────────────────────────────────────┐
|
||||
│ [添加账号] 平台 ▾ 状态 ▾ [刷新] │
|
||||
│ 平台 备注/账号 状态 最后活跃 操作 │
|
||||
│ B站 品牌主号 已绑定 08-21 12:03 重新登录 解绑 │
|
||||
│ 抖音 投放号 绑定中 - 查看挑战 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
登录弹窗(服务器生成一次二维码,服务端后台轮询)
|
||||
┌──────────────────────────────┐
|
||||
│ 登录 B站 │
|
||||
│ ┌──────────┐ │
|
||||
│ │ QR 图片 │ │
|
||||
│ └──────────┘ │
|
||||
│ 等待扫码 → 已扫码 → 登录成功 │
|
||||
│ [稍后处理] │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
网页不会生成配对码,客户端也不会再要求网页生成第二个码。真实扫码成功只能由平台适配器轮询确认;没有扫码时保持“等待扫码”,不能自动激活账号。
|
||||
|
||||
### 3. 新建发布
|
||||
|
||||
```text
|
||||
┌─新建发布─────────────────────────────────────────────────────────────────────┐
|
||||
│ ①素材 ②账号 ③内容 ④时间/预览 │
|
||||
│ [上传素材] [选择账号] 标题 [____________] (●)立即 ( )定时 │
|
||||
│ ┌──────────────┐ ☑ B站 品牌号 正文 ┌───────────────┐ │
|
||||
│ │ 视频缩略图 │ ☑ 抖音 投放号 [ ] │ 平台预览 │ │
|
||||
│ └──────────────┘ 标签 [____________] │ 标题/正文/封面 │ │
|
||||
│ └───────────────┘ │
|
||||
│ [保存草稿] [提交审核] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
排期日历中的草稿/驳回任务可点击打开排期抽屉,修改计划时间或清除为立即发布;已入队、执行中和已完成任务只读。
|
||||
|
||||
### 4. 任务详情与日志
|
||||
|
||||
```text
|
||||
┌─任务 #1042 详情──────────────────────────────────────────────────────────────┐
|
||||
│ 春季活动主片 B站 / 品牌号 状态:执行中 [取消] │
|
||||
│ 内容、素材、计划时间、审核批注 │
|
||||
├─执行日志─────────────────────────────────────────────────────────────────────┤
|
||||
│ 12:10:01 created 任务已创建 │
|
||||
│ 12:10:03 pending_review 已提交审核 │
|
||||
│ 12:10:12 dispatched 平台适配器已接收任务 │
|
||||
│ 12:10:13 running 平台适配器开始处理 │
|
||||
│ 12:10:29 failed network: 上传分片失败 │
|
||||
│ [重试] │
|
||||
├─结果─────────────────────────────────────────────────────────────────────────┤
|
||||
│ 发布链接:- 平台回执:- 原始 cookie/token:永不显示 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 状态与 API 约定
|
||||
|
||||
账号:`unbound → binding → active`,登录失败为 `expired` 或 `suspended`;挑战:`active/pending/scanned/solved/expired/suspended`。任务:`draft → pending_review → queued → dispatched → running → success/failed`,失败允许 `retry → queued`。
|
||||
|
||||
登录绑定返回 `challengeId、qrUrl、qrToken、prompt、expiresAt`。后续状态由 Browser WebSocket `challenge.status` 推送,刷新页面再通过 `GET /challenges` 和 `GET /accounts` 补齐,不依赖单次推送。
|
||||
@@ -0,0 +1,129 @@
|
||||
# EveryPublish Web-only V1 逻辑图
|
||||
|
||||
## 1. 系统拓扑
|
||||
|
||||
```text
|
||||
┌───────────────────────┐ REST / Browser WS ┌──────────────────────────────┐
|
||||
│ 浏览器 │◄────────────────────────────►│ Go server │
|
||||
│ React + TDesign │ │ Gin API + JWT + GORM │
|
||||
└───────────┬───────────┘ │ MySQL + Redis │
|
||||
│ ├──────────────┬───────────────┤
|
||||
│ │ WebExecutor │ PlatformAdapter│
|
||||
│ │ mock │ 本机 profile │
|
||||
│ └──────┬───────┴───────┬───────┘
|
||||
│ │ │
|
||||
└────────────────────────────── localhost ─────────┴───────► 目标平台
|
||||
```
|
||||
|
||||
## 2. 登录
|
||||
|
||||
```text
|
||||
输入邮箱/密码
|
||||
│
|
||||
├─ POST /auth/login ──> Argon2 校验 ──> access + refresh
|
||||
│ │
|
||||
│ └─ 失败:统一错误码、登录限速、审计
|
||||
└─ GET /auth/me + connectWS ──> 工作台
|
||||
```
|
||||
|
||||
## 3. 素材到任务
|
||||
|
||||
```text
|
||||
拖入文件
|
||||
│ POST /materials multipart
|
||||
▼
|
||||
流式 SHA-256 ── 同工作区查重 ──┬─ 命中:返回已有素材(dedup)
|
||||
└─ 未命中:写本地存储 + metadata
|
||||
│
|
||||
▼
|
||||
新建发布向导
|
||||
│
|
||||
POST /tasks(draft)
|
||||
│
|
||||
submit → approve / reject → queued
|
||||
```
|
||||
|
||||
## 4. 任务执行
|
||||
|
||||
```text
|
||||
queued
|
||||
│ dispatcher poll / immediate trigger
|
||||
▼
|
||||
dispatched → running ── WebExecutor/PlatformAdapter ──┬─ success ──> published URL + receipt
|
||||
├─ challenge ──> Challenge Center / solve / resume
|
||||
├─ cancel ──> context cancel + cancelled
|
||||
└─ failed ──> error category + retry
|
||||
```
|
||||
|
||||
排期任务在 `schedule_at` 到期前保持 `queued`;草稿/驳回任务可在排期日历通过 `PUT /tasks/:id` 设置或清除时间,已执行任务只读。
|
||||
|
||||
## 5. 真实平台登录
|
||||
|
||||
```text
|
||||
网页点击“登录账号”
|
||||
│
|
||||
├─ POST /accounts/:id/bind
|
||||
├─ PlatformAdapter.BeginLogin 生成唯一二维码 token/url
|
||||
├─ 网页显示 QR;用户使用目标平台手机客户端扫码
|
||||
├─ worker 轮询 86101/86090/0 等平台状态
|
||||
├─ confirmed 后保存 workspace/account 隔离的加密凭据
|
||||
└─ account.status = active / expired / suspended
|
||||
```
|
||||
|
||||
## 6. 凭据导入与检查
|
||||
|
||||
```text
|
||||
网页粘贴 Cookie
|
||||
│ POST /accounts/:id/credentials
|
||||
├─ 适配器校验平台必需字段
|
||||
├─ AES-256-GCM Store.Save(workspaceID, accountID)
|
||||
└─ 只返回 active;Cookie 不进入响应/日志/WS
|
||||
|
||||
点击“检查”
|
||||
│ POST /accounts/:id/check
|
||||
├─ adapter.CheckCredentials -> 平台 nav/profile 接口
|
||||
├─ 有效:active + lastActiveAt
|
||||
└─ 失效:expired + lastError
|
||||
```
|
||||
|
||||
## 7. 参考系统逻辑图:MediaPublishPlatform
|
||||
|
||||
```text
|
||||
Vue 页面
|
||||
├─ /uploadSave ───────> Flask 保存 videoFile + SQLite file_records
|
||||
├─ /getAccounts ──────> SQLite user_info
|
||||
├─ /login (SSE) ──────> Thread + Queue
|
||||
│ └─ Playwright 可视 Chrome 登录
|
||||
│ └─ storage_state 写 cookiesFile
|
||||
├─ /postVideo ────────> SQLite publish_task_records
|
||||
└─ /postVideos... ────> multiFileUploader
|
||||
└─ file → platform → account 串行尝试
|
||||
└─ BaseFileUploader + platform_configs
|
||||
```
|
||||
|
||||
## 8. 参考系统逻辑图:social-auto-upload-web-ui
|
||||
|
||||
```text
|
||||
Vue/Element Plus
|
||||
├─ /login (SSE) ──────> Flask blueprint / platform.login
|
||||
├─ /importAccount ────> cookie parse → temp file → sync profile
|
||||
├─ /postVideo ────────> task_id → background executor
|
||||
└─ /postVideo/status ─> memory/task status polling
|
||||
|
||||
Registry
|
||||
└─ platform id → BasePlatform
|
||||
├─ login
|
||||
├─ check_cookie
|
||||
├─ sync_profile
|
||||
└─ publish_video
|
||||
```
|
||||
|
||||
## 9. 参考逻辑到 EveryPublish 的映射
|
||||
|
||||
```text
|
||||
参考 SSE Queue -> Challenge 表 + Browser WS + REST 刷新
|
||||
参考 SQLite task record -> Task + TaskEvent + workspace 条件
|
||||
参考 storage_state cookie -> 服务器本机受保护 profile/credential store
|
||||
参考 multi-account fallback -> Task adapter 的账号候选与错误分类
|
||||
参考 platform_configs -> Go PlatformAdapter registry(每个平台独立测试)
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# EveryPublish Web-only V1 方案
|
||||
|
||||
> 版本:V1-web · 2026-08-21
|
||||
> 当前交付面只有网页端。macOS/Windows 客户端、Agent 配对、设备管理和 Agent WSS 暂停,不属于本阶段验收。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
EveryPublish V1 是部署在当前设备上的本地 Web 控制台:用户打开浏览器登录,在同一个网页里管理平台账号、素材、发布任务、审核、挑战和结果。发布执行在服务器进程内的本地 worker 完成,不要求安装客户端、不要求生成配对码、不要求绑定设备。
|
||||
|
||||
```text
|
||||
浏览器(React + TDesign)
|
||||
│ HTTPS/REST + Browser WebSocket
|
||||
▼
|
||||
Go server(认证、工作区、素材、任务、挑战、审计)
|
||||
│
|
||||
├── WebExecutor(V1 本地模拟/联调,默认可用)
|
||||
└── PlatformAdapter(真实平台,按平台逐个接入)
|
||||
│
|
||||
└── 本机浏览器 profile / cookie vault / QR 登录
|
||||
```
|
||||
|
||||
## 2. 明确停止项
|
||||
|
||||
- 暂停 `client/macos`、`client/desktop`、`client/ui` 的新功能、安装包和启动验收。
|
||||
- 暂停网页端 Agent 配对码、Agent 设备列表、Agent 总览、远程吊销和客户端日志入口。
|
||||
- 暂停依赖 Agent WSS 的任务下发;V1 任务由 WebExecutor/PlatformAdapter 在 Go server 所在设备执行。
|
||||
- 不把当前已有的“假 Agent 在线”当作网页端完成证据。
|
||||
|
||||
旧客户端代码保留在仓库中作为后续迁移材料,但不得出现在 V1 菜单、默认流程和验收清单中。
|
||||
|
||||
## 3. V1 页面
|
||||
|
||||
| 页面 | 必须可用的功能 |
|
||||
|---|---|
|
||||
| 工作台 | 今日任务统计、成功/失败、待处理挑战、最近任务、执行器健康 |
|
||||
| 账号管理 | 添加账号、平台选择、网页扫码登录、Cookie 导入、登录状态检查、重新登录、删除未绑定账号 |
|
||||
| 素材库 | 视频/图片上传、进度、SHA-256 去重、预览、分组/标签、删除、下载直链 |
|
||||
| 新建发布 | 选择素材、选择一个或多个账号、统一标题/正文/标签、封面、立即/定时、草稿保存 |
|
||||
| 任务中心 | 队列/运行/成功/失败/取消、进度、失败原因、重试、取消、详情、状态时间线和结果链接 |
|
||||
| 排期日历 | 按日期查看和编辑可编辑任务 |
|
||||
| 审核中心 | 待审、通过、驳回和批注;可配置跳过审核的本地模式保留在设置 |
|
||||
| 挑战中心 | QR 图片、验证码、APP 确认、过期、重发、解决记录 |
|
||||
| 通知 | 任务失败、登录过期、挑战到达、审核结果 |
|
||||
| 成员/工作区 | 单机 V1 保留基础工作区和成员模型,权限先保证 owner/admin/operator/reviewer 隔离,并支持成员切换工作区 |
|
||||
| 设置 | 工作区资料、安全(密码修改);通知统一使用站内通知页,外部通知渠道不进入 V1 |
|
||||
| 审计日志 | 登录、素材、账号、任务、审核、挑战和设置操作 |
|
||||
|
||||
## 4. 连接与状态
|
||||
|
||||
V1 只使用网页到 Go server 的两种连接:REST 完成请求,Browser WebSocket 推送任务/挑战/通知。浏览器刷新后通过 REST 补齐状态,不能只依赖 WebSocket。
|
||||
|
||||
```text
|
||||
打开网页
|
||||
├─ 未登录 ──> 登录页 ──失败/限速──> 保留表单并说明原因
|
||||
└─ 已登录 ──> GET /auth/me + GET /tasks + GET /challenges
|
||||
│
|
||||
├─ WebSocket 已连接 ──> 实时更新
|
||||
└─ WebSocket 失败 ──> 顶部显示“实时连接断开”,REST 轮询兜底
|
||||
```
|
||||
|
||||
任务状态统一为:`draft → pending_review → queued → dispatched → running → success/failed`,可从 `failed → queued` 重试,`draft/rejected → deleted`,可取消状态 → `cancelled`。
|
||||
|
||||
## 5. 执行器边界
|
||||
|
||||
`WebExecutor` 是 Go server 的稳定接口,先提供 `mock` 实现保证本地全链路,随后按平台接入真实执行器。真实执行器需要从参考仓库借鉴 Playwright/profile/cookie/QR/SSE 逻辑,但必须隔离在 adapter 目录,不能把 Python 示例代码直接散落到 API handler。
|
||||
|
||||
```text
|
||||
TaskDispatcher
|
||||
├─ 读取任务与账号
|
||||
├─ 生成短时素材访问令牌
|
||||
├─ WebExecutor.Submit(ctx, task)
|
||||
├─ 记录 running / progress / result / error
|
||||
└─ 广播 task.status
|
||||
```
|
||||
|
||||
当前实现状态:`mock` 固定走本机 mock executor;`web` 对已注册平台走本机 PlatformAdapter,未接入的平台明确保留 mock fallback。B站已接入 QR 登录、加密凭据、UPOS 分片上传和投稿;真实平台成功必须有平台返回的 URL/receipt,`mock://` 只能用于自动化测试。
|
||||
|
||||
真实平台登录需要用户在浏览器或手机完成扫码;当前无法人工扫码时,只能验证 QR 产生、挑战状态、超时、重发和 mock 解决,不能声称真实账号登录已验收。
|
||||
|
||||
## 6. 验收门槛
|
||||
|
||||
### 本地自动验收
|
||||
|
||||
1. 注册/登录/刷新/退出和过期 token。
|
||||
2. 上传素材、重复 SHA-256 去重、预览和删除。
|
||||
3. 创建草稿、提交审核、通过/驳回、重提、取消、重试。
|
||||
4. mock executor 将任务推进到 success,并写入结果 URL/receipt。
|
||||
5. Browser WebSocket 断开后 REST 仍可恢复页面状态。
|
||||
6. 账号登录流程可产生 QR,挑战可重发/过期/模拟解决。
|
||||
7. 普通成员不能调用管理员动作,workspace 数据不能串租户。
|
||||
|
||||
自动化 smoke 还必须覆盖:注册后的再次登录、refresh token 轮换、空挑战确认值被拒绝、签名素材直链只能使用一次、未登录账号不能创建发布任务,以及 `EXECUTOR_MODE=mock` 和 `EXECUTOR_MODE=web` 两种本机执行模式。
|
||||
|
||||
### 人工验收(用户执行)
|
||||
|
||||
- 用户在目标平台完成一次真实扫码登录。
|
||||
- 用户选一个真实账号、上传真实素材、发布一个测试任务。
|
||||
- 用户确认目标平台内容、任务结果和失败重试。
|
||||
|
||||
## 7. 参考代码许可和借鉴边界
|
||||
|
||||
- `references/social-auto-upload`:MIT,HEAD `1c66b7d`;借鉴 uploader 抽象、统一校验、QR 登录和平台 CLI 参数,不直接复制其当前 Web 层(README 已说明 Web 非主线)。
|
||||
- `references/social-auto-upload-web-ui`:MIT,HEAD `85e783c`;借鉴 Flask 蓝图拆分、Registry、SSE 登录、串行任务队列、素材中心和批量发布参数。
|
||||
- `references/MediaPublishPlatform`:MIT,HEAD `0813236`;借鉴统一 BaseFileUploader、批量账号轮换、定时参数和发布记录页面,但不复制其 SQLite/明文 Cookie/全局 CORS。
|
||||
- 任何复制的 MIT 代码必须保留版权/许可证说明;新增代码优先用当前 Go/React 技术栈重写,参考仓库作为 `references/` 只读材料。
|
||||
+42
-47
@@ -1,60 +1,55 @@
|
||||
# EveryPublish 网页端测试报告(检查点①)
|
||||
# EveryPublish Web-only V1 测试报告
|
||||
|
||||
> 日期:2026-08-20 · 范围:阶段一网页端 D1-D7 交付 · 结论:**核心链路全部通过,可进入用户验收**
|
||||
> 更新:2026-08-21。当前报告只覆盖 Web-only 本机模式;真实平台扫码和发布仍需用户手动验收。
|
||||
|
||||
## 一、测试概况
|
||||
## 当前实测结论
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| Go 单元+集成测试 | ✅ 全绿(auth 4 项、task 状态机 4 项、API 集成 4 场景、WSS 全链路 1 场景) |
|
||||
| TypeScript 类型检查 | ✅ `tsc --noEmit` 0 错误 |
|
||||
| 前端生产构建 | ✅ `pnpm build` 成功(仅 chunk 体积提示,非错误) |
|
||||
| 手动链路测试 | ✅ 24 项断言 24 通过(含 3 项环境/断言修正后复测通过) |
|
||||
| 断线重连兜底 | ✅ 下线不丢任务、重连 1 秒内自动补发 |
|
||||
本轮已用 `EXECUTOR_MODE=mock` 和 `EXECUTOR_MODE=web` 启动实际 Go server,并通过 HTTP 调用跑通:
|
||||
|
||||
## 二、六条核心链路测试结果
|
||||
```text
|
||||
注册/登录/刷新/me 200,refresh token 轮换,工作区/owner 角色可见
|
||||
未登录创建任务 401,业务写操作不绕过鉴权
|
||||
添加 douyin 账号 200
|
||||
创建网页 QR 挑战 200,kind=qr,qrUrl=mock://...
|
||||
提交 mock 登录挑战 200,账号 status=active
|
||||
空挑战确认值 400,挑战保持 active
|
||||
上传 demo.mp4 200,kind=video
|
||||
签名素材直链 首次下载 200,重复使用 404/410
|
||||
创建任务/提交/审核 200
|
||||
本机 Web executor queued → dispatched → running → success
|
||||
结果 mock://everypublish/task/<id> + receipt
|
||||
Browser WebSocket open(新鲜 JWT)
|
||||
客户端/设备路由 404,不进入网页菜单/流程
|
||||
任务时间线 6 个事件:draft>pending_review>queued>dispatched>running>success
|
||||
```
|
||||
|
||||
| # | 链路 | 关键断言 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 1 | 注册登录 | 注册 200 / 重复邮箱 409 / 错误密码 401 / 刷新轮换后旧 refresh 401 / me 查询 | ✅ |
|
||||
| 2 | 工作空间 | 创建 / 改名 | ✅ |
|
||||
| 3 | 素材直传 | 上传 sha256 / 同文件去重 dedup=true / 直链一次 200 二次 404(一次性) | ✅ |
|
||||
| 4 | 账号绑定 | 新增→bind→challenge 推送→扫码→active(绑定到设备) | ✅ |
|
||||
| 5 | 任务全生命周期 | 创建→提交→审核→下发→执行→success;驳回→rejected→重提;取消 | ✅ |
|
||||
| 6 | 成员协作 | 邀请→加入→角色 operator 生效 | ✅ |
|
||||
| 7 | 审计 | auth.login / task.create 等动作埋点落库、按动作筛选 | ✅ |
|
||||
| 8 | 权限 | 普通用户访问 admin API = 403;admin 账号 = 200 | ✅ |
|
||||
扩展 smoke 还覆盖:运行时状态、工作区名称更新、成员邀请/加入/查看/权限拒绝/移除、工作区切换后 refresh 保持选中工作区、账号资料更新和重复绑定幂等、mock 凭据导入和登录检查的明确 409、挑战挂起→重发→解决、素材类型筛选/下载/inline 预览和删除、任务驳回→重提→取消、草稿删除、排期设置后清除、通知读全、审计列表、密码修改后重新登录、权限检查。当前输出会带 `coverage` 数组,避免把单条成功任务误认为全量网页验证。
|
||||
|
||||
## 三、发现的问题与处理
|
||||
B站 adapter 另有 `httptest` 自动验证:二维码生成与 86101/86090/0 轮询、上下文取消、凭据加密落盘、UPOS 分片真实 `ETag`、merge、CSRF URL 编码、非 2xx 和空素材错误分类。
|
||||
|
||||
| 问题 | 类型 | 处理 |
|
||||
服务端集成测试还用 fake adapter 验证了 `/accounts/:id/bind` 的真实 handler 链路:生成 QR、后台轮询 pending→confirmed、保存凭据、更新 challenge=solved 和 account=active。
|
||||
|
||||
## 工程检查
|
||||
|
||||
| 检查项 | 结果 | 备注 |
|
||||
|---|---|---|
|
||||
| 假执行器未绑定到测试工作空间(导致绑定/下发无设备) | 测试环境 | 每工作空间独立假执行器实例;非代码缺陷 |
|
||||
| 审计断言用 `=1` 而实际多记录 | 测试脚本 | 修正断言为存在性;审计功能正常 |
|
||||
| 下发器 ack 竞态(先推后写库) | 代码缺陷 | 已修复:先落库 dispatched 再推送(D4 已改) |
|
||||
| 服务器异常退出残留 online 状态 | 代码缺陷 | 已修复:启动自愈重置 offline |
|
||||
| 8080 被 nginx 占用 | 环境 | 后端改用 :8090 |
|
||||
| Go server test | 通过 | 含 API、WS、handler、local mock dispatcher 和任务时间线测试 |
|
||||
| Go server vet | 通过 | `go vet ./...` |
|
||||
| Dispatcher adapter 单测 | 通过 | 注册 adapter 的真实执行路径、逐账号结果和 context cancel |
|
||||
| Web TypeScript | 通过 | `npx tsc --noEmit` |
|
||||
| Web production build | 通过 | `npm run build`;仅有 chunk 体积提示 |
|
||||
| Web smoke | 通过 | `cd apps/web && npm test`;分别在 `EXECUTOR_MODE=mock` 和 `EXECUTOR_MODE=web` server 上覆盖认证、权限、挑战、素材、审核、通知、审计、Browser WS 和任务状态机 |
|
||||
| Web lint | 未通过 | 仓库原有 53 个规则错误,主要是字符串拼接/格式;不是本轮 TypeScript/build 阻断项,需后续单独清理 |
|
||||
| 浏览器完整 UI 自动化 | 未完成 | 当前运行环境没有可用浏览器控制服务;API、生产构建和 Browser WS 已实测 |
|
||||
|
||||
## 四、用户验收清单(请您逐项验证)
|
||||
## 重复回归
|
||||
|
||||
1. 打开 http://127.0.0.1:3003/login/index 注册新账号并登录(应进入工作台)。
|
||||
2. 工作台:看到 Agent 设备(在线/离线)与最近任务实时状态。
|
||||
3. 账号管理:添加账号 → 点「绑定」→ 弹窗等待 → 客户端扫码后自动变「已绑定」(无客户端时可跳过,观察「绑定中」状态)。
|
||||
4. 素材库:上传视频 → 列表出现,重复上传同一文件应提示去重。
|
||||
5. 发布管理 → 新建发布:四步向导选择素材/账号/填内容 → 创建草稿。
|
||||
6. 任务列表:草稿「提交审核」→ 审核中心「通过」→ 状态变绿「发布成功」(需在线客户端)。
|
||||
7. 成员管理:邀请一个邮箱 → 生成链接 → 复制。
|
||||
8. 挑战中心 / 通知 / 审计:数据可查看、操作可用。
|
||||
9. 设置 → Agent 设备:可吊销设备(吊销后设备下线)。
|
||||
10. 退出登录 → 重新登录(令牌刷新正常)。
|
||||
最新源码重新编译后:`EXECUTOR_MODE=mock` 连续 3 次、`EXECUTOR_MODE=web` 连续 2 次均通过;加入成员/工作区/排期/密码回归后,8090 最新二进制再次通过。每次时间线均严格为 `draft>pending_review>queued>dispatched>running>success`,未再出现并发轮询造成的重复 `dispatched/running` 事件。
|
||||
|
||||
## 五、环境与运行状态
|
||||
## 仍需用户手动验收
|
||||
|
||||
- 后端 Go 服务:http://127.0.0.1:8090(`/health` 200)
|
||||
- 前端 dev:http://127.0.0.1:3003(生产构建已通过,`apps/web/dist/`)
|
||||
- MySQL(Docker everypublish-mysql)+ Redis(本机)运行中
|
||||
- 假执行器:demo 工作空间 + tester1 工作空间各一台(模拟客户端)
|
||||
1. 在账号管理选择 B站并完成真实扫码,确认账号从“等待扫码/已扫码”变为“已绑定”。
|
||||
2. 选择真实 B站账号和视频素材发布一条测试内容,确认平台页面内容、链接、回执和任务日志。
|
||||
3. 检查平台风控/验证码/登录过期时挑战、失败和重试是否符合预期。
|
||||
|
||||
## 六、结论
|
||||
|
||||
网页端阶段一交付满足《docs/delivery-plan.md》§7 第 1-5 项验收。**建议进入用户验收;确认通过后开始阶段二(Windows 客户端 D8-D11)。**
|
||||
没有人工扫码时,不能把真实平台登录或真实平台发布写成“已完成”。mock URL 只证明网页任务状态机和本机执行器联调。
|
||||
|
||||
@@ -5,4 +5,9 @@ REDIS_PASSWORD=
|
||||
JWT_SECRET=please-change-me-in-production
|
||||
BASE_URL=http://127.0.0.1:8090
|
||||
STORAGE_DIR=./data/materials
|
||||
CREDENTIAL_DIR=./data/credentials
|
||||
EXECUTOR_MODE=mock
|
||||
BILIBILI_MEMBER_BASE=https://member.bilibili.com
|
||||
BILIBILI_PASSPORT_BASE=https://passport.bilibili.com
|
||||
BILIBILI_UPOS_SCHEME=https
|
||||
STATIC_DIR=../apps/web/dist
|
||||
|
||||
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
workspace_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
platform VARCHAR(32) NOT NULL DEFAULT '',
|
||||
account_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
avatar_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unbound',
|
||||
health INT NOT NULL DEFAULT 100,
|
||||
@@ -109,6 +110,21 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
KEY idx_tasks_schedule_at (schedule_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
created_at DATETIME(3) NULL,
|
||||
updated_at DATETIME(3) NULL,
|
||||
workspace_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
task_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
type VARCHAR(32) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
message VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
data TEXT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_task_events_workspace_id (workspace_id),
|
||||
KEY idx_task_events_task_id (task_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_devices (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
created_at DATETIME(3) NULL,
|
||||
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
@@ -36,6 +37,7 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
|
||||
@@ -88,8 +88,8 @@ func TestAccountChallengeFlow(t *testing.T) {
|
||||
List []models.Challenge `json:"list"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &cl)
|
||||
if len(cl.List) != 1 || cl.List[0].Kind != "pending" {
|
||||
t.Fatalf("expect 1 pending challenge, got %d", len(cl.List))
|
||||
if len(cl.List) != 1 || cl.List[0].Kind != "qr" || cl.List[0].QRURL == "" {
|
||||
t.Fatalf("expect 1 web QR challenge, got %d", len(cl.List))
|
||||
}
|
||||
// 挂起 → 重发
|
||||
code, env = doReq(t, "POST", fmt.Sprintf("/api/v1/challenges/%d/suspend", cl.List[0].ID), access, nil)
|
||||
@@ -148,10 +148,45 @@ func TestMaterialAndTaskFlow(t *testing.T) {
|
||||
if code != 404 {
|
||||
t.Fatalf("one-time token should 404 on reuse, got %d", code)
|
||||
}
|
||||
accountRespCode, accountEnv := doReq(t, "POST", "/api/v1/accounts", access, gin.H{"platform": "douyin", "accountName": "任务测试账号"})
|
||||
if accountRespCode != 200 || accountEnv.Code != 0 {
|
||||
t.Fatalf("create task account failed: %d %s", accountRespCode, accountEnv.Message)
|
||||
}
|
||||
var taskAccount models.Account
|
||||
_ = json.Unmarshal(accountEnv.Data, &taskAccount)
|
||||
bindCode, bindEnv := doReq(t, "POST", fmt.Sprintf("/api/v1/accounts/%d/bind", taskAccount.ID), access, nil)
|
||||
if bindCode != 200 || bindEnv.Code != 0 {
|
||||
t.Fatalf("task account bind failed: %d %s", bindCode, bindEnv.Message)
|
||||
}
|
||||
var challengeList struct {
|
||||
List []models.Challenge `json:"list"`
|
||||
}
|
||||
_, challengeEnv := doReq(t, "GET", "/api/v1/challenges", access, nil)
|
||||
_ = json.Unmarshal(challengeEnv.Data, &challengeList)
|
||||
if len(challengeList.List) == 0 {
|
||||
t.Fatal("task account challenge missing")
|
||||
}
|
||||
challengeCode, challengeEnv := doReq(t, "POST", fmt.Sprintf("/api/v1/challenges/%d/solve", challengeList.List[0].ID), access, gin.H{"value": "web-test-login"})
|
||||
if challengeCode != 200 || challengeEnv.Code != 0 {
|
||||
t.Fatalf("task account challenge solve failed: %d %s", challengeCode, challengeEnv.Message)
|
||||
}
|
||||
// 后端不能接受尚未登录的账号作为发布目标,即使客户端绕过了筛选。
|
||||
unboundCode, unboundEnv := doReq(t, "POST", "/api/v1/accounts", access, gin.H{"platform": "bilibili", "remark": "未登录账号"})
|
||||
if unboundCode != 200 || unboundEnv.Code != 0 {
|
||||
t.Fatalf("create unbound account failed: %d %s", unboundCode, unboundEnv.Message)
|
||||
}
|
||||
var unbound models.Account
|
||||
_ = json.Unmarshal(unboundEnv.Data, &unbound)
|
||||
badTaskCode, _ := doReq(t, "POST", "/api/v1/tasks", access, gin.H{
|
||||
"title": "不应创建", "content": "", "accountIds": []uint64{unbound.ID}, "materialIds": []uint64{matID},
|
||||
})
|
||||
if badTaskCode != 400 {
|
||||
t.Fatalf("unbound account task should be rejected, got %d", badTaskCode)
|
||||
}
|
||||
// 建任务 → 提交 → 驳回 → 重提 → 通过 → 取消
|
||||
code, env = doReq(t, "POST", "/api/v1/tasks", access, gin.H{
|
||||
"title": "新品发布", "content": "正文", "tags": []string{"新品"},
|
||||
"accountIds": []uint64{1}, "materialIds": []uint64{matID},
|
||||
"accountIds": []uint64{taskAccount.ID}, "materialIds": []uint64{matID},
|
||||
})
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("create task failed: %d %s", code, env.Message)
|
||||
@@ -189,13 +224,15 @@ func TestMaterialAndTaskFlow(t *testing.T) {
|
||||
}
|
||||
code, env = doReq(t, "GET", fmt.Sprintf("/api/v1/tasks/%d", taskRow.ID), access, nil)
|
||||
_ = json.Unmarshal(env.Data, &taskRow)
|
||||
if taskRow.Status != "queued" {
|
||||
t.Fatalf("expect queued, got %s", taskRow.Status)
|
||||
if taskRow.Status != "queued" && taskRow.Status != "dispatched" && taskRow.Status != "running" && taskRow.Status != "success" {
|
||||
t.Fatalf("expect web executor to accept task, got %s", taskRow.Status)
|
||||
}
|
||||
if taskRow.Status == "queued" || taskRow.Status == "dispatched" || taskRow.Status == "running" {
|
||||
code, _ = doReq(t, "POST", fmt.Sprintf("/api/v1/tasks/%d/cancel", taskRow.ID), access, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("cancel failed: %d", code)
|
||||
}
|
||||
}
|
||||
// 通知:驳回+通过 2 条
|
||||
code, env = doReq(t, "GET", "/api/v1/notifications", access, nil)
|
||||
if code != 200 {
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"everypublish/server/internal/api/response"
|
||||
"everypublish/server/internal/credentials"
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/platform"
|
||||
"everypublish/server/internal/ws"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// AccountHandler 账号台账处理器(凭据仅在 Agent 本机保险库,服务器零凭据)
|
||||
// AccountHandler 账号台账处理器(Web-only 本机执行器)。
|
||||
type AccountHandler struct {
|
||||
DB *gorm.DB
|
||||
Hub *ws.Hub
|
||||
Registry *platform.Registry
|
||||
Credentials *credentials.Store
|
||||
ExecutorMode string
|
||||
PollInterval time.Duration
|
||||
pollMu sync.Mutex
|
||||
pollCancels map[uint64]context.CancelFunc
|
||||
pollGeneration map[uint64]uint64
|
||||
pollSeq uint64
|
||||
}
|
||||
|
||||
var platforms = map[string]bool{
|
||||
@@ -29,8 +41,13 @@ var platforms = map[string]bool{
|
||||
type accountReq struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
AgentDeviceID uint64 `json:"agentDeviceId"`
|
||||
AccountName string `json:"accountName"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
IPProfile string `json:"ipProfile"`
|
||||
}
|
||||
|
||||
type credentialsReq struct {
|
||||
Cookies string `json:"cookies" binding:"required"`
|
||||
}
|
||||
|
||||
// List 账号台账(筛选 platform/status)
|
||||
@@ -57,7 +74,7 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// Create 新增账号(初始 unbound;凭据只登记元数据)
|
||||
// Create 新增账号(初始 unbound;V1 凭据由本机 Web executor 管理)
|
||||
func (h *AccountHandler) Create(c *gin.Context) {
|
||||
var req accountReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -71,8 +88,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
account := models.Account{
|
||||
WorkspaceID: c.GetUint64("wsid"),
|
||||
Platform: req.Platform,
|
||||
AccountName: req.AccountName,
|
||||
Remark: req.Remark,
|
||||
AgentDeviceID: req.AgentDeviceID,
|
||||
AvatarURL: req.AvatarURL,
|
||||
IPProfile: req.IPProfile,
|
||||
Status: "unbound",
|
||||
Health: 100,
|
||||
}
|
||||
@@ -91,19 +110,26 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var req accountReq
|
||||
var req struct {
|
||||
Remark string `json:"remark"`
|
||||
AccountName string `json:"accountName"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
IPProfile string `json:"ipProfile"`
|
||||
}
|
||||
if err = c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{"remark": req.Remark}
|
||||
var account models.Account
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{"remark": req.Remark, "avatar_url": req.AvatarURL, "ip_profile": req.IPProfile}
|
||||
if req.AccountName != "" {
|
||||
updates["account_name"] = req.AccountName
|
||||
}
|
||||
if req.AgentDeviceID > 0 {
|
||||
updates["agent_device_id"] = req.AgentDeviceID
|
||||
}
|
||||
if err = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).Updates(updates).Error; err != nil {
|
||||
if err = h.DB.Model(&account).Updates(updates).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
@@ -131,11 +157,14 @@ func (h *AccountHandler) Delete(c *gin.Context) {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "删除失败")
|
||||
return
|
||||
}
|
||||
if h.Credentials != nil {
|
||||
_ = h.Credentials.Delete(account.WorkspaceID, account.ID)
|
||||
}
|
||||
response.Audit(c, "account.delete", "account:"+itoa(id), nil)
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// Bind 发起绑定:创建挑战记录(pending),等待客户端扫码(D4 WSS 联动)
|
||||
// Bind 发起绑定:创建网页端挑战,由当前服务器上的 Web adapter 继续处理。
|
||||
func (h *AccountHandler) Bind(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -147,19 +176,50 @@ func (h *AccountHandler) Bind(c *gin.Context) {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
if account.Status == "active" {
|
||||
response.Fail(c, http.StatusConflict, 3002, "账号已绑定")
|
||||
if account.Status == "binding" {
|
||||
var existing models.Challenge
|
||||
if findErr := h.DB.Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Order("id desc").First(&existing).Error; findErr == nil {
|
||||
response.OK(c, gin.H{"challengeId": existing.ID, "account": account.ID, "status": "binding", "qrUrl": existing.QRURL, "qrToken": existing.QRToken, "prompt": existing.Prompt, "expiresAt": existing.ExpiresAt})
|
||||
return
|
||||
}
|
||||
}
|
||||
var staleChallenges []models.Challenge
|
||||
_ = h.DB.Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Find(&staleChallenges).Error
|
||||
for _, stale := range staleChallenges {
|
||||
h.cancelLoginPoll(stale.ID)
|
||||
}
|
||||
if len(staleChallenges) > 0 {
|
||||
_ = h.DB.Model(&models.Challenge{}).Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Update("status", "suspended").Error
|
||||
}
|
||||
// A registered adapter owns the login session. The browser receives one QR
|
||||
// URL and the server polls it; no second client-side pairing code is needed.
|
||||
var adapter platform.Adapter
|
||||
if h.ExecutorMode != "mock" && h.Registry != nil {
|
||||
adapter = h.Registry.Get(account.Platform)
|
||||
}
|
||||
var session platform.LoginSession
|
||||
if adapter != nil {
|
||||
var beginErr error
|
||||
session, beginErr = adapter.BeginLogin(c.Request.Context())
|
||||
if beginErr != nil {
|
||||
response.Fail(c, http.StatusBadGateway, 2007, adapterErrorMessage(beginErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
challenge := models.Challenge{
|
||||
WorkspaceID: c.GetUint64("wsid"),
|
||||
AccountID: account.ID,
|
||||
Platform: account.Platform,
|
||||
Kind: "pending",
|
||||
Kind: "qr",
|
||||
Status: "active",
|
||||
Prompt: "等待客户端发起扫码,稍后此处展示二维码",
|
||||
Prompt: "请在网页中完成 " + account.Platform + " 登录",
|
||||
QRToken: session.Token,
|
||||
QRURL: session.URL,
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
if adapter == nil {
|
||||
challenge.QRURL = "mock://everypublish/login/" + strconv.FormatUint(account.ID, 10)
|
||||
}
|
||||
err = h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err = tx.Create(&challenge).Error; err != nil {
|
||||
return err
|
||||
@@ -170,26 +230,253 @@ func (h *AccountHandler) Bind(c *gin.Context) {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "发起绑定失败")
|
||||
return
|
||||
}
|
||||
// 定向下发:优先账号指定设备,否则工作空间首个在线设备
|
||||
if h.Hub != nil {
|
||||
target := account.AgentDeviceID
|
||||
if target == 0 || !h.Hub.AgentOnline(target) {
|
||||
if online := h.Hub.OnlineDevices(c.GetUint64("wsid")); len(online) > 0 {
|
||||
target = online[0]
|
||||
}
|
||||
}
|
||||
if target != 0 {
|
||||
env := proto.NewEnvelope(uuid.NewString(), proto.TypeChallenge, proto.Challenge{
|
||||
ChallengeID: strconv.FormatUint(challenge.ID, 10),
|
||||
AccountID: strconv.FormatUint(account.ID, 10),
|
||||
Platform: account.Platform,
|
||||
Kind: "qr",
|
||||
Prompt: "请使用手机客户端扫码登录 " + account.Platform,
|
||||
ExpiresAt: challenge.ExpiresAt.UnixMilli(),
|
||||
h.Hub.BroadcastWS(c.GetUint64("wsid"), "challenge.status", gin.H{
|
||||
"challengeId": challenge.ID, "accountId": account.ID, "status": challenge.Status,
|
||||
"kind": challenge.Kind, "platform": challenge.Platform, "qrUrl": challenge.QRURL,
|
||||
"qrToken": challenge.QRToken, "prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt,
|
||||
})
|
||||
_ = h.Hub.SendToAgent(target, env)
|
||||
}
|
||||
}
|
||||
response.Audit(c, "account.bind", "account:"+itoa(account.ID), gin.H{"challengeId": challenge.ID})
|
||||
response.OK(c, gin.H{"challengeId": challenge.ID, "account": account.ID, "status": "binding"})
|
||||
if adapter != nil {
|
||||
h.StartLoginPoll(challenge, account, c.GetUint64("uid"), adapter)
|
||||
}
|
||||
response.OK(c, gin.H{"challengeId": challenge.ID, "account": account.ID, "status": "binding", "qrUrl": challenge.QRURL, "qrToken": challenge.QRToken, "prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt})
|
||||
}
|
||||
|
||||
// ImportCredentials accepts a user-supplied platform cookie string. The value
|
||||
// is written directly to the encrypted adapter store and never returned or
|
||||
// included in audit details.
|
||||
func (h *AccountHandler) ImportCredentials(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var req credentialsReq
|
||||
if err = c.ShouldBindJSON(&req); err != nil || len(strings.TrimSpace(req.Cookies)) < 8 || len(req.Cookies) > 64*1024 {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "凭据格式不合法")
|
||||
return
|
||||
}
|
||||
var account models.Account
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
if h.ExecutorMode == "mock" || h.Registry == nil {
|
||||
response.Fail(c, http.StatusConflict, 3006, "当前 mock 模式不支持真实凭据导入")
|
||||
return
|
||||
}
|
||||
adapter := h.Registry.Get(account.Platform)
|
||||
if adapter == nil {
|
||||
response.Fail(c, http.StatusConflict, 3006, "该平台尚未接入真实适配器")
|
||||
return
|
||||
}
|
||||
if err = adapter.SaveCredentials(account.WorkspaceID, account.ID, strings.TrimSpace(req.Cookies)); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, adapterErrorMessage(err))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if err = h.DB.Model(&account).Updates(map[string]interface{}{"status": "active", "last_active_at": &now, "last_error": ""}).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "账号状态更新失败")
|
||||
return
|
||||
}
|
||||
var activeChallenges []models.Challenge
|
||||
_ = h.DB.Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Find(&activeChallenges).Error
|
||||
for _, challenge := range activeChallenges {
|
||||
h.cancelLoginPoll(challenge.ID)
|
||||
}
|
||||
_ = h.DB.Model(&models.Challenge{}).Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Update("status", "suspended").Error
|
||||
_ = Notify(h.DB, account.WorkspaceID, []uint64{c.GetUint64("uid")}, "challenge", "账号凭据已导入", "账号已恢复可用")
|
||||
if h.Hub != nil {
|
||||
h.Hub.BroadcastWS(account.WorkspaceID, "account.status", gin.H{"accountId": account.ID, "status": "active", "platform": account.Platform, "prompt": "凭据已导入"})
|
||||
}
|
||||
response.Audit(c, "account.credentials_import", "account:"+itoa(account.ID), gin.H{"platform": account.Platform})
|
||||
response.OK(c, gin.H{"accountId": account.ID, "status": "active", "lastActiveAt": now})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) Check(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var account models.Account
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
if h.Registry == nil || h.ExecutorMode == "mock" {
|
||||
response.Fail(c, http.StatusConflict, 3006, "当前模式不支持真实账号检查")
|
||||
return
|
||||
}
|
||||
checker, ok := h.Registry.Get(account.Platform).(platform.CredentialChecker)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusConflict, 3006, "该平台尚未提供账号检查")
|
||||
return
|
||||
}
|
||||
if err = checker.CheckCredentials(c.Request.Context(), account.WorkspaceID, account.ID); err != nil {
|
||||
message := adapterErrorMessage(err)
|
||||
_ = h.DB.Model(&account).Updates(map[string]interface{}{"status": "expired", "last_error": message}).Error
|
||||
response.Fail(c, http.StatusBadGateway, 2007, message)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_ = h.DB.Model(&account).Updates(map[string]interface{}{"status": "active", "last_active_at": &now, "last_error": ""}).Error
|
||||
response.Audit(c, "account.credentials_check", "account:"+itoa(account.ID), gin.H{"platform": account.Platform})
|
||||
response.OK(c, gin.H{"accountId": account.ID, "status": "active", "lastActiveAt": now})
|
||||
}
|
||||
|
||||
// Unbind 解绑账号,清除本机执行器关联但保留账号台账记录。
|
||||
func (h *AccountHandler) Unbind(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var account models.Account
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&account).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&account).Updates(map[string]interface{}{
|
||||
"status": "unbound", "account_name": "", "last_error": "",
|
||||
}).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "解绑失败")
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&models.Challenge{}).Where("account_id = ? and workspace_id = ? and status = ?", account.ID, account.WorkspaceID, "active").Update("status", "suspended").Error
|
||||
if h.Credentials != nil {
|
||||
_ = h.Credentials.Delete(account.WorkspaceID, account.ID)
|
||||
}
|
||||
response.Audit(c, "account.unbind", "account:"+itoa(id), nil)
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) pollLogin(ctx context.Context, challengeID uint64, account models.Account, userID uint64, adapter platform.Adapter) {
|
||||
interval := h.PollInterval
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
var challenge models.Challenge
|
||||
if err := h.DB.Where("id = ? and workspace_id = ?", challengeID, account.WorkspaceID).First(&challenge).Error; err != nil || challenge.Status != "active" {
|
||||
return
|
||||
}
|
||||
if time.Now().After(challenge.ExpiresAt) {
|
||||
h.finishLogin(challenge, account, userID, "expired", "登录二维码已过期")
|
||||
return
|
||||
}
|
||||
poll, err := adapter.PollLogin(ctx, challenge.QRToken)
|
||||
if err == nil {
|
||||
switch poll.State {
|
||||
case platform.LoginPending, platform.LoginScanned:
|
||||
prompt := poll.Prompt
|
||||
if prompt == "" {
|
||||
prompt = challenge.Prompt
|
||||
}
|
||||
_ = h.DB.Model(&challenge).Update("prompt", prompt).Error
|
||||
h.broadcastChallenge(challenge, string(poll.State), prompt)
|
||||
case platform.LoginConfirmed:
|
||||
if saveErr := adapter.SaveCredentials(account.WorkspaceID, account.ID, poll.Cookies); saveErr != nil {
|
||||
h.finishLogin(challenge, account, userID, "suspended", adapterErrorMessage(saveErr))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if h.DB.Model(&challenge).Updates(map[string]interface{}{"status": "solved", "solved_at": &now, "prompt": "登录成功"}).Error != nil {
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ?", account.ID, account.WorkspaceID).Updates(map[string]interface{}{"status": "active", "last_active_at": &now, "last_error": ""}).Error
|
||||
_ = Notify(h.DB, account.WorkspaceID, []uint64{userID}, "challenge", "登录挑战已完成", "账号已恢复可用")
|
||||
h.broadcastChallenge(challenge, "solved", "登录成功")
|
||||
return
|
||||
}
|
||||
} else if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
h.finishLogin(challenge, account, userID, "expired", "登录等待已超时")
|
||||
return
|
||||
} else if platform.CategoryOf(err) != platform.Network {
|
||||
h.finishLogin(challenge, account, userID, "suspended", adapterErrorMessage(err))
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
h.finishLogin(challenge, account, userID, "expired", "登录等待已超时")
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartLoginPoll is shared with challenge resend so every real QR session has
|
||||
// exactly one backend worker responsible for polling and credential persistence.
|
||||
func (h *AccountHandler) StartLoginPoll(challenge models.Challenge, account models.Account, userID uint64, adapter platform.Adapter) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
h.pollMu.Lock()
|
||||
if h.pollCancels == nil {
|
||||
h.pollCancels = make(map[uint64]context.CancelFunc)
|
||||
}
|
||||
if h.pollGeneration == nil {
|
||||
h.pollGeneration = make(map[uint64]uint64)
|
||||
}
|
||||
if old := h.pollCancels[challenge.ID]; old != nil {
|
||||
old()
|
||||
}
|
||||
h.pollSeq++
|
||||
generation := h.pollSeq
|
||||
h.pollCancels[challenge.ID] = cancel
|
||||
h.pollGeneration[challenge.ID] = generation
|
||||
h.pollMu.Unlock()
|
||||
go func() {
|
||||
h.pollLogin(ctx, challenge.ID, account, userID, adapter)
|
||||
h.pollMu.Lock()
|
||||
if h.pollGeneration[challenge.ID] == generation {
|
||||
delete(h.pollCancels, challenge.ID)
|
||||
delete(h.pollGeneration, challenge.ID)
|
||||
}
|
||||
h.pollMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *AccountHandler) cancelLoginPoll(challengeID uint64) {
|
||||
h.pollMu.Lock()
|
||||
cancel := h.pollCancels[challengeID]
|
||||
h.pollMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AccountHandler) finishLogin(challenge models.Challenge, account models.Account, userID uint64, status, message string) {
|
||||
_ = h.DB.Model(&challenge).Updates(map[string]interface{}{"status": status, "prompt": message}).Error
|
||||
accountStatus := "expired"
|
||||
if status == "suspended" {
|
||||
accountStatus = "suspended"
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ?", account.ID, account.WorkspaceID).Updates(map[string]interface{}{"status": accountStatus, "last_error": message}).Error
|
||||
_ = Notify(h.DB, account.WorkspaceID, []uint64{userID}, "challenge", "登录需要处理", message)
|
||||
h.broadcastChallenge(challenge, status, message)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) broadcastChallenge(challenge models.Challenge, status, prompt string) {
|
||||
if h.Hub == nil {
|
||||
return
|
||||
}
|
||||
h.Hub.BroadcastWS(challenge.WorkspaceID, "challenge.status", gin.H{
|
||||
"challengeId": challenge.ID, "accountId": challenge.AccountID, "status": status,
|
||||
"kind": challenge.Kind, "platform": challenge.Platform, "prompt": prompt,
|
||||
})
|
||||
}
|
||||
|
||||
func adapterErrorMessage(err error) string {
|
||||
var pe *platform.Error
|
||||
if errors.As(err, &pe) && strings.TrimSpace(pe.Message) != "" {
|
||||
return pe.Message
|
||||
}
|
||||
return "平台登录暂时不可用,请稍后重试"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/platform"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
gormsqlite "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type loginTestAdapter struct {
|
||||
mu sync.Mutex
|
||||
polls int
|
||||
saved string
|
||||
}
|
||||
|
||||
func (a *loginTestAdapter) Platform() string { return "test-platform" }
|
||||
func (a *loginTestAdapter) BeginLogin(context.Context) (platform.LoginSession, error) {
|
||||
return platform.LoginSession{Token: "token-1", URL: "https://qr.test/1"}, nil
|
||||
}
|
||||
func (a *loginTestAdapter) PollLogin(context.Context, string) (platform.LoginPoll, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.polls++
|
||||
if a.polls == 1 {
|
||||
return platform.LoginPoll{State: platform.LoginPending, Prompt: "等待扫码"}, nil
|
||||
}
|
||||
return platform.LoginPoll{State: platform.LoginConfirmed, Cookies: "cookie-value"}, nil
|
||||
}
|
||||
func (a *loginTestAdapter) SaveCredentials(_ uint64, _ uint64, cookies string) error {
|
||||
a.mu.Lock()
|
||||
a.saved = cookies
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (a *loginTestAdapter) Publish(context.Context, platform.PublishInput) (platform.PublishResult, error) {
|
||||
return platform.PublishResult{}, nil
|
||||
}
|
||||
|
||||
func TestAccountBindPollsAdapterAndActivatesAccount(t *testing.T) {
|
||||
db, err := gorm.Open(gormsqlite.Open(t.TempDir()+"/account.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.Account{}, &models.Challenge{}, &models.Notification{}, &models.AuditLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := models.Account{WorkspaceID: 9, Platform: "test-platform", Status: "unbound"}
|
||||
if err = db.Create(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter := &loginTestAdapter{}
|
||||
registry := platform.NewRegistry()
|
||||
if err = registry.Register(adapter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &AccountHandler{DB: db, Registry: registry, ExecutorMode: "web", PollInterval: 5 * time.Millisecond}
|
||||
gin.SetMode(gin.TestMode)
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/1/bind", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = req
|
||||
ctx.Params = gin.Params{{Key: "id", Value: "1"}}
|
||||
ctx.Set("wsid", uint64(9))
|
||||
ctx.Set("uid", uint64(3))
|
||||
h.Bind(ctx)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("bind status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ChallengeID uint64 `json:"challengeId"`
|
||||
QRURL string `json:"qrUrl"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Data.ChallengeID == 0 || envelope.Data.QRURL != "https://qr.test/1" {
|
||||
t.Fatalf("unexpected bind response: %s", recorder.Body.String())
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var got models.Account
|
||||
_ = db.First(&got, account.ID).Error
|
||||
if got.Status == "active" {
|
||||
adapter.mu.Lock()
|
||||
saved := adapter.saved
|
||||
adapter.mu.Unlock()
|
||||
if saved != "cookie-value" {
|
||||
t.Fatalf("credentials not saved: %q", saved)
|
||||
}
|
||||
var challenge models.Challenge
|
||||
_ = db.First(&challenge, envelope.Data.ChallengeID).Error
|
||||
if challenge.Status != "solved" {
|
||||
t.Fatalf("challenge status=%s", challenge.Status)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("adapter login worker did not activate account")
|
||||
}
|
||||
@@ -22,10 +22,9 @@ type tenantRow struct {
|
||||
models.Workspace
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
DeviceCount int64 `json:"deviceCount"`
|
||||
}
|
||||
|
||||
// Tenants 租户列表(含 owner 邮箱与成员/设备数)
|
||||
// Tenants 租户列表(含 owner 邮箱与成员数)
|
||||
func (h *AdminHandler) Tenants(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
@@ -51,7 +50,6 @@ func (h *AdminHandler) Tenants(c *gin.Context) {
|
||||
row.OwnerEmail = owner.Email
|
||||
}
|
||||
h.DB.Model(&models.Member{}).Where("workspace_id = ?", ws.ID).Count(&row.MemberCount)
|
||||
h.DB.Model(&models.AgentDevice{}).Where("workspace_id = ?", ws.ID).Count(&row.DeviceCount)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
response.OK(c, gin.H{"list": rows, "total": total, "page": page, "size": size})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -52,18 +55,45 @@ func (h *AgentHandler) PairCode(c *gin.Context) {
|
||||
// Pair 设备配对(无鉴权,凭一次性码;注册 Ed25519 公钥)
|
||||
func (h *AgentHandler) Pair(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
DeviceName string `json:"deviceName" binding:"required"`
|
||||
Code string `json:"code"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
OS string `json:"os"`
|
||||
Version string `json:"version"`
|
||||
PublicKey string `json:"publicKey" binding:"required"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "请求体必须是 JSON")
|
||||
return
|
||||
}
|
||||
req.Code = strings.ToUpper(strings.TrimSpace(req.Code))
|
||||
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
||||
req.PublicKey = strings.TrimSpace(req.PublicKey)
|
||||
if req.Code == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "连接码不能为空")
|
||||
return
|
||||
}
|
||||
if !validPairCode(req.Code) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "配对码应为 6 位大写字母或数字")
|
||||
return
|
||||
}
|
||||
if req.PublicKey == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "设备公钥不能为空,请重启客户端后重试")
|
||||
return
|
||||
}
|
||||
publicKey, keyErr := base64.StdEncoding.DecodeString(req.PublicKey)
|
||||
if keyErr != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "设备公钥格式不合法")
|
||||
return
|
||||
}
|
||||
if req.DeviceName == "" {
|
||||
req.DeviceName = "macOS Agent"
|
||||
}
|
||||
var pc models.PairingCode
|
||||
if err := h.DB.Where("code = ? and used = ?", req.Code, false).First(&pc).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "查询配对码失败")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 2006, "配对码无效")
|
||||
return
|
||||
}
|
||||
@@ -84,7 +114,7 @@ func (h *AgentHandler) Pair(c *gin.Context) {
|
||||
// 原子占用:仅当仍 unused 且未过期时置 used=true;RowsAffected==1 才视为抢到,
|
||||
// 防止并发用同一码配出多台设备。
|
||||
res := tx.Model(&models.PairingCode{}).
|
||||
Where("id = ? and used = ?", pc.ID, false).
|
||||
Where("id = ? and used = ? and expires_at > ?", pc.ID, false, time.Now()).
|
||||
Update("used", true)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
@@ -112,6 +142,18 @@ func (h *AgentHandler) Pair(c *gin.Context) {
|
||||
response.OK(c, gin.H{"deviceId": device.ID, "workspaceId": device.WorkspaceID})
|
||||
}
|
||||
|
||||
func validPairCode(code string) bool {
|
||||
if len(code) != 6 {
|
||||
return false
|
||||
}
|
||||
for _, r := range code {
|
||||
if !strings.ContainsRune(pairAlphabet, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Devices 设备列表(在线状态来自 Hub,此处以 DB 状态兜底)
|
||||
func (h *AgentHandler) Devices(c *gin.Context) {
|
||||
var list []models.AgentDevice
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidPairCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{name: "valid", code: "AB23CD", want: true},
|
||||
{name: "ambiguous characters rejected", code: "AB01CD", want: false},
|
||||
{name: "lowercase rejected before normalization", code: "ab12cd", want: false},
|
||||
{name: "wrong length", code: "AB12C", want: false},
|
||||
{name: "unicode rejected", code: "AB12CD", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validPairCode(tt.code); got != tt.want {
|
||||
t.Fatalf("validPairCode(%q) = %v, want %v", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -42,6 +43,11 @@ type logoutReq struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
type changePasswordReq struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
// Register 注册:创建用户 + 默认工作空间 + owner 成员
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req registerReq
|
||||
@@ -177,8 +183,13 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
response.Fail(c, http.StatusUnauthorized, 2003, "用户不存在")
|
||||
return
|
||||
}
|
||||
workspaceID := claims.WorkspaceID
|
||||
var member models.Member
|
||||
if err = h.DB.Where("user_id = ?", user.ID).Order("id asc").First(&member).Error; err != nil {
|
||||
memberQuery := h.DB.Where("user_id = ?", user.ID)
|
||||
if workspaceID != 0 {
|
||||
memberQuery = memberQuery.Where("workspace_id = ?", workspaceID)
|
||||
}
|
||||
if err = memberQuery.Order("id asc").First(&member).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "工作空间数据缺失")
|
||||
return
|
||||
}
|
||||
@@ -203,6 +214,39 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req changePasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "密码格式不合法")
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := h.DB.First(&user, c.GetUint64("uid")).Error; err != nil {
|
||||
response.Fail(c, http.StatusUnauthorized, 1002, "用户不存在")
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(req.CurrentPassword, user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
response.Fail(c, http.StatusBadRequest, 2002, "当前密码错误")
|
||||
return
|
||||
}
|
||||
if req.CurrentPassword == req.NewPassword {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "新密码不能与当前密码相同")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "密码更新失败")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&user).Update("password_hash", hash).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "密码更新失败")
|
||||
return
|
||||
}
|
||||
response.Audit(c, "auth.password_change", "user:"+itoa(user.ID), nil)
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// Me 当前用户信息
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
var user models.User
|
||||
@@ -213,6 +257,34 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
response.OK(c, gin.H{"user": userDTO(user), "workspaceId": c.GetUint64("wsid"), "memberRole": c.GetString("mrole")})
|
||||
}
|
||||
|
||||
// SwitchWorkspace issues a fresh access/refresh pair for another workspace
|
||||
// where the current user is a member. The old token remains valid until its
|
||||
// normal expiry, but all resource handlers use the newly selected workspace.
|
||||
func (h *AuthHandler) SwitchWorkspace(c *gin.Context) {
|
||||
wsid, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var member models.Member
|
||||
if err = h.DB.Where("workspace_id = ? and user_id = ?", wsid, c.GetUint64("uid")).First(&member).Error; err != nil {
|
||||
response.Fail(c, http.StatusForbidden, 1003, "不是该工作空间成员")
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err = h.DB.First(&user, c.GetUint64("uid")).Error; err != nil || user.Status != "active" {
|
||||
response.Fail(c, http.StatusUnauthorized, 1002, "用户不可用")
|
||||
return
|
||||
}
|
||||
access, refresh, err := h.issuePair(user.ID, wsid, user.Role, member.Role)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
response.Audit(c, "workspace.switch", "workspace:"+itoa(wsid), nil)
|
||||
response.OK(c, gin.H{"accessToken": access, "refreshToken": refresh, "workspace": gin.H{"id": wsid}, "memberRole": member.Role})
|
||||
}
|
||||
|
||||
// issuePair 签发令牌对并把 refresh jti 登记到 Redis
|
||||
func (h *AuthHandler) issuePair(uid, wsid uint64, role, mrole string) (string, string, error) {
|
||||
access, err := auth.IssueAccess(h.Cfg.JWTSecret, uid, wsid, role, mrole)
|
||||
@@ -220,7 +292,7 @@ func (h *AuthHandler) issuePair(uid, wsid uint64, role, mrole string) (string, s
|
||||
return "", "", err
|
||||
}
|
||||
jti := uuid.NewString()
|
||||
refresh, err := auth.IssueRefresh(h.Cfg.JWTSecret, uid, jti)
|
||||
refresh, err := auth.IssueRefresh(h.Cfg.JWTSecret, uid, wsid, jti)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,18 +11,35 @@ import (
|
||||
|
||||
"everypublish/server/internal/api/response"
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/platform"
|
||||
"everypublish/server/internal/ws"
|
||||
)
|
||||
|
||||
// ChallengeHandler 二次验证挑战处理器
|
||||
type ChallengeHandler struct {
|
||||
DB *gorm.DB
|
||||
Hub *ws.Hub
|
||||
Registry *platform.Registry
|
||||
ExecutorMode string
|
||||
StartLoginPoll func(models.Challenge, models.Account, uint64, platform.Adapter)
|
||||
}
|
||||
|
||||
// List 挑战列表(过期软置 expired)
|
||||
func (h *ChallengeHandler) List(c *gin.Context) {
|
||||
h.DB.Model(&models.Challenge{}).
|
||||
Where("workspace_id = ? and status = ? and expires_at < ?", c.GetUint64("wsid"), "active", time.Now()).
|
||||
Update("status", "expired")
|
||||
var expired []models.Challenge
|
||||
h.DB.Where("workspace_id = ? and status = ? and expires_at < ?", c.GetUint64("wsid"), "active", time.Now()).Find(&expired)
|
||||
if len(expired) > 0 {
|
||||
ids := make([]uint64, 0, len(expired))
|
||||
for _, challenge := range expired {
|
||||
ids = append(ids, challenge.ID)
|
||||
}
|
||||
h.DB.Model(&models.Challenge{}).Where("id in ?", ids).Update("status", "expired")
|
||||
accountIDs := make([]uint64, 0, len(expired))
|
||||
for _, challenge := range expired {
|
||||
accountIDs = append(accountIDs, challenge.AccountID)
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).Where("workspace_id = ? and id in ? and status = ?", c.GetUint64("wsid"), accountIDs, "binding").Updates(map[string]interface{}{"status": "expired", "last_error": "登录二维码已过期"}).Error
|
||||
}
|
||||
q := h.DB.Model(&models.Challenge{}).Where("workspace_id = ?", c.GetUint64("wsid"))
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("status = ?", s)
|
||||
@@ -34,7 +52,8 @@ func (h *ChallengeHandler) List(c *gin.Context) {
|
||||
response.OK(c, gin.H{"list": list})
|
||||
}
|
||||
|
||||
// Solve 人工完成挑战(验证码/APP确认;扫码由 Agent 完成)
|
||||
// Solve 完成网页挑战。mock QR 只接受显式联调值;真实平台挑战必须由 adapter
|
||||
// 在用户完成扫码后回写,不能通过空请求把账号标成 active。
|
||||
func (h *ChallengeHandler) Solve(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -44,7 +63,10 @@ func (h *ChallengeHandler) Solve(c *gin.Context) {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err = c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "请输入挑战确认值")
|
||||
return
|
||||
}
|
||||
var challenge models.Challenge
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&challenge).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "挑战不存在")
|
||||
@@ -54,16 +76,35 @@ func (h *ChallengeHandler) Solve(c *gin.Context) {
|
||||
response.Fail(c, http.StatusConflict, 3003, "挑战已结束")
|
||||
return
|
||||
}
|
||||
value := strings.TrimSpace(req.Value)
|
||||
if value == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "挑战确认值不能为空")
|
||||
return
|
||||
}
|
||||
if challenge.Kind == "qr" && !strings.HasPrefix(challenge.QRURL, "mock://") {
|
||||
response.Fail(c, http.StatusConflict, 3003, "真实二维码必须由平台登录适配器确认")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if err = h.DB.Model(&challenge).Updates(map[string]interface{}{"status": "solved", "solved_at": &now}).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).
|
||||
Where("id = ? and workspace_id = ?", challenge.AccountID, c.GetUint64("wsid")).
|
||||
Updates(map[string]interface{}{"status": "active", "last_active_at": &now}).Error
|
||||
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{c.GetUint64("uid")}, "challenge", "登录挑战已完成", "账号已恢复可用")
|
||||
if h.Hub != nil {
|
||||
h.Hub.BroadcastWS(c.GetUint64("wsid"), "challenge.status", gin.H{
|
||||
"challengeId": challenge.ID, "accountId": challenge.AccountID, "status": "solved",
|
||||
})
|
||||
h.Hub.BroadcastWS(c.GetUint64("wsid"), "notification.new", gin.H{"kind": "challenge", "title": "登录挑战已完成", "content": "账号已恢复可用"})
|
||||
}
|
||||
response.Audit(c, "challenge.solve", "challenge:"+itoa(id), gin.H{"kind": challenge.Kind})
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// Resend 一键重发(重置过期时间,实际重发出 Agent 执行)
|
||||
// Resend 一键重发(重置过期时间,交由当前 Web adapter 继续处理)
|
||||
func (h *ChallengeHandler) Resend(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -79,14 +120,57 @@ func (h *ChallengeHandler) Resend(c *gin.Context) {
|
||||
response.Fail(c, http.StatusConflict, 3003, "当前状态不可重发")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&challenge).Updates(map[string]interface{}{
|
||||
var account models.Account
|
||||
if err = h.DB.Where("id = ? and workspace_id = ?", challenge.AccountID, c.GetUint64("wsid")).First(&account).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "账号不存在")
|
||||
return
|
||||
}
|
||||
var adapter platform.Adapter
|
||||
var session platform.LoginSession
|
||||
if h.ExecutorMode != "mock" && h.Registry != nil {
|
||||
adapter = h.Registry.Get(account.Platform)
|
||||
if adapter != nil {
|
||||
session, err = adapter.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadGateway, 2007, adapterErrorMessage(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": "active", "expires_at": time.Now().Add(30 * time.Minute),
|
||||
}).Error; err != nil {
|
||||
"prompt": "请在网页中完成 " + account.Platform + " 登录",
|
||||
}
|
||||
if adapter != nil {
|
||||
updates["qr_token"] = session.Token
|
||||
updates["qr_url"] = session.URL
|
||||
}
|
||||
if err = h.DB.Model(&challenge).Updates(updates).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&account).Updates(map[string]interface{}{"status": "binding", "last_error": ""}).Error
|
||||
if adapter != nil && h.StartLoginPoll != nil {
|
||||
challenge.QRToken = session.Token
|
||||
challenge.QRURL = session.URL
|
||||
challenge.Status = "active"
|
||||
challenge.ExpiresAt = updates["expires_at"].(time.Time)
|
||||
challenge.Prompt = updates["prompt"].(string)
|
||||
h.StartLoginPoll(challenge, account, c.GetUint64("uid"), adapter)
|
||||
} else {
|
||||
challenge.Status = "active"
|
||||
challenge.ExpiresAt = updates["expires_at"].(time.Time)
|
||||
challenge.Prompt = updates["prompt"].(string)
|
||||
}
|
||||
if h.Hub != nil {
|
||||
h.Hub.BroadcastWS(challenge.WorkspaceID, "challenge.status", gin.H{
|
||||
"challengeId": challenge.ID, "accountId": challenge.AccountID, "status": "active",
|
||||
"kind": challenge.Kind, "platform": challenge.Platform, "qrUrl": challenge.QRURL,
|
||||
"prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt,
|
||||
})
|
||||
}
|
||||
response.Audit(c, "challenge.resend", "challenge:"+itoa(id), nil)
|
||||
response.OK(c, nil)
|
||||
response.OK(c, gin.H{"challengeId": challenge.ID, "qrUrl": challenge.QRURL, "qrToken": challenge.QRToken, "prompt": challenge.Prompt, "expiresAt": challenge.ExpiresAt})
|
||||
}
|
||||
|
||||
// Suspend 挂起(超时/人工)
|
||||
@@ -109,6 +193,7 @@ func (h *ChallengeHandler) Suspend(c *gin.Context) {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
_ = h.DB.Model(&models.Account{}).Where("id = ? and workspace_id = ? and status = ?", challenge.AccountID, c.GetUint64("wsid"), "binding").Updates(map[string]interface{}{"status": "expired", "last_error": "登录挑战已挂起"}).Error
|
||||
response.Audit(c, "challenge.suspend", "challenge:"+itoa(id), nil)
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ func (h *MaterialHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
kind := "image"
|
||||
if strings.HasPrefix(header.Header.Get("Content-Type"), "video/") {
|
||||
contentType := strings.ToLower(header.Header.Get("Content-Type"))
|
||||
if strings.HasPrefix(contentType, "video/") || isVideoExtension(ext) {
|
||||
kind = "video"
|
||||
}
|
||||
group := strings.TrimSpace(c.PostForm("group"))
|
||||
@@ -125,6 +126,15 @@ func (h *MaterialHandler) Upload(c *gin.Context) {
|
||||
response.OK(c, gin.H{"material": material, "dedup": false})
|
||||
}
|
||||
|
||||
func isVideoExtension(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".mp4", ".mov", ".avi", ".mkv", ".m4v", ".webm", ".flv", ".wmv":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// URL 生成签名直链(10 分钟一次性)
|
||||
func (h *MaterialHandler) URL(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -171,10 +181,15 @@ func (h *MaterialHandler) ServeFile(c *gin.Context) {
|
||||
_ = h.DB.Delete(&token).Error
|
||||
abspath := filepath.Join(h.Cfg.StorageDir, material.StorageKey)
|
||||
clean := filepath.Clean(abspath)
|
||||
if !strings.HasPrefix(clean, filepath.Clean(h.Cfg.StorageDir)) {
|
||||
root := filepath.Clean(h.Cfg.StorageDir) + string(os.PathSeparator)
|
||||
if !strings.HasPrefix(clean, root) {
|
||||
response.Fail(c, http.StatusForbidden, 1003, "非法路径")
|
||||
return
|
||||
}
|
||||
if c.Query("inline") == "1" {
|
||||
c.File(abspath)
|
||||
return
|
||||
}
|
||||
c.FileAttachment(abspath, material.Name)
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ func (h *MemberHandler) UpdateRole(c *gin.Context) {
|
||||
response.Fail(c, http.StatusForbidden, 1003, "不可修改 owner 角色")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&member).Update("role", req.Role).Error; err != nil {
|
||||
if err = h.DB.Model(&member).Where("workspace_id = ?", c.GetUint64("wsid")).Update("role", req.Role).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -39,6 +40,54 @@ func marshalJSONArray[T any](arr []T) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) validateTargets(wsid uint64, accountIDs, materialIDs []uint64) error {
|
||||
var accounts []models.Account
|
||||
if err := h.DB.Where("workspace_id = ? and id in ? and status = ?", wsid, accountIDs, "active").Find(&accounts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(accounts) != len(uniqueIDs(accountIDs)) {
|
||||
return fmt.Errorf("账号不存在、不属于当前工作区或尚未完成登录")
|
||||
}
|
||||
var materials []models.Material
|
||||
if err := h.DB.Where("workspace_id = ? and id in ? and status = ?", wsid, materialIDs, "ready").Find(&materials).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(materials) != len(uniqueIDs(materialIDs)) {
|
||||
return fmt.Errorf("素材不存在、未就绪或不属于当前工作区")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueIDs(values []uint64) []uint64 {
|
||||
seen := make(map[uint64]struct{}, len(values))
|
||||
result := make([]uint64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func recordTaskEvent(db *gorm.DB, row *models.Task, eventType, message string, data interface{}) {
|
||||
raw := ""
|
||||
if data != nil {
|
||||
if encoded, err := json.Marshal(data); err == nil {
|
||||
raw = string(encoded)
|
||||
}
|
||||
}
|
||||
_ = db.Create(&models.TaskEvent{
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
TaskID: row.ID,
|
||||
Type: eventType,
|
||||
Status: row.Status,
|
||||
Message: message,
|
||||
Data: raw,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// List 任务列表(状态筛选 + 分页)
|
||||
func (h *TaskHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
@@ -46,7 +95,7 @@ func (h *TaskHandler) List(c *gin.Context) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
if size < 1 || size > 300 {
|
||||
size = 20
|
||||
}
|
||||
q := h.DB.Model(&models.Task{}).Where("workspace_id = ?", c.GetUint64("wsid"))
|
||||
@@ -82,6 +131,10 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if err := h.validateTargets(c.GetUint64("wsid"), req.AccountIDs, req.MaterialIDs); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1004, err.Error())
|
||||
return
|
||||
}
|
||||
priority := req.Priority
|
||||
if priority < 1 || priority > 10 {
|
||||
priority = 5
|
||||
@@ -100,16 +153,36 @@ func (h *TaskHandler) Create(c *gin.Context) {
|
||||
}
|
||||
if req.ScheduleAt != nil {
|
||||
ts := time.UnixMilli(*req.ScheduleAt)
|
||||
if !ts.After(time.Now()) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "定时发布时间必须晚于当前时间")
|
||||
return
|
||||
}
|
||||
row.ScheduleAt = &ts
|
||||
}
|
||||
if err := h.DB.Create(&row).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "创建失败")
|
||||
return
|
||||
}
|
||||
recordTaskEvent(h.DB, &row, "created", "任务已创建", nil)
|
||||
response.Audit(c, "task.create", "task:"+itoa(row.ID), gin.H{"title": req.Title})
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
// Events 返回任务时间线,并强制按 workspace 隔离。
|
||||
func (h *TaskHandler) Events(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
var events []models.TaskEvent
|
||||
if err = h.DB.Where("task_id = ? and workspace_id = ?", id, c.GetUint64("wsid")).Order("id asc").Find(&events).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "任务时间线加载失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"list": events})
|
||||
}
|
||||
|
||||
// Get 任务详情
|
||||
func (h *TaskHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -146,6 +219,14 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
response.Fail(c, http.StatusConflict, 3005, "仅草稿或已驳回任务可编辑")
|
||||
return
|
||||
}
|
||||
if len(req.AccountIDs) == 0 || len(req.MaterialIDs) == 0 {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "至少选择一个账号和素材")
|
||||
return
|
||||
}
|
||||
if err := h.validateTargets(c.GetUint64("wsid"), req.AccountIDs, req.MaterialIDs); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 1004, err.Error())
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"title": req.Title,
|
||||
"content": req.Content,
|
||||
@@ -155,7 +236,15 @@ func (h *TaskHandler) Update(c *gin.Context) {
|
||||
}
|
||||
if req.ScheduleAt != nil {
|
||||
ts := time.UnixMilli(*req.ScheduleAt)
|
||||
if !ts.After(time.Now()) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "定时发布时间必须晚于当前时间")
|
||||
return
|
||||
}
|
||||
updates["schedule_at"] = &ts
|
||||
} else {
|
||||
// nil represents an explicit immediate schedule in the JSON contract.
|
||||
// The update endpoint has no meaningful partial-update semantics.
|
||||
updates["schedule_at"] = nil
|
||||
}
|
||||
if err = h.DB.Model(&row).Updates(updates).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
@@ -215,6 +304,7 @@ func (h *TaskHandler) apply(c *gin.Context, action task.Action, extra map[string
|
||||
return nil
|
||||
}
|
||||
row.Status = next
|
||||
recordTaskEvent(h.DB, &row, string(action), "状态变更为 "+next, extra)
|
||||
return &row
|
||||
}
|
||||
|
||||
@@ -230,9 +320,15 @@ func (h *TaskHandler) Submit(c *gin.Context) {
|
||||
func (h *TaskHandler) Approve(c *gin.Context) {
|
||||
if row := h.apply(c, task.ActionApprove, nil); row != nil {
|
||||
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{row.CreatedBy}, "task", "任务已通过审核", row.Title)
|
||||
if h.Dispatcher != nil && h.Dispatcher.Hub != nil {
|
||||
h.Dispatcher.Hub.BroadcastWS(row.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务已通过审核", "content": row.Title})
|
||||
}
|
||||
response.Audit(c, "task.approve", "task:"+itoa(row.ID), nil)
|
||||
if h.Dispatcher != nil {
|
||||
_ = h.Dispatcher.TryDispatch(row.ID) // 在线设备立即下发
|
||||
_ = h.Dispatcher.TryDispatch(row.ID)
|
||||
// TryDispatch can synchronously advance a Web-only mock task. Return
|
||||
// the persisted state instead of the stale queued copy from apply.
|
||||
_ = h.DB.Where("id = ? and workspace_id = ?", row.ID, row.WorkspaceID).First(row).Error
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -246,6 +342,9 @@ func (h *TaskHandler) Reject(c *gin.Context) {
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if row := h.apply(c, task.ActionReject, map[string]interface{}{"review_note": req.Note, "reviewed_by": c.GetUint64("uid")}); row != nil {
|
||||
_ = Notify(h.DB, c.GetUint64("wsid"), []uint64{row.CreatedBy}, "task", "任务被驳回", req.Note)
|
||||
if h.Dispatcher != nil && h.Dispatcher.Hub != nil {
|
||||
h.Dispatcher.Hub.BroadcastWS(row.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务被驳回", "content": req.Note})
|
||||
}
|
||||
response.Audit(c, "task.reject", "task:"+itoa(row.ID), gin.H{"note": req.Note})
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -273,6 +372,9 @@ func (h *TaskHandler) Retry(c *gin.Context) {
|
||||
// Cancel 取消
|
||||
func (h *TaskHandler) Cancel(c *gin.Context) {
|
||||
if row := h.apply(c, task.ActionCancel, nil); row != nil {
|
||||
if h.Dispatcher != nil {
|
||||
h.Dispatcher.Cancel(row.ID)
|
||||
}
|
||||
response.Audit(c, "task.cancel", "task:"+itoa(row.ID), nil)
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 1001, "参数不合法")
|
||||
return
|
||||
}
|
||||
if id != c.GetUint64("wsid") {
|
||||
response.Fail(c, http.StatusNotFound, 1004, "工作空间不存在")
|
||||
return
|
||||
}
|
||||
if err = h.DB.Model(&models.Workspace{}).Where("id = ?", id).Update("name", req.Name).Error; err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 5000, "更新失败")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"everypublish/server/internal/api/response"
|
||||
)
|
||||
|
||||
// WorkspaceRoles enforces the member role from the current JWT. Resource
|
||||
// handlers still apply workspace_id predicates; this middleware only handles
|
||||
// the action-level permission boundary.
|
||||
func WorkspaceRoles(roles ...string) gin.HandlerFunc {
|
||||
allowed := make(map[string]struct{}, len(roles))
|
||||
for _, role := range roles {
|
||||
allowed[role] = struct{}{}
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if _, ok := allowed[c.GetString("mrole")]; !ok {
|
||||
response.Fail(c, http.StatusForbidden, 1003, "当前角色无权执行此操作")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -12,11 +11,13 @@ import (
|
||||
"everypublish/server/internal/api/middleware"
|
||||
"everypublish/server/internal/cache"
|
||||
"everypublish/server/internal/config"
|
||||
"everypublish/server/internal/credentials"
|
||||
"everypublish/server/internal/platform"
|
||||
"everypublish/server/internal/ws"
|
||||
)
|
||||
|
||||
// Router 装配 gin 路由
|
||||
func Router(cfg *config.Config, db *gorm.DB, rds *cache.Redis, hub *ws.Hub, serverKey ed25519.PrivateKey, dsp *ws.Dispatcher) *gin.Engine {
|
||||
func Router(cfg *config.Config, db *gorm.DB, rds *cache.Redis, hub *ws.Hub, dsp *ws.Dispatcher) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
@@ -35,23 +36,41 @@ func Router(cfg *config.Config, db *gorm.DB, rds *cache.Redis, hub *ws.Hub, serv
|
||||
})
|
||||
})
|
||||
|
||||
// WSS 双通道:Agent 设备签名 / 浏览器 JWT
|
||||
r.GET("/ws/agent", ws.HandleAgent(cfg, db, hub, serverKey))
|
||||
r.GET("/ws/browser", ws.HandleBrowser(cfg, hub))
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.Audit(db))
|
||||
api.GET("/runtime/status", middleware.AuthRequired(cfg), func(c *gin.Context) {
|
||||
mode := cfg.ExecutorMode
|
||||
adapters := []string(nil)
|
||||
if dsp != nil && dsp.Registry != nil {
|
||||
adapters = dsp.Registry.Names()
|
||||
}
|
||||
dbOK := false
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
dbOK = sqlDB.Ping() == nil
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": gin.H{
|
||||
"executorMode": mode, "adapters": adapters, "db": dbOK, "redis": rds.Ping() == nil,
|
||||
}})
|
||||
})
|
||||
|
||||
authH := &handlers.AuthHandler{DB: db, Rds: rds, Cfg: cfg}
|
||||
wsH := &handlers.WorkspaceHandler{DB: db}
|
||||
mbH := &handlers.MemberHandler{DB: db, Cfg: cfg}
|
||||
adH := &handlers.AuditHandler{DB: db}
|
||||
accH := &handlers.AccountHandler{DB: db, Hub: hub}
|
||||
var registry *platform.Registry
|
||||
var credentialStore *credentials.Store
|
||||
if dsp != nil {
|
||||
registry = dsp.Registry
|
||||
credentialStore = dsp.Credentials
|
||||
}
|
||||
accH := &handlers.AccountHandler{DB: db, Hub: hub, Registry: registry, Credentials: credentialStore, ExecutorMode: cfg.ExecutorMode}
|
||||
matH := &handlers.MaterialHandler{DB: db, Cfg: cfg}
|
||||
tskH := &handlers.TaskHandler{DB: db, Dispatcher: dsp}
|
||||
chlH := &handlers.ChallengeHandler{DB: db}
|
||||
chlH := &handlers.ChallengeHandler{DB: db, Hub: hub, Registry: registry, ExecutorMode: cfg.ExecutorMode}
|
||||
chlH.StartLoginPoll = accH.StartLoginPoll
|
||||
ntfH := &handlers.NotificationHandler{DB: db}
|
||||
agH := &handlers.AgentHandler{DB: db, Hub: hub}
|
||||
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
@@ -59,7 +78,9 @@ func Router(cfg *config.Config, db *gorm.DB, rds *cache.Redis, hub *ws.Hub, serv
|
||||
auth.POST("/login", authH.Login)
|
||||
auth.POST("/refresh", authH.Refresh)
|
||||
auth.POST("/logout", middleware.AuthRequired(cfg), authH.Logout)
|
||||
auth.POST("/password", middleware.AuthRequired(cfg), authH.ChangePassword)
|
||||
auth.GET("/me", middleware.AuthRequired(cfg), authH.Me)
|
||||
auth.POST("/switch-workspace/:id", middleware.AuthRequired(cfg), authH.SwitchWorkspace)
|
||||
}
|
||||
|
||||
wsg := api.Group("/workspaces", middleware.AuthRequired(cfg))
|
||||
@@ -86,17 +107,20 @@ func Router(cfg *config.Config, db *gorm.DB, rds *cache.Redis, hub *ws.Hub, serv
|
||||
acc := api.Group("/accounts", middleware.AuthRequired(cfg))
|
||||
{
|
||||
acc.GET("", accH.List)
|
||||
acc.POST("", accH.Create)
|
||||
acc.PUT("/:id", accH.Update)
|
||||
acc.DELETE("/:id", accH.Delete)
|
||||
acc.POST("/:id/bind", accH.Bind)
|
||||
acc.POST("", middleware.WorkspaceRoles("owner", "admin", "operator"), accH.Create)
|
||||
acc.PUT("/:id", middleware.WorkspaceRoles("owner", "admin", "operator"), accH.Update)
|
||||
acc.DELETE("/:id", middleware.WorkspaceRoles("owner", "admin"), accH.Delete)
|
||||
acc.POST("/:id/bind", middleware.WorkspaceRoles("owner", "admin", "operator"), accH.Bind)
|
||||
acc.POST("/:id/credentials", middleware.WorkspaceRoles("owner", "admin", "operator"), accH.ImportCredentials)
|
||||
acc.POST("/:id/check", middleware.WorkspaceRoles("owner", "admin", "operator"), accH.Check)
|
||||
acc.POST("/:id/unbind", middleware.WorkspaceRoles("owner", "admin"), accH.Unbind)
|
||||
}
|
||||
|
||||
mat := api.Group("/materials", middleware.AuthRequired(cfg))
|
||||
{
|
||||
mat.GET("", matH.List)
|
||||
mat.POST("", matH.Upload)
|
||||
mat.DELETE("/:id", matH.Delete)
|
||||
mat.POST("", middleware.WorkspaceRoles("owner", "admin", "operator"), matH.Upload)
|
||||
mat.DELETE("/:id", middleware.WorkspaceRoles("owner", "admin", "operator"), matH.Delete)
|
||||
mat.GET("/:id/url", matH.URL)
|
||||
}
|
||||
|
||||
@@ -108,24 +132,25 @@ func Router(cfg *config.Config, db *gorm.DB, rds *cache.Redis, hub *ws.Hub, serv
|
||||
tsk := api.Group("/tasks", middleware.AuthRequired(cfg))
|
||||
{
|
||||
tsk.GET("", tskH.List)
|
||||
tsk.POST("", tskH.Create)
|
||||
tsk.POST("", middleware.WorkspaceRoles("owner", "admin", "operator"), tskH.Create)
|
||||
tsk.GET("/:id/events", tskH.Events)
|
||||
tsk.GET("/:id", tskH.Get)
|
||||
tsk.PUT("/:id", tskH.Update)
|
||||
tsk.DELETE("/:id", tskH.Delete)
|
||||
tsk.POST("/:id/submit", tskH.Submit)
|
||||
tsk.POST("/:id/approve", tskH.Approve)
|
||||
tsk.POST("/:id/reject", tskH.Reject)
|
||||
tsk.POST("/:id/resubmit", tskH.Resubmit)
|
||||
tsk.POST("/:id/retry", tskH.Retry)
|
||||
tsk.POST("/:id/cancel", tskH.Cancel)
|
||||
tsk.PUT("/:id", middleware.WorkspaceRoles("owner", "admin", "operator"), tskH.Update)
|
||||
tsk.DELETE("/:id", middleware.WorkspaceRoles("owner", "admin", "operator"), tskH.Delete)
|
||||
tsk.POST("/:id/submit", middleware.WorkspaceRoles("owner", "admin", "operator"), tskH.Submit)
|
||||
tsk.POST("/:id/approve", middleware.WorkspaceRoles("owner", "admin", "reviewer"), tskH.Approve)
|
||||
tsk.POST("/:id/reject", middleware.WorkspaceRoles("owner", "admin", "reviewer"), tskH.Reject)
|
||||
tsk.POST("/:id/resubmit", middleware.WorkspaceRoles("owner", "admin", "operator"), tskH.Resubmit)
|
||||
tsk.POST("/:id/retry", middleware.WorkspaceRoles("owner", "admin", "operator"), tskH.Retry)
|
||||
tsk.POST("/:id/cancel", middleware.WorkspaceRoles("owner", "admin", "operator"), tskH.Cancel)
|
||||
}
|
||||
|
||||
chl := api.Group("/challenges", middleware.AuthRequired(cfg))
|
||||
{
|
||||
chl.GET("", chlH.List)
|
||||
chl.POST("/:id/solve", chlH.Solve)
|
||||
chl.POST("/:id/resend", chlH.Resend)
|
||||
chl.POST("/:id/suspend", chlH.Suspend)
|
||||
chl.POST("/:id/solve", middleware.WorkspaceRoles("owner", "admin", "operator", "reviewer"), chlH.Solve)
|
||||
chl.POST("/:id/resend", middleware.WorkspaceRoles("owner", "admin", "operator", "reviewer"), chlH.Resend)
|
||||
chl.POST("/:id/suspend", middleware.WorkspaceRoles("owner", "admin", "operator", "reviewer"), chlH.Suspend)
|
||||
}
|
||||
|
||||
ntf := api.Group("/notifications", middleware.AuthRequired(cfg))
|
||||
@@ -135,21 +160,11 @@ func Router(cfg *config.Config, db *gorm.DB, rds *cache.Redis, hub *ws.Hub, serv
|
||||
ntf.POST("/read-all", ntfH.ReadAll)
|
||||
}
|
||||
|
||||
ag := api.Group("/agent")
|
||||
{
|
||||
ag.POST("/pair-code", middleware.AuthRequired(cfg), agH.PairCode)
|
||||
ag.GET("/devices", middleware.AuthRequired(cfg), agH.Devices)
|
||||
ag.PUT("/devices/:id/revoke", middleware.AuthRequired(cfg), agH.Revoke)
|
||||
}
|
||||
api.POST("/agent/pair", agH.Pair)
|
||||
|
||||
admH := &handlers.AdminHandler{DB: db, Hub: hub}
|
||||
adm := api.Group("/admin", middleware.AuthRequired(cfg), middleware.AdminRequired())
|
||||
{
|
||||
adm.GET("/tenants", admH.Tenants)
|
||||
adm.PUT("/tenants/:id/status", admH.TenantStatus)
|
||||
adm.GET("/agents", admH.Agents)
|
||||
adm.PUT("/agents/:id/revoke", admH.AgentRevoke)
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
@@ -2,8 +2,6 @@ package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
@@ -36,17 +34,17 @@ func TestMain(m *testing.M) {
|
||||
adm, err := gorm.Open(gormmysql.Open(adminDSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Println("skip: mysql admin connect failed:", err)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = adm.Exec("CREATE DATABASE IF NOT EXISTS everypublish_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci").Error; err != nil {
|
||||
fmt.Println("skip: create test db failed:", err)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
testDSN := "root:everypublish@tcp(127.0.0.1:3306)/everypublish_test?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
testDB, err = gorm.Open(gormmysql.Open(testDSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Println("skip: test db connect failed:", err)
|
||||
os.Exit(0)
|
||||
os.Exit(1)
|
||||
}
|
||||
resetTestDB()
|
||||
|
||||
@@ -57,12 +55,12 @@ func TestMain(m *testing.M) {
|
||||
JWTSecret: "test-secret",
|
||||
BaseURL: "http://127.0.0.1:8090",
|
||||
StorageDir: "/tmp/everypublish-test",
|
||||
ExecutorMode: "mock",
|
||||
}
|
||||
rds := cache.New(cfg)
|
||||
testHub = ws.NewHub()
|
||||
dsp := ws.NewDispatcher(testDB, testHub, cfg)
|
||||
_, serverKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
testRouter = api.Router(cfg, testDB, rds, testHub, serverKey, dsp)
|
||||
testRouter = api.Router(cfg, testDB, rds, testHub, dsp)
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -73,8 +71,8 @@ func resetTestDB() {
|
||||
testHub.Clear()
|
||||
}
|
||||
for _, t := range []interface{}{
|
||||
&models.TransferToken{}, &models.PairingCode{}, &models.Notification{}, &models.AuditLog{},
|
||||
&models.Challenge{}, &models.AgentDevice{}, &models.Task{}, &models.Material{},
|
||||
&models.TransferToken{}, &models.Notification{}, &models.AuditLog{},
|
||||
&models.Challenge{}, &models.AgentDevice{}, &models.PairingCode{}, &models.TaskEvent{}, &models.Task{}, &models.Material{},
|
||||
&models.Account{}, &models.Member{}, &models.Workspace{}, &models.User{},
|
||||
} {
|
||||
testDB.Migrator().DropTable(t)
|
||||
@@ -174,6 +172,24 @@ func TestWorkspaceAndMemberFlow(t *testing.T) {
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("create ws failed: %d %s", code, env.Message)
|
||||
}
|
||||
var secondWS models.Workspace
|
||||
_ = json.Unmarshal(env.Data, &secondWS)
|
||||
code, _ = doReq(t, "PUT", fmt.Sprintf("/api/v1/workspaces/%d", secondWS.ID), aAccess, gin.H{"name": "越权空间"})
|
||||
if code != 404 {
|
||||
t.Fatalf("cross-workspace update should be 404, got %d", code)
|
||||
}
|
||||
var ownWorkspaces struct {
|
||||
List []models.Workspace `json:"list"`
|
||||
}
|
||||
code, env = doReq(t, "GET", "/api/v1/workspaces", aAccess, nil)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list own workspaces failed: %d %s", code, env.Message)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &ownWorkspaces)
|
||||
if len(ownWorkspaces.List) == 0 {
|
||||
t.Fatal("owner should have at least one workspace")
|
||||
}
|
||||
primaryWSID := ownWorkspaces.List[0].ID
|
||||
// A 邀请 B(viewer)
|
||||
code, env = doReq(t, "POST", "/api/v1/members/invite", aAccess, gin.H{"email": "worker@test.com", "role": "viewer"})
|
||||
if code != 200 || env.Code != 0 {
|
||||
@@ -192,6 +208,17 @@ func TestWorkspaceAndMemberFlow(t *testing.T) {
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("join failed: %d %s", code, env.Message)
|
||||
}
|
||||
code, env = doReq(t, "POST", fmt.Sprintf("/api/v1/auth/switch-workspace/%d", primaryWSID), bAccess, nil)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("workspace switch failed: %d %s", code, env.Message)
|
||||
}
|
||||
var switched tokens
|
||||
_ = json.Unmarshal(env.Data, &switched)
|
||||
bAccess = switched.AccessToken
|
||||
code, _ = doReq(t, "POST", "/api/v1/accounts", bAccess, gin.H{"platform": "douyin", "remark": "viewer-forbidden"})
|
||||
if code != 403 {
|
||||
t.Fatalf("viewer account write should be 403, got %d", code)
|
||||
}
|
||||
// A 查成员:2 人
|
||||
code, env = doReq(t, "GET", "/api/v1/members", aAccess, nil)
|
||||
if code != 200 || env.Code != 0 {
|
||||
@@ -219,6 +246,23 @@ func TestWorkspaceAndMemberFlow(t *testing.T) {
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("update role failed: %d %s", code, env.Message)
|
||||
}
|
||||
code, env = doReq(t, "POST", fmt.Sprintf("/api/v1/auth/switch-workspace/%d", primaryWSID), bAccess, nil)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("workspace switch after role update failed: %d %s", code, env.Message)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &switched)
|
||||
var switchInfo struct {
|
||||
MemberRole string `json:"memberRole"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &switchInfo)
|
||||
if switchInfo.MemberRole != "operator" {
|
||||
t.Fatalf("workspace switch should reflect updated role, got %q", switchInfo.MemberRole)
|
||||
}
|
||||
bAccess = switched.AccessToken
|
||||
code, env = doReq(t, "POST", "/api/v1/accounts", bAccess, gin.H{"platform": "douyin", "remark": "operator-allowed"})
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("operator account write should be allowed: %d %s", code, env.Message)
|
||||
}
|
||||
// owner 不可被移除
|
||||
code, _ = doReq(t, "DELETE", fmt.Sprintf("/api/v1/members/%d", ownerMember), aAccess, nil)
|
||||
if code != 403 {
|
||||
|
||||
+43
-224
@@ -2,246 +2,65 @@ package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
func readEnv(t *testing.T, conn *websocket.Conn) *proto.Envelope {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// TestBrowserWSOnly verifies the Web-only realtime contract. Agent/device WSS
|
||||
// is intentionally not part of the V1 router.
|
||||
func TestBrowserWSOnly(t *testing.T) {
|
||||
resetTestDB()
|
||||
access, _ := register(t, "browser-ws@test.com")
|
||||
srv := httptest.NewServer(testRouter)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/browser?token=" + access
|
||||
conn, _, err := websocket.Dial(context.Background(), wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("browser websocket dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "done")
|
||||
|
||||
code, env := doReq(t, "POST", "/api/v1/accounts", access, gin.H{"platform": "douyin", "remark": "browser ws"})
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("create account failed: %d %s", code, env.Message)
|
||||
}
|
||||
var account struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &account); err != nil || account.ID == 0 {
|
||||
t.Fatalf("account response missing id: %s", env.Data)
|
||||
}
|
||||
code, env = doReq(t, "POST", fmt.Sprintf("/api/v1/accounts/%d/bind", account.ID), access, nil)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("bind failed: %d %s", code, env.Message)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ws read: %v", err)
|
||||
t.Fatalf("browser websocket did not receive challenge event: %v", err)
|
||||
}
|
||||
var env proto.Envelope
|
||||
if err = json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("ws unmarshal: %v", err)
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Type != "challenge.status" && event.Type != "notification.new" {
|
||||
t.Fatalf("unexpected browser event type: %s", event.Type)
|
||||
}
|
||||
return &env
|
||||
}
|
||||
|
||||
func writeEnv(t *testing.T, conn *websocket.Conn, env *proto.Envelope) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(env)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatalf("ws write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSAgentFullLoop(t *testing.T) {
|
||||
resetTestDB()
|
||||
access, _ := register(t, "agentuser@test.com")
|
||||
|
||||
// 1. 配对
|
||||
code, env := doReq(t, "POST", "/api/v1/agent/pair-code", access, nil)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("pair-code failed: %d %s", code, env.Message)
|
||||
}
|
||||
var pc struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &pc)
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
pubB64 := base64.StdEncoding.EncodeToString(pub)
|
||||
code, env = doReq(t, "POST", "/api/v1/agent/pair", "", gin.H{
|
||||
"code": pc.Code, "deviceName": "test-device", "os": "test", "version": "0.0.1", "publicKey": pubB64,
|
||||
})
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("pair failed: %d %s", code, env.Message)
|
||||
}
|
||||
var pd struct {
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &pd)
|
||||
if pd.DeviceID == 0 {
|
||||
t.Fatal("deviceId missing")
|
||||
}
|
||||
|
||||
// 2. 坏签名 hello 应被拒
|
||||
srv := httptest.NewServer(testRouter)
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/agent"
|
||||
badConn, _, err := websocket.Dial(context.Background(), wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
badHello := proto.NewEnvelope("id1", proto.TypeHello, proto.DeviceHello{
|
||||
DeviceID: strconv.FormatUint(pd.DeviceID, 10),
|
||||
Nonce: "n1",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Sig: base64.StdEncoding.EncodeToString([]byte("bad-sig")),
|
||||
Version: "0.0.1",
|
||||
})
|
||||
rawBad, _ := json.Marshal(badHello)
|
||||
_ = badConn.Write(context.Background(), websocket.MessageText, rawBad)
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, err = badConn.Read(ctx2)
|
||||
cancel2()
|
||||
if err == nil {
|
||||
t.Fatal("bad signature should be rejected")
|
||||
}
|
||||
|
||||
// 3. 正常握手
|
||||
conn, _, err := websocket.Dial(context.Background(), wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
nonce := "agent-test-nonce"
|
||||
ts := time.Now().UnixMilli()
|
||||
sig := ed25519.Sign(priv, []byte(nonce+"|"+strconv.FormatInt(ts, 10)))
|
||||
writeEnv(t, conn, proto.NewEnvelope("id2", proto.TypeHello, proto.DeviceHello{
|
||||
DeviceID: strconv.FormatUint(pd.DeviceID, 10),
|
||||
Nonce: nonce,
|
||||
TS: ts,
|
||||
Sig: base64.StdEncoding.EncodeToString(sig),
|
||||
Version: "0.0.1",
|
||||
}))
|
||||
ackEnv := readEnv(t, conn)
|
||||
if ackEnv.Type != proto.TypeHelloAck {
|
||||
t.Fatalf("expect hello.ack, got %s", ackEnv.Type)
|
||||
}
|
||||
var ack proto.HelloAck
|
||||
_ = json.Unmarshal(ackEnv.Payload, &ack)
|
||||
if ack.SessionID == "" || ack.ServerNonce == "" {
|
||||
t.Fatal("hello.ack missing fields")
|
||||
}
|
||||
|
||||
// 4. 心跳 → heartbeat.ack
|
||||
writeEnv(t, conn, proto.NewEnvelope("id3", proto.TypeHeartbeat, proto.Heartbeat{DeviceID: "1", TS: time.Now().UnixMilli()}))
|
||||
hbAck := readEnv(t, conn)
|
||||
if hbAck.Type != proto.TypeHeartbeatAck {
|
||||
t.Fatalf("expect heartbeat.ack, got %s", hbAck.Type)
|
||||
}
|
||||
|
||||
// 5. 账号绑定 → challenge.new → solve → active
|
||||
code, env = doReq(t, "POST", "/api/v1/accounts", access, gin.H{"platform": "douyin", "accountName": "抖音号01"})
|
||||
if code != 200 {
|
||||
t.Fatalf("create account failed: %d", code)
|
||||
}
|
||||
var acc models.Account
|
||||
_ = json.Unmarshal(env.Data, &acc)
|
||||
code, _ = doReq(t, "POST", fmt.Sprintf("/api/v1/accounts/%d/bind", acc.ID), access, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("bind failed: %d", code)
|
||||
}
|
||||
chEnv := readEnv(t, conn)
|
||||
if chEnv.Type != proto.TypeChallenge {
|
||||
t.Fatalf("expect challenge.new, got %s", chEnv.Type)
|
||||
}
|
||||
var ch proto.Challenge
|
||||
_ = json.Unmarshal(chEnv.Payload, &ch)
|
||||
if ch.AccountID == "" {
|
||||
t.Fatal("challenge missing accountId")
|
||||
}
|
||||
writeEnv(t, conn, proto.NewEnvelope("id5", proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: ch.ChallengeID, Value: "fake-token"}))
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
code, env = doReq(t, "GET", "/api/v1/accounts?status=active", access, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("accounts list failed: %d", code)
|
||||
}
|
||||
var al struct {
|
||||
List []models.Account `json:"list"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &al)
|
||||
if len(al.List) != 1 || al.List[0].AgentDeviceID != pd.DeviceID {
|
||||
t.Fatalf("account should be active bound to device, got %+v", al.List)
|
||||
}
|
||||
|
||||
// 6. 任务闭环(含下发延迟测量)+ 浏览器实时事件
|
||||
browserConn, _, err := websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http")+"/ws/browser?token="+access, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("browser dial: %v", err)
|
||||
}
|
||||
matID, _ := uploadMaterial(t, access, "loop.mp4", []byte("loop-content"))
|
||||
code, env = doReq(t, "POST", "/api/v1/tasks", access, gin.H{
|
||||
"title": "实时闭环任务", "content": "正文",
|
||||
"accountIds": []uint64{acc.ID}, "materialIds": []uint64{matID},
|
||||
})
|
||||
if code != 200 {
|
||||
t.Fatalf("create task failed: %d", code)
|
||||
}
|
||||
var taskRow models.Task
|
||||
_ = json.Unmarshal(env.Data, &taskRow)
|
||||
code, _ = doReq(t, "POST", fmt.Sprintf("/api/v1/tasks/%d/submit", taskRow.ID), access, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("submit failed: %d", code)
|
||||
}
|
||||
t0 := time.Now()
|
||||
code, _ = doReq(t, "POST", fmt.Sprintf("/api/v1/tasks/%d/approve", taskRow.ID), access, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("approve failed: %d", code)
|
||||
}
|
||||
pushEnv := readEnv(t, conn)
|
||||
latency := time.Since(t0)
|
||||
t.Logf("下发延迟 approve→task.push = %s", latency)
|
||||
if latency > 2*time.Second {
|
||||
t.Fatalf("dispatch latency too high: %s", latency)
|
||||
}
|
||||
if pushEnv.Type != proto.TypeTaskPush {
|
||||
t.Fatalf("expect task.push, got %s", pushEnv.Type)
|
||||
}
|
||||
var push proto.TaskPush
|
||||
_ = json.Unmarshal(pushEnv.Payload, &push)
|
||||
if push.TaskID == "" || len(push.MaterialURLs) == 0 {
|
||||
t.Fatalf("task.push missing fields: %+v", push)
|
||||
}
|
||||
writeEnv(t, conn, proto.NewEnvelope(pushEnv.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
|
||||
writeEnv(t, conn, proto.NewEnvelope("id6", proto.TypeTaskResult, proto.TaskResult{
|
||||
TaskID: push.TaskID, Status: "success",
|
||||
PublishedURL: "https://example.com/v/1",
|
||||
FinishedAt: time.Now().UnixMilli(),
|
||||
}))
|
||||
// 浏览器侧应收到三条 task.status(dispatched/running/success),第三条为 success
|
||||
bEvents := []string{}
|
||||
var lastPayload struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
e := readEnv(t, browserConn)
|
||||
bEvents = append(bEvents, e.Type)
|
||||
_ = json.Unmarshal(e.Payload, &lastPayload)
|
||||
}
|
||||
t.Logf("browser events: %v (last=%s)", bEvents, lastPayload.Status)
|
||||
if bEvents[0] != "task.status" || bEvents[1] != "task.status" || bEvents[2] != "task.status" {
|
||||
t.Fatalf("browser should receive 3 task.status events, got %v", bEvents)
|
||||
}
|
||||
if lastPayload.Status != "success" {
|
||||
t.Fatalf("last event should be success, got %s", lastPayload.Status)
|
||||
}
|
||||
// 落库校验(轮询至 success)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
code, env = doReq(t, "GET", fmt.Sprintf("/api/v1/tasks/%d", taskRow.ID), access, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("task get failed: %d", code)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &taskRow)
|
||||
if taskRow.Status == "success" || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if taskRow.Status != "success" {
|
||||
t.Fatalf("expect success, got %s", taskRow.Status)
|
||||
}
|
||||
if !strings.Contains(taskRow.PublishedURLs, "https://example.com/v/1") {
|
||||
t.Fatalf("publishedUrls not recorded: %s", taskRow.PublishedURLs)
|
||||
code, _ = doReq(t, "GET", "/api/v1/agent/devices", access, nil)
|
||||
if code != 404 {
|
||||
t.Fatalf("agent route must be absent in Web-only mode, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestJWTAccessExpired(t *testing.T) {
|
||||
|
||||
func TestJWTRefreshRoundTrip(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
token, err := IssueRefresh(secret, 9, "jti-1")
|
||||
token, err := IssueRefresh(secret, 9, 7, "jti-1")
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestJWTRefreshRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if claims.UID != 9 || claims.JTI != "jti-1" {
|
||||
if claims.UID != 9 || claims.WorkspaceID != 7 || claims.JTI != "jti-1" {
|
||||
t.Fatalf("claims mismatch: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type AccessClaims struct {
|
||||
// RefreshClaims 刷新令牌声明
|
||||
type RefreshClaims struct {
|
||||
UID uint64 `json:"uid"`
|
||||
WorkspaceID uint64 `json:"wsid"`
|
||||
JTI string `json:"jti"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -53,10 +54,10 @@ func IssueAccess(secret string, uid, wsid uint64, role, memberRole string) (stri
|
||||
}
|
||||
|
||||
// IssueRefresh 签发刷新令牌
|
||||
func IssueRefresh(secret string, uid uint64, jti string) (string, error) {
|
||||
func IssueRefresh(secret string, uid, wsid uint64, jti string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := RefreshClaims{
|
||||
UID: uid, JTI: jti,
|
||||
UID: uid, WorkspaceID: wsid, JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(RefreshTTL)),
|
||||
|
||||
@@ -15,8 +15,13 @@ type Config struct {
|
||||
BaseURL string
|
||||
PublicBaseURL string // 浏览器侧访问地址(邀请链接等)
|
||||
StorageDir string
|
||||
CredentialDir string // 平台登录凭据加密目录
|
||||
StaticDir string // 前端生产构建目录(空=不托管静态)
|
||||
MaxUploadBytes int64 // multipart 上传体积上限
|
||||
ExecutorMode string // mock=本地联调;web=启用已注册平台适配器并保留未接入平台 mock fallback
|
||||
BilibiliMemberBase string
|
||||
BilibiliPassportBase string
|
||||
BilibiliUpOSScheme string
|
||||
}
|
||||
|
||||
// Load 从环境变量加载
|
||||
@@ -36,8 +41,13 @@ func Load() *Config {
|
||||
BaseURL: getenv("BASE_URL", "http://127.0.0.1:8090"),
|
||||
PublicBaseURL: getenv("PUBLIC_BASE_URL", getenv("BASE_URL", "http://127.0.0.1:8090")),
|
||||
StorageDir: getenv("STORAGE_DIR", "./data/materials"),
|
||||
CredentialDir: getenv("CREDENTIAL_DIR", "./data/credentials"),
|
||||
StaticDir: getenv("STATIC_DIR", "../apps/web/dist"),
|
||||
MaxUploadBytes: getenvInt64("MAX_UPLOAD_BYTES", 2<<30),
|
||||
ExecutorMode: getenv("EXECUTOR_MODE", "mock"),
|
||||
BilibiliMemberBase: getenv("BILIBILI_MEMBER_BASE", "https://member.bilibili.com"),
|
||||
BilibiliPassportBase: getenv("BILIBILI_PASSPORT_BASE", "https://passport.bilibili.com"),
|
||||
BilibiliUpOSScheme: getenv("BILIBILI_UPOS_SCHEME", "https"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package credentials
|
||||
|
||||
// Package credentials stores platform secrets outside the SQL account model.
|
||||
// Files are encrypted with AES-256-GCM and keyed by workspace/account IDs so a
|
||||
// leaked account row or browser event never contains cookies.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var errInvalidID = errors.New("workspace and account IDs must be positive")
|
||||
|
||||
// Store is a process-safe encrypted credential store.
|
||||
type Store struct {
|
||||
root string
|
||||
key []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Open creates or loads a 32-byte master key in root. The key file is never
|
||||
// returned by an API and is permission-restricted to the current user.
|
||||
func Open(root string) (*Store, error) {
|
||||
if root == "" {
|
||||
return nil, errors.New("credential directory is empty")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPath := filepath.Join(root, "master.key")
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
key = make([]byte, 32)
|
||||
if _, err = io.ReadFull(rand.Reader, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = writePrivate(keyPath, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("credential master key must be 32 bytes")
|
||||
}
|
||||
return &Store{root: root, key: append([]byte(nil), key...)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) path(workspaceID, accountID uint64) (string, error) {
|
||||
if workspaceID == 0 || accountID == 0 {
|
||||
return "", errInvalidID
|
||||
}
|
||||
dir := filepath.Join(s.root, strconv.FormatUint(workspaceID, 10))
|
||||
return filepath.Join(dir, strconv.FormatUint(accountID, 10)+".enc"), nil
|
||||
}
|
||||
|
||||
// Save encrypts value and atomically replaces the account file.
|
||||
func (s *Store) Save(workspaceID, accountID uint64, value []byte) error {
|
||||
path, err := s.path(workspaceID, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, value, nil)
|
||||
payload := struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Data string `json:"data"`
|
||||
}{base64.RawStdEncoding.EncodeToString(nonce), base64.RawStdEncoding.EncodeToString(ciphertext)}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".credential-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err = tmp.Chmod(0o600); err == nil {
|
||||
_, err = tmp.Write(raw)
|
||||
}
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
// Load decrypts credentials for one workspace/account pair.
|
||||
func (s *Store) Load(workspaceID, accountID uint64) ([]byte, error) {
|
||||
path, err := s.path(workspaceID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
raw, err := os.ReadFile(path)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, fmt.Errorf("credential envelope: %w", err)
|
||||
}
|
||||
nonce, err := base64.RawStdEncoding.DecodeString(payload.Nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credential nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.RawStdEncoding.DecodeString(payload.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credential data: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nonce) != gcm.NonceSize() {
|
||||
return nil, errors.New("credential nonce has invalid size")
|
||||
}
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("credential authentication failed")
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// Delete removes credentials. Missing files are treated as success.
|
||||
func (s *Store) Delete(workspaceID, accountID uint64) error {
|
||||
path, err := s.path(workspaceID, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err = os.Remove(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func writePrivate(path string, value []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".master-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err = tmp.Chmod(0o600); err == nil {
|
||||
_, err = tmp.Write(value)
|
||||
}
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoreRoundTripAndIsolation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := []byte(`{"cookies":"SESSDATA=secret"}`)
|
||||
if err = s.Save(7, 11, secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Load(7, 11)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(secret) {
|
||||
t.Fatalf("round trip mismatch: %q", got)
|
||||
}
|
||||
if _, err = s.Load(8, 11); !os.IsNotExist(err) {
|
||||
t.Fatalf("workspace isolation failed: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "7", "11.enc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) == string(secret) {
|
||||
t.Fatal("credential file is plaintext")
|
||||
}
|
||||
if err = s.Delete(7, 11); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsInvalidIDs(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.Save(0, 1, []byte("x")); err == nil {
|
||||
t.Fatal("expected invalid ID error")
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.Account{},
|
||||
&models.Material{},
|
||||
&models.Task{},
|
||||
&models.TaskEvent{},
|
||||
&models.AgentDevice{},
|
||||
&models.Challenge{},
|
||||
&models.AuditLog{},
|
||||
|
||||
@@ -38,7 +38,7 @@ type Member struct {
|
||||
Role string `gorm:"size:16;default:viewer" json:"role"`
|
||||
}
|
||||
|
||||
// Account 平台账号(凭据仅在 Agent 本机保险库,服务器零凭据)
|
||||
// Account 平台账号(Web-only V1 的本机执行器持有登录 profile;Agent 字段仅为迁移兼容)
|
||||
type Account struct {
|
||||
Base
|
||||
WorkspaceID uint64 `gorm:"index" json:"workspaceId"`
|
||||
@@ -48,7 +48,7 @@ type Account struct {
|
||||
AvatarURL string `gorm:"size:512" json:"avatarUrl"`
|
||||
Status string `gorm:"size:16;default:unbound" json:"status"`
|
||||
Health int `gorm:"default:100" json:"health"`
|
||||
AgentDeviceID uint64 `gorm:"index" json:"agentDeviceId"`
|
||||
AgentDeviceID uint64 `gorm:"index" json:"-"` // legacy column; not part of Web-only API
|
||||
IPProfile string `gorm:"size:191" json:"ipProfile"`
|
||||
LastActiveAt *time.Time `json:"lastActiveAt"`
|
||||
LastError string `gorm:"size:512" json:"lastError"`
|
||||
@@ -93,6 +93,17 @@ type Task struct {
|
||||
ReviewNote string `gorm:"size:512" json:"reviewNote"`
|
||||
}
|
||||
|
||||
// TaskEvent 任务状态时间线(用于网页详情、审计和失败定位)。
|
||||
type TaskEvent struct {
|
||||
Base
|
||||
WorkspaceID uint64 `gorm:"index" json:"workspaceId"`
|
||||
TaskID uint64 `gorm:"index" json:"taskId"`
|
||||
Type string `gorm:"size:32" json:"type"`
|
||||
Status string `gorm:"size:32" json:"status"`
|
||||
Message string `gorm:"size:1024" json:"message"`
|
||||
Data string `gorm:"type:text" json:"data"`
|
||||
}
|
||||
|
||||
// AgentDevice 客户端设备(配对时注册 Ed25519 公钥)
|
||||
type AgentDevice struct {
|
||||
Base
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"everypublish/server/internal/credentials"
|
||||
"everypublish/server/internal/platform"
|
||||
)
|
||||
|
||||
// Adapter implements the server-side Bilibili API boundary. The exported
|
||||
// endpoint fields make the adapter straightforward to use with an httptest
|
||||
// server while retaining production defaults in New.
|
||||
type Adapter struct {
|
||||
Store *credentials.Store
|
||||
Credentials *credentials.Store // compatibility alias for callers using this name
|
||||
HTTP *http.Client
|
||||
MemberBase string
|
||||
PassportBase string
|
||||
UpOSScheme string
|
||||
PollTimeout time.Duration
|
||||
PollInterval time.Duration
|
||||
MaxRetries int
|
||||
}
|
||||
|
||||
func New(store *credentials.Store) *Adapter {
|
||||
return &Adapter{Store: store, Credentials: store, HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
MemberBase: "https://member.bilibili.com", PassportBase: "https://passport.bilibili.com",
|
||||
UpOSScheme: "https", PollTimeout: 3 * time.Minute, PollInterval: 2 * time.Second, MaxRetries: 2}
|
||||
}
|
||||
|
||||
func (a *Adapter) Platform() string { return "bilibili" }
|
||||
|
||||
func (a *Adapter) BeginLogin(ctx context.Context) (platform.LoginSession, error) {
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
Key string `json:"qrcode_key"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := a.getJSON(ctx, a.passportBase()+"/x/passport-login/web/qrcode/generate", "", &ret); err != nil {
|
||||
return platform.LoginSession{}, err
|
||||
}
|
||||
if ret.Code != 0 || ret.Data.Key == "" || ret.Data.URL == "" {
|
||||
return platform.LoginSession{}, perr(platform.PlatformChanged, "invalid QR response", nil)
|
||||
}
|
||||
return platform.LoginSession{Token: ret.Data.Key, URL: ret.Data.URL}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) PollLogin(ctx context.Context, token string) (platform.LoginPoll, error) {
|
||||
if token == "" {
|
||||
return platform.LoginPoll{}, perr(platform.Validation, "QR token is empty", nil)
|
||||
}
|
||||
q := url.Values{"qrcode_key": []string{token}}
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := a.getJSON(ctx, a.passportBase()+"/x/passport-login/web/qrcode/poll?"+q.Encode(), "", &ret); err != nil {
|
||||
return platform.LoginPoll{}, err
|
||||
}
|
||||
switch ret.Code {
|
||||
case 86101:
|
||||
return platform.LoginPoll{State: platform.LoginPending, Prompt: "请使用哔哩哔哩手机客户端扫码"}, nil
|
||||
case 86090:
|
||||
return platform.LoginPoll{State: platform.LoginScanned, Prompt: "已扫码,请在哔哩哔哩手机客户端确认登录"}, nil
|
||||
case 0:
|
||||
cookies := cookiesFromURL(ret.Data.URL)
|
||||
if cookies == "" {
|
||||
return platform.LoginPoll{}, perr(platform.PlatformChanged, "login response did not contain cookies", nil)
|
||||
}
|
||||
return platform.LoginPoll{State: platform.LoginConfirmed, Cookies: cookies}, nil
|
||||
default:
|
||||
return platform.LoginPoll{}, perr(platform.PlatformChanged, "QR poll rejected: "+ret.Message, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) SaveCredentials(workspaceID, accountID uint64, cookies string) error {
|
||||
if strings.TrimSpace(cookies) == "" {
|
||||
return perr(platform.Validation, "cookies are empty", nil)
|
||||
}
|
||||
if cookieValue(cookies, "SESSDATA") == "" || cookieValue(cookies, "bili_jct") == "" || cookieValue(cookies, "DedeUserID") == "" {
|
||||
return perr(platform.Validation, "Bilibili cookies must include SESSDATA, bili_jct and DedeUserID", nil)
|
||||
}
|
||||
s := a.store()
|
||||
if s == nil {
|
||||
return perr(platform.Unknown, "credential store is not configured", nil)
|
||||
}
|
||||
if err := s.Save(workspaceID, accountID, []byte(cookies)); err != nil {
|
||||
return perr(platform.Unknown, "saving credentials", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) CheckCredentials(ctx context.Context, workspaceID, accountID uint64) error {
|
||||
s := a.store()
|
||||
if s == nil {
|
||||
return perr(platform.Unknown, "credential store is not configured", nil)
|
||||
}
|
||||
raw, err := s.Load(workspaceID, accountID)
|
||||
if err != nil {
|
||||
return perr(platform.AuthRequired, "credentials are unavailable", err)
|
||||
}
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
IsLogin bool `json:"isLogin"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := a.getJSON(ctx, a.memberBase()+"/x/web-interface/nav", string(raw), &ret); err != nil {
|
||||
return err
|
||||
}
|
||||
if ret.Code != 0 || !ret.Data.IsLogin {
|
||||
return perr(platform.AuthRequired, "Bilibili login has expired", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Publish(ctx context.Context, in platform.PublishInput) (platform.PublishResult, error) {
|
||||
s := a.store()
|
||||
if s == nil {
|
||||
return platform.PublishResult{}, perr(platform.Unknown, "credential store is not configured", nil)
|
||||
}
|
||||
raw, err := s.Load(in.WorkspaceID, in.AccountID)
|
||||
if err != nil {
|
||||
return platform.PublishResult{}, perr(platform.AuthRequired, "credentials are unavailable", err)
|
||||
}
|
||||
if len(in.MaterialURLs) == 0 {
|
||||
return platform.PublishResult{}, perr(platform.Validation, "material is required", nil)
|
||||
}
|
||||
if len(in.MaterialURLs) > 1 {
|
||||
return platform.PublishResult{}, perr(platform.Validation, "Bilibili currently accepts one video per task", nil)
|
||||
}
|
||||
path, cleanup, err := a.download(ctx, in.MaterialURLs[0])
|
||||
if err != nil {
|
||||
return platform.PublishResult{}, err
|
||||
}
|
||||
defer cleanup()
|
||||
part, err := a.upload(ctx, string(raw), path)
|
||||
if err != nil {
|
||||
return platform.PublishResult{}, err
|
||||
}
|
||||
bvid, err := a.submit(ctx, string(raw), in, part)
|
||||
if err != nil {
|
||||
return platform.PublishResult{}, err
|
||||
}
|
||||
if bvid == "" {
|
||||
return platform.PublishResult{}, perr(platform.PlatformChanged, "submission did not return bvid", nil)
|
||||
}
|
||||
return platform.PublishResult{URL: "https://www.bilibili.com/video/" + bvid, Receipt: bvid}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) download(ctx context.Context, rawURL string) (string, func(), error) {
|
||||
raw, err := a.request(ctx, http.MethodGet, rawURL, "", nil, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
f, err := os.CreateTemp("", "ep-bilibili-*")
|
||||
if err != nil {
|
||||
return "", nil, perr(platform.Unknown, "creating temporary material", err)
|
||||
}
|
||||
name := f.Name()
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
os.Remove(name)
|
||||
return "", nil, perr(platform.Network, "saving material", err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
os.Remove(name)
|
||||
return "", nil, perr(platform.Network, "saving material", err)
|
||||
}
|
||||
return name, func() { _ = os.Remove(name) }, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) upload(ctx context.Context, cookies, path string) (map[string]interface{}, error) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, perr(platform.Validation, "material unavailable", err)
|
||||
}
|
||||
name, total := filepath.Base(path), st.Size()
|
||||
if total <= 0 {
|
||||
return nil, perr(platform.Validation, "material is empty", nil)
|
||||
}
|
||||
q := url.Values{"r": {"upos"}, "profile": {"ugcupos/bup"}, "ssl": {"0"}, "version": {"2.8.12"}, "build": {"2081200"}, "name": {name}, "size": {strconv.FormatInt(total, 10)}}
|
||||
var pre struct {
|
||||
OK int `json:"OK"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Auth string `json:"auth"`
|
||||
BizID int `json:"biz_id"`
|
||||
UposURI string `json:"upos_uri"`
|
||||
ChunkSize int `json:"chunk_size"`
|
||||
}
|
||||
if err := a.getJSON(ctx, a.memberBase()+"/preupload?upcdn=bda2&probe_version=20221109&"+q.Encode(), cookies, &pre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pre.OK != 1 || pre.Endpoint == "" || pre.UposURI == "" {
|
||||
return nil, perr(platform.PlatformChanged, "invalid preupload response", nil)
|
||||
}
|
||||
upURL := a.upURL(pre.Endpoint, pre.UposURI)
|
||||
var up struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
}
|
||||
if err := a.postJSON(ctx, upURL+"?uploads&output=json", cookies, map[string]string{"X-Upos-Auth": pre.Auth}, nil, &up); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if up.UploadID == "" {
|
||||
return nil, perr(platform.PlatformChanged, "upload id missing", nil)
|
||||
}
|
||||
chunk := pre.ChunkSize
|
||||
if chunk <= 0 {
|
||||
chunk = 7 * 1024 * 1024
|
||||
}
|
||||
count := int((total + int64(chunk) - 1) / int64(chunk))
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, perr(platform.Validation, "material unavailable", err)
|
||||
}
|
||||
defer f.Close()
|
||||
parts := make([]map[string]interface{}, 0, count)
|
||||
buf := make([]byte, chunk)
|
||||
for i := 0; i < count; i++ {
|
||||
n, er := io.ReadFull(f, buf)
|
||||
if er != nil && er != io.ErrUnexpectedEOF && er != io.EOF {
|
||||
return nil, perr(platform.Network, "reading material", er)
|
||||
}
|
||||
pq := url.Values{"uploadId": {up.UploadID}, "partNumber": {strconv.Itoa(i + 1)}, "chunk": {strconv.Itoa(i)}, "chunks": {strconv.Itoa(count)}, "size": {strconv.Itoa(n)}, "start": {strconv.Itoa(i * chunk)}, "end": {strconv.Itoa(i*chunk + n)}, "total": {strconv.FormatInt(total, 10)}}
|
||||
etag, err := a.put(ctx, upURL+"?"+pq.Encode(), cookies, map[string]string{"X-Upos-Auth": pre.Auth}, buf[:n])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: part %d", err, i+1)
|
||||
}
|
||||
if etag == "" {
|
||||
return nil, perr(platform.PlatformChanged, "upload response missing ETag", nil)
|
||||
}
|
||||
parts = append(parts, map[string]interface{}{"partNumber": i + 1, "eTag": etag})
|
||||
}
|
||||
mq := url.Values{"name": {name}, "uploadId": {up.UploadID}, "biz_id": {strconv.Itoa(pre.BizID)}, "output": {"json"}, "profile": {"ugcupos/bup"}}
|
||||
var merged struct {
|
||||
OK int `json:"OK"`
|
||||
}
|
||||
if err := a.postJSON(ctx, upURL+"?"+mq.Encode(), cookies, map[string]string{"X-Upos-Auth": pre.Auth}, map[string]interface{}{"parts": parts}, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if merged.OK != 1 {
|
||||
return nil, perr(platform.PlatformChanged, "merge failed", nil)
|
||||
}
|
||||
base := strings.TrimSuffix(filepath.Base(pre.UposURI), filepath.Ext(filepath.Base(pre.UposURI)))
|
||||
return map[string]interface{}{"title": strings.TrimSuffix(name, filepath.Ext(name)), "filename": base, "desc": ""}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) submit(ctx context.Context, cookies string, in platform.PublishInput, part map[string]interface{}) (string, error) {
|
||||
tag := strings.Join(in.Tags, ",")
|
||||
if tag == "" {
|
||||
tag = "日常"
|
||||
}
|
||||
tid := in.CategoryID
|
||||
if tid <= 0 {
|
||||
tid = 174
|
||||
}
|
||||
body := map[string]interface{}{"title": truncate(in.Title, 80), "desc": in.Content, "desc_v2": []map[string]interface{}{{"raw_text": in.Content, "type": 1, "biz_id": ""}}, "copyright": 1, "source": "", "tid": tid, "tag": tag, "dynamic": "", "videos": []map[string]interface{}{part}}
|
||||
q := url.Values{"csrf": {cookieValue(cookies, "bili_jct")}}
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Bvid string `json:"bvid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := a.postJSON(ctx, a.memberBase()+"/x/vu/web/add?"+q.Encode(), cookies, nil, body, &ret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ret.Code != 0 {
|
||||
return "", perr(platform.Validation, ret.Message, nil)
|
||||
}
|
||||
return ret.Data.Bvid, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) getJSON(ctx context.Context, rawURL, cookies string, out interface{}) error {
|
||||
raw, err := a.request(ctx, http.MethodGet, rawURL, cookies, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return perr(platform.PlatformChanged, "invalid JSON response", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) postJSON(ctx context.Context, rawURL, cookies string, headers map[string]string, body, out interface{}) error {
|
||||
var data io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return perr(platform.Validation, "invalid request", err)
|
||||
}
|
||||
data = bytes.NewReader(raw)
|
||||
}
|
||||
raw, err := a.request(ctx, http.MethodPost, rawURL, cookies, headers, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return perr(platform.PlatformChanged, "invalid JSON response", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) put(ctx context.Context, rawURL, cookies string, headers map[string]string, body []byte) (string, error) {
|
||||
for attempt := 0; attempt <= a.retries(); attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", perr(platform.Validation, "invalid URL", err)
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
req.Header.Set("Referer", "https://member.bilibili.com")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := a.client().Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", err
|
||||
}
|
||||
if attempt < a.retries() {
|
||||
if !sleep(ctx, retryDelay(attempt)) {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "", perr(platform.Network, "network request failed", err)
|
||||
}
|
||||
_, readErr := io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return "", perr(platform.Network, "reading response", readErr)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if resp.StatusCode >= 500 && attempt < a.retries() {
|
||||
if !sleep(ctx, retryDelay(attempt)) {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
cat := platform.Unknown
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
cat = platform.AuthRequired
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
cat = platform.RateLimited
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
cat = platform.Network
|
||||
}
|
||||
return "", perr(cat, "HTTP "+strconv.Itoa(resp.StatusCode), nil)
|
||||
}
|
||||
return strings.Trim(resp.Header.Get("ETag"), "\""), nil
|
||||
}
|
||||
return "", perr(platform.Network, "upload failed", nil)
|
||||
}
|
||||
|
||||
func (a *Adapter) request(ctx context.Context, method, rawURL, cookies string, headers map[string]string, body io.Reader) ([]byte, error) {
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = io.ReadAll(body)
|
||||
if err != nil {
|
||||
return nil, perr(platform.Network, "reading request body", err)
|
||||
}
|
||||
}
|
||||
var last error
|
||||
for attempt := 0; attempt <= a.retries(); attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
reqBody = bytes.NewReader(payload)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, reqBody)
|
||||
if err != nil {
|
||||
return nil, perr(platform.Validation, "invalid URL", err)
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
req.Header.Set("Referer", "https://member.bilibili.com")
|
||||
if body != nil && method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := a.client().Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
last = perr(platform.Network, "network request failed", err)
|
||||
if attempt < a.retries() {
|
||||
if !sleep(ctx, retryDelay(attempt)) {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, perr(platform.Network, "reading response", readErr)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
cat := platform.Unknown
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
cat = platform.AuthRequired
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
cat = platform.RateLimited
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
cat = platform.Network
|
||||
}
|
||||
last = perr(cat, "HTTP "+strconv.Itoa(resp.StatusCode), nil)
|
||||
if resp.StatusCode >= 500 && attempt < a.retries() {
|
||||
if !sleep(ctx, retryDelay(attempt)) {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
|
||||
func (a *Adapter) client() *http.Client {
|
||||
if a.HTTP != nil {
|
||||
return a.HTTP
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
func (a *Adapter) retries() int {
|
||||
if a.MaxRetries < 0 {
|
||||
return 0
|
||||
}
|
||||
if a.MaxRetries == 0 {
|
||||
return 2
|
||||
}
|
||||
return a.MaxRetries
|
||||
}
|
||||
func retryDelay(n int) time.Duration { return time.Duration(25*(1<<n)) * time.Millisecond }
|
||||
func sleep(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
func (a *Adapter) memberBase() string {
|
||||
if a.MemberBase != "" {
|
||||
return strings.TrimRight(a.MemberBase, "/")
|
||||
}
|
||||
return "https://member.bilibili.com"
|
||||
}
|
||||
func (a *Adapter) passportBase() string {
|
||||
if a.PassportBase != "" {
|
||||
return strings.TrimRight(a.PassportBase, "/")
|
||||
}
|
||||
return "https://passport.bilibili.com"
|
||||
}
|
||||
func (a *Adapter) store() *credentials.Store {
|
||||
if a.Store != nil {
|
||||
return a.Store
|
||||
}
|
||||
return a.Credentials
|
||||
}
|
||||
func (a *Adapter) upURL(endpoint, uri string) string {
|
||||
scheme := a.UpOSScheme
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
||||
return strings.TrimRight(endpoint, "/") + "/" + strings.TrimPrefix(uri, "upos://")
|
||||
}
|
||||
return scheme + "://" + strings.TrimRight(strings.TrimPrefix(endpoint, "//"), "/") + "/" + strings.TrimPrefix(uri, "upos://")
|
||||
}
|
||||
|
||||
// cookiesFromURL extracts only the three cookies accepted by Bilibili's
|
||||
// cross-domain login redirect; all query values are URL-decoded by net/url.
|
||||
func cookiesFromURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
q := u.Query()
|
||||
sess := q.Get("SESSDATA")
|
||||
csrf := q.Get("bili_jct")
|
||||
uid := q.Get("DedeUserID")
|
||||
if sess == "" || csrf == "" || uid == "" {
|
||||
return ""
|
||||
}
|
||||
return "SESSDATA=" + sess + "; bili_jct=" + csrf + "; DedeUserID=" + uid
|
||||
}
|
||||
func cookieValue(cookies, key string) string {
|
||||
for _, p := range strings.Split(cookies, ";") {
|
||||
p = strings.TrimSpace(p)
|
||||
i := strings.IndexByte(p, '=')
|
||||
if i > 0 && p[:i] == key {
|
||||
return p[i+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
func perr(cat platform.ErrorCategory, msg string, err error) error {
|
||||
return &platform.Error{Category: cat, Message: msg, Err: err}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"everypublish/server/internal/credentials"
|
||||
"everypublish/server/internal/platform"
|
||||
)
|
||||
|
||||
func TestQRLoginAndCredentialSave(t *testing.T) {
|
||||
var poll int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(r.URL.Path, "/generate") {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"url":"https://qr.test/x","qrcode_key":"k"}}`))
|
||||
return
|
||||
}
|
||||
poll++
|
||||
switch poll {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{"code":86101}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{"code":86090}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"url":"https://passport.test/c?SESSDATA=s%2Bv&bili_jct=j%2Fv&DedeUserID=7"}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
store, err := credentials.Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New(store)
|
||||
a.PassportBase = srv.URL
|
||||
s, err := a.BeginLogin(context.Background())
|
||||
if err != nil || s.Token != "k" {
|
||||
t.Fatalf("begin: %#v %v", s, err)
|
||||
}
|
||||
p, err := a.PollLogin(context.Background(), s.Token)
|
||||
if err != nil || p.State != platform.LoginPending {
|
||||
t.Fatalf("pending: %#v %v", p, err)
|
||||
}
|
||||
p, err = a.PollLogin(context.Background(), s.Token)
|
||||
if err != nil || p.State != platform.LoginScanned {
|
||||
t.Fatalf("scanned: %#v %v", p, err)
|
||||
}
|
||||
p, err = a.PollLogin(context.Background(), s.Token)
|
||||
if err != nil || p.Cookies != "SESSDATA=s+v; bili_jct=j/v; DedeUserID=7" {
|
||||
t.Fatalf("confirmed: %#v %v", p, err)
|
||||
}
|
||||
if err := a.SaveCredentials(1, 2, p.Cookies); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.Load(1, 2)
|
||||
if err != nil || string(got) != p.Cookies {
|
||||
t.Fatalf("stored=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishUPOSUsesETagAndEncodedCSRF(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := credentials.Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Save(1, 2, []byte("SESSDATA=s; bili_jct=a&b; DedeUserID=7")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sawPart, sawMerge, sawSubmit bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/material":
|
||||
_, _ = w.Write([]byte("video"))
|
||||
case "/preupload":
|
||||
_, _ = w.Write([]byte(`{"OK":1,"endpoint":"//` + r.Host + `","auth":"auth","biz_id":1,"upos_uri":"upos://folder/v.mp4","chunk_size":2}`))
|
||||
case "/folder/v.mp4":
|
||||
if r.Method == http.MethodPost && r.URL.Query().Has("uploads") {
|
||||
_, _ = w.Write([]byte(`{"upload_id":"u1"}`))
|
||||
} else if r.Method == http.MethodPut {
|
||||
sawPart = true
|
||||
w.Header().Set("ETag", `"real-etag"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else if r.Method == http.MethodPost {
|
||||
sawMerge = true
|
||||
_, _ = w.Write([]byte(`{"OK":1}`))
|
||||
}
|
||||
case "/x/vu/web/add":
|
||||
if r.URL.Query().Get("csrf") != "a&b" {
|
||||
t.Errorf("csrf not decoded: %q", r.URL.RawQuery)
|
||||
}
|
||||
sawSubmit = true
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"bvid":"BV1"}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
a := New(store)
|
||||
a.MemberBase = srv.URL
|
||||
a.UpOSScheme = "http"
|
||||
material := srv.URL + "/material"
|
||||
result, err := a.Publish(context.Background(), platform.PublishInput{WorkspaceID: 1, AccountID: 2, Title: "title", Content: "body", MaterialURLs: []string{material}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.URL != "https://www.bilibili.com/video/BV1" || !sawPart || !sawMerge || !sawSubmit {
|
||||
t.Fatalf("result=%#v part=%v merge=%v submit=%v", result, sawPart, sawMerge, sawSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCredentialsRejectsIncompleteCookie(t *testing.T) {
|
||||
store, err := credentials.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := New(store).SaveCredentials(1, 1, "SESSDATA=only"); err == nil || platform.CategoryOf(err) != platform.Validation {
|
||||
t.Fatalf("expected validation error for incomplete cookie, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCredentialsUsesEncryptedCookieAndNavEndpoint(t *testing.T) {
|
||||
store, err := credentials.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Save(1, 2, []byte("SESSDATA=s; bili_jct=j; DedeUserID=7")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/x/web-interface/nav" || r.Header.Get("Cookie") != "SESSDATA=s; bili_jct=j; DedeUserID=7" {
|
||||
t.Fatalf("unexpected nav request: %s cookie=%q", r.URL.Path, r.Header.Get("Cookie"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"isLogin":true}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
a := New(store)
|
||||
a.MemberBase = srv.URL
|
||||
if err := a.CheckCredentials(context.Background(), 1, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginState is deliberately small: the web UI maps it to pending/scanned/
|
||||
// solved without knowing platform-specific numeric QR codes.
|
||||
type LoginState string
|
||||
|
||||
const (
|
||||
LoginPending LoginState = "pending"
|
||||
LoginScanned LoginState = "scanned"
|
||||
LoginConfirmed LoginState = "confirmed"
|
||||
)
|
||||
|
||||
type LoginSession struct {
|
||||
Token string
|
||||
URL string
|
||||
}
|
||||
|
||||
type LoginPoll struct {
|
||||
State LoginState
|
||||
Cookies string
|
||||
Prompt string
|
||||
}
|
||||
|
||||
type PublishInput struct {
|
||||
WorkspaceID uint64
|
||||
AccountID uint64
|
||||
TaskID uint64
|
||||
Title string
|
||||
Content string
|
||||
Tags []string
|
||||
MaterialURLs []string
|
||||
CategoryID int
|
||||
ScheduleAt *time.Time
|
||||
}
|
||||
|
||||
type PublishResult struct {
|
||||
URL string
|
||||
Receipt string
|
||||
}
|
||||
|
||||
type ErrorCategory string
|
||||
|
||||
const (
|
||||
AuthRequired ErrorCategory = "auth_required"
|
||||
ChallengeRequired ErrorCategory = "challenge_required"
|
||||
Validation ErrorCategory = "validation"
|
||||
RateLimited ErrorCategory = "rate_limited"
|
||||
Network ErrorCategory = "network"
|
||||
PlatformChanged ErrorCategory = "platform_changed"
|
||||
Unknown ErrorCategory = "unknown"
|
||||
)
|
||||
|
||||
// Error is safe to show in task history: it carries a category and excludes
|
||||
// cookies, tokens and raw response bodies by construction.
|
||||
type Error struct {
|
||||
Category ErrorCategory
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("%s: %s", e.Category, e.Message)
|
||||
}
|
||||
return string(e.Category)
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error { return e.Err }
|
||||
|
||||
func CategoryOf(err error) ErrorCategory {
|
||||
var pe *Error
|
||||
if errors.As(err, &pe) && pe.Category != "" {
|
||||
return pe.Category
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
// Adapter is the server-side boundary for a platform. Implementations own
|
||||
// cookies/profile details; API handlers only persist challenge state.
|
||||
type Adapter interface {
|
||||
Platform() string
|
||||
BeginLogin(context.Context) (LoginSession, error)
|
||||
PollLogin(context.Context, string) (LoginPoll, error)
|
||||
SaveCredentials(workspaceID, accountID uint64, cookies string) error
|
||||
Publish(context.Context, PublishInput) (PublishResult, error)
|
||||
}
|
||||
|
||||
// CredentialChecker is optional so a platform can expose a cheap login health
|
||||
// probe without forcing every adapter to implement a platform-specific API.
|
||||
type CredentialChecker interface {
|
||||
CheckCredentials(context.Context, uint64, uint64) error
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
adapters map[string]Adapter
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry { return &Registry{adapters: make(map[string]Adapter)} }
|
||||
|
||||
func (r *Registry) Register(adapter Adapter) error {
|
||||
if r == nil || adapter == nil || adapter.Platform() == "" {
|
||||
return errors.New("invalid platform adapter")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.adapters[adapter.Platform()] = adapter
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) Adapter {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.adapters[name]
|
||||
}
|
||||
|
||||
func (r *Registry) Has(name string) bool { return r.Get(name) != nil }
|
||||
|
||||
func (r *Registry) Names() []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names := make([]string, 0, len(r.adapters))
|
||||
for name := range r.adapters {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -1,34 +1,50 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"everypublish/server/internal/config"
|
||||
"everypublish/server/internal/credentials"
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/platform"
|
||||
"everypublish/server/internal/task"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Dispatcher 任务下发器(MVP:进程内轮询 + 审批/重试即时触发;接口预留二期换 asynq)
|
||||
// Dispatcher 任务执行器(Web-only 本机 worker;审批/重试即时触发)
|
||||
type Dispatcher struct {
|
||||
DB *gorm.DB
|
||||
Hub *Hub
|
||||
Cfg *config.Config
|
||||
Registry *platform.Registry
|
||||
Credentials *credentials.Store
|
||||
Interval time.Duration
|
||||
runsMu sync.Mutex
|
||||
runs map[uint64]context.CancelFunc
|
||||
}
|
||||
|
||||
type publishTarget struct {
|
||||
account models.Account
|
||||
adapter platform.Adapter
|
||||
}
|
||||
|
||||
// NewDispatcher 构造下发器
|
||||
func NewDispatcher(db *gorm.DB, hub *Hub, cfg *config.Config) *Dispatcher {
|
||||
return &Dispatcher{DB: db, Hub: hub, Cfg: cfg, Interval: time.Second}
|
||||
return &Dispatcher{DB: db, Hub: hub, Cfg: cfg, Interval: time.Second, runs: make(map[uint64]context.CancelFunc)}
|
||||
}
|
||||
|
||||
// Start 后台轮询:把到期且排队的任务下发给在线设备
|
||||
// Start 后台轮询:把到期且排队的任务交给当前执行模式
|
||||
func (d *Dispatcher) Start(stop <-chan struct{}) {
|
||||
if d.Interval <= 0 {
|
||||
d.Interval = time.Second
|
||||
@@ -57,7 +73,7 @@ func (d *Dispatcher) poll() {
|
||||
}
|
||||
}
|
||||
|
||||
// TryDispatch 立即下发单个任务(审批/重试后调用);无在线设备返回 false
|
||||
// TryDispatch 立即执行单个任务(审批/重试后调用)。
|
||||
func (d *Dispatcher) TryDispatch(taskID uint64) bool {
|
||||
var row models.Task
|
||||
if err := d.DB.First(&row, taskID).Error; err != nil || row.Status != task.Queued {
|
||||
@@ -66,96 +82,364 @@ func (d *Dispatcher) TryDispatch(taskID uint64) bool {
|
||||
if row.ScheduleAt != nil && row.ScheduleAt.After(time.Now()) {
|
||||
return false
|
||||
}
|
||||
devID := d.pickDevice(&row)
|
||||
if devID == 0 {
|
||||
if d.Cfg != nil && d.Cfg.ExecutorMode != "mock" && d.Registry != nil {
|
||||
ids := decodeIDs(row.AccountIDs)
|
||||
targets := make([]publishTarget, 0, len(ids))
|
||||
allRegistered := len(ids) > 0
|
||||
hasRegistered := false
|
||||
for _, id := range ids {
|
||||
var account models.Account
|
||||
if d.DB.Where("id = ? and workspace_id = ?", id, row.WorkspaceID).First(&account).Error != nil {
|
||||
allRegistered = false
|
||||
continue
|
||||
}
|
||||
adapter := d.Registry.Get(account.Platform)
|
||||
if adapter == nil {
|
||||
allRegistered = false
|
||||
continue
|
||||
}
|
||||
hasRegistered = true
|
||||
targets = append(targets, publishTarget{account: account, adapter: adapter})
|
||||
}
|
||||
if allRegistered {
|
||||
return d.dispatchPlatform(&row, targets)
|
||||
}
|
||||
if hasRegistered {
|
||||
return d.failUnsupportedPlatform(&row)
|
||||
}
|
||||
}
|
||||
return d.dispatchLocalMock(&row)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) failUnsupportedPlatform(row *models.Task) bool {
|
||||
next, ok := task.Next(row.Status, task.ActionDispatch)
|
||||
if !ok || d.DB.Model(row).Update("status", next).Error != nil {
|
||||
return false
|
||||
}
|
||||
push := d.buildPush(&row, devID)
|
||||
if push == nil {
|
||||
return false
|
||||
}
|
||||
// 先落库 dispatched,再推送,避免 ack 早于状态落库的竞态
|
||||
if next, ok := task.Next(row.Status, task.ActionDispatch); ok {
|
||||
_ = d.DB.Model(&row).Update("status", next).Error
|
||||
row.Status = next
|
||||
d.recordEvent(row, string(task.ActionDispatch), "任务包含尚未接入真实适配器的平台")
|
||||
d.broadcastTask(*row)
|
||||
if next, ok = task.Next(row.Status, task.ActionFail); ok {
|
||||
message := "任务包含尚未接入真实适配器的平台,请拆分任务后重试"
|
||||
_ = d.DB.Model(row).Updates(map[string]interface{}{"status": next, "error_message": message}).Error
|
||||
row.Status = next
|
||||
row.ErrorMessage = message
|
||||
d.recordEvent(row, string(task.ActionFail), message)
|
||||
d.broadcastTask(*row)
|
||||
}
|
||||
env := proto.NewEnvelope(uuid.NewString(), proto.TypeTaskPush, push)
|
||||
if err := d.Hub.SendToAgent(devID, env); err != nil {
|
||||
// 下发失败(设备离线/队列满)回退 queued,避免卡在 dispatched
|
||||
_ = d.DB.Model(&row).Update("status", task.Queued).Error
|
||||
return false
|
||||
}
|
||||
d.Hub.BroadcastWS(row.WorkspaceID, "task.status", taskEvent(row))
|
||||
return true
|
||||
}
|
||||
|
||||
// pickDevice 选设备:账号已绑定设备则必须在线上才下发(保持一账号一IP),
|
||||
// 未绑定设备的账号才兜底任意在线设备。
|
||||
func (d *Dispatcher) pickDevice(row *models.Task) uint64 {
|
||||
var accountIDs []uint64
|
||||
_ = json.Unmarshal([]byte(row.AccountIDs), &accountIDs)
|
||||
if len(accountIDs) > 0 {
|
||||
var accounts []models.Account
|
||||
d.DB.Where("id in ? and workspace_id = ?", accountIDs, row.WorkspaceID).Find(&accounts)
|
||||
hasAssigned := false
|
||||
for _, a := range accounts {
|
||||
if a.AgentDeviceID == 0 {
|
||||
continue
|
||||
// dispatchPlatform runs a registered adapter on the server itself. A task is
|
||||
// marked dispatched before the goroutine starts so retries/cancel operations
|
||||
// observe the same state machine as the local mock executor.
|
||||
func (d *Dispatcher) dispatchPlatform(row *models.Task, targets []publishTarget) bool {
|
||||
next, ok := task.Next(row.Status, task.ActionDispatch)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
hasAssigned = true
|
||||
if d.Hub.AgentOnline(a.AgentDeviceID) {
|
||||
return a.AgentDeviceID
|
||||
claim := d.DB.Model(&models.Task{}).Where("id = ? and workspace_id = ? and status = ?", row.ID, row.WorkspaceID, task.Queued).Update("status", next)
|
||||
if claim.Error != nil || claim.RowsAffected != 1 {
|
||||
return false
|
||||
}
|
||||
row.Status = next
|
||||
d.recordEvent(row, string(task.ActionDispatch), "平台适配器已接收任务")
|
||||
d.broadcastTask(*row)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
d.runsMu.Lock()
|
||||
if d.runs == nil {
|
||||
d.runs = make(map[uint64]context.CancelFunc)
|
||||
}
|
||||
if hasAssigned {
|
||||
return 0 // 已绑定设备但离线:不下发,保持 IP 隔离
|
||||
}
|
||||
}
|
||||
online := d.Hub.OnlineDevices(row.WorkspaceID)
|
||||
if len(online) > 0 {
|
||||
return online[0]
|
||||
}
|
||||
return 0
|
||||
d.runs[row.ID] = cancel
|
||||
d.runsMu.Unlock()
|
||||
go d.runPlatform(ctx, row.ID, row.WorkspaceID, targets)
|
||||
return true
|
||||
}
|
||||
|
||||
// buildPush 组装 TaskPush(素材生成一次性签名直链)
|
||||
func (d *Dispatcher) buildPush(row *models.Task, devID uint64) *proto.TaskPush {
|
||||
var materialIDs []uint64
|
||||
_ = json.Unmarshal([]byte(row.MaterialIDs), &materialIDs)
|
||||
urls := make([]string, 0, len(materialIDs))
|
||||
for _, mid := range materialIDs {
|
||||
var mat models.Material
|
||||
if err := d.DB.First(&mat, mid).Error; err != nil {
|
||||
continue
|
||||
func (d *Dispatcher) runPlatform(ctx context.Context, taskID, workspaceID uint64, targets []publishTarget) {
|
||||
defer func() {
|
||||
d.runsMu.Lock()
|
||||
delete(d.runs, taskID)
|
||||
d.runsMu.Unlock()
|
||||
}()
|
||||
var row models.Task
|
||||
if err := d.DB.Where("id = ? and workspace_id = ?", taskID, workspaceID).First(&row).Error; err != nil || row.Status != task.Dispatched {
|
||||
return
|
||||
}
|
||||
token := models.TransferToken{MaterialID: mat.ID, Token: uuid.NewString(), ExpiresAt: time.Now().Add(10 * time.Minute)}
|
||||
if err := d.DB.Create(&token).Error; err != nil {
|
||||
continue
|
||||
if next, ok := task.Next(row.Status, task.ActionStart); ok {
|
||||
claim := d.DB.Model(&models.Task{}).Where("id = ? and workspace_id = ? and status = ?", row.ID, row.WorkspaceID, task.Dispatched).Update("status", next)
|
||||
if claim.Error != nil || claim.RowsAffected != 1 {
|
||||
return
|
||||
}
|
||||
urls = append(urls, d.Cfg.BaseURL+"/api/v1/files/"+token.Token)
|
||||
row.Status = next
|
||||
d.recordEvent(&row, string(task.ActionStart), "平台适配器开始处理")
|
||||
d.broadcastTask(row)
|
||||
}
|
||||
var accountIDs []uint64
|
||||
_ = json.Unmarshal([]byte(row.AccountIDs), &accountIDs)
|
||||
var tags []string
|
||||
_ = json.Unmarshal([]byte(row.Tags), &tags)
|
||||
push := &proto.TaskPush{
|
||||
TaskID: strconv.FormatUint(row.ID, 10),
|
||||
Title: row.Title,
|
||||
Content: row.Content,
|
||||
Tags: tags,
|
||||
MaterialURLs: urls,
|
||||
Priority: row.Priority,
|
||||
|
||||
var err error
|
||||
results := make([]platform.PublishResult, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
input, inputErr := d.publishInput(row, target.account)
|
||||
if inputErr != nil {
|
||||
err = inputErr
|
||||
break
|
||||
}
|
||||
if len(accountIDs) > 0 {
|
||||
var acc models.Account
|
||||
if err := d.DB.First(&acc, accountIDs[0]).Error; err == nil {
|
||||
push.Platform = acc.Platform
|
||||
push.AccountID = strconv.FormatUint(acc.ID, 10)
|
||||
push.AccountName = acc.AccountName
|
||||
result, publishErr := target.adapter.Publish(ctx, input)
|
||||
if publishErr != nil {
|
||||
err = publishErr
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(result.URL) == "" {
|
||||
err = &platform.Error{Category: platform.PlatformChanged, Message: "平台未返回发布链接"}
|
||||
break
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if err == nil && len(results) == len(targets) {
|
||||
urlsList := make([]string, 0, len(results))
|
||||
receiptsList := make([]string, 0, len(results))
|
||||
for _, result := range results {
|
||||
urlsList = append(urlsList, result.URL)
|
||||
receiptsList = append(receiptsList, result.Receipt)
|
||||
}
|
||||
urls, _ := json.Marshal(urlsList)
|
||||
receipts, _ := json.Marshal(receiptsList)
|
||||
now := time.Now()
|
||||
tx := d.DB.Begin()
|
||||
committed := false
|
||||
if tx.Error == nil {
|
||||
result := tx.Model(&models.Task{}).Where("id = ? and workspace_id = ? and status = ?", row.ID, row.WorkspaceID, task.Running).Updates(map[string]interface{}{
|
||||
"status": task.Success, "published_urls": string(urls), "receipts": string(receipts),
|
||||
"error_message": "", "published_at": now,
|
||||
})
|
||||
txErr := result.Error
|
||||
if txErr == nil && result.RowsAffected != 1 {
|
||||
txErr = context.Canceled
|
||||
}
|
||||
if txErr == nil {
|
||||
txErr = tx.Create(&models.TaskEvent{WorkspaceID: row.WorkspaceID, TaskID: row.ID, Type: string(task.ActionSuccess), Status: task.Success, Message: "平台发布完成"}).Error
|
||||
}
|
||||
if txErr == nil {
|
||||
txErr = tx.Commit().Error
|
||||
}
|
||||
if txErr != nil {
|
||||
tx.Rollback()
|
||||
} else {
|
||||
committed = true
|
||||
}
|
||||
}
|
||||
if row.ScheduleAt != nil {
|
||||
push.ScheduleAt = row.ScheduleAt.UnixMilli()
|
||||
if committed {
|
||||
row.Status = task.Success
|
||||
row.PublishedURLs = string(urls)
|
||||
row.Receipts = string(receipts)
|
||||
row.PublishedAt = &now
|
||||
d.notifySuccess(row)
|
||||
d.broadcastTask(row)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
message := "平台发布失败"
|
||||
if err != nil {
|
||||
message = safePlatformError(err)
|
||||
}
|
||||
var current models.Task
|
||||
if d.DB.Where("id = ? and workspace_id = ?", row.ID, row.WorkspaceID).First(¤t).Error == nil && current.Status == task.Cancelled {
|
||||
return
|
||||
}
|
||||
if next, ok := task.Next(row.Status, task.ActionFail); ok {
|
||||
updates := map[string]interface{}{"status": next, "error_message": message}
|
||||
if len(results) > 0 {
|
||||
partialURLs := make([]string, 0, len(results))
|
||||
partialReceipts := make([]string, 0, len(results))
|
||||
for _, result := range results {
|
||||
partialURLs = append(partialURLs, result.URL)
|
||||
partialReceipts = append(partialReceipts, result.Receipt)
|
||||
}
|
||||
partialURLJSON, _ := json.Marshal(partialURLs)
|
||||
partialReceiptJSON, _ := json.Marshal(partialReceipts)
|
||||
updates["published_urls"] = string(partialURLJSON)
|
||||
updates["receipts"] = string(partialReceiptJSON)
|
||||
message = "部分账号已发布;" + message
|
||||
updates["error_message"] = message
|
||||
}
|
||||
_ = d.DB.Model(&models.Task{}).Where("id = ? and workspace_id = ? and status = ?", row.ID, row.WorkspaceID, task.Running).Updates(updates).Error
|
||||
row.Status = next
|
||||
row.ErrorMessage = message
|
||||
d.recordEvent(&row, string(task.ActionFail), message)
|
||||
d.broadcastTask(row)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel interrupts a real adapter run. The task handler still performs the
|
||||
// persisted state transition so the database and worker agree on cancellation.
|
||||
func (d *Dispatcher) Cancel(taskID uint64) {
|
||||
d.runsMu.Lock()
|
||||
cancel := d.runs[taskID]
|
||||
d.runsMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) publishInput(row models.Task, account models.Account) (platform.PublishInput, error) {
|
||||
materialIDs := decodeIDs(row.MaterialIDs)
|
||||
if len(materialIDs) == 0 {
|
||||
return platform.PublishInput{}, &platform.Error{Category: platform.Validation, Message: "任务没有素材"}
|
||||
}
|
||||
var materials []models.Material
|
||||
if err := d.DB.Where("workspace_id = ? and id in ? and status = ?", row.WorkspaceID, materialIDs, "ready").Find(&materials).Error; err != nil {
|
||||
return platform.PublishInput{}, &platform.Error{Category: platform.Unknown, Message: "读取素材失败", Err: err}
|
||||
}
|
||||
if len(materials) != len(materialIDs) {
|
||||
return platform.PublishInput{}, &platform.Error{Category: platform.Validation, Message: "任务素材不存在或未就绪"}
|
||||
}
|
||||
urls := make([]string, 0, len(materials))
|
||||
for _, material := range materials {
|
||||
token := models.TransferToken{MaterialID: material.ID, Token: uuid.NewString(), ExpiresAt: time.Now().Add(10 * time.Minute)}
|
||||
if err := d.DB.Create(&token).Error; err != nil {
|
||||
return platform.PublishInput{}, &platform.Error{Category: platform.Unknown, Message: "生成素材访问链接失败", Err: err}
|
||||
}
|
||||
base := ""
|
||||
if d.Cfg != nil {
|
||||
base = strings.TrimRight(d.Cfg.BaseURL, "/")
|
||||
}
|
||||
if base == "" {
|
||||
base = "http://127.0.0.1:8090"
|
||||
}
|
||||
urls = append(urls, base+"/api/v1/files/"+url.QueryEscape(token.Token))
|
||||
}
|
||||
var tags []string
|
||||
if row.Tags != "" {
|
||||
_ = json.Unmarshal([]byte(row.Tags), &tags)
|
||||
}
|
||||
return platform.PublishInput{WorkspaceID: row.WorkspaceID, AccountID: account.ID, TaskID: row.ID, Title: row.Title, Content: row.Content, Tags: tags, MaterialURLs: urls, ScheduleAt: row.ScheduleAt}, nil
|
||||
}
|
||||
|
||||
func (d *Dispatcher) notifySuccess(row models.Task) {
|
||||
if row.CreatedBy != 0 {
|
||||
_ = d.DB.Create(&models.Notification{WorkspaceID: row.WorkspaceID, UserID: row.CreatedBy, Kind: "task", Title: "任务执行完成", Content: row.Title}).Error
|
||||
}
|
||||
if d.Hub != nil {
|
||||
d.Hub.BroadcastWS(row.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务执行完成", "content": row.Title})
|
||||
}
|
||||
}
|
||||
|
||||
func decodeIDs(raw string) []uint64 {
|
||||
var ids []uint64
|
||||
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func safePlatformError(err error) string {
|
||||
var pe *platform.Error
|
||||
if errors.As(err, &pe) {
|
||||
return pe.Error()
|
||||
}
|
||||
return "平台发布失败"
|
||||
}
|
||||
|
||||
// dispatchLocalMock is the Web-only V1 executor. It deliberately produces a
|
||||
// mock:// receipt so a local test can prove the state machine without claiming
|
||||
// that a real platform accepted the content.
|
||||
func (d *Dispatcher) dispatchLocalMock(row *models.Task) bool {
|
||||
next, ok := task.Next(row.Status, task.ActionDispatch)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
claim := d.DB.Model(&models.Task{}).Where("id = ? and workspace_id = ? and status = ?", row.ID, row.WorkspaceID, task.Queued).Update("status", next)
|
||||
if claim.Error != nil || claim.RowsAffected != 1 {
|
||||
return false
|
||||
}
|
||||
row.Status = next
|
||||
d.recordEvent(row, string(task.ActionDispatch), "本机 Web 执行器已接收任务")
|
||||
d.broadcastTask(*row)
|
||||
go func(taskID uint64, workspaceID uint64) {
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
var running models.Task
|
||||
if err := d.DB.Where("id = ? and workspace_id = ?", taskID, workspaceID).First(&running).Error; err != nil || running.Status != task.Dispatched {
|
||||
return
|
||||
}
|
||||
if next, ok := task.Next(running.Status, task.ActionStart); ok {
|
||||
claim := d.DB.Model(&models.Task{}).Where("id = ? and workspace_id = ? and status = ?", running.ID, running.WorkspaceID, task.Dispatched).Update("status", next)
|
||||
if claim.Error != nil || claim.RowsAffected != 1 {
|
||||
return
|
||||
}
|
||||
running.Status = next
|
||||
d.recordEvent(&running, string(task.ActionStart), "本机 Web 执行器开始处理")
|
||||
d.broadcastTask(running)
|
||||
}
|
||||
time.Sleep(180 * time.Millisecond)
|
||||
if err := d.DB.Where("id = ? and workspace_id = ?", taskID, workspaceID).First(&running).Error; err != nil || running.Status != task.Running {
|
||||
return
|
||||
}
|
||||
next, ok := task.Next(running.Status, task.ActionSuccess)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
urls, _ := json.Marshal([]string{fmt.Sprintf("mock://everypublish/task/%d", taskID)})
|
||||
receipts, _ := json.Marshal([]string{"mock executor completed"})
|
||||
now := time.Now()
|
||||
tx := d.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return
|
||||
}
|
||||
result := tx.Model(&models.Task{}).Where("id = ? and workspace_id = ? and status = ?", running.ID, running.WorkspaceID, task.Running).Updates(map[string]interface{}{
|
||||
"status": next, "published_urls": string(urls), "receipts": string(receipts),
|
||||
"error_message": "", "published_at": now,
|
||||
})
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
running.Status = next
|
||||
running.PublishedURLs = string(urls)
|
||||
running.Receipts = string(receipts)
|
||||
running.PublishedAt = &now
|
||||
if err := tx.Create(&models.TaskEvent{
|
||||
WorkspaceID: running.WorkspaceID,
|
||||
TaskID: running.ID,
|
||||
Type: string(task.ActionSuccess),
|
||||
Status: running.Status,
|
||||
Message: "本机 mock 执行完成",
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return
|
||||
}
|
||||
if running.CreatedBy != 0 {
|
||||
_ = d.DB.Create(&models.Notification{
|
||||
WorkspaceID: running.WorkspaceID,
|
||||
UserID: running.CreatedBy,
|
||||
Kind: "task",
|
||||
Title: "任务执行完成",
|
||||
Content: running.Title,
|
||||
}).Error
|
||||
}
|
||||
if d.Hub != nil {
|
||||
d.Hub.BroadcastWS(running.WorkspaceID, "notification.new", gin.H{"kind": "task", "title": "任务执行完成", "content": running.Title})
|
||||
}
|
||||
d.broadcastTask(running)
|
||||
}(row.ID, row.WorkspaceID)
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *Dispatcher) recordEvent(row *models.Task, eventType, message string) {
|
||||
_ = d.DB.Create(&models.TaskEvent{
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
TaskID: row.ID,
|
||||
Type: eventType,
|
||||
Status: row.Status,
|
||||
Message: message,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (d *Dispatcher) broadcastTask(row models.Task) {
|
||||
if d.Hub != nil {
|
||||
d.Hub.BroadcastWS(row.WorkspaceID, "task.status", taskEvent(row))
|
||||
}
|
||||
return push
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/server/internal/config"
|
||||
"everypublish/server/internal/models"
|
||||
"everypublish/server/internal/task"
|
||||
|
||||
gormsqlite "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDispatcherLocalMock(t *testing.T) {
|
||||
db, err := gorm.Open(gormsqlite.Open("file:dispatcher_mock?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.Task{}, &models.TaskEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.Task{WorkspaceID: 1, Title: "web-only", Status: task.Queued, AccountIDs: "[]", MaterialIDs: "[]"}
|
||||
if err = db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := NewDispatcher(db, NewHub(), &config.Config{ExecutorMode: "mock"})
|
||||
if !d.TryDispatch(row.ID) {
|
||||
t.Fatal("mock dispatch should accept queued task")
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var got models.Task
|
||||
if err = db.First(&got, row.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status == task.Success {
|
||||
if got.PublishedURLs == "" || got.Receipts == "" {
|
||||
t.Fatal("mock success must include URL and receipt")
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("mock task did not reach success")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user