server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物

This commit is contained in:
Qiufeng
2026-08-21 10:53:32 +08:00
parent 0daa9782c9
commit 3585c39bab
103 changed files with 5842 additions and 3197 deletions
+8 -2
View File
@@ -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/
+19 -1
View File
@@ -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 验收"
+59 -46
View File
@@ -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。
- 所有任务、挑战和素材错误必须能在页面和日志中定位。
- 不做刷量、绕过平台风控、矩阵养号、私信轰炸等功能。
+2 -2
View File
@@ -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}>
+138 -50
View File
@@ -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>
);
-100
View File
@@ -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>
);
}
+42 -51
View File
@@ -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: '状态',
-1
View File
@@ -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 }}
/>
+12 -4
View File
@@ -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
+19 -38
View File
@@ -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) {
+29 -1
View File
@@ -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) => {
+78 -2
View File
@@ -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>
);
}
+34 -4
View File
@@ -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 }}>
+51 -7
View File
@@ -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>
+18 -9
View File
@@ -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>
);
}
-107
View File
@@ -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>
);
}
+24 -3
View File
@@ -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>
+28 -11
View File
@@ -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 -6
View File
@@ -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')),
-10
View File
@@ -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 设备' },
},
],
},
{
+3 -1
View File
@@ -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);
},
+18
View File
@@ -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;
}
+1 -1
View File
@@ -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
+35 -2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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);
+4 -4
View File
@@ -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": {
+1
View File
@@ -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'),
+28 -15
View File
@@ -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';
});
+2
View File
@@ -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>
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
+76
View File
@@ -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"
+18
View File
@@ -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"
+5
View File
@@ -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
View File
@@ -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 联调时覆盖;生产环境保持官方地址并遵守平台规则。
+3 -30
View File
@@ -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/` 目录,只作为历史资料,不进入当前构建、启动和验收流程。
+2 -50
View File
@@ -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) 为准。
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 验收。
+5
View File
@@ -0,0 +1,5 @@
# macOS 客户端安装说明(暂停)
当前 Web-only V1 不需要安装 macOS 客户端。请按 [`README.md`](../README.md) 启动 Go server 和 React Web,浏览器访问本机控制台。
旧客户端代码和安装脚本保留在 `client/` 目录,仅作历史资料,不进入当前构建、启动和验收流程。
+7
View File
@@ -0,0 +1,7 @@
# macOS 客户端历史归档
当前任务只交付 Web-only V1。macOS/Windows 客户端、Agent WSS、设备配对和设备管理全部暂停,不在网页菜单、默认路由、开发排期或验收清单中。
`client/macos/`、`client/desktop/` 和 `client/core/` 仅保留为历史实验代码,当前不得继续开发,也不能作为网页端功能完成的依据。
网页端必须在当前服务器设备上独立完成:登录、账号挑战、素材上传、发布任务、审核、队列、结果、通知和审计。真实平台扫码需要用户后续手动验收;在没有扫码时只允许验证二维码生成、轮询等待、过期、重发和 mock 联调。
+3
View File
@@ -0,0 +1,3 @@
# macOS 客户端线框历史归档
当前不开发客户端,因此不保留可执行的客户端页面线框。Web-only V1 的网页 ASCII 线框以 [`docs/page-wireframes.md`](page-wireframes.md) 为准。
+40 -580
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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。
+78
View File
@@ -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 结果代替真实成功。
+99
View File
@@ -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
View File
@@ -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
View File
@@ -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` 默认构建。
+116
View File
@@ -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` 补齐,不依赖单次推送。
+129
View File
@@ -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(每个平台独立测试)
```
+105
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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,
+2
View File
@@ -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
+42 -5
View File
@@ -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 {
+322 -35
View File
@@ -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")
}
+1 -3
View File
@@ -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})
+47 -5
View File
@@ -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)
}
})
}
}
+74 -2
View File
@@ -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
}
+94 -9
View File
@@ -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)
}
+17 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+104 -2
View File
@@ -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
+27
View File
@@ -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()
}
}
+50 -35
View File
@@ -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
+53 -9
View File
@@ -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
View File
@@ -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)
}
}
+2 -2
View File
@@ -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)
}
}
+3 -2
View File
@@ -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)),
+10
View File
@@ -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"),
}
}
+193
View File
@@ -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)
}
+49
View File
@@ -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")
}
}
+1
View File
@@ -43,6 +43,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.Account{},
&models.Material{},
&models.Task{},
&models.TaskEvent{},
&models.AgentDevice{},
&models.Challenge{},
&models.AuditLog{},
+13 -2
View File
@@ -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)
}
}
+146
View File
@@ -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
}
+363 -79
View File
@@ -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(&current).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