提交 ade792f7 authored 作者: 王超's avatar 王超

v1

上级
# Legal service API config
LEGAL_BASE_URL=http://sys.12348.gov.cn/resources
LEGAL_API_PREFIX=/resources/api
# AES encryption config
AES_KEY=1234567890123456
AES_IV=1234567890123456
# App config
APP_NAME=法律抢单系统
APP_ENV=development
SECRET_KEY=change-me-in-production
# Database
DATABASE_URL=sqlite:///./legal_system.db
# Server
HOST=0.0.0.0
PORT=8000
.venv/
chunk_*.js
vendor.js
manifest.js
frontend/node_modules
\ No newline at end of file
# 法律抢单系统 — Docker 部署文档
## 架构
```
用户浏览器 → Nginx (端口 80)
├── / → 前端 (React SPA)
└── /api/ → 后端 (FastAPI :8080)
```
两个独立容器,通过 Docker 网络连接:
- **`law_order_frontend`** — Nginx 反代 + React 静态文件
- **`law_order_backend`** — FastAPI + uvicorn + SQLite
---
## 前置准备
### 服务器环境
| 项目 | 要求 |
|------|------|
| OS | Ubuntu 26.04 |
| Docker | 24.0+ (`docker --version`) |
| 端口 | 80 (HTTP) |
| 磁盘 | 2GB+ (镜像 + 数据) |
### 安装 Docker
```bash
apt update && apt install -y docker.io
systemctl enable --now docker
docker --version
```
---
## 部署步骤
### 1. 上传项目到服务器
```bash
# 在 Windows 上打包(⚠️ 注意 .env 文件已复制到 backend/ 目录)
tar -czf law_order_deploy.tar.gz \
backend/Dockerfile backend/main.py backend/app/ backend/requirements.txt backend/.env \
frontend/Dockerfile frontend/src/ frontend/package.json frontend/index.html frontend/vite.config.js \
nginx/nginx.conf
# 上传到服务器
scp law_order_deploy.tar.gz user@server:/opt/law_order/
# 在服务器解压
cd /opt/law_order && tar -xzf law_order_deploy.tar.gz
```
或者直接在服务器克隆代码仓库。
### 2. 创建目录
```bash
cd /opt/law_order
mkdir -p backend/data backend/logs
```
### 3. 创建 `.env` 文件
```bash
vim backend/.env
```
内容(按需修改):
```
# 法网 API 配置
LEGAL_BASE_URL=http://sys.12348.gov.cn/resources
LEGAL_API_PREFIX=/resources/api
# AES 加密
AES_KEY=1234567890123456
AES_IV=1234567890123456
# 应用配置
APP_NAME=法律抢单系统
APP_ENV=production
SECRET_KEY=<生成随机密钥>
# 数据库(容器内路径)
DATABASE_URL=sqlite:///./data/app.db
# 抢单配置
GRAB_INTERVAL_MS=300
HEARTBEAT_INTERVAL_S=60
# 管理员账号
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<你的密码>
```
### 4. 构建镜像
```bash
cd /opt/law_order
# 构建后端
docker build -t law_order_backend:latest ./backend
# 构建前端
docker build -t law_order_frontend:latest ./frontend
```
### 5. 创建网络
```bash
docker network create law_order_net
```
### 6. 启动容器
```bash
# 后端 (挂载数据 + 日志 + .env)
docker run -d --name law_order_backend \
--network law_order_net \
--restart unless-stopped \
-v /opt/law_order/backend/data:/app/data \
-v /opt/law_order/backend/logs:/app/logs \
-v /opt/law_order/backend/.env:/app/.env \
law_order_backend:latest
# 前端 (Nginx 反代)
docker run -d --name law_order_frontend \
--network law_order_net \
--restart unless-stopped \
-p 80:80 \
law_order_frontend:latest
```
### 7. 验证
```bash
# 检查容器状态
docker ps
# 检查后端日志
docker logs -f law_order_backend
# 测试 API
curl http://localhost/api/health
# 访问前端
# 浏览器打开 http://<服务器IP>
```
---
## 日常运维
### 查看日志
```bash
docker logs -f law_order_backend # 后端日志
docker logs -f law_order_frontend # 前端日志
cat /opt/law_order/backend/logs/app_*.log # 文件日志
```
### 重启容器
```bash
docker restart law_order_backend law_order_frontend
```
### 停止容器
```bash
docker stop law_order_backend law_order_frontend
```
### 更新部署
```bash
# 1. 停止旧容器
docker stop law_order_backend law_order_frontend
# 2. 删除旧容器
docker rm law_order_backend law_order_frontend
# 3. 重新构建镜像
docker build -t law_order_backend:latest ./backend
docker build -t law_order_frontend:latest ./frontend
# 4. 重新启动 (见步骤 6)
```
### 数据库备份
```bash
cp /opt/law_order/backend/data/app.db /opt/law_order/backend/data/app.db.bak.$(date +%Y%m%d)
```
### 清理无用镜像
```bash
docker image prune -a
```
---
## 环境变量说明
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `DATABASE_URL` | `sqlite:///./data/app.db` | 数据库路径(容器内) |
| `AES_KEY` | `1234567890123456` | 法网 AES 加密密钥 |
| `AES_IV` | `1234567890123456` | 法网 AES 初始化向量 |
| `SECRET_KEY` | `change-me-in-production` | JWT 签名密钥 |
| `GRAB_INTERVAL_MS` | `300` | 抢单轮询间隔 (ms) |
| `HEARTBEAT_INTERVAL_S` | `60` | 心跳保活间隔 (s) |
| `ADMIN_USERNAME` | `admin` | 管理员用户名 |
| `ADMIN_PASSWORD` | `Admin@2026` | 管理员密码 |
---
## 防火墙配置
```bash
# 仅开放 80 端口
ufw allow 80/tcp
ufw enable
```
如果需要通过 HTTPS 访问,建议在前面加一个反向代理(如 Nginx + Let's Encrypt)。
---
## 故障排查
### 前端 404
```bash
# 检查 Nginx 配置是否正确转发到后端
docker exec law_order_frontend cat /etc/nginx/conf.d/default.conf
```
### 后端启动失败
```bash
# 查看启动日志
docker logs law_order_backend
# 常见原因:.env 文件不存在、权限不足、端口冲突
```
### 抢单不工作
```bash
# 1. 确认抢单引擎已启动
curl http://localhost/api/fw/grab/start
# 2. 查看后端日志
docker logs -f law_order_backend | grep -i grab
# 3. 检查账号会话
curl http://localhost/api/fw/sessions
```
### 账号被法网封禁
```bash
# 在 Web 界面退出被限制的账号,等一段时间后再登录
# 或手动断开连接:
curl -X POST http://localhost/api/fw/sessions/disconnect \
-H "Content-Type: application/json" \
-d '{"account_id": <id>}'
```
# 法律抢单系统
中国法网智能业务办理系统
## 技术栈
- **后端**: FastAPI + SQLAlchemy + SQLite
- **前端**: React (待开发)
- **部署**: Docker (待配置)
## 项目结构
```
法律抢单系统/
├── backend/
│ ├── main.py # 主入口
│ ├── app/
│ │ ├── api/ # API路由
│ │ │ ├── login.py # 中国法网登录
│ │ │ ├── accounts.py # 账号管理
│ │ │ ├── sessions.py # 会话管理
│ │ │ └── orders.py # 订单管理(抢单/解答)
│ │ ├── core/ # 核心配置
│ │ │ ├── config.py # 系统配置
│ │ │ ├── crypto.py # AES加密工具
│ │ │ └── database.py # 数据库配置
│ │ ├── models/ # 数据模型
│ │ │ └── models.py
│ │ ├── schemas/ # Pydantic schemas
│ │ │ └── schemas.py
│ │ └── services/ # 业务逻辑(待扩展)
│ ├── data/ # SQLite数据库目录
│ ├── requirements.txt
│ └── run.bat # 启动脚本
├── frontend/ # React前端(待开发)
├── api_analysis.json # API逆向分析结果
└── 开发计划.txt
```
## 快速启动
### 1. 安装依赖
```bash
cd backend
pip install -r requirements.txt
```
### 2. 启动后端
```bash
# Windows
run.bat
# Linux/Mac
cd backend
python -m uvicorn main:app --host 0.0.0.0 --port 8080 --reload
```
### 3. 访问API文档
启动后访问: http://localhost:8080/docs
## API接口
### 登录
```
POST /api/fw/login
Body: {"phone": "18917209703", "password": "200663Ai@"}
```
### 账号管理
```
GET /api/accounts # 账号列表
POST /api/accounts # 创建账号
PUT /api/accounts/{id}/password # 重置密码
DELETE /api/accounts/{id} # 删除账号
```
### 会话管理
```
GET /api/sessions # 会话列表
POST /api/sessions/login # 登录账号
POST /api/sessions/{id}/disconnect # 断开
```
### 订单管理
```
GET /api/orders/pending # 待处理订单
POST /api/orders/grab # 抢单
POST /api/orders/answer # 解答
```
## 开发进度
### Phase 1: 逆向分析 ✅ DONE
- [x] 密码加密算法破解 (AES-CBC ZeroPadding)
- [x] 登录API分析
- [x] 抢单API分析
- [x] 解答API分析
- [x] 订单结构分析
### Phase 2: 后端基础 ✅ DONE
- [x] FastAPI框架搭建
- [x] 账号管理CRUD
- [x] 中国法网登录
- [x] 会话管理(基础)
- [x] 订单管理(基础)
### Phase 3: 会话管理 (TODO)
- [ ] 多账号并发登录
- [ ] 心跳保活机制
- [ ] 会话状态监控
- [ ] 反拉黑策略
### Phase 4: 抢单引擎 (TODO)
- [ ] 定时轮询抢单
- [ ] 多账号并发抢单
- [ ] 抢单/处理状态绑定
- [ ] 抢单频率配置
### Phase 5: 前端React (TODO)
- [ ] 普通用户界面
- [ ] 管理员Dashboard
- [ ] 订单处理页面
### Phase 6: Docker部署 (TODO)
- [ ] Dockerfile编写
- [ ] docker-compose配置
- [ ] Ubuntu 26.04部署测试
## 账号列表
| 手机号 | 密码 |
|--------|------|
| 18917209703 | 200663Ai@ |
| 19542820841 | Fabao@123 |
| 13764780450 | Fabao@123 |
| 13661598223 | Bst202606+ |
| 15800562587 | Fabao@1234 |
{
"phase": "Phase 1 - 逆向分析完成",
"encryption": {
"algorithm": "AES-CBC",
"key": "1234567890123456",
"iv": "1234567890123456",
"padding": "ZeroPadding",
"output": "Base64"
},
"base_url": "http://sys.12348.gov.cn/resources",
"apis": {
"login": {
"url": "/api/login",
"method": "POST"
},
"query_order": {
"url": "/api/message_order/query_order",
"method": "POST"
},
"grap_order": {
"url": "/api/message_order/grap_order",
"method": "POST"
},
"answer": {
"url": "/api/message_order/answer",
"method": "POST"
},
"query_current_service": {
"url": "/api/message_order/query_current_service",
"method": "POST"
},
"query_history_service": {
"url": "/api/message_order/query_history_service",
"method": "POST"
},
"get_order_detail": {
"url": "/api/message_order/get_message_order_by_id",
"method": "GET"
},
"get_order_for_answer": {
"url": "/api/message_order/get_message_order_by_id_for_answer",
"method": "GET"
},
"cancel_grap_order": {
"url": "/api/message_order/cancel_grap_order",
"method": "POST"
},
"report": {
"url": "/api/message_order/report",
"method": "POST"
}
},
"accounts": [
{
"phone": "18917209703",
"password": "200663Ai@",
"userId": 2059,
"name": "周晓"
},
{
"phone": "19542820841",
"password": "Fabao@123"
},
{
"phone": "13764780450",
"password": "Fabao@123"
},
{
"phone": "13661598223",
"password": "Bst202606+"
},
{
"phone": "15800562587",
"password": "Fabao@1234"
}
]
}
\ No newline at end of file
webpackJsonp([19],{"/ROx":function(t,e){},0:function(t,e,a){a("j1ja"),t.exports=a("NHnr")},"0gU3":function(t,e){t.exports="data:image/png;base64,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"},"2juw":function(t,e){t.exports="data:image/png;base64,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"},"3Pew":function(t,e){},"3X+C":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxMzggNzkuMTU5ODI0LCAyMDE2LzA5LzE0LTAxOjA5OjAxICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+IEmuOgAAAe5JREFUOI2l1UuIjlEcBvDffI0hQ0wNC5KSsnCJ3LOQphhKsphcFjbYycbCTiMLSrayMElsRlkpZUMWNlbkuiA7Mo0kJFN6LM6bXt98880MT/17O+d/znPO+788pyOJNujGDmzAMszFN3zCS9zHm1YbOyYg7sBeHMYHPKoIvqITi7ClOvAprmDkL4YkzTYzyZkkl5OsaeGv27wkJ5PcTLKi7mte2EhyNslgklmTkNZtT5LhJMvrxPurW0pyKMnFJF3TIK2TDyWZm0QDq3Eai6u4XsRYU8wHcA5L2yT6bpWHAWhUkxtxCXfGJaFgKdYqVdIOQ+jH/EZtshs9SkX8K0bwHNsbTY7dOPIfxPAY6zrxDjNqjuVK3J/9I/FbHOjEjWlsWjCFNZ81xbgdflTfUzihtHZbTJX4Cq7hJ3ZV4z6tE92DLxNpxUToxTFsq8YvlPr+XlvTj631G68y+S+O4gIGFXEaayKl9MSTekueTnJ8Gi3claSnaW5hkttJeus3voWdiiROBWNKBdRxFA8xWid+h3uKbkzWuq3Qj5W4jnGy2ZnkfGVzphGWvioEf/S71aJZlSZfTbJ+ikI/nGRd3dfuadqHg/ioPE2vladphiKxm5UKeIXLeP8XwSR1PFtphE1Ygnn4pZTaSzyoDhyH39OP6PCFyvOkAAAAAElFTkSuQmCC"},"4Lha":function(t,e,a){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var n=a("mvHQ"),r=a.n(n),i=a("EZqJ"),s=a("pEkY"),o=a("tH3/"),c=a("9jCu"),u={name:"layout",components:{Navbar:i.default,sidebar:s.default},data:function(){return{reformMsg:{},allMsgLists:{},showfade:!1}},mounted:function(){if(null!=localStorage.getItem("fwusername")){clearInterval(this.store.state.msgTimer);var t=setInterval(this.msgInform,5e3);this.store.commit("msgTimerFun",t),clearInterval(this.store.state.htimer);var e=setInterval(this.heart,6e4);this.store.commit("heartBeat",e)}},methods:{view:function(){this.showfade=!1,this.$router.push({name:"msgList"})},close:function(){this.showfade=!1},heart:function(){Object(c.x)({}).then(function(t){t.data.code})},msgInform:function(){var t=this,e=JSON.parse(localStorage.getItem("fwuserInfo")).userName;Object(o.a)({}).then(function(a){var n=a.data;if(1==n.code){var i=n.data,s=JSON.parse(localStorage.getItem("msg"))||{},o=s[e];if(i){t.reformMsg=n.data;var c=t.$refs.alert_audio;c.currentTime=0,t.reformMsg&&(t.showfade=!0,setTimeout(t.close,5e3)),o||(o=[]);var u=o.concat(n.data);s[e]=u,localStorage.setItem("msg",r()(s)),t.allMsgLists=JSON.parse(localStorage.getItem("msg")),t.msgLists=t.allMsgLists[e],t.msgLists.forEach(function(t){if(c.play(),2!=t.messageType&&4!=t.messageType&&13!=t.messageType&&14!=t.messageType&&17!=t.messageType&&16!=t.messageType||(t.readStatus=1),0==t.readStatus){localStorage.setItem("readStatus",r()(0))}else{localStorage.setItem("readStatus",r()(1))}},t),localStorage.setItem("msg",r()(s))}else o&&(t.msgLists=o,t.msgLists.forEach(function(t){if(2!=t.messageType&&4!=t.messageType&&13!=t.messageType&&14!=t.messageType&&16!=t.messageType&&17!=t.messageType||(t.readStatus=1),0==t.readStatus){localStorage.setItem("readStatus",r()(0))}else{localStorage.setItem("readStatus",r()(1))}}),localStorage.setItem("msg",r()(s)))}})}}},l={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"app-wrapper"},[a("navbar",{staticClass:"app-header"}),t._v(" "),a("audio",{ref:"alert_audio",attrs:{controls:"controls",hidden:"",src:"/resources/static/sound/alert.mp3"}}),t._v(" "),a("div",{staticClass:"main-container clearfix"},[a("input",{attrs:{type:"checkbox",id:"sidemenu"}}),t._v(" "),a("div",{staticClass:"fw-side-main"},[a("sidebar")],1),t._v(" "),a("div",{staticClass:"fw-main-body"},[t._m(0),t._v(" "),a("router-view")],1),t._v(" "),a("div",{staticClass:"msgInfo"},t._l(t.reformMsg,function(e,n){return a("div",{key:n},[t.showfade?a("div",{staticClass:"infoList"},[a("h3",{staticClass:"font-normal"},[t._v("系统提示")]),t._v(" "),a("p",[t._v(t._s(e.message))]),t._v(" "),5!=e.messageType&&6!=e.messageType?a("div",[a("p",[t._v("订单类型:"+t._s(e.productName))]),t._v(" "),a("p",[t._v("分类:"+t._s(e.businessType))])]):t._e(),t._v(" "),a("p",{staticClass:"fr"},[a("el-button",{attrs:{id:"msgInfo",type:"primary",size:"mini"},on:{click:t.view}},[t._v("查看")])],1),t._v(" "),a("p",{staticClass:"close-position"},[a("i",{staticClass:"el-icon-close",on:{click:function(e){return t.close()}}})])]):t._e()])}),0)])],1)},staticRenderFns:[function(){var t=this.$createElement,e=this._self._c||t;return e("label",{attrs:{id:"sideMenuControl",for:"sidemenu"}},[e("img",{staticClass:"imgrotate",attrs:{src:a("vrxY"),alt:""}})])}]};var m=a("VU/8")(u,l,!1,function(t){a("7FUL")},null,null);e.default=m.exports},"7FUL":function(t,e){},"9jCu":function(t,e,a){"use strict";e.h=function(t){return Object(n.a)({url:"/message_order/query_order",method:"post",data:t})},e.d=function(t){return Object(n.a)({url:"/message_order/query_current_service",method:"post",data:t})},e.y=function(t){return Object(n.a)({url:"/message_order/query_history_service",method:"post",data:t})},e.f=function(t){return Object(n.a)({url:"/expert_remark_order/query_remark_order",method:"post",data:t})},e.H=function(t){return Object(n.a)({url:"/message_order/submit_modify",method:"post",data:t})},e.w=function(t){return Object(n.a)({url:"/message_order/grap_order",method:"post",data:t})},e.a=function(t){return Object(n.a)({url:"/message_order/answer",method:"post",data:t})},e.F=function(t){return Object(n.a)({url:"/message_order/report",method:"post",data:t})},e.c=function(t){return Object(n.a)({url:"/message_order/cancel_grap_order",method:"post",data:t})},e.q=function(t){return Object(n.a)({url:"/message_order/get_message_order_by_id",method:"get",params:t})},e.r=function(t){return Object(n.a)({url:"/message_order/get_message_order_by_id_for_answer",method:"get",params:t})},e.E=function(t){return Object(n.a)({url:"/message_order/refused_modify",method:"post",data:t})},e.b=function(t){return Object(n.a)({url:"/basic_data/get_business_type_treedata",method:"post",data:t})},e.D=function(t){return Object(n.a)({url:"/basic_data/query_order_tag",method:"post",data:t})},e.p=function(t){return Object(n.a)({url:"/get_lawyer_info",method:"get",params:t})},e.u=function(t){return Object(n.a)({url:"/get_resource_info",method:"get",params:t})},e.J=function(t){return Object(n.a)({url:"/user/update_laywer_info",method:"post",data:t})},e.m=function(t){return Object(n.a)({url:"/get_expert_info",method:"get",params:t})},e.t=function(t){return Object(n.a)({url:"/basic_data/get_report_reason",method:"post",data:t})},e.A=function(t){return Object(n.a)({url:"/user/modify_good_at",method:"post",data:t})},e.l=function(t){return Object(n.a)({url:"/basic_data/get_config_item",method:"post",data:t})},e.s=function(t){return Object(n.a)({url:"/train_res/getNewItem",method:"post",data:t})},e.n=function(t){return Object(n.a)({url:"/train_res/getHisItem",method:"post",data:t})},e.G=function(t){return Object(n.a)({url:"/train_res/sign",method:"get",params:t})},e.z=function(t){return Object(n.a)({url:"/train_res/lookup",method:"get",params:t})},e.e=function(t){return Object(n.a)({url:"/train_res/done",method:"get",params:t})},e.g=function(t){return Object(n.a)({url:"/basic_data/query_order_tag",method:"post",data:t})},e.i=function(t){return Object(n.a)({url:"/basic_data/query_system_region",method:"post",data:t})},e.j=function(t){return Object(n.a)({url:"/basic_data/get_answer_template",method:"post",data:t})},e.I=function(t){return Object(n.a)({url:"/message_order/time_out_on_page",method:"get",params:t})},e.x=function(){return Object(n.a)({url:"/user/upload_online_status",method:"post"})},e.v=function(t){return Object(n.a)({url:"/basic_data/get_tag_treedata_by_business_id",method:"post",data:t})},e.k=function(){return Object(n.a)({url:"/basic_data/get_business_type_treedata",method:"post"})},e.o=function(){return Object(n.a)({url:"/basic_data/get_law_treedata",method:"post"})},e.C=function(t){return Object(n.a)({url:"/basic_data/query_law_info",method:"post",data:t})},e.B=r;var n=a("vLgD");function r(t){return Object(n.a)({url:"/basic_data/query_business_type",method:"post",data:t})}},"9nkn":function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAWCAYAAAArdgcFAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxMzggNzkuMTU5ODI0LCAyMDE2LzA5LzE0LTAxOjA5OjAxICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+IEmuOgAAAW5JREFUOI3F1T9oFEEYhvHfrXcJWJlomc6AKVJoY4Ja21vkTxMQYxclKRUs06fSKgpip4iFaBEtA0IKBUXBYCHBJmCCTQ7CET6L3SWXdTcc3oovDAPPzj58zM5824gIaOEe5nDK3+cXnmAZnUYmf4iTuIWdPuTDeIA9zIuIsxHxJSJaEaGG0YqIzxExmuAC3qDTR8Xd6eAtzicYQLsmcZ42BpKapUdSJR9Co4QPV6w/3at8Cbt4XuAr0pO0UuCP8RN3e5FPZvOVAp/I5ksFfrmCa5bI72AbLwt8ATfwqMCvYxr3e5F/x2IJ/4DbJXw9G3/kv5yWWpJvyximavSO4VMTgfGa5eN42sQWNqVfvK68wpaISCLiY0RM19QVZzJfkvfzUbyQ3sCvfVR8TtoKruFbLocTuIiRihev4iZWsVax5gc2cMDRS3SAd8dUdSab3+PZsfVn+afnvHtb8rx22Ly6Myj9z7axX/J8DbPd4Df1ktMAWuOwFQAAAABJRU5ErkJggg=="},A66B:function(t,e,a){t.exports=function(t){return function(){return a("Opzk")("./"+t+".vue")}}},BMV4:function(t,e){},"EBG+":function(t,e){t.exports="data:image/png;base64,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"},EZqJ:function(t,e,a){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var n=a("M9A7"),r=(a("9jCu"),{data:function(){return{name:"",Islawyer:!0,fullscreenLoading:!1,isRead:!0}},created:function(){null==localStorage.getItem("fwusername")&&this.$router.push("/"),1==localStorage.getItem("readStatus")?this.isRead=!1:this.isRead=!0,this.getImg()},methods:{logout:function(){var t=this;this.$confirm("确认退出吗?","提示",{confirmButtonText:"确定",cancelButtonText:"取消"}).then(function(){clearInterval(t.store.state.fwtimer),clearInterval(t.store.state.msgTimer),clearInterval(t.store.state.fwtimer1),clearInterval(t.store.state.htimer),localStorage.removeItem("fwusername"),localStorage.removeItem("fwuserInfo"),t.fullscreenLoading=!0,Object(n.d)().then(function(e){1==e.data.code&&(t.fullscreenLoading=!1,t.$router.push("/"),t.$message({message:"登出成功",type:"success"}))}).catch(function(t){this.$router.push("/"),this.fullscreenLoading=!1,this.systemError()})})},msgView:function(){1==localStorage.getItem("readStatus")?this.isRead=!1:this.isRead=!0,this.$router.push({name:"msgList"})},getImg:function(){1==JSON.parse(localStorage.getItem("fwuserInfo")).loginPersonType?this.Islawyer=!0:this.Islawyer=!1},worktable:function(){1==JSON.parse(localStorage.getItem("fwuserInfo")).loginPersonType?this.$router.push({name:"lawyerIndex"}):this.$router.push({name:"expertIndex"})}}}),i={render:function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"common-header"},[t._m(0),t._v(" "),n("div",{staticClass:"fw-nav"},[n("div",{staticClass:"relative",on:{click:t.msgView}},[n("el-tooltip",{attrs:{content:"消息中心",placement:"top"}},[n("img",{attrs:{src:a("Sbtz"),alt:""}})]),t._v(" "),t.isRead?n("i",{staticClass:"fw-badge fixed-right"}):t._e()],1),t._v(" "),n("div",{staticClass:"m-l-30"},[n("el-tooltip",{attrs:{content:"工作台",placement:"top"}},[n("img",{attrs:{src:a("9nkn"),alt:""},on:{click:t.worktable}})])],1),t._v(" "),n("div",{staticClass:"m-l-30"},[n("el-tooltip",{attrs:{content:"历史服务",placement:"top"}},[n("img",{attrs:{src:a("3X+C"),alt:""},on:{click:function(e){return t.$router.push({name:"historyService"})}}})])],1),t._v(" "),n("div",{staticClass:"m-l-30"},[n("el-dropdown",{attrs:{trigger:"click"}},[n("div",{staticClass:"fw-flex-common pointer"},[t.Islawyer?n("img",{staticClass:"personImg",attrs:{src:a("0gU3"),alt:""}}):n("img",{staticClass:"personImg",attrs:{src:a("EBG+"),alt:""}}),t._v(" "),n("div",{staticClass:"fw-white m-l-sm"},[t._v(t._s(t.name)+"\n "),n("i",{staticClass:"el-icon-arrow-down el-icon--right"})])]),t._v(" "),n("el-dropdown-menu",{staticClass:"logout",attrs:{slot:"dropdown"},slot:"dropdown"},[n("el-dropdown-item",{nativeOn:{click:function(e){return t.$router.push({name:"personCenter"})}}},[t._v("个人中心")]),t._v(" "),n("el-dropdown-item",{nativeOn:{click:function(e){return t.$router.push({name:"updatePassword"})}}},[t._v("修改密码")]),t._v(" "),n("el-dropdown-item",{directives:[{name:"loading",rawName:"v-loading.fullscreen.lock",value:t.fullscreenLoading,expression:"fullscreenLoading",modifiers:{fullscreen:!0,lock:!0}}],staticClass:"b-bn",nativeOn:{click:function(e){return t.logout.apply(null,arguments)}}},[t._v("退出登录")])],1)],1)],1)])])},staticRenderFns:[function(){var t=this.$createElement,e=this._self._c||t;return e("div",{staticClass:"fw-logo"},[e("img",{attrs:{src:a("iQH9"),alt:""}}),this._v(" "),e("span",{staticClass:"m-l-xs"},[this._v("中国法网服务端")])])}]};var s=a("VU/8")(r,i,!1,function(t){a("3Pew")},"data-v-215bd58e",null);e.default=s.exports},FH8w:function(t,e){},JHCx:function(t,e){},M9A7:function(t,e,a){"use strict";e.c=function(t){return Object(n.a)({url:"/login",method:"post",data:t})},e.d=function(t){return Object(n.a)({url:"/logout",method:"post",data:t})},e.f=function(t){return Object(n.a)({url:"/reset_password",method:"post",data:t})},e.b=function(t){return Object(n.a)({url:"/get_verification_code",method:"get",params:t})},e.a=function(t){return Object(n.a)({url:"/forget_password",method:"post",data:t})},e.e=function(t){return Object(n.a)({url:"/modify_password",method:"post",data:t})};var n=a("vLgD")},NDkO:function(t,e){},NHnr:function(t,e,a){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var n={};a.d(n,"timeAgo",function(){return Z}),a.d(n,"parseTime",function(){return N}),a.d(n,"MillisecondToDate",function(){return U}),a.d(n,"sec_to_time",function(){return D}),a.d(n,"formatTime",function(){return G}),a.d(n,"nFormatter",function(){return P}),a.d(n,"html2Text",function(){return Y}),a.d(n,"toThousandslsFilter",function(){return Q}),a.d(n,"sysCodeFileter",function(){return W}),a.d(n,"fileUri",function(){return H});var r,i=a("fZjL"),s=a.n(i),o=a("7+uW"),c=a("bOdI"),u=a.n(c),l=(r={data:"watermark",props:{content:{required:!1,default:""}},computed:{showText:function(){return this.content?this.content:"中国法律服务网 "}}},u()(r,"data",function(){return{url:""}}),u()(r,"created",function(){this.createWatermark()}),u()(r,"methods",{createWatermark:function(){var t=document.createElement("canvas");t.style.background="transparent";var e=t.getContext("2d"),a=2*e.measureText(this.showText).width*2,n=2.5*e.measureText(this.showText).width;t.setAttribute("width",a),t.setAttribute("height",1*n),e.font="800 20px 微软雅黑",e.strokeStyle="rgba(147, 151, 156, 0.5)",e.textAlign="center",e.setTransform(1,-.5,0,1,0,0),e.strokeText(this.showText,a/2.5,n),this.url=t.toDataURL("image/png")}}),r),m={render:function(){var t=this.$createElement,e=this._self._c||t;return e("div",{staticClass:"watermarkContainer"},[e("div",{staticClass:"watermark",style:{backgroundImage:"url('"+this.url+"')"}})])},staticRenderFns:[]};var d=a("VU/8")(l,m,!1,function(t){a("/ROx")},"data-v-f9a8d066",null).exports,A=(a("VNjL"),{name:"App",mounted:function(){},components:{watermark:d},methods:{}}),g={render:function(){var t=this.$createElement,e=this._self._c||t;return e("div",{attrs:{id:"app"}},[e("watermark"),this._v(" "),e("router-view")],1)},staticRenderFns:[]};var p=a("VU/8")(A,g,!1,function(t){a("PZeG")},"data-v-98398f48",null).exports,f=a("NYxO"),h=a("/ocq"),I=a("4Lha"),v=a("A66B");o.default.use(h.a);var C=new h.a({routes:[{path:"/",component:v("login")},{path:"/forgetPassword",component:v("forgetPassword")},{path:"/updatePassword",component:I.default,children:[{path:"",component:v("updatePassword"),name:"updatePassword",meta:{title:"修改密码"}}]},{path:"/expert",component:I.default,children:[{path:"expertIndex",component:v("expert/expertIndex"),name:"expertIndex",meta:{title:"专家端首页"}},{path:"expertDetail/:id",component:v("expert/expertDetail"),name:"expertDetail",meta:{title:"专家点评"}}]},{path:"/lawyer",component:I.default,children:[{path:"lawyerIndex",component:v("lawyer/lawyerIndex"),name:"lawyerIndex",meta:{title:"律师端首页"}},{path:"lawyerDetail/:id",component:v("lawyer/lawyerDetail"),name:"lawyerDetail",meta:{title:"解答订单详情"}},{path:"remodifyDetail/:id",component:v("lawyer/remodifyDetail"),name:"remodifyDetail",meta:{title:"需整改的订单"}},{path:"viewDetail/:id",component:v("lawyer/viewDetail"),name:"viewDetail",meta:{title:"需整改的订单"}},{path:"historyService",component:v("lawyer/historyService"),name:"historyService",meta:{title:"历史服务"}},{path:"personCenter",component:v("lawyer/personCenter"),name:"personCenter",meta:{title:"个人中心"}},{path:"msgList",component:v("lawyer/msgList"),name:"msgList",meta:{title:"消息"}},{path:"answerDetail",component:v("lawyer/answerDetail"),name:"answerDetail",meta:{title:"解答"}}]}]}),y=(a("BMV4"),a("zL8q")),S=a.n(y),w=(a("tvR6"),a("j1ja"),a("xAvK")),k=a.n(w),E=a("//Fk"),R=a.n(E),j=a("woOf"),T=a.n(j),b=a("vLgD");var z={state:{fileDomain:""},mutations:{SET_CONFIG:function(t,e){T()(t,e)}},actions:{FetchConfigs:function(t){var e=t.commit;return new R.a(function(t,a){Object(b.a)({url:"/conf",method:"post"}).then(function(n){n.data||a("error");var r=n.data.data;e("SET_CONFIG",r),t(n)}).catch(function(t){a(t)})})}}},O={fileDomain:function(t){return t.config.fileDomain}};o.default.use(f.a);var x=new f.a.Store({modules:{config:z},getters:O}),L=(a("NDkO"),a("pFYg")),J=a.n(L);function B(t,e){return 1===t?t+e:t+e+"s"}function Z(t){var e=Date.now()/1e3-Number(t);return e<3600?B(~~(e/60)," minute"):e<86400?B(~~(e/3600)," hour"):B(~~(e/86400)," day")}function N(t,e){if(0===arguments.length)return null;10===(t+"").length&&(t=1e3*+t);var a=e||"{y}-{m}-{d} {h}:{i}:{s}",n=void 0,r={y:(n="object"===(void 0===t?"undefined":J()(t))?t:new Date(parseInt(t))).getFullYear(),m:n.getMonth()+1,d:n.getDate(),h:n.getHours(),i:n.getMinutes(),s:n.getSeconds(),a:n.getDay()};return a.replace(/{(y|m|d|h|i|s|a)+}/g,function(t,e){var a=r[e];return"a"===e?["一","二","三","四","五","六","日"][a-1]:(t.length>0&&a<10&&(a="0"+a),a||0)})}function U(t){var e=parseFloat(t)/1e3;return null!==e&&""!==e&&(e=e>60&&e<3600?parseInt(e/60)+":"+parseInt(60*(parseFloat(e/60)-parseInt(e/60))):e>=3600?parseInt(e/3600)+":"+parseInt(60*(parseFloat(e/3600)-parseInt(e/3600)))+":"+parseInt(60*(parseFloat(60*(parseFloat(e/3600)-parseInt(e/3600)))-parseInt(60*(parseFloat(e/3600)-parseInt(e/3600))))):parseInt(e)),e}function D(t){var e;if(t>-1){var a=Math.floor(t/3600),n=Math.floor(t/60)%60,r=t%60;e=a<10?"0"+a+":":a+":",n<10&&(e+="0"),e+=n+":",r<10&&(e+="0"),e+=r}return e}function G(t,e){t=1e3*+t;var a=new Date(t),n=(Date.now()-a)/1e3;return n<30?"刚刚":n<3600?Math.ceil(n/60)+"分钟前":n<86400?Math.ceil(n/3600)+"小时前":n<172800?"1天前":e?N(t,e):a.getMonth()+1+"月"+a.getDate()+"日"+a.getHours()+"时"+a.getMinutes()+"分"}function P(t,e){for(var a=[{value:1e18,symbol:"E"},{value:1e15,symbol:"P"},{value:1e12,symbol:"T"},{value:1e9,symbol:"G"},{value:1e6,symbol:"M"},{value:1e3,symbol:"k"}],n=0;n<a.length;n++)if(t>=a[n].value)return(t/a[n].value+.1).toFixed(e).replace(/\.0+$|(\.[0-9]*[1-9])0+$/,"$1")+a[n].symbol;return t.toString()}function Y(t){var e=document.createElement("div");return e.innerHTML=t,e.textContent||e.innerText}function Q(t){return(+t||0).toString().replace(/^-?\d+/g,function(t){return t.replace(/(?=(?!\b)(\d{3})+$)/g,",")})}function W(t,e){var a=o.default.prototype.SYS_CODE[e];return a?(a.forEach(function(e){if(e.key===t)return e.display_name}),t+"未定义"):e+"未定义"}function H(t){return x.getters.fileDomain+t}o.default.use(f.a),o.default.use(S.a,{}),o.default.config.productionTip=!1,s()(n).forEach(function(t){o.default.filter(t,n[t])}),o.default.prototype.CONSTANT=k.a,o.default.prototype.store=new f.a.Store({state:{loginUserType:1,fwtimer:0,fwtimer1:0,msgTimer:0,htimer:0,activeName:"msg",isRead:0,isClose:!0},mutations:{closeFun:function(t,e){t.isClose=e},typeChange:function(t,e){t.loginUserType=e},fwtimerFun:function(t,e){t.fwtimer=e},msgTimerFun:function(t,e){t.msgTimer=e},fwtimerFun1:function(t,e){t.fwtimer1=e},mainChange:function(t,e){t.activeName=e},readCount:function(t,e){t.isRead=e},heartBeat:function(t,e){t.htimer=e}}}),o.default.prototype.systemError=function(){this.$alert("系统错误!请重试!","系统错误",{confirmButtonText:"确定"})},o.default.prototype.codeError=function(){this.$alert("数据获取失败","code错误",{confirmButtonText:"确定"})},C.beforeEach(function(t,e,a){var n=localStorage.getItem("fwusername");null==n&&"/"!=t.path&&"/modifyPassword"!=t.path&&"/forgetPassword"!=t.path?a({path:"/"}):a()}),new o.default({el:"#app",router:C,store:x,components:{App:p},template:"<App/>"})},Opzk:function(t,e,a){var n={"./expert/expertDetail.vue":["wRLW",0,10],"./expert/expertIndex.vue":["e6Qc",0,8],"./forgetPassword.vue":["w7qI",9],"./lawyer/answerDetail.vue":["Kf45",15],"./lawyer/components/commonTop.vue":["VNjL"],"./lawyer/components/currentService.vue":["C//8",0,17],"./lawyer/components/expertComm.vue":["4+1m",0,5],"./lawyer/components/hisService.vue":["poiH",0,16],"./lawyer/components/msgConsult.vue":["SS42",2,0],"./lawyer/historyService.vue":["EGpJ",0,6],"./lawyer/lawyerDetail.vue":["5io4",4,0],"./lawyer/lawyerIndex.vue":["zMTL",1,0],"./lawyer/msgList.vue":["Nzo6",13],"./lawyer/personCenter.vue":["WoTx",7],"./lawyer/remodifyDetail.vue":["B8d9",0,12],"./lawyer/viewDetail.vue":["3BhG",0,11],"./layout/components/navbar.vue":["EZqJ"],"./layout/components/sidebar.vue":["pEkY"],"./layout/layout.vue":["4Lha"],"./login.vue":["Quw4",3],"./updatePassword.vue":["9iKb",14]};function r(t){var e=n[t];return e?Promise.all(e.slice(1).map(a.e)).then(function(){return a(e[0])}):Promise.reject(new Error("Cannot find module '"+t+"'."))}r.keys=function(){return Object.keys(n)},r.id="Opzk",t.exports=r},PZeG:function(t,e){},Sbtz:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAWCAYAAADAQbwGAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxMzggNzkuMTU5ODI0LCAyMDE2LzA5LzE0LTAxOjA5OjAxICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+IEmuOgAAAXZJREFUOI3l1M+LTmEYxvHPyztDWXizMlOyMRazZJQfUZNS/gI1+QOwsRhmYWVlYYskFjIrZWyUUpZGUYpiYT3EhimapNRlcd5Hx+l1cLJz1d1T932u73Oec56uXhIt2oQTOICPuIlHbYZ1LbM+TmEXFvEaZ7GzDdgb8YY9HMEctuAyHgw3v4oJPMUNvP8dsI951REf4i7e1OZj2IfjGOACnv9ETFKv+SR3ksw0+s3akORckqUkU/VZ/aH9Se4N1zZYqX6Si0muJFlf+vWfMocneNz20Wv6hkvYhoOlWYCT2I77fwgrWsFL7G0Cp4brq78EFs+Pq1SAA6zhawfgKjY3gWMdQHWNN4H/TP8xcBqfOjKKb7oAZ7EH1zoCl1WX+zTG+6rkeIa32NoRehvncbivupi7cb0jrChY7SXZiB2qLCzqYQHvcKvWn8BJVeAuN4AfsDIqsYsO4QxeqBJ6gKND4wK+jDK1AWEGx4YnWFNF2yI+/8rwHRl2xYWq6uLdAAAAAElFTkSuQmCC"},VNjL:function(t,e,a){"use strict";Object.defineProperty(e,"__esModule",{value:!0});a("9jCu");var n={render:function(){this.$createElement;this._self._c;return this._m(0)},staticRenderFns:[function(){var t=this.$createElement,e=this._self._c||t;return e("div",{staticClass:"com-top clearfix"},[e("div",{staticClass:"fw-title"},[this._v("法律服务人员工作台")])])}]};var r=a("VU/8")({data:function(){return{}}},n,!1,function(t){a("tYWG")},"data-v-291dc50a",null);e.default=r.exports},iQH9:function(t,e){t.exports="data:image/png;base64,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"},nhTu:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABTElEQVQ4jY3TvYoUQRTF8d/09siqoOC4owwa+ACGq4jJ5CIGRoKCgUY+gpGPIIgvYGQmaGoiA6LGpibKBuIHm6jgrsdgqpei7Rk80FR11f+eW9S9NUqipzO4iOAP2jKHN/hUw6PKYIwr+IbX+N0zHuMSJnhxsJ9EkkNJbiWZlP913yTJzRJzYHAtyXQA3kwyHliflhgtZtjF5+q4J3EH57CHBZ6WO1HYXcwabBeg1m1s4j6eYI7zPWaB7RYbJUutH3iJLzhc1o70mD1stNj3rx6X8RTu4QPeDXD7jWWdV+kuvuPhwCmhbdYEj3Aaz/BrFdQW526sFTzA17WxSWZJ5gO1PprkUZLrKxpqnmTWYAfHMO1l+IlXeD+QfYrj2Onc2iQ3kmz9Rytv1a3cf0xXLW99YfgxXcYJPO/2a4NOZ3GhzLseaSyr8hYfa/gvNgcFoVEhw2cAAAAASUVORK5CYII="},pEkY:function(t,e,a){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var n=a("9jCu"),r={components:{getConfigItem:n.l},data:function(){return{configItem:{},configItems:[],newItems:[],hisTrains:[],trainTitle:"",trainId:0,isDone:!1,isSign:!1,hiddenButton:!1,sortKey:"sort",activeNames:[]}},created:function(){this.fetchConfigItem(),this.fetchNewItem(),this.fetchHisItem()},methods:{handleChange:function(t){console.log(t)},fetchConfigItem:function(){var t=this;Object(n.l)(this.configItem).then(function(e){1==e.data.code?t.configItems=e.data.data.list:t.codeError()})},fetchNewItem:function(){var t=this;Object(n.s)(this.configItem).then(function(e){1==e.data.code?null!=e.data.data.newItems&&(t.newItems=e.data.data.newItems,t.trainTitle=e.data.data.trainTitle,t.trainId=e.data.data.trainId,t.isSign=e.data.data.isSign,t.isDone=e.data.data.isDone,t.hiddenButton=e.data.data.hiddenButton):t.codeError()})},fetchHisItem:function(){var t=this;Object(n.n)(this.configItem).then(function(e){1==e.data.code?null!=e.data.data.hisTrains&&(t.hisTrains=e.data.data.hisTrains):t.codeError()})},signIn:function(){var t=this,e={id:this.trainId};Object(n.G)(e).then(function(e){"1"===e.data.code?(t.$message({type:"success",message:"签到成功!"}),t.isSign=!0):t.$message.error(e.data.msg)})},doStudy:function(){var t=this,e={id:this.trainId};Object(n.e)(e).then(function(e){"1"===e.data.code?(t.$message({type:"success",message:"学习完成!"}),t.isDone=!0):t.$message.error(e.data.msg)})},readItem:function(t,e,a){var r=this,i={id:this.trainId,itemid:t};a||this.hiddenButton?window.open(e,"_blank"):(this.$message({type:"success",message:"查阅成功"}),Object(n.z)(i).then(function(t){1==t.data.code?(r.newItems=t.data.data.newItems,window.open(e,"_blank")):r.$message({type:"error",message:"稍后再试!"})}))}}},i={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"absolute-sidebars"},[this.newItems.length>0?a("div",[t._m(0),t._v(" "),t.hiddenButton?t._e():a("div",{staticClass:"btn_stu"},[a("div",{staticStyle:{color:"#ffffff","font-size":"1.1rem","font-weight":"600",float:"left","margin-left":"20px","margin-top":"2px"}},[t._v(t._s(t.trainTitle))]),t._v(" "),t.isSign?a("el-button",{staticClass:"btn_stu_complete",attrs:{size:"mini",type:"primary"}},[t._v("已签到")]):t._e(),t._v(" "),t.isSign?t._e():a("el-button",{staticClass:"btn_stu_uncomplete",attrs:{size:"mini",type:"primary"},nativeOn:{click:function(e){return t.signIn.apply(null,arguments)}}},[t._v("签到")])],1),t._v(" "),a("ul",[t._l(t.newItems,function(e){return a("li",{key:e.id},[e.isRead&&!t.hiddenButton?a("i",{staticClass:"el-icon-circle-check icon-check"}):t._e(),t._v(" "),e.isRead||t.hiddenButton?t._e():a("i",{staticClass:"el-icon-warning-outline icon-w-check"}),t._v(" "),a("a",{attrs:{href:"javascript:void(0)"},on:{click:function(a){return t.readItem(e.id,e.link,e.isRead)}}},[t._v(t._s(e.title))])])}),t._v(" "),t.hiddenButton?t._e():a("div",{staticClass:"done_stu"},[t.isDone?a("el-button",{staticClass:"done_stu_complete",attrs:{size:"mini",type:"primary"}},[t._v("已完成学习")]):t._e(),t._v(" "),t.isDone?t._e():a("el-button",{staticClass:"done_stu_uncomplete",attrs:{size:"mini",type:"primary"},nativeOn:{click:function(e){return t.doStudy.apply(null,arguments)}}},[t._v("完成学习")])],1),t._v(" "),a("hr",{staticStyle:{width:"80%"}})],2)]):t._e(),t._v(" "),this.hisTrains.length>0?a("div",{staticClass:"sidebar_collapse"},[0==this.newItems.length?a("div",{staticClass:"help"},[t._m(1)]):t._e(),t._v(" "),a("el-collapse",{staticClass:"custom-collapse",attrs:{accordion:""},model:{value:t.activeNames,callback:function(e){t.activeNames=e},expression:"activeNames"}},t._l(t.hisTrains,function(e){return a("el-collapse-item",{key:e.id,attrs:{name:e.id,title:e.trainTitle}},[a("ul",t._l(e.items,function(e){return a("li",{key:e.id},[a("a",{attrs:{href:"javascript:void(0)"},on:{click:function(a){return t.readItem(e.id,e.link,!0)}}},[t._v(t._s(e.title))])])}),0)])}),1),t._v(" "),a("hr",{staticStyle:{width:"80%"}})],1):t._e(),t._v(" "),a("ul",[t._m(2),t._v(" "),t._l(t.configItems,function(e){return a("li",{key:e.id},[a("a",{attrs:{href:e.link,target:"_blank"}},[t._v(t._s(e.title))])])})],2)])},staticRenderFns:[function(){var t=this.$createElement,e=this._self._c||t;return e("div",{staticClass:"help"},[e("a",{staticClass:"ft16",attrs:{href:"javascript:void(0)"}},[e("img",{staticStyle:{width:"16px",height:"16px","margin-right":"2px","margin-bottom":"4px"},attrs:{src:a("2juw")}}),e("span",{staticClass:"m-l-xs",staticStyle:{"font-weight":"500"}},[this._v("学习培训")])])])},function(){var t=this.$createElement,e=this._self._c||t;return e("a",{staticClass:"ft16",attrs:{href:"javascript:void(0)"}},[e("img",{staticStyle:{width:"16px",height:"16px","margin-right":"2px","margin-bottom":"4px"},attrs:{src:a("2juw")}}),e("span",{staticClass:"m-l-xs",staticStyle:{"font-weight":"500"}},[this._v("历史培训")])])},function(){var t=this.$createElement,e=this._self._c||t;return e("li",{staticClass:"help"},[e("a",{staticClass:"ft16",attrs:{href:"javascript:void(0)"}},[e("img",{attrs:{src:a("nhTu")}}),e("span",{staticClass:"m-l-xs"},[this._v("帮助文档")])])])}]};var s=a("VU/8")(r,i,!1,function(t){a("FH8w"),a("JHCx")},"data-v-70f245bf",null);e.default=s.exports},"tH3/":function(t,e,a){"use strict";e.a=function(t){return Object(n.a)({url:"/message/get_message_list",method:"post",data:t})};var n=a("vLgD")},tYWG:function(t,e){},tvR6:function(t,e){},vLgD:function(t,e,a){"use strict";var n=a("//Fk"),r=a.n(n),i=a("mtWM"),s=a.n(i),o=a("zL8q"),c=(a.n(o),s.a.create({baseURL:"/resources/api",timeout:3e4}));new URLSearchParams;c.interceptors.request.use(function(t){return t},function(t){console.log(t),r.a.reject(t)}),c.interceptors.response.use(function(t){var e=t.data;return"3"===e.code?(Object(o.Message)({message:e.msg,type:"error",duration:5e3}),r.a.reject("error")):"2"===e.code?(o.MessageBox.confirm("你已被登出,可以继续留在该页面,或者重新登录","确定登出",{confirmButtonText:"重新登录",cancelButtonText:"取消",type:"warning"}).then(function(){localStorage.removeItem("fwusername"),localStorage.removeItem("fwuserInfo"),location.reload()}),r.a.reject("error")):t},function(t){return console.log("err"+t),r.a.reject(t)}),e.a=c},vrxY:function(t,e){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAeCAYAAAA2Lt7lAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxMzggNzkuMTU5ODI0LCAyMDE2LzA5LzE0LTAxOjA5OjAxICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+IEmuOgAAAm1JREFUSIm1ll1IU2Ecxp+zr8Zs2XLlEl0Utjb7GhEyaDAzIbyosLpQMlLMPqBAiGgi2JcV1C5KgshEW1IraF0kYl0oE1YYmghGLWdiZbGFc+U+3NbW6WIOZLmzc46dB9675///ve85vM/7J37OBIxgpwgAHwA3gFEATgCxZJOAZXMAEAHImlsFALwAbAA+zjfxFgFIlgxAGYAiAAQXgIR0AAxcAhIQNZcAANgFgE8JeNU/klt3rvmkyz0tYQGQAlBTAnboNk9qt6wfvHzdfHR84nsmC4gq7SeqOlQ6UKTX2ky3LDXvHRNZDAEKvrG+QZ/OtVGz1g2C8Dx49KI8RyEfz1kt99MECGkBAECVn+dZJs341v6wu1KWKf2izMueoVHGY3STDXrt5wyJ2Nxyv/NIIBiy7i4pHEtXwzgqtm9TuyQScWvznadV/sDsywP7DO+o/IS9f8QUnA0LmYKmPL+kz7vsezcVrBs8dXy/LZVP8NjaWxKNxhgDAEAoEoQHhj4UIx5yC4pgG9dPrD1b++zDxadPHDRrNqyZTuVjFddtHV26t8OjhcYzla3K3GwflZcx4PbdZzudn75qLtRXt6yULw+l89MGkCRJ3LhpKf0x5VU0Ndbeky6V/KZTRwsQjcZ4V00dZaFwRNzUWGsWLxH98zSmUtosCoUj/PNX2ipIksSlhhoLk+YAYpQn8PmDwovX2g+vkstcZ+squgmCIBk0BwA/JcA5NrlCla90HKve85ph44RcrO8BTXVy+WT6ADi4BPQAiHEFeAPAAXAzVQxhXvgtZnRMlhdAH+Z2/j8AfxD/kS7Eh18HFhh+/wJcLMK6ayV9mwAAAABJRU5ErkJggg=="},xAvK:function(t,e){t.exports={remark_type:[{id:0,name:"点评"},{id:1,name:"直接覆盖"},{id:2,name:"整改"}],service_type:[{id:0,name:"在线咨询"},{id:1,name:"留言咨询"}],law_question:[{id:0,name:"很准确"},{id:1,name:"准确"},{id:2,name:"不准确"},{id:3,name:"未判断"}],law_gist:[{id:0,name:"很充分"},{id:1,name:"充分"},{id:2,name:"不充分"},{id:3,name:"未列明"}],law_suggest:[{id:0,name:"非常可行有效"},{id:1,name:"可行有效"},{id:2,name:"不可行有效"},{id:3,name:"未给出"}]}}},[0]);
\ No newline at end of file
__pycache__
*.pyc
*.pyo
.env
.venv
node_modules
tests
*.md
.git
# Legal service API config
LEGAL_BASE_URL=http://sys.12348.gov.cn/resources
LEGAL_API_PREFIX=/resources/api
# AES encryption config
AES_KEY=1234567890123456
AES_IV=1234567890123456
# App config
APP_NAME=法律抢单系统
APP_ENV=development
SECRET_KEY=change-me-in-production
# Database
DATABASE_URL=sqlite:///./legal_system.db
# Server
HOST=0.0.0.0
PORT=8000
# Backend — Python 3.11 + FastAPI + uvicorn
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source
COPY main.py main.py
COPY app/ app/
COPY .env .env
# Create directories
RUN mkdir -p /app/data /app/logs
EXPOSE 8080
# Run — no --reload, single worker
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
"""账号管理"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.models import Account
from app.schemas.schemas import AccountCreate, AccountResponse
from app.api.auth_api import require_admin_dep
router = APIRouter()
@router.get("/accounts")
def list_accounts(
skip: int = 0,
limit: int = 100,
current_user = None, # Use require_admin_dep in routes
db: Session = Depends(get_db)
):
"""获取账号列表"""
accounts = db.query(Account).offset(skip).limit(limit).all()
result = []
for a in accounts:
result.append({
"id": a.id,
"phone": a.phone,
"role": a.role,
"is_active": a.is_active,
"name": a.name,
"user_id": a.user_id,
"group_name": a.group_name,
"created_at": str(a.created_at) if a.created_at else None
})
return result
@router.post("/accounts")
def create_account(
account: AccountCreate,
current_user = None,
db: Session = Depends(get_db)
):
"""创建账号"""
existing = db.query(Account).filter(Account.phone == account.phone).first()
if existing:
raise HTTPException(status_code=400, detail="手机号已存在")
db_account = Account(
phone=account.phone,
password=account.password,
user_id=account.user_id,
group_name=account.group_name
)
db.add(db_account)
db.commit()
db.refresh(db_account)
return {
"id": db_account.id,
"phone": db_account.phone,
"role": db_account.role,
"is_active": db_account.is_active,
"name": db_account.name,
"user_id": db_account.user_id,
"created_at": str(db_account.created_at) if db_account.created_at else None
}
@router.put("/accounts/{account_id}/password")
def reset_password(
account_id: int,
new_password: str = None,
current_user = None,
db: Session = Depends(get_db)
):
"""重置账号密码"""
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
if new_password:
account.password = new_password
db.commit()
return {"msg": "密码重置成功"}
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
current_user = None,
db: Session = Depends(get_db)
):
"""删除账号"""
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
db.delete(account)
db.commit()
return {"msg": "账号已删除"}
@router.put("/accounts/{account_id}/group")
def update_group(
account_id: int,
group_data: dict = None,
current_user = None,
db: Session = Depends(get_db)
):
"""更新账号分组"""
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
group_name = group_data.get("group_name") if group_data else None
account.group_name = group_name
db.commit()
return {"msg": "分组已更新", "account": {
"id": account.id,
"phone": account.phone,
"group_name": account.group_name
}}
@router.get("/accounts/groups")
def list_groups(
current_user = None,
db: Session = Depends(get_db)
):
"""获取所有分组列表"""
groups = db.query(Account.group_name).filter(Account.group_name.isnot(None)).distinct().all()
result = []
for g in groups:
count = db.query(Account).filter(Account.group_name == g.group_name).count()
result.append({"group_name": g.group_name, "account_count": count})
return result
@router.post("/accounts/batch-group")
def batch_update_group(
group_data: dict = None,
current_user = None,
db: Session = Depends(get_db)
):
"""批量更新账号分组"""
account_ids = group_data.get("account_ids", []) if group_data else []
group_name = group_data.get("group_name") if group_data else None
for account_id in account_ids:
account = db.query(Account).filter(Account.id == account_id).first()
if account:
account.group_name = group_name
db.commit()
return {"msg": f"已更新 {len(account_ids)} 个账号分组"}
"""用户认证 API"""
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.models import User, UserRole
from pydantic import BaseModel
from app.schemas.schemas import (
UserCreate, UserLogin, UserResponse, UserUpdate, LoginResponse
)
from datetime import datetime, timedelta
import jwt
import bcrypt as bcrypt_lib
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
# JWT 配置
SECRET_KEY = "law-system-secret-key-2026"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
def hash_password(password: str) -> str:
"""SHA-256 密码哈希"""
import hashlib
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return bcrypt_lib.checkpw(plain_password.encode(), hashed_password.encode())
def create_admin_if_not_exists():
"""创建管理员账号(如果不存在)"""
from app.core.database import SessionLocal
from app.core.config import settings
db = SessionLocal()
try:
admin = db.query(User).filter(User.username == "admin").first()
if not admin:
admin = User(
username="admin",
password_hash=hash_password(settings.ADMIN_PASSWORD),
role=UserRole.ADMIN.value,
name="系统管理员"
)
db.add(admin)
db.commit()
logger.info("管理员账号已创建")
return admin
finally:
db.close()
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建 JWT Token"""
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""验证 JWT Token"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def get_current_user_from_token(token: str, db: Session) -> User:
"""从 Token 获取当前用户"""
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Token 已过期")
username = payload.get("username")
if not username:
raise HTTPException(status_code=401, detail="Token 无效")
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
if not user.is_active:
raise HTTPException(status_code=403, detail="用户已被禁用")
return user
def get_current_user(
authorization: Optional[str] = Header(None),
db: Session = Depends(get_db)
) -> User:
"""FastAPI dependency - get current user from Bearer token"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未登录")
token = authorization.replace("Bearer ", "")
return get_current_user_from_token(token, db)
def require_admin(current_user: User) -> User:
"""Require admin role"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return current_user
def require_admin_dep(
current_user: User = Depends(get_current_user)
) -> User:
"""FastAPI dependency - require admin role"""
require_admin(current_user)
return current_user
def get_optional_user(
authorization: Optional[str] = Header(None),
db: Session = Depends(get_db)
) -> Optional[User]:
"""Get current user if logged in, None otherwise"""
if not authorization or not authorization.startswith("Bearer "):
return None
try:
token = authorization.replace("Bearer ", "")
return get_current_user_from_token(token, db)
except Exception:
return None
# --- 注册 ---
@router.post("/auth/register")
def register(user_data: UserCreate, db: Session = Depends(get_db)) -> LoginResponse:
"""用户注册(手机号 + 密码,无短信验证)"""
existing = db.query(User).filter(
(User.username == user_data.phone) | (User.phone == user_data.phone)
).first()
if existing:
raise HTTPException(status_code=400, detail="手机号已注册")
db_user = User(
username=user_data.phone,
password_hash=hash_password(user_data.password),
role=UserRole.USER.value,
phone=user_data.phone,
name=user_data.name or user_data.phone
)
db.add(db_user)
db.commit()
db.refresh(db_user)
token = create_access_token(data={"username": db_user.username})
return LoginResponse(
success=True,
message="注册成功",
user=UserResponse.model_validate(db_user),
token=token
)
# --- 登录 ---
@router.post("/auth/login")
def login(login_data: UserLogin, db: Session = Depends(get_db)) -> LoginResponse:
"""用户登录 - 支持 users 表和 accounts 表"""
from app.models.models import Account
# Try users table first (username match)
user = db.query(User).filter(User.username == login_data.username).first()
# If not found and looks like a phone number, try accounts table
if not user:
account = db.query(Account).filter(Account.phone == login_data.username).first()
if account:
# Check plain-text password from accounts table
if account.password != login_data.password:
raise HTTPException(status_code=401, detail="用户或密码错误")
if not account.is_active:
raise HTTPException(status_code=403, detail="账号已被禁用")
# Create a synthetic user response for account login
token = create_access_token(data={
"username": account.phone,
"account_id": account.id,
"user_id": account.user_id,
"role": "user"
})
return LoginResponse(
success=True,
message="登录成功",
user=UserResponse(
id=account.id,
username=account.phone,
role="user",
is_active=True,
phone=account.phone,
name=account.name,
created_at=str(account.created_at) if account.created_at else None
),
token=token
)
raise HTTPException(status_code=401, detail="用户或密码错误")
# Standard user login (users table)
if not verify_password(login_data.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户或密码错误")
if not user.is_active:
raise HTTPException(status_code=403, detail="用户已被禁用")
token = create_access_token(data={"username": user.username})
return LoginResponse(
success=True,
message="登录成功",
user=UserResponse.model_validate(user),
token=token
)
# --- 获取当前用户 ---
@router.get("/auth/me")
def get_me(current_user: User = Depends(get_current_user)) -> UserResponse:
"""获取当前用户信息"""
return UserResponse.model_validate(current_user)
# --- 管理员: 用户管理 ---
@router.get("/auth/users")
def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(require_admin_dep),
db: Session = Depends(get_db)
):
"""管理员: 用户列表"""
users = db.query(User).offset(skip).limit(limit).all()
return [UserResponse.model_validate(u) for u in users]
@router.post("/auth/users")
def add_user(
user_data: UserCreate,
current_user: User = Depends(require_admin_dep),
db: Session = Depends(get_db)
) -> UserResponse:
"""管理员: 添加普通用户"""
existing = db.query(User).filter(
(User.username == user_data.phone) | (User.phone == user_data.phone)
).first()
if existing:
raise HTTPException(status_code=400, detail="手机号已存在")
db_user = User(
username=user_data.phone,
password_hash=hash_password(user_data.password),
role=UserRole.USER.value,
phone=user_data.phone,
name=user_data.name or user_data.phone
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return UserResponse.model_validate(db_user)
class PasswordReset(BaseModel):
password: str
@router.put("/auth/users/{username}/password")
def reset_password(
username: str,
data: PasswordReset,
current_user: User = Depends(require_admin_dep),
db: Session = Depends(get_db)
) -> dict:
"""管理员: 重置用户密码"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.password_hash = hash_password(data.password)
db.commit()
return {"success": True, "message": "密码重置成功"}
@router.delete("/auth/users/{username}")
def delete_user(
username: str,
current_user: User = Depends(require_admin_dep),
db: Session = Depends(get_db)
) -> dict:
"""管理员: 删除用户"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.username == "admin":
raise HTTPException(status_code=400, detail="不能删除管理员")
db.delete(user)
db.commit()
return {"success": True, "message": "用户已删除"}
@router.put("/auth/users/{username}")
def update_user(
username: str,
user_data: UserUpdate,
current_user: User = Depends(require_admin_dep),
db: Session = Depends(get_db)
) -> UserResponse:
"""管理员: 更新用户信息"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user_data.password:
user.password_hash = hash_password(user_data.password)
if user_data.name is not None:
user.name = user_data.name
if user_data.is_active is not None:
user.is_active = user_data.is_active
db.commit()
db.refresh(user)
return UserResponse.model_validate(user)
"""抢单引擎控制 API"""
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from app.services.grab_engine import grab_engine
from app.services.session_manager import session_manager
from app.core.config import settings
from pydantic import BaseModel
router = APIRouter()
class GrabConfig(BaseModel):
interval_ms: int = 500
@router.post("/fw/grab/start")
async def start_grab(config: GrabConfig = None):
"""启动抢单引擎"""
if config:
grab_engine.interval = config.interval_ms / 1000.0
if not session_manager.get_all_sessions():
return {"code": "0", "msg": "没有活跃的会话,请先添加账号"}
if grab_engine.running:
return {"code": "0", "msg": "抢单引擎已在运行中"}
# Start in background using asyncio.create_task
grab_engine.task = asyncio.create_task(grab_engine.start())
return {
"code": "1",
"msg": "抢单引擎已启动",
"interval_ms": int(grab_engine.interval * 1000),
"sessions": len(session_manager.get_all_sessions())
}
@router.post("/fw/grab/stop")
async def stop_grab():
"""停止抢单引擎"""
grab_engine.stop()
return {"code": "1", "msg": "抢单引擎已停止"}
@router.get("/fw/grab/status")
def grab_status():
"""获取抢单状态"""
stats = grab_engine.get_stats()
stats["sessions"] = session_manager.get_all_sessions()
return stats
@router.get("/fw/grab/results")
def grab_results(limit: int = 20):
"""获取抢单结果"""
results = grab_engine.results[-limit:]
return {"code": "1", "results": results, "total": grab_engine.total_grabbed}
@router.post("/fw/grab/once")
def grab_once():
"""立即执行一次抢单"""
results = []
for account_id, session in session_manager.sessions.items():
if session.status != "connected":
continue
orders = session.query_order()
if orders:
result = session.grab_order(orders[0].get("id"))
results.append({
"account_id": account_id,
"phone": session.account.phone,
"order_id": orders[0].get("id"),
"result": result
})
return {"code": "1", "results": results}
"""登录模块 - 中国法网登录"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.core.config import settings
from app.core.crypto import aes_encrypt
import requests
router = APIRouter()
class LoginRequest(BaseModel):
phone: str
password: str
@router.post("/fw/login")
async def login_fw(req: LoginRequest):
"""登录中国法网"""
encrypted_pwd = aes_encrypt(req.password)
login_data = {
"username": req.phone,
"password": encrypted_pwd,
"terrace": 0,
"userAgent": "M5.0"
}
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
try:
session = requests.Session()
session.headers.update(headers)
resp = session.post(f"{settings.BASE_URL}/api/login", json=login_data, timeout=10)
data = resp.json()
if data.get("code") in ("1", 1):
return {
"code": "1",
"msg": "登录成功",
"data": data.get("data"),
"cookies": dict(session.cookies)
}
else:
return {
"code": data.get("code", "3"),
"msg": data.get("msg", "登录失败")
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
"""订单管理 + 解答 API"""
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from sqlalchemy.orm import Session
from sqlalchemy import or_
from app.core.database import get_db
from app.models.models import Order, Account, Session as FwSession
from app.core.config import settings
from app.services.session_manager import session_manager
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
import logging
import json
import requests
logger = logging.getLogger(__name__)
router = APIRouter()
# 中国法网 API 基础 URL (注意包含 /api 前缀,与 session_manager 一致)
FW_BASE_URL = "http://sys.12348.gov.cn/resources/api"
# --- Pydantic Models ---
class AnswerRequest(BaseModel):
"""提交解答请求"""
account_id: int
order_id: int
answer_content: str = Field(..., description="解答内容,至少10字")
answer_title: Optional[str] = None
begin_sentences: Optional[str] = None
end_sentences: Optional[str] = None
business_id: Optional[int] = None
answer_template_id: Optional[int] = None
order_tag_list: Optional[List[Dict[str, Any]]] = []
class OrderDetailRequest(BaseModel):
"""获取订单详情请求"""
account_id: int
order_id: int
class CurrentServiceRequest(BaseModel):
"""查询当前服务请求"""
account_id: int
page_num: int = 1
page_size: int = 10
class TagItem(BaseModel):
id: str
# --- 本地订单查询 ---
@router.get("/orders/pending")
def get_pending_orders(
account_id: Optional[int] = None,
user_id: Optional[int] = None,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=200),
authorization: Optional[str] = Header(None),
db: Session = Depends(get_db)
):
"""获取已处理订单(已解答/已完成)- 支持分页"""
query = db.query(Order).filter(
or_(
Order.status == "answering",
Order.status == "answered",
Order.status == "completed"
)
)
total = query.count()
orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": o.id,
"order_id": o.order_id,
"order_no": o.order_no,
"title": o.title,
"consult_content": o.consult_content,
"answer_content": o.answer_content,
"status": o.status,
"account_id": o.account_id,
"created_at": str(o.created_at) if o.created_at else None,
"grabbed_at": str(o.grabbed_at) if o.grabbed_at else None,
} for o in orders
]
}
def _filter_orders_by_group(query, token: str, db: Session):
"""Filter orders by user role + account groups.
- Admin: sees ALL orders (no filter)
- Regular user: sees orders from accounts whose username matches the user's accounts
- Group sharing: if user is assigned to a group, sees orders from all accounts in that group
"""
from app.api.auth_api import get_current_user_from_token
user = get_current_user_from_token(token, db)
if not user:
return query
# Admin sees everything
if user.role == "admin":
return query
# For regular users:
# Find accounts directly owned by this user (via Account.user_id == user.id)
owned_accounts = db.query(Account).filter(Account.user_id == user.id).all()
if not owned_accounts:
return query.filter(Order.account_id.is_(None))
# Step 2: Collect group names from owned accounts
group_names = set()
for acc in owned_accounts:
if acc.group_name:
group_names.add(acc.group_name)
# Step 3: Build the final account ID list
account_ids = [acc.id for acc in owned_accounts]
# Step 4: If user has group access, include all accounts in those groups
if group_names:
group_accounts = db.query(Account).filter(Account.group_name.in_(group_names)).all()
for acc in group_accounts:
if acc.id not in account_ids:
account_ids.append(acc.id)
if not account_ids:
return query.filter(Order.account_id.is_(None))
return query.filter(Order.account_id.in_(account_ids))
@router.get("/orders/grabbed")
def get_grabbed_orders(
account_id: Optional[int] = None,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=200),
authorization: Optional[str] = Header(None),
db: Session = Depends(get_db)
):
"""获取待处理订单(已抢未解答)- 支持分页"""
query = db.query(Order).filter(Order.status == "grabbed")
token = authorization.replace("Bearer ", "") if authorization else None
# If account_id specified, use that; otherwise apply group filter
if account_id:
query = query.filter(Order.account_id == account_id)
elif token:
query = _filter_orders_by_group(query, token, db)
total = query.count()
orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": o.id,
"order_id": o.order_id,
"order_no": o.order_no,
"title": o.title,
"consult_content": o.consult_content,
"answer_content": o.answer_content,
"status": o.status,
"account_id": o.account_id,
"created_at": str(o.created_at) if o.created_at else None,
"grabbed_at": str(o.grabbed_at) if o.grabbed_at else None,
} for o in orders
]
}
@router.get("/orders/all")
def get_all_orders(
status: Optional[str] = None,
account_id: Optional[int] = None,
user_id: Optional[int] = None,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=200),
db: Session = Depends(get_db)
):
"""获取所有订单 - 支持分页"""
query = db.query(Order)
if status:
query = query.filter(Order.status == status)
if account_id:
query = query.filter(Order.account_id == account_id)
if user_id:
query = query.filter(Order.user_id == user_id)
total = query.count()
orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": o.id,
"order_id": o.order_id,
"order_no": o.order_no,
"title": o.title,
"consult_content": o.consult_content[:200] if o.consult_content else None,
"answer_content": o.answer_content[:200] if o.answer_content else None,
"answer_title": o.answer_title,
"status": o.status,
"account_id": o.account_id,
"user_id": o.user_id,
"grabbed_at": str(o.grabbed_at) if o.grabbed_at else None,
"answered_at": str(o.answered_at) if o.answered_at else None,
"created_at": str(o.created_at) if o.created_at else None,
} for o in orders
]
}
@router.get("/orders/history")
def get_history_orders(
user_id: Optional[int] = None,
limit: int = 100,
db: Session = Depends(get_db)
):
"""获取已处理/历史订单"""
query = db.query(Order).filter(
or_(
Order.status == "answered",
Order.status == "completed"
)
)
if user_id:
query = query.filter(Order.user_id == user_id)
orders = query.order_by(Order.answered_at.desc()).limit(limit).all()
return [
{
"id": o.id,
"order_id": o.order_id,
"order_no": o.order_no,
"title": o.title,
"answer_content": o.answer_content[:200] if o.answer_content else None,
"answer_title": o.answer_title,
"status": o.status,
"account_id": o.account_id,
"user_id": o.user_id,
"answered_at": str(o.answered_at) if o.answered_at else None,
} for o in orders
]
# --- 真实法网 API 对接 ---
def _get_session_cookies(account_id: int, db: Session) -> dict:
"""获取账号的会话 cookies"""
session = db.query(FwSession).filter(
FwSession.account_id == account_id,
FwSession.status == "connected"
).first()
if not session:
raise HTTPException(status_code=400, detail="账号未连接,请先登录")
return json.loads(session.cookies)
@router.post("/orders/query/current")
def query_current_service(req: CurrentServiceRequest):
"""查询当前服务订单(从真实法网API)"""
from app.core.database import SessionLocal
db = SessionLocal()
try:
account = db.query(Account).filter(Account.id == req.account_id).first()
if not account:
return {"success": False, "message": "账号不存在"}
cookies = _get_session_cookies(req.account_id, db)
resp = requests.post(
f"{FW_BASE_URL}/message_order/query_current_service",
json={{"pageNum": req.page_num, "pageSize": req.page_size}},
cookies=cookies,
timeout=10
)
data = resp.json()
if data.get("code") == 1:
return {"success": True, "data": data.get("data", {})}
return {"success": False, "message": f"查询失败: {{data.get('msg', '未知错误')}}"}
except Exception as e:
logger.error(f"query_current_service error: {{e}}")
return {"success": False, "message": str(e)}
finally:
db.close()
@router.post("/orders/detail")
def get_order_detail_for_answer(req: OrderDetailRequest):
"""获取订单详情用于解答(使用内存中会话,确保cookies实时有效)"""
# Use in-memory session first (fresh cookies), fall back to DB cookies
fw_session = None
if req.account_id in session_manager.sessions:
fw_session = session_manager.sessions[req.account_id]
if fw_session and fw_session.status == "connected":
# Use in-memory session's requests_session
resp = fw_session.requests_session.get(
f"{FW_BASE_URL}/message_order/get_message_order_by_id_for_answer",
params={"id": req.order_id},
timeout=10
)
else:
# Fallback: use DB cookies
from app.core.database import SessionLocal
db = SessionLocal()
try:
account = db.query(Account).filter(Account.id == req.account_id).first()
if not account:
return {"success": False, "message": "账号不存在"}
db_session = db.query(FwSession).filter(
FwSession.account_id == req.account_id,
FwSession.status == "connected"
).first()
if not db_session:
return {"success": False, "message": "账号未连接,请先在系统设置中登录"}
cookies = json.loads(db_session.cookies)
resp = requests.get(
f"{FW_BASE_URL}/message_order/get_message_order_by_id_for_answer",
params={"id": req.order_id},
cookies=cookies,
timeout=10
)
finally:
db.close()
try:
data = resp.json()
except (json.JSONDecodeError, ValueError):
logger.error(f"get_order_detail: invalid JSON response, status={resp.status_code}, body={resp.text[:200]}")
return {"success": False, "message": f"法网返回无效数据 (HTTP {resp.status_code})"}
if data.get("code") in ("1", 1):
order_data = data.get("data", {})
# Update local order
from app.core.database import SessionLocal
db = SessionLocal()
try:
local_order = db.query(Order).filter(Order.order_id == req.order_id).first()
if local_order:
local_order.status = "answering"
local_order.answer_title = order_data.get("answerTitle")
local_order.begin_sentences = order_data.get("beginSentences")
local_order.end_sentences = order_data.get("endSentences")
local_order.business_id = order_data.get("businessId")
local_order.answer_template_id = order_data.get("answerTemplateId")
db.commit()
finally:
db.close()
return {
"success": True,
"data": order_data,
"businessTypeList": order_data.get("businessTypeList", []),
"orderTagList": order_data.get("orderTagList", []),
"lawInfoList": order_data.get("lawInfoList", []),
}
return {"success": False, "message": f"获取详情失败: {data.get('msg', '未知错误')}"}
@router.post("/orders/answer")
def submit_answer(req: AnswerRequest):
"""提交解答(使用内存中会话,确保cookies实时有效)"""
from app.core.database import SessionLocal
# Validate answer content
if len(req.answer_content.strip()) < 10:
return {"success": False, "message": "解答内容至少10个字"}
# Use in-memory session first (fresh cookies), fall back to DB cookies
fw_session = None
if req.account_id in session_manager.sessions:
fw_session = session_manager.sessions[req.account_id]
if fw_session and fw_session.status == "connected":
# Use in-memory session's requests_session
resp = fw_session.requests_session.post(
f"{FW_BASE_URL}/message_order/answer",
json={
"id": req.order_id,
"businessId": req.business_id,
"answerContent": req.answer_content,
"answerTitle": req.answer_title,
"beginSentences": req.begin_sentences,
"endSentences": req.end_sentences,
"answerTemplateId": req.answer_template_id,
"orderTagList": req.order_tag_list or [],
},
timeout=15
)
else:
# Fallback: use DB cookies
db = SessionLocal()
try:
account = db.query(Account).filter(Account.id == req.account_id).first()
if not account:
return {"success": False, "message": "账号不存在"}
db_session = db.query(FwSession).filter(
FwSession.account_id == req.account_id,
FwSession.status == "connected"
).first()
if not db_session:
return {"success": False, "message": "账号未连接,请先在系统设置中登录"}
cookies = json.loads(db_session.cookies)
resp = requests.post(
f"{FW_BASE_URL}/message_order/answer",
json={
"id": req.order_id,
"businessId": req.business_id,
"answerContent": req.answer_content,
"answerTitle": req.answer_title,
"beginSentences": req.begin_sentences,
"endSentences": req.end_sentences,
"answerTemplateId": req.answer_template_id,
"orderTagList": req.order_tag_list or [],
},
cookies=cookies,
timeout=15
)
finally:
db.close()
try:
result = resp.json()
except (json.JSONDecodeError, ValueError):
logger.error(f"submit_answer: invalid JSON response, status={resp.status_code}, body={resp.text[:500]}")
return {"success": False, "message": f"法网返回无效数据 (HTTP {resp.status_code})"}
# Log raw response for debugging
logger.info(f"submit_answer raw response: code={result.get('code')}, msg={result.get('msg')}, data={result.get('data')}")
if result.get("code") in ("1", 1):
# Update local order
db = SessionLocal()
try:
order = db.query(Order).filter(Order.order_id == req.order_id).first()
if order:
order.status = "answered"
order.answer_content = req.answer_content
order.answer_title = req.answer_title
order.answered_at = datetime.now()
db.commit()
finally:
db.close()
# Update in-memory session answered count
if fw_session:
fw_session.answered_count += 1
return {"success": True, "message": "回答成功", "data": result.get("data")}
return {"success": False, "message": f"提交失败: {result.get('msg', '未知错误')}"}
@router.post("/orders/grab/external")
def grab_external_order(account_id: int, order_id: int):
"""手动抢单(通过法网API)"""
if account_id not in session_manager.sessions:
raise HTTPException(status_code=400, detail="会话不存在")
session = session_manager.sessions[account_id]
result = session.grab_order(order_id)
return {"code": result.get("code"), "msg": result.get("msg")}
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Dict
from ..database import get_db, init_db
from ..models.models import LegalAccount, User, Order, UserRole, SessionStatus
from ..schemas.schemas import (
UserCreate, UserLogin, UserOut,
LegalAccountCreate, LegalAccountOut, LegalAccountLogin, LegalAccountBatchLogin,
OrderOut, OrderReply, DashboardStat, AccountStat
)
from ..services.auth import (
create_admin_if_not_exists, register_user, authenticate_user,
add_user_as_admin, reset_user_password
)
from ..services.legal_session import SessionManager
from ..services.order_grabber import OrderGrabber
from ..utils.crypto import aes_encrypt
import json
router = APIRouter()
# Global session manager (will be initialized in main.py)
session_manager: SessionManager = None
order_grabber: OrderGrabber = None
# ---- User endpoints ----
@router.post("/api/users/register", response_model=UserOut)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
try:
return register_user(db, user_data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/users/login", response_model=UserOut)
def login(login_data: UserLogin, db: Session = Depends(get_db)):
try:
return authenticate_user(db, login_data)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
@router.post("/api/admin/users/add", response_model=UserOut)
def admin_add_user(phone: str, password: str, db: Session = Depends(get_db)):
"""Admin adds a user"""
try:
return add_user_as_admin(db, phone, password)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/admin/users/reset-password")
def admin_reset_password(phone: str, password: str, db: Session = Depends(get_db)):
"""Admin resets user password"""
try:
reset_user_password(db, phone, password)
return {"message": "密码重置成功"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/users/list", response_model=List[UserOut])
def list_users(db: Session = Depends(get_db)):
users = db.query(User).all()
return users
# ---- Legal Account endpoints ----
@router.post("/api/legal-accounts/add", response_model=LegalAccountOut)
def add_legal_account(account: LegalAccountCreate, db: Session = Depends(get_db)):
"""Add a legal platform account"""
existing = db.query(LegalAccount).filter(LegalAccount.phone == account.phone).first()
if existing:
raise HTTPException(status_code=400, detail="账号已存在")
legal_account = LegalAccount(
phone=account.phone,
password=aes_encrypt(account.password),
raw_password=account.password,
status=SessionStatus.DISCONNECTED,
)
db.add(legal_account)
db.commit()
db.refresh(legal_account)
return legal_account
@router.post("/api/legal-accounts/login")
def legal_account_login(data: LegalAccountLogin, db: Session = Depends(get_db)):
"""Login a legal account to the platform"""
if not session_manager:
raise HTTPException(status_code=500, detail="Session manager not initialized")
try:
session = session_manager.login_account(data.account_id)
return {
"message": "登录成功",
"account_id": data.account_id,
"status": session.account.status.value
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/legal-accounts/batch-login")
def legal_accounts_batch_login(data: LegalAccountBatchLogin, db: Session = Depends(get_db)):
"""Batch login legal accounts"""
if not session_manager:
raise HTTPException(status_code=500, detail="Session manager not initialized")
results = session_manager.batch_login(data.account_ids)
return {"results": results}
@router.post("/api/legal-accounts/logout")
def legal_account_logout(account_id: int, db: Session = Depends(get_db)):
"""Logout a legal account"""
if session_manager:
session_manager.logout_account(account_id)
return {"message": "已退出"}
@router.get("/api/legal-accounts/list", response_model=List[LegalAccountOut])
def list_legal_accounts(db: Session = Depends(get_db)):
accounts = db.query(LegalAccount).all()
return accounts
# ---- Order endpoints ----
@router.get("/api/orders/pending", response_model=List[OrderOut])
def list_pending_orders(db: Session = Depends(get_db)):
"""List pending orders"""
orders = db.query(Order).filter(Order.status == "pending").order_by(Order.grab_time.desc()).all()
return orders
@router.get("/api/orders/all", response_model=List[OrderOut])
def list_all_orders(db: Session = Depends(get_db)):
"""List all orders"""
orders = db.query(Order).order_by(Order.grab_time.desc()).all()
return orders
@router.post("/api/orders/reply")
def reply_order(data: OrderReply, db: Session = Depends(get_db)):
"""Reply to an order"""
order = db.query(Order).filter(Order.id == data.order_id).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
# Reply via platform
if session_manager and order.assigned_account_id in session_manager.sessions:
session = session_manager.sessions[order.assigned_account_id]
result = session.reply_order(order.order_no, data.reply_content)
if result and result.get("code") == 1:
order.reply_content = data.reply_content
order.status = "completed"
order.complete_time = datetime.now()
# Update account stats
account = session.account
account.processed_count += 1
account.current_order_id = None
account.order_count = account.order_count # keep total
db.commit()
return {"message": "回复成功", "result": result}
# Fallback: save locally
order.reply_content = data.reply_content
order.status = "completed"
order.complete_time = datetime.now()
db.commit()
return {"message": "已保存回复"}
@router.post("/api/orders/{order_id}/assign")
def assign_order(order_id: int, user_id: int, db: Session = Depends(get_db)):
"""Assign order to a user"""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
order.assigned_user_id = user_id
order.status = "processing"
db.commit()
return {"message": "分配成功"}
# ---- Dashboard endpoints ----
@router.get("/api/dashboard/stats", response_model=DashboardStat)
def get_dashboard_stats(db: Session = Depends(get_db)):
"""Get dashboard statistics"""
from datetime import datetime, timedelta
today = datetime.now().date()
total_accounts = db.query(LegalAccount).count()
connected = db.query(LegalAccount).filter(
LegalAccount.status == SessionStatus.CONNECTED
).count()
blocked = db.query(LegalAccount).filter(
LegalAccount.status == SessionStatus.BLOCKED
).count()
# Today's orders
today_start = datetime.combine(today, datetime.min.time())
total_today = db.query(Order).filter(
Order.grab_time >= today_start
).count()
pending = db.query(Order).filter(Order.status == "pending").count()
processed = db.query(Order).filter(Order.status == "completed").count()
return DashboardStat(
total_accounts=total_accounts,
connected_accounts=connected,
blocked_accounts=blocked,
total_orders_today=total_today,
pending_orders=pending,
processed_orders=processed
)
@router.get("/api/dashboard/accounts", response_model=List[AccountStat])
def get_account_stats(db: Session = Depends(get_db)):
"""Get account statistics"""
accounts = db.query(LegalAccount).all()
stats = []
for acc in accounts:
stats.append(AccountStat(
account_id=acc.id,
phone=acc.phone,
status=acc.status.value,
order_count=acc.order_count,
processed_count=acc.processed_count,
heartbeat_at=acc.heartbeat_at
))
return stats
# ---- Grabber control ----
@router.post("/api/grabber/start")
def start_grabber(db: Session = Depends(get_db)):
"""Start the order grabber"""
global order_grabber
if not session_manager:
raise HTTPException(status_code=500, detail="Session manager not initialized")
order_grabber = OrderGrabber(session_manager, db)
# Start in background
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(order_grabber.start())
return {"message": "抢单引擎已启动"}
@router.post("/api/grabber/stop")
def stop_grabber():
"""Stop the order grabber"""
global order_grabber
if order_grabber:
order_grabber.stop()
order_grabber = None
return {"message": "抢单引擎已停止"}
@router.get("/api/grabber/stats")
def get_grabber_stats():
"""Get grabber statistics"""
if order_grabber:
return {"stats": order_grabber.get_stats()}
return {"stats": []}
"""会话管理 API"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.models import Session as SessionModel, Account
from app.schemas.schemas import SessionResponse, SessionAction
from app.core.config import settings
from app.core.crypto import aes_encrypt
from app.services.session_manager import session_manager
from pydantic import BaseModel
import requests
import logging
import json
from datetime import datetime
logger = logging.getLogger(__name__)
router = APIRouter()
class AccountIdRequest(BaseModel):
account_id: int
@router.get("/fw/sessions")
def list_fw_sessions():
"""获取所有法网会话状态"""
return session_manager.get_all_sessions()
@router.post("/fw/sessions/disconnect")
def disconnect_fw_session(req: AccountIdRequest):
"""退出法网会话(仅断开,不删除 — 保留在列表中方便重新登录)"""
if req.account_id in session_manager.sessions:
session = session_manager.sessions[req.account_id]
phone = session.account_phone
session.status = "disconnected"
session.requests_session = None # 释放 cookies,让律师能登录法网
logger.info(f"Account {phone} disconnected (still in session list)")
return {"code": "1", "msg": "已退出", "sessions": session_manager.get_all_sessions()}
return {"code": "0", "msg": "会话不存在"}
@router.post("/fw/sessions/add")
def add_fw_session(req: AccountIdRequest):
"""添加法网账号并登录"""
# 如果已存在,先断开旧会话
if req.account_id in session_manager.sessions:
old_session = session_manager.sessions[req.account_id]
if old_session.requests_session:
pass # 保留旧 session,直接重新 login
success = session_manager.add_account(req.account_id)
if success:
return {"code": "1", "msg": "登录成功", "sessions": session_manager.get_all_sessions()}
return {"code": "0", "msg": "登录失败"}
@router.post("/fw/sessions/remove")
def remove_fw_session(req: AccountIdRequest):
"""移除法网会话"""
session_manager.remove_account(req.account_id)
return {"code": "1", "msg": "会话已移除"}
@router.post("/fw/sessions/heartbeat")
def heartbeat_all():
"""对所有会话发送心跳"""
count = session_manager.heartbeat_all()
sessions = session_manager.get_all_sessions()
return {"code": "1", "msg": f"心跳完成: {count}个会话活跃", "count": count, "sessions": sessions}
@router.get("/sessions", response_model=list[SessionResponse])
def list_sessions(status: str = None, db: Session = Depends(get_db)):
query = db.query(SessionModel)
if status:
query = query.filter(SessionModel.status == status)
return query.all()
@router.post("/sessions/login")
def login_session(req: SessionAction, db: Session = Depends(get_db)):
"""登录指定账号"""
account = db.query(Account).filter(Account.id == req.account_id).first()
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
encrypted_pwd = aes_encrypt(account.password)
login_data = {
"username": account.phone,
"password": encrypted_pwd,
"terrace": 0,
"userAgent": "M5.0"
}
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
session = requests.Session()
session.headers.update(headers)
resp = session.post(f"{settings.BASE_URL}/api/login", json=login_data, timeout=10)
data = resp.json()
if data.get("code") != "1":
raise HTTPException(status_code=400, detail=data.get("msg", "登录失败"))
user_data = data.get("data", {})
account.user_id = user_data.get("userId")
account.name = user_data.get("userName")
db.commit()
db_session = SessionModel(
account_id=req.account_id,
status="connected",
cookies=json.dumps(dict(session.cookies)),
headers=json.dumps(dict(session.headers)),
last_heartbeat=datetime.now()
)
db.add(db_session)
db.commit()
db.refresh(db_session)
return {"msg": "登录成功", "session": db_session}
@router.post("/sessions/{session_id}/disconnect")
def disconnect_session(session_id: int, db: Session = Depends(get_db)):
db_session = db.query(SessionModel).filter(SessionModel.id == session_id).first()
if not db_session:
raise HTTPException(status_code=404, detail="会话不存在")
db_session.status = "disconnected"
db.commit()
return {"msg": "会话已断开"}
"""系统配置"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
BASE_URL: str = "http://sys.12348.gov.cn/resources"
AES_KEY: str = "1234567890123456"
AES_IV: str = "1234567890123456"
DATABASE_URL: str = "sqlite:///./data/app.db"
GRAB_INTERVAL_MS: int = 300
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "Admin@2026"
HEARTBEAT_INTERVAL_S: int = 30
model_config = {
"env_file": ".env",
"extra": "ignore"
}
settings = Settings()
"""AES 加密解密工具"""
from Crypto.Cipher import AES
import base64
from app.core.config import settings
def aes_encrypt(plaintext: str) -> str:
"""AES-CBC ZeroPadding 加密,返回 Base64"""
key = settings.AES_KEY.encode("utf-8")
iv = settings.AES_IV.encode("utf-8")
data = plaintext.encode("utf-8")
# ZeroPadding
padding_len = 16 - (len(data) % 16)
padded = data + b"\x00" * padding_len
cipher = AES.new(key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(padded)
return base64.b64encode(ciphertext).decode("utf-8")
def aes_decrypt(encrypted: str) -> str:
"""AES-CBC ZeroPadding 解密"""
key = settings.AES_KEY.encode("utf-8")
iv = settings.AES_IV.encode("utf-8")
data = base64.b64decode(encrypted)
cipher = AES.new(key, AES.MODE_CBC, iv)
padded = cipher.decrypt(data)
return padded.rstrip(b"\x00").decode("utf-8")
"""数据库配置"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} # SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
"""数据模型"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Float, Text, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
from enum import Enum
class UserRole(str, Enum):
ADMIN = "admin"
USER = "user"
class User(Base):
"""系统用户(注册账号)"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True) # 手机号或 admin
password_hash = Column(String(255), nullable=False) # bcrypt 加密
role = Column(String(20), default=UserRole.USER.value) # admin / user
phone = Column(String(20), nullable=True) # 手机号(注册时用)
name = Column(String(50), nullable=True) # 用户名
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
accounts = relationship("Account", back_populates="user", foreign_keys="Account.user_id")
@property
def is_admin(self):
return self.role == UserRole.ADMIN.value
class Account(Base):
"""平台账号(中国法网账号)"""
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
phone = Column(String(20), unique=True, nullable=False, index=True)
password = Column(String(100), nullable=False) # 明文密码,用于加密后登录
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # 关联用户
name = Column(String(50), nullable=True) # 法网上的名字
role = Column(String(20), default="user") # admin, user
is_active = Column(Boolean, default=True)
group_name = Column(String(50), nullable=True) # 账号分组
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="accounts", foreign_keys=[user_id])
class Session(Base):
"""登录会话"""
__tablename__ = "sessions"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
account_id = Column(Integer, nullable=False, index=True)
status = Column(String(20), default="disconnected") # connected, disconnected, blocked, disabled
session_id = Column(String(100), nullable=True) # 法网session
cookies = Column(Text, nullable=True) # JSON字符串
headers = Column(Text, nullable=True) # JSON字符串
grabbed_count = Column(Integer, default=0) # 抢到的单数
answered_count = Column(Integer, default=0) # 已处理的单数
last_heartbeat = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class Order(Base):
"""订单记录"""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
order_id = Column(Integer, unique=True, nullable=False, index=True) # 法网订单ID
order_no = Column(String(50), nullable=True) # 订单编号
title = Column(String(200), nullable=True)
consult_content = Column(Text, nullable=True)
answer_content = Column(Text, nullable=True)
answer_title = Column(String(200), nullable=True) # 解答标题
begin_sentences = Column(Text, nullable=True) # 开头语
end_sentences = Column(Text, nullable=True) # 结束语
answer_template_id = Column(Integer, nullable=True) # 解答模板ID
business_id = Column(Integer, nullable=True) # 业务类型ID
order_status = Column(Integer, nullable=True)
status = Column(String(20), default="pending", nullable=True) # pending/grabbed/answering/answered/completed/blocked
user_id = Column(Integer, nullable=True) # 关联用户
session_id = Column(Integer, nullable=True) # 关联的会话
account_id = Column(Integer, nullable=True) # 关联的账号
grabbed_at = Column(DateTime, nullable=True)
answered_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
"""Pydantic schemas"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
# User schemas
class UserCreate(BaseModel):
phone: str
password: str
name: Optional[str] = None
class UserLogin(BaseModel):
username: str # 手机号或 admin
password: str
class UserResponse(BaseModel):
id: int
username: str
role: str
is_active: bool
phone: Optional[str] = None
name: Optional[str] = None
created_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
password: Optional[str] = None
name: Optional[str] = None
is_active: Optional[bool] = None
# Login response
class LoginResponse(BaseModel):
success: bool
message: str
user: Optional[UserResponse] = None
token: Optional[str] = None
# Account schemas
class AccountCreate(BaseModel):
phone: str
password: str
user_id: Optional[int] = None
group_name: Optional[str] = None
class AccountResponse(BaseModel):
id: int
phone: str
role: str
is_active: bool
group_name: Optional[str] = None
name: Optional[str] = None
user_id: Optional[int] = None
user: Optional[UserResponse] = None
model_config = {"from_attributes": True}
# Session schemas
class SessionResponse(BaseModel):
id: int
account_id: int
status: str
grabbed_count: int
answered_count: int
last_heartbeat: Optional[datetime] = None
created_at: datetime
model_config = {"from_attributes": True}
class SessionAction(BaseModel):
account_id: int
action: str # login, logout, disconnect
# Order schemas
class OrderResponse(BaseModel):
id: int
order_id: int
order_no: Optional[str] = None
title: Optional[str] = None
consult_content: Optional[str] = None
answer_content: Optional[str] = None
order_status: Optional[int] = None
user_id: Optional[int] = None
session_id: Optional[int] = None
account_id: Optional[int] = None
grabbed_at: Optional[datetime] = None
answered_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class OrderAnswer(BaseModel):
order_id: int
answer_content: str
answer_title: Optional[str] = None
"""Answer service - 解答表单提交"""
import asyncio
import json
import time
import logging
from datetime import datetime
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.models import Order, Account, Session as FwSession
import requests
logger = logging.getLogger(__name__)
# 中国法网 API 基础 URL
FW_BASE_URL = "http://sys.12348.gov.cn/resources"
class AnswerService:
"""解答服务"""
@staticmethod
def get_order_for_answer(db: Session, account_id: int, order_id: int) -> Dict[str, Any]:
"""获取订单详情用于解答
调用真实系统的 get_message_order_by_id_for_answer API
"""
# Get account and session
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
return {"success": False, "message": "账号不存在"}
# Get active session
session = db.query(FwSession).filter(
FwSession.account_id == account_id,
FwSession.status == "connected"
).first()
if not session:
return {"success": False, "message": "账号未连接"}
# Parse cookies
cookies = json.loads(session.cookies)
# Call real API
try:
resp = requests.post(
f"{FW_BASE_URL}/message_order/get_message_order_by_id_for_answer",
params={"id": order_id},
cookies=cookies,
timeout=10
)
if resp.status_code == 200:
data = resp.json()
if data.get("code") == 1:
order_data = data.get("data", {})
# Update local order
order = db.query(Order).filter(Order.id == order_id).first()
if order:
order.status = "answering"
order.answer_title = order_data.get("answerTitle")
order.begin_sentences = order_data.get("beginSentences")
order.end_sentences = order_data.get("endSentences")
order.answer_content = order_data.get("answerContent")
order.business_id = order_data.get("businessId")
order.answer_template_id = order_data.get("answerTemplateId")
db.commit()
return {
"success": True,
"data": order_data,
"businessTypeList": order_data.get("businessTypeList", []),
"orderTagList": order_data.get("orderTagList", []),
"lawInfoList": order_data.get("lawInfoList", []),
}
return {"success": False, "message": f"API 错误: {resp.text}"}
except Exception as e:
return {"success": False, "message": str(e)}
@staticmethod
def submit_answer(db: Session, account_id: int, answer_data: Dict[str, Any]) -> Dict[str, Any]:
"""提交解答
调用真实系统的 /message_order/answer API
"""
order_id = answer_data.get("id")
if not order_id:
return {"success": False, "message": "订单ID缺失"}
# Get account and session
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
return {"success": False, "message": "账号不存在"}
session = db.query(FwSession).filter(
FwSession.account_id == account_id,
FwSession.status == "connected"
).first()
if not session:
return {"success": False, "message": "账号未连接"}
# Validate answer content
answer_content = answer_data.get("answerContent", "").strip()
if len(answer_content) < 10:
return {"success": False, "message": "解答内容至少10个字"}
# Build request data for real API
fw_data = {
"id": order_id,
"businessId": answer_data.get("businessId"),
"answerContent": answer_content,
"answerTitle": answer_data.get("answerTitle"),
"beginSentences": answer_data.get("beginSentences"),
"endSentences": answer_data.get("endSentences"),
"answerTemplateId": answer_data.get("answerTemplateId"),
"orderTagList": answer_data.get("orderTagList", []),
}
cookies = json.loads(session.cookies)
try:
resp = requests.post(
f"{FW_BASE_URL}/message_order/answer",
json=fw_data,
cookies=cookies,
timeout=15
)
if resp.status_code == 200:
result = resp.json()
if result.get("code") == 1:
# Update local order
order = db.query(Order).filter(Order.id == order_id).first()
if order:
order.status = "answered"
order.answer_content = answer_content
order.answered_at = datetime.now()
db.commit()
return {
"success": True,
"message": "回答成功",
"data": result.get("data")
}
return {"success": False, "message": f"提交失败: {result.get('msg', resp.text)}"}
except Exception as e:
# On failure, mark as error but keep content for retry
order = db.query(Order).filter(Order.id == order_id).first()
if order:
order.status = "answering"
order.answer_content = answer_content
db.commit()
return {"success": False, "message": str(e)}
@staticmethod
def get_answer_templates(db: Session, account_id: int) -> Dict[str, Any]:
"""获取解答模板
调用真实系统的 /basic_data/get_answer_template API
"""
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
return {"success": False, "message": "账号不存在"}
session = db.query(FwSession).filter(
FwSession.account_id == account_id,
FwSession.status == "connected"
).first()
if not session:
return {"success": False, "message": "账号未连接"}
cookies = json.loads(session.cookies)
try:
resp = requests.post(
f"{FW_BASE_URL}/basic_data/get_answer_template",
json={},
cookies=cookies,
timeout=10
)
if resp.status_code == 200:
data = resp.json()
if data.get("code") == 1:
return {"success": True, "data": data.get("data", [])}
return {"success": False, "message": "获取模板失败"}
except Exception as e:
return {"success": False, "message": str(e)}
# Create singleton
answer_service = AnswerService()
from sqlalchemy.orm import Session
from ..models.models import User, UserRole
from ..schemas.schemas import UserCreate, UserLogin, UserOut
from passlib.hash import bcrypt
from datetime import datetime
def create_admin_if_not_exists(db: Session):
"""Create default admin account"""
admin = db.query(User).filter(User.phone == "admin").first()
if not admin:
admin = User(
phone="admin",
password=bcrypt.hash("Admin@2026"),
role=UserRole.ADMIN
)
db.add(admin)
db.commit()
return admin
def register_user(db: Session, user_data: UserCreate) -> UserOut:
"""Register a new user"""
existing = db.query(User).filter(User.phone == user_data.phone).first()
if existing:
raise ValueError("手机号已存在")
user = User(
phone=user_data.phone,
password=bcrypt.hash(user_data.password),
role=UserRole.USER
)
db.add(user)
db.commit()
db.refresh(user)
return UserOut.model_validate(user)
def authenticate_user(db: Session, login: UserLogin) -> UserOut:
"""Authenticate user"""
user = db.query(User).filter(User.phone == login.phone).first()
if not user:
raise ValueError("用户不存在")
if user.phone == "admin" and login.password == "Admin@2026":
# Special case for admin - allow plaintext check
pass
elif not bcrypt.verify(login.password, user.password):
raise ValueError("密码错误")
return UserOut.model_validate(user)
def add_user_as_admin(db: Session, phone: str, password: str) -> UserOut:
"""Admin manually adds a user"""
existing = db.query(User).filter(User.phone == phone).first()
if existing:
raise ValueError("手机号已存在")
user = User(
phone=phone,
password=bcrypt.hash(password),
role=UserRole.USER
)
db.add(user)
db.commit()
db.refresh(user)
return UserOut.model_validate(user)
def reset_user_password(db: Session, phone: str, new_password: str):
"""Admin resets user password"""
user = db.query(User).filter(User.phone == phone).first()
if not user:
raise ValueError("用户不存在")
user.password = bcrypt.hash(new_password)
db.commit()
"""抢单引擎 - 自动轮询抢单"""
import asyncio
import logging
import time
import random
from typing import List
from datetime import datetime
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.models.models import Order, Account
from app.services.session_manager import session_manager
from app.core.config import settings
logger = logging.getLogger(__name__)
class GrabEngine:
"""抢单引擎 - 支持并发、频率控制、状态绑定"""
def __init__(self, interval_ms: int = 500):
self.interval = interval_ms / 1000.0
self.running = False
self.task: asyncio.Task = None
self.results: List[dict] = []
self.total_grabbed = 0
self.grabbed_order_ids = set() # Track grabbed orders to avoid duplicates
async def start(self):
"""启动抢单引擎"""
self.running = True
logger.info(f"Grab engine started (interval: {self.interval}s)")
while self.running:
try:
grabbed_this_round = False
for account_id, session in list(session_manager.sessions.items()):
if session.status != "connected":
continue
# Random delay per account to avoid detection
delay = random.uniform(0.3, self.interval)
await asyncio.sleep(delay)
if not self.running:
break
# Query orders
orders = session.query_order()
if not orders:
continue
logger.info(f"Account {session.account_phone}: found {len(orders)} available order(s)")
for order in orders:
order_id = order.get("id")
if not order_id or order_id in self.grabbed_order_ids:
continue
# Try to grab
result = session.grab_order(order_id)
if result.get("code") == "1":
self.grabbed_order_ids.add(order_id)
self.total_grabbed += 1
grabbed_this_round = True
# Save to database
self._save_order_to_db(order_id, account_id, order)
# 抢单成功 → 断开会话(释放法网登录,管理员手动上线)
if account_id in session_manager.sessions:
sess = session_manager.sessions[account_id]
sess.status = "disconnected"
sess.requests_session = None
# Record result
self.results.append({
"order_id": order_id,
"account_id": account_id,
"phone": session.account_phone,
"title": order.get("title", order.get("content", "")),
"grab_time": time.time(),
"status": "grabbed",
})
logger.info(f"✅ Grabbed order {order_id} by {session.account_phone} — 已断开(管理员请手动上线)")
# Continue to next account
break
else:
msg = result.get("msg", "")
code = result.get("code")
logger.info(f"Failed to grab order {order_id}: code={code} - {msg}")
# Clean up grabbed set if order no longer available
if "不存在" in msg:
self.grabbed_order_ids.discard(order_id)
await asyncio.sleep(max(0.1, self.interval - 0.3))
except asyncio.CancelledError:
logger.info("Grab engine cancelled")
break
except Exception as e:
logger.error(f"Grab engine error: {str(e)}")
await asyncio.sleep(self.interval)
def _save_order_to_db(self, order_id: int, account_id: int, order: dict):
"""保存抢到的订单到数据库"""
db = SessionLocal()
try:
# Check if order already exists (use order_id not order_no)
existing = db.query(Order).filter(Order.order_id == order_id).first()
if existing:
existing.status = "grabbed"
existing.account_id = account_id
existing.grabbed_at = datetime.now()
db.commit()
logger.info(f"Order {order_id} status updated to grabbed")
return
# Create new order with correct field names
new_order = Order(
order_id=order_id,
order_no=str(order_id),
title=order.get("title", ""),
consult_content=(order.get("content") or order.get("consultContent")) or "",
status="grabbed",
account_id=account_id,
grabbed_at=datetime.now(),
)
db.add(new_order)
db.commit()
logger.info(f"Order {order_id} saved to database")
except Exception as e:
logger.error(f"Failed to save order {order_id}: {str(e)}")
db.rollback()
finally:
db.close()
def stop(self):
"""停止抢单引擎"""
self.running = False
if self.task:
self.task.cancel()
logger.info(f"Grab engine stopped. Total grabbed: {self.total_grabbed}")
def get_stats(self) -> dict:
"""获取抢单统计"""
# Count grabbed orders by account
by_account = {}
for r in self.results:
phone = r.get("phone", "unknown")
by_account[phone] = by_account.get(phone, 0) + 1
return {
"running": self.running,
"total_grabbed": self.total_grabbed,
"by_account": by_account,
"recent_results": self.results[-20:], # Last 20
"tracked_orders": len(self.grabbed_order_ids),
}
def clear_old_results(self, keep: int = 50):
"""清理历史结果,保留最近的N条"""
if len(self.results) > keep:
self.results = self.results[-keep:]
def clear_old_grabbed(self):
"""清理过期的已抢订单记录(避免内存泄漏)"""
# Keep only recent 1000 order IDs
if len(self.grabbed_order_ids) > 1000:
self.grabbed_order_ids.clear()
logger.warning("Grabbed order IDs cache cleared to prevent memory leak")
# Global instance
grab_engine = GrabEngine()
import asyncio
import time
import logging
from typing import Dict, List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from ..models.models import LegalAccount, Order, SessionStatus
from ..services.legal_session import SessionManager
logger = logging.getLogger(__name__)
class OrderGrabber:
"""Concurrent order grabber for multiple accounts"""
def __init__(self, session_manager: SessionManager, db: Session, interval_ms: int = 500):
self.session_manager = session_manager
self.db = db
self.interval = interval_ms / 1000.0 # convert to seconds
self.running = False
self.task = None
async def start(self):
"""Start the order grabbing loop"""
self.running = True
logger.info(f"Order grabber started with {self.interval*1000}ms interval")
while self.running:
try:
await self._grab_cycle()
except Exception as e:
logger.error(f"Grab cycle error: {e}")
await asyncio.sleep(self.interval)
async def _grab_cycle(self):
"""One cycle of grabbing"""
active_sessions = self.session_manager.sessions
for account_id, session in active_sessions.items():
account = session.account
# Skip if account is not connected or already processing an order
if account.status != SessionStatus.CONNECTED:
continue
if account.current_order_id:
# Account is processing an order, skip grabbing
continue
# Query for orders
result = session.query_orders()
if not result or result.get("code") != 1:
continue
orders = result.get("data", [])
if not orders:
continue
# Grab the first available order
for order in orders:
order_id = order.get("id", "")
if not order_id:
continue
grab_result = session.grab_order(order_id)
if grab_result and grab_result.get("code") == 1:
# Successfully grabbed
logger.info(f"Account {account.phone} grabbed order {order_id}")
# Save order to our database
order_record = Order(
order_no=order_id,
title=order.get("title", ""),
content=order.get("content", ""),
status="pending",
assigned_account_id=account_id,
grab_time=datetime.now()
)
self.db.add(order_record)
# Update account
account.order_count += 1
account.current_order_id = order_id
self.db.commit()
break # Only grab one order per cycle
def stop(self):
"""Stop the order grabbing loop"""
self.running = False
logger.info("Order grabber stopped")
def get_stats(self) -> List[dict]:
"""Get grabbing statistics"""
stats = []
for account_id, session in self.session_manager.sessions.items():
account = session.account
stats.append({
"account_id": account_id,
"phone": account.phone,
"status": account.status.value,
"order_count": account.order_count,
"processed_count": account.processed_count,
"current_order_id": account.current_order_id,
"is_grabbing": self.running and account.status == SessionStatus.CONNECTED and not account.current_order_id
})
return stats
"""会话管理器 - 多账号并发管理"""
import asyncio
import json
import time
import random
import logging
from datetime import datetime
from typing import Dict, Optional, List
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.models.models import Session as SessionModel, Account
from app.core.config import settings
from app.core.crypto import aes_encrypt
import requests
logger = logging.getLogger(__name__)
# User-Agent pool for rotation
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
]
class AccountSession:
"""Single account session management"""
def __init__(self, account, db):
self.account = account
self.db = db
# Cache scalar attributes before DB session closes (prevents DetachedInstanceError)
self.account_id = account.id
self.account_phone = account.phone
self.account_name = account.name
self.account_password = account.password
self.account_user_id = account.user_id
self.requests_session = requests.Session()
self.status = "disconnected"
self.last_activity = None
self.grabbed_count = 0
self.answered_count = 0
self.ua = random.choice(USER_AGENTS)
# Configure session headers
self.requests_session.headers.update({
"Content-Type": "application/json",
"User-Agent": self.ua,
"Referer": "http://sys.12348.gov.cn/resources/#/",
})
def login(self):
"""Login to China Legal Net - uses temporary DB session for writes"""
try:
encrypted_pwd = aes_encrypt(self.account_password)
login_data = {
"username": self.account_phone,
"password": encrypted_pwd,
"terrace": 0,
"userAgent": "M5.0",
}
resp = self.requests_session.post(
f"{settings.BASE_URL}/api/login",
json=login_data,
timeout=10,
)
data = resp.json()
if data.get("code") in ("1", 1):
user_data = data.get("data", {})
# Update cached values
self.account_user_id = user_data.get("userId")
self.account_name = user_data.get("userName")
# Use temporary DB session for writes (self.db may be closed after restore)
tmp_db = SessionLocal()
try:
# Only update account name, NOT user_id
# user_id is the system User.id (set by admin in Settings),
# NOT the 法网 userId from login response
db_account = tmp_db.query(Account).filter(Account.id == self.account_id).first()
if db_account:
# Update name from login response (safe)
new_name = user_data.get("userName")
if new_name and new_name != db_account.name:
db_account.name = new_name
# Update or create session record in DB
db_session = (
tmp_db.query(SessionModel)
.filter(SessionModel.account_id == self.account_id)
.first()
)
if db_session:
db_session.status = "connected"
db_session.cookies = json.dumps(dict(self.requests_session.cookies))
db_session.last_heartbeat = datetime.now()
else:
db_session = SessionModel(
account_id=self.account_id,
status="connected",
cookies=json.dumps(dict(self.requests_session.cookies)),
last_heartbeat=datetime.now(),
)
tmp_db.add(db_session)
tmp_db.commit()
finally:
tmp_db.close()
self.status = "connected"
self.last_activity = datetime.now()
logger.info(f"Account {self.account_phone} logged in successfully")
return True
else:
logger.warning(f"Account {self.account_phone} login failed: {data.get('msg')}")
return False
except Exception as e:
logger.error(f"Account {self.account_phone} login error: {e}")
return False
def session_expired_relogin(self):
"""Session expired - relogin to restore session"""
logger.warning(f"Account {self.account_phone} session expired, relogging...")
return self.login()
def query_order(self):
"""Query available orders"""
if self.status != "connected":
return []
try:
resp = self.requests_session.post(
f"{settings.BASE_URL}/api/message_order/query_order",
json={"pageNum": 1, "pageSize": 10},
timeout=5,
)
data = resp.json()
if data.get("code") in ("1", 1):
raw_data = data.get("data") or {}
order_list = raw_data.get("list") or []
# 只在有订单时打印日志,空列表不刷屏
if order_list:
logger.info(f"Account {self.account_phone}: query_order found {len(order_list)} order(s)")
self.last_activity = datetime.now()
return order_list
elif data.get("code") in ("2", 2): # not logged in
logger.warning(f"Account {self.account_phone} query returned code 2, attempting relogin...")
if self.session_expired_relogin():
# Retry query after relogin
try:
resp = self.requests_session.post(
f"{settings.BASE_URL}/api/message_order/query_order",
json={"pageNum": 1, "pageSize": 10},
timeout=5,
)
data = resp.json()
if data.get("code") in ("1", 1):
self.last_activity = datetime.now()
return (data.get("data") or {}).get("list") or []
except Exception as e:
logger.error(f"Retry query order error for {self.account_phone}: {str(e)}")
return []
return []
except Exception as e:
logger.error(f"Query order error for {self.account_phone}: {str(e)}")
return []
def grab_order(self, order_id):
"""Grab an order - uses grap_order API (法网拼写)"""
if self.status != "connected":
return {"code": "0", "msg": "未登录"}
try:
resp = self.requests_session.post(
f"{settings.BASE_URL}/api/message_order/grap_order",
json={"id": order_id, "servicePersonalId": self.account_user_id},
timeout=5,
)
data = resp.json()
if data.get("code") in ("1", 1):
self.grabbed_count += 1
self.last_activity = datetime.now()
logger.info(f"Account {self.account_phone} grabbed order {order_id}")
return data
except Exception as e:
logger.error(f"Grab order error: {str(e)}")
return {"code": "3", "msg": str(e)}
def heartbeat(self):
"""Heartbeat - query current service with auto-relogin on failure"""
try:
resp = self.requests_session.post(
f"{settings.BASE_URL}/api/message_order/query_current_service",
json={"pageNum": 1, "pageSize": 1},
timeout=5,
)
data = resp.json()
if data.get("code") in ("1", 1):
self.last_activity = datetime.now()
return True
elif data.get("code") in ("2", 2):
# Session expired - try to relogin
if self.status == "connected" or self.status == "disconnected":
logger.warning(f"Account {self.account_phone} session expired, attempting relogin...")
result = self.login()
if result:
logger.info(f"Account {self.account_phone} relogin successful")
return True
else:
self.status = "disconnected"
logger.error(f"Account {self.account_phone} relogin failed")
return False
return False
except Exception as e:
logger.error(f"Heartbeat error: {str(e)}")
# On error, try relogin if currently connected
if self.status == "connected":
logger.warning(f"Account {self.account_phone} heartbeat error, attempting relogin...")
result = self.login()
if result:
logger.info(f"Account {self.account_phone} relogin after error successful")
return True
return False
def get_status(self):
"""Get session status"""
return {
"account_id": self.account_id,
"phone": self.account_phone,
"name": self.account_name,
"status": self.status,
"grabbed_count": self.grabbed_count,
"answered_count": self.answered_count,
"last_activity": str(self.last_activity) if self.last_activity else None,
}
class SessionManager:
"""Session manager for multiple account sessions"""
def __init__(self):
self.sessions = {}
self.running = False
def init(self):
"""Initialize - mark sessions for background restore (non-blocking)"""
db = SessionLocal()
try:
# Just count connected sessions, don't block startup with login
db_sessions = (
db.query(SessionModel)
.filter(SessionModel.status == "connected")
.all()
)
pending_accounts = []
for db_session in db_sessions:
account = (
db.query(Account)
.filter(Account.id == db_session.account_id)
.first()
)
if account:
pending_accounts.append(account.id)
logger.info(f"Found session to restore for account {account.phone}")
finally:
db.close()
# Restore in background thread to avoid blocking startup
import threading
threading.Thread(target=self._restore_all, args=(pending_accounts,), daemon=True).start()
logger.info(f"Background restore started for {len(pending_accounts)} accounts")
def _restore_all(self, account_ids):
"""Restore all sessions in background - each account gets its own DB session"""
for account_id in account_ids:
db = SessionLocal()
try:
account = db.query(Account).filter(Account.id == account_id).first()
if account:
session = AccountSession(account, db)
if session.login():
self.sessions[account.id] = session
logger.info(f"Restored session for account {account.phone}")
else:
logger.warning(f"Failed to restore session for account {account.phone}")
else:
logger.warning(f"Account {account_id} not found")
except Exception as e:
logger.error(f"Error restoring account {account_id}: {e}")
finally:
db.close()
import time
time.sleep(2) # Small delay between logins
def add_account(self, account_id):
"""Add account and login"""
db = SessionLocal()
try:
account = db.query(Account).filter(Account.id == account_id).first()
if not account:
logger.error(f"Account {account_id} not found")
return False
session = AccountSession(account, db)
if session.login():
self.sessions[account_id] = session
logger.info(f"Added session for account {account.phone}")
return True
else:
logger.error(f"Failed to login account {account.phone}")
return False
finally:
db.close()
def remove_account(self, account_id):
"""Remove account session"""
if account_id in self.sessions:
del self.sessions[account_id]
logger.info(f"Removed session for account {account_id}")
def get_all_sessions(self):
"""Get all session statuses"""
return [s.get_status() for s in self.sessions.values()]
def heartbeat_all(self):
"""Send heartbeat to all sessions"""
count = 0
for session in self.sessions.values():
if session.status == "connected":
if session.heartbeat():
count += 1
return count
def query_all_orders(self):
"""Query orders from all sessions"""
all_orders = []
for account_id, session in self.sessions.items():
if session.status == "connected":
orders = session.query_order()
for order in orders:
order["session_id"] = account_id
order["phone"] = session.account.phone
all_orders.extend(orders)
return all_orders
def grab_order(self, account_id, order_id):
"""Grab order with specific account"""
if account_id in self.sessions:
return self.sessions[account_id].grab_order(order_id)
return {"code": "0", "msg": "会话不存在"}
def auto_grab(self, min_interval=0.5, max_interval=1.5):
"""Auto grab orders with random delay"""
results = []
for account_id, session in self.sessions.items():
if session.status != "connected":
continue
# Random delay to avoid detection
delay = random.uniform(min_interval, max_interval)
time.sleep(delay)
orders = session.query_order()
if orders:
# Try to grab the first available order
result = session.grab_order(orders[0].get("id"))
results.append({
"account_id": account_id,
"phone": session.account.phone,
"result": result,
})
return results
def start_heartbeat_loop(self, interval=30):
"""Start heartbeat loop (needs async environment)"""
async def loop():
self.running = True
while self.running:
await asyncio.sleep(interval)
count = self.heartbeat_all()
logger.info(f"Heartbeat: {count} sessions active")
return loop()
def stop(self):
"""Stop all sessions"""
self.running = False
logger.info("Session manager stopped")
# Global instance
session_manager = SessionManager()
from Crypto.Cipher import AES
import base64
from ..config import AES_KEY, AES_IV
def aes_encrypt(password: str, key: str = AES_KEY, iv: str = AES_IV) -> str:
"""Encrypt password with AES-CBC ZeroPadding and return Base64"""
plaintext = password.encode("utf-8")
padding_len = 16 - (len(plaintext) % 16)
padded = plaintext + b"\x00" * padding_len
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
return base64.b64encode(cipher.encrypt(padded)).decode("utf-8")
def aes_decrypt(encrypted: str, key: str = AES_KEY, iv: str = AES_IV) -> str:
"""Decrypt Base64 AES-CBC ZeroPadding"""
ciphertext = base64.b64decode(encrypted)
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
padded = cipher.decrypt(ciphertext)
return padded.rstrip(b"\x00").decode("utf-8")
This source diff could not be displayed because it is too large. You can view the blob instead.
"""
法律抢单系统 - 后端服务
"""
import logging
import asyncio
import os
from datetime import datetime
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
from app.api import login, accounts, sessions, orders, grab_engine as grab_engine_api, auth_api
from app.core.database import engine, SessionLocal
from app.models.models import Base, User, UserRole
from app.services.grab_engine import grab_engine
from app.services.session_manager import session_manager
import bcrypt
# Setup logging — 直接配置 root logger(避免被 uvicorn 覆盖)
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, f"app_{datetime.now().strftime('%Y%m%d')}.log")
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
# Remove any existing handlers (uvicorn may have added its own)
for h in root_logger.handlers[:]:
root_logger.removeHandler(h)
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
))
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
))
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
logger = logging.getLogger(__name__)
app = FastAPI(
title="法律抢单系统",
description="中国法网智能业务办理系统",
version="1.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth_api.router, prefix="/api", tags=["用户认证"])
app.include_router(login.router, prefix="/api", tags=["登录"])
app.include_router(accounts.router, prefix="/api", tags=["账号管理"])
app.include_router(sessions.router, prefix="/api", tags=["会话管理"])
app.include_router(orders.router, prefix="/api", tags=["订单管理"])
app.include_router(grab_engine_api.router, prefix="/api", tags=["抢单引擎"])
@app.on_event("startup")
async def startup():
print("=" * 60)
print("法律抢单系统启动")
print("=" * 60)
logging.info("法律抢单系统启动")
# Create tables if not exist
Base.metadata.create_all(bind=engine)
# Create admin account if not exists
db = SessionLocal()
try:
admin = db.query(User).filter(User.username == "admin").first()
if not admin:
admin = User(
username="admin",
password_hash=bcrypt.hashpw(b"Admin@2026", bcrypt.gensalt()).decode(),
role=UserRole.ADMIN.value,
name="系统管理员"
)
db.add(admin)
db.commit()
logging.info("管理员账号已创建")
finally:
db.close()
# Initialize session manager (non-blocking)
session_manager.init()
logging.info(f"恢复 {len(session_manager.get_all_sessions())} 个会话")
# Start heartbeat task to keep sessions alive
asyncio.create_task(heartbeat_loop())
logging.info("心跳保活任务已启动 (60s/次)")
async def heartbeat_loop():
"""定期心跳保活所有在线会话"""
while True:
try:
for acc_id, session in list(session_manager.sessions.items()):
if session.status == "connected":
result = session.heartbeat()
if result:
logging.debug(f"Heartbeat OK: {session.account_phone}")
else:
logging.warning(f"Heartbeat failed: {session.account_phone}, status={session.status}")
except Exception as e:
logging.error(f"Heartbeat loop error: {e}")
await asyncio.sleep(60)
@app.on_event("shutdown")
def shutdown():
grab_engine.stop()
logging.info("法律抢单系统关闭")
@app.get("/api/health")
def health_check():
return {"status": "ok"}
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.2
pydantic-settings==2.0.3
requests==2.31.0
pycryptodome==3.19.0
apscheduler==3.10.4
aiohttp==3.9.1
@echo off
cd /d "%~dp0backend"
python -m uvicorn main:app --host 0.0.0.0 --port 8080 --reload
@echo off
cd /d %~dp0
echo Starting backend server...
call .venv\Scripts\python.exe -m uvicorn main:app --host 0.0.0.0 --port 8080
node_modules
dist
.git
*.md
*.log
.DS_Store
# Frontend — build React + serve with Nginx
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>法律抢单系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "legal-order-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"antd": "^5.12.0",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"@ant-design/icons": "^5.2.6",
"dayjs": "^1.11.10"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
.logo {
background: #001529;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.admin-name {
color: #333;
font-weight: 500;
}
.status-connected {
color: #52c41a;
}
.status-disconnected {
color: #ff4d4f;
}
.status-error {
color: #faad14;
}
.stat-card {
text-align: center;
padding: 24px;
}
.stat-number {
font-size: 36px;
font-weight: bold;
color: #1890ff;
}
.stat-label {
font-size: 14px;
color: #8c8c8c;
margin-top: 8px;
}
.grab-controls {
display: flex;
gap: 16px;
margin-bottom: 24px;
align-items: center;
}
.order-card {
margin-bottom: 16px;
}
.order-card .ant-card-body {
padding: 16px 24px;
}
.session-card {
margin-bottom: 16px;
}
.session-status {
display: flex;
align-items: center;
gap: 8px;
}
.session-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.session-dot.connected {
background: #52c41a;
}
.session-dot.disconnected {
background: #ff4d4f;
}
.session-dot.error {
background: #faad14;
}
import React, { useState, useEffect } from "react"
import { Layout, Menu, Typography, Badge, Dropdown, Space, Avatar } from "antd"
import { Routes, Route, Navigate, useNavigate, useLocation } from "react-router-dom"
import {
DashboardOutlined,
UnorderedListOutlined,
CloudUploadOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
} from "@ant-design/icons"
import DashboardPage from "./pages/DashboardPage"
import OrdersPage from "./pages/OrdersPage"
import GrabPage from "./pages/GrabPage"
import SettingsPage from "./pages/SettingsPage"
import AnswerPage from "./pages/AnswerPage"
import LoginPage from "./pages/LoginPage"
import "./App.css"
const { Header, Content, Sider } = Layout
const { Title, Text } = Typography
// Check if user is logged in
function getLoginStatus() {
const token = localStorage.getItem("token")
const user = localStorage.getItem("user")
if (token && user) {
try {
return { loggedIn: true, user: JSON.parse(user) }
} catch {
localStorage.removeItem("token")
localStorage.removeItem("user")
}
}
return { loggedIn: false, user: null }
}
function App() {
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const [loginStatus, setLoginStatus] = useState(() => getLoginStatus())
const { user } = loginStatus
// Poll for login state every 300ms - fixes same-tab login redirect
const [pollTick, setPollTick] = useState(0)
useEffect(() => {
const token = localStorage.getItem("token")
const user = localStorage.getItem("user")
if (token && user) {
try {
const u = JSON.parse(user)
if (!loginStatus.loggedIn || loginStatus.user?.id !== u.id) {
setLoginStatus({ loggedIn: true, user: u })
}
} catch(e) {
localStorage.removeItem("token")
localStorage.removeItem("user")
}
}
}, [pollTick, loginStatus])
useEffect(() => {
const timer = setInterval(() => setPollTick(t => t + 1), 300)
// Listen for login-success event from LoginPage for immediate redirect
const handleLoginEvent = () => {
const token = localStorage.getItem("token")
const user = localStorage.getItem("user")
if (token && user) {
try {
const u = JSON.parse(user)
setLoginStatus({ loggedIn: true, user: u })
} catch {
localStorage.removeItem("token")
localStorage.removeItem("user")
}
}
}
window.addEventListener("login-success", handleLoginEvent)
return () => {
clearInterval(timer)
window.removeEventListener("login-success", handleLoginEvent)
}
}, [])
// Handle logout
const handleLogout = () => {
localStorage.removeItem("token")
localStorage.removeItem("user")
setLoginStatus({ loggedIn: false, user: null })
navigate("/login")
}
// Build menu items based on role
const menuItems = []
if (user?.role === "admin") {
// Admin: full menu
menuItems.push(
{ key: "/", icon: <DashboardOutlined />, label: "仪表盘" },
{ key: "/orders", icon: <UnorderedListOutlined />, label: "订单管理" },
{ key: "/grab", icon: <CloudUploadOutlined />, label: "抢单中心" },
{ key: "/settings", icon: <SettingOutlined />, label: "系统设置" }
)
} else {
// Regular user: only show "我的订单"
menuItems.push(
{ key: "/orders", icon: <UnorderedListOutlined />, label: "我的订单" }
)
}
// If not logged in, only show login page
if (!loginStatus.loggedIn) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}
// User dropdown menu
const userMenuItems = [
{
key: "profile",
icon: <UserOutlined />,
label: `${user?.username} (${user?.role === "admin" ? "管理员" : "普通用户"})`,
disabled: true,
},
{ type: "divider" },
{
key: "logout",
icon: <LogoutOutlined />,
label: "退出登录",
onClick: handleLogout,
},
]
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}>
<div className="logo" onClick={() => navigate(menuItems[0]?.key || "/")} style={{ cursor: "pointer" }}>
<Title level={5} style={{ color: "#fff", textAlign: "center", padding: "16px 0", margin: 0 }}>
{collapsed ? "法抢" : "法律抢单系统"}
</Title>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header style={{ padding: "0 24px", background: "#fff", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Title level={4} style={{ margin: 0 }}>法律抢单系统</Title>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: "pointer", padding: "0 8px" }}>
<Avatar style={{ backgroundColor: "#1890ff" }}>
{user?.role === "admin" ? <LogoutOutlined /> : <UserOutlined />}
</Avatar>
<Text strong>{user?.username}</Text>
</Space>
</Dropdown>
</Header>
<Content style={{ margin: "16px 24px", padding: 24, background: "#fff", minHeight: 400 }}>
<Routes>
<Route path="/" element={user?.role === "admin" ? <DashboardPage /> : <Navigate to="/orders" replace />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/grab" element={user?.role === "admin" ? <GrabPage /> : <Navigate to="/orders" replace />} />
<Route path="/settings" element={user?.role === "admin" ? <SettingsPage /> : <Navigate to="/orders" replace />} />
<Route path="/orders/answer/:orderId/:accountId" element={<AnswerPage />} />
<Route path="/login" element={<Navigate to={menuItems[0]?.key || "/orders"} replace />} />
<Route path="*" element={<Navigate to={menuItems[0]?.key || "/orders"} replace />} />
</Routes>
</Content>
</Layout>
</Layout>
)
}
export default App
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - auto-attach Authorization token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = 'Bearer ' + token
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor
api.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error.message)
return Promise.reject(error)
}
)
export default api
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>,
)
import React, { useState, useEffect } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { Card, Form, Input, Select, Button, message, Spin, Alert, Row, Col, Typography } from "antd"
const { Title, Text, Paragraph } = Typography
const BASE_URL = "/api"
function AnswerPage() {
const { orderId, accountId } = useParams()
const navigate = useNavigate()
const [form] = Form.useForm()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [orderData, setOrderData] = useState(null)
const [businessOptions, setBusinessOptions] = useState([])
const [tagOptions, setTagOptions] = useState([])
const [lawOptions, setLawOptions] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
fetchOrderDetail()
}, [orderId, accountId])
const fetchOrderDetail = async () => {
try {
setLoading(true)
setError(null)
const resp = await fetch(`${BASE_URL}/orders/detail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account_id: parseInt(accountId), order_id: parseInt(orderId) })
})
const data = await resp.json()
if (data.success) {
const od = data.data
setOrderData(od)
form.setFieldsValue({
answerTitle: od.answerTitle || "",
beginSentences: od.beginSentences || "",
endSentences: od.endSentences || "",
answerContent: od.answerContent || "",
businessId: od.businessId || "",
answerTemplateId: od.answerTemplateId || "",
})
const bOpts = (data.businessTypeList || []).map(b => ({ label: b.name || b.text, value: b.id || b.value }))
setBusinessOptions(bOpts)
const tOpts = (data.orderTagList || []).map(t => ({ label: t.name || t.text, value: t.id || t.value }))
setTagOptions(tOpts)
const lOpts = (data.lawInfoList || []).map(l => ({ label: l.name || l.title || l, value: l.id || l }))
setLawOptions(lOpts)
} else {
setError(data.message || "获取订单详情失败")
}
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
if (!values.answerContent || values.answerContent.trim().length < 10) {
message.error("解答内容至少10个字")
return
}
setSubmitting(true)
const resp = await fetch(`${BASE_URL}/orders/answer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
account_id: parseInt(accountId),
order_id: parseInt(orderId),
answer_content: values.answerContent,
answer_title: values.answerTitle || "",
begin_sentences: values.beginSentences || "",
end_sentences: values.endSentences || "",
business_id: values.businessId ? parseInt(values.businessId) : null,
answer_template_id: values.answerTemplateId ? parseInt(values.answerTemplateId) : null,
order_tag_list: []
})
})
const data = await resp.json()
if (data.success) {
message.success("解答提交成功!")
navigate("/orders")
} else {
message.error(`提交失败: ${data.message}`)
}
} catch (e) {
if (e.errorFields) return
message.error(e.message)
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" />
<p>正在加载订单详情...</p>
</div>
)
}
if (error) {
return (
<div style={{ padding: 24 }}>
<Alert message="加载失败" description={error} type="error" showIcon
action={<Button onClick={() => navigate(-1)}>返回</Button>} />
</div>
)
}
return (
<div style={{ padding: 24, maxWidth: 900, margin: "0 auto" }}>
<Card title={<Title level={4} style={{ margin: 0 }}>📝 解答订单</Title>}
extra={<Button onClick={() => navigate(-1)}>返回</Button>}>
{orderData ? (
<>
<Card size="small" style={{ marginBottom: 16, background: "#f9f9f9" }}>
<Title level={5} style={{ margin: "0 0 8px 0" }}>咨询内容</Title>
<Paragraph style={{ whiteSpace: "pre-wrap" }}>
{orderData.consultContent || orderData.content || "无内容"}
</Paragraph>
</Card>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item label="解答标题" name="answerTitle">
<Input placeholder="输入解答标题" />
</Form.Item>
<Form.Item label="开头语" name="beginSentences">
<Input.TextArea rows={2} placeholder="输入开头语,如:您好!感谢您的咨询..." />
</Form.Item>
<Form.Item label="解答内容 *" name="answerContent"
rules={[{ required: true, message: "请输入解答内容" }, { min: 10, message: "解答内容至少10个字" }]}>
<Input.TextArea rows={10} placeholder="在此处输入解答内容..." />
</Form.Item>
<Form.Item label="结束语" name="endSentences">
<Input.TextArea rows={2} placeholder="输入结束语,如:如有其他问题,欢迎继续咨询。" />
</Form.Item>
<Row gutter={[16, 16]}>
<Col span={8}>
<Form.Item label="业务类型" name="businessId">
<Select style={{ width: "100%" }} options={businessOptions}
placeholder="选择业务类型" allowClear />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="法律依据" name="lawInfo">
<Select style={{ width: "100%" }} mode="multiple" options={lawOptions}
placeholder="选择法律依据" allowClear />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="标签" name="tagIds">
<Select style={{ width: "100%" }} mode="multiple" options={tagOptions}
placeholder="选择标签" allowClear />
</Form.Item>
</Col>
</Row>
</Form>
<div style={{ textAlign: "center", marginTop: 16 }}>
<Button type="primary" size="large" onClick={handleSubmit}
loading={submitting} style={{ minWidth: 120 }}>
提交解答
</Button>
</div>
</>
) : (
<Alert message="订单数据异常" type="error" />
)}
</Card>
</div>
)
}
export default AnswerPage
import React, { useState, useEffect } from "react"
import { Row, Col, Card, Statistic, Table, Tag, Button, Space, Divider, message } from "antd"
import {
UserOutlined,
CloudServerOutlined,
CloudUploadOutlined,
CheckCircleOutlined,
SyncOutlined,
} from "@ant-design/icons"
import api from "../api/client"
function DashboardPage() {
const [loading, setLoading] = useState(false)
const [sessions, setSessions] = useState([])
const [grabStats, setGrabStats] = useState({ running: false, total_grabbed: 0, by_account: {}, recent_results: [] })
const refreshData = async () => {
setLoading(true)
try {
const [sessionsRes, grabRes] = await Promise.all([
api.get("/fw/sessions"),
api.get("/fw/grab/status"),
])
setSessions(sessionsRes.data)
setGrabStats(grabRes.data)
} catch (e) {
console.error("Failed to refresh:", e)
} finally {
setLoading(false)
}
}
useEffect(() => {
refreshData()
const timer = setInterval(refreshData, 5000)
return () => clearInterval(timer)
}, [])
const sessionColumns = [
{ title: "账号", dataIndex: "phone", key: "phone" },
{ title: "状态", key: "status", render: (_, r) => {
const colors = { connected: "success", disconnected: "error", error: "warning" }
const texts = { connected: "在线", disconnected: "离线", error: "异常" }
return <Tag color={colors[r.status] || "default"}>{texts[r.status] || r.status}</Tag>
}},
{ title: "抢单数", dataIndex: "grabbed_count", key: "grabbed_count" },
{ title: "最后活跃", dataIndex: "last_activity", key: "last_activity", render: v => v ? new Date(v).toLocaleTimeString() : "-" },
{
title: "操作", key: "action", width: 180,
render: (_, r) => {
if (r.status === "connected") {
return (
<Button size="small" danger onClick={() => handleRemoveAccount(r.account_id)}>
退出
</Button>
)
} else {
return (
<Button size="small" type="primary" onClick={() => handleAddAccount(r.account_id)} disabled={r.status === "error"}>
登录
</Button>
)
}
},
},
]
const handleAddAccount = async (account_id) => {
try {
const res = await api.post("/fw/sessions/add", { account_id })
if (res.data.code === "1") {
message.success(`${sessions.find(s => s.account_id === account_id)?.phone || account_id} 登录成功`)
refreshData()
} else {
message.error(res.data.msg || "登录失败")
}
} catch (e) {
message.error("登录失败")
}
}
const handleRemoveAccount = async (account_id) => {
try {
const phone = sessions.find(s => s.account_id === account_id)?.phone || account_id
const res = await api.post("/fw/sessions/disconnect", { account_id })
if (res.data.code === "1") {
message.success(`${phone} 已退出`)
refreshData()
} else {
message.error(res.data.msg || "退出失败")
}
} catch (e) {
message.error("退出失败")
}
}
const recentColumns = [
{ title: "订单号", dataIndex: "order_id", key: "order_id" },
{ title: "标题", dataIndex: "title", key: "title", ellipsis: true },
{ title: "账号", dataIndex: "phone", key: "phone" },
{ title: "抢单时间", dataIndex: "grab_time", key: "grab_time", render: v => v ? new Date(v * 1000).toLocaleTimeString() : "-" },
]
const totalGrabbed = Object.values(grabStats.by_account).reduce((a, b) => a + b, 0)
const connectedCount = sessions.filter(s => s.status === "connected").length
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 24 }}>
<h2 style={{ margin: 0 }}>系统概览</h2>
<Button icon={<SyncOutlined />} onClick={refreshData} loading={loading}>刷新</Button>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="活跃会话" value={connectedCount} prefix={<CloudServerOutlined />} valueStyle={{ color: "#3f8600" }} suffix={`/ ${sessions.length}`} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日抢单" value={totalGrabbed} prefix={<CloudUploadOutlined />} valueStyle={{ color: "#1890ff" }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="总抢单数" value={grabStats.total_grabbed} prefix={<CloudUploadOutlined />} valueStyle={{ color: "#722ed1" }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="抢单状态" value={grabStats.running ? "运行中" : "已停止"} prefix={grabStats.running ? <CheckCircleOutlined /> : <SyncOutlined />} valueStyle={{ color: grabStats.running ? "#3f8600" : "#d4d4d4" }} />
</Card>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Card title="会话状态" bordered={false}>
<Table dataSource={sessions} columns={sessionColumns} pagination={false} size="small" rowKey="account_id" />
</Card>
</Col>
<Col span={12}>
<Card title="最近抢单记录" bordered={false}>
<Table dataSource={grabStats.recent_results} columns={recentColumns} pagination={false} size="small" rowKey="order_id" />
</Card>
</Col>
</Row>
</div>
)
}
export default DashboardPage
import React, { useState, useEffect } from "react"
import { Card, Button, Space, Tag, Table, InputNumber, Alert, message, Collapse, Descriptions, Row, Col } from "antd"
import {
PlayCircleOutlined,
PauseCircleOutlined,
ThunderboltOutlined,
SyncOutlined,
} from "@ant-design/icons"
import api from "../api/client"
function GrabPage() {
const [loading, setLoading] = useState(false)
const [grabStats, setGrabStats] = useState({ running: false, total_grabbed: 0, by_account: {}, recent_results: [], tracked_orders: 0 })
const [interval, setIntervalMs] = useState(300)
const [sessions, setSessions] = useState([])
const refreshData = async () => {
setLoading(true)
try {
const [grabRes, sessionRes] = await Promise.all([
api.get("/fw/grab/status"),
api.get("/fw/sessions"),
])
setGrabStats(grabRes.data)
setSessions(sessionRes.data)
} catch (e) {
console.error("Refresh failed:", e)
} finally {
setLoading(false)
}
}
useEffect(() => {
refreshData()
const timer = setInterval(refreshData, 3000)
return () => clearInterval(timer)
}, [])
const handleStart = async () => {
try {
const res = await api.post("/fw/grab/start", { interval_ms: interval })
if (res.data.code === "1") {
message.success("抢单引擎已启动")
refreshData()
} else {
message.error(res.data.msg)
}
} catch (e) {
message.error("启动失败")
}
}
const handleStop = async () => {
try {
const res = await api.post("/fw/grab/stop")
if (res.data.code === "1") {
message.info("抢单引擎已停止")
refreshData()
}
} catch (e) {
message.error("停止失败")
}
}
const handleGrabOnce = async () => {
try {
const res = await api.post("/fw/grab/once")
message.info(`抢单完成: ${res.data.results.length}个结果`)
refreshData()
} catch (e) {
message.error("抢单失败")
}
}
const sessionStatusColor = (status) => {
return { connected: "#52c41a", disconnected: "#ff4d4f", error: "#faad14" }[status] || "#d9d9d9"
}
const columns = [
{ title: "订单号", dataIndex: "order_id", key: "order_id", width: 120 },
{ title: "标题", dataIndex: "title", key: "title", ellipsis: true },
{ title: "账号", dataIndex: "phone", key: "phone", width: 140 },
{ title: "状态", key: "status", width: 80, render: (_, r) => <Tag color="blue">{r.status || "grabbed"}</Tag> },
{ title: "时间", dataIndex: "grab_time", key: "grab_time", width: 140, render: v => v ? new Date(v * 1000).toLocaleString() : "-" },
]
const hasSessions = sessions.filter(s => s.status === "connected").length > 0
return (
<div>
<h2 style={{ margin: "0 0 24px" }}>抢单中心</h2>
{!hasSessions && (
<Alert message="没有在线会话,请先在设置中登录账号" type="warning" showIcon style={{ marginBottom: 24 }} />
)}
<Card style={{ marginBottom: 24 }}>
<Space>
<Button
type="primary"
size="large"
icon={<PlayCircleOutlined />}
onClick={handleStart}
disabled={grabStats.running || !hasSessions}
>
启动抢单
</Button>
<Button
danger
size="large"
icon={<PauseCircleOutlined />}
onClick={handleStop}
disabled={!grabStats.running}
>
停止抢单
</Button>
<Button
size="large"
icon={<ThunderboltOutlined />}
onClick={handleGrabOnce}
disabled={!hasSessions}
>
立即抢单
</Button>
<div style={{ marginLeft: 16 }}>
<span style={{ marginRight: 8 }}>轮询间隔:</span>
<InputNumber min={100} max={5000} value={interval} onChange={(v) => { if (Number.isFinite(v)) setIntervalMs(Math.round(v)) }} addonAfter="ms" style={{ width: 140 }} />
</div>
</Space>
</Card>
<Row style={{ marginBottom: 24 }}>
{sessions.filter(s => s.status === "connected").map(s => (
<Tag color={sessionStatusColor(s.status)} key={s.account_id} style={{ marginRight: 8 }}>
{s.phone} ({s.grabbed_count}单)
</Tag>
))}
</Row>
<Descriptions bordered column={4} style={{ marginBottom: 24 }}>
<Descriptions.Item label="状态">{grabStats.running ? <Tag color="green">运行中</Tag> : <Tag color="default">已停止</Tag>}</Descriptions.Item>
<Descriptions.Item label="总抢单数">{grabStats.total_grabbed}</Descriptions.Item>
<Descriptions.Item label="跟踪订单">{grabStats.tracked_orders}</Descriptions.Item>
<Descriptions.Item label="活跃会话">{sessions.filter(s => s.status === "connected").length}</Descriptions.Item>
</Descriptions>
<Card title="抢单记录">
<Table
dataSource={grabStats.recent_results}
columns={columns}
rowKey="order_id"
pagination={{ pageSize: 10 }}
/>
</Card>
</div>
)
}
export default GrabPage
import React, { useState } from "react"
import { Form, Input, Button, Card, Typography, message, Tabs } from "antd"
import { UserOutlined, LockOutlined } from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import api from "../api/client"
const { Title, Paragraph } = Typography
function LoginPage() {
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState("login")
const navigate = useNavigate()
const [form] = Form.useForm()
const handleLogin = async (values) => {
setLoading(true)
try {
const res = await api.post("/auth/login", {
username: values.username,
password: values.password,
})
if (res.data.success) {
// Save user first, then token (App.jsx polls every 300ms and requires both)
localStorage.setItem("user", JSON.stringify(res.data.user))
localStorage.setItem("token", res.data.token)
// Dispatch event to immediately notify App.jsx of login
window.dispatchEvent(new CustomEvent("login-success"))
message.success("登录成功")
// Redirect based on role
if (res.data.user.role === "admin") {
navigate("/")
} else {
navigate("/orders")
}
} else {
message.error(res.data.message || "登录失败")
}
} catch (error) {
const errMsg = error.response?.data?.detail || "登录失败,请检查账号密码"
message.error(errMsg)
} finally {
setLoading(false)
}
}
const handleRegister = async (values) => {
setLoading(true)
try {
const res = await api.post("/auth/register", {
phone: values.phone,
password: values.password,
name: values.phone,
})
if (res.data.id) {
message.success("注册成功,请登录")
setActiveTab("login")
form.setFieldsValue({ phone: values.phone })
}
} catch (error) {
const errMsg = error.response?.data?.detail || "注册失败"
message.error(errMsg)
} finally {
setLoading(false)
}
}
const tabItems = [
{
key: "login",
label: "登录",
children: (
<Form form={form} layout="vertical" onFinish={handleLogin}>
<Form.Item
name="username"
label="账号"
rules={[{ required: true, message: "请输入账号" }]}
>
<Input
prefix={<UserOutlined />}
placeholder="手机号 或 admin"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
size="large"
>
登录
</Button>
</Form.Item>
</Form>
),
},
{
key: "register",
label: "注册",
children: (
<Form layout="vertical" onFinish={handleRegister}>
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: "请输入手机号" },
{ pattern: /^1[3-9]\d{9}$/, message: "手机号格式不正确" },
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入手机号"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: "请输入密码" },
{ min: 6, message: "密码至少6位" },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="confirm"
label="确认密码"
dependencies={["password"]}
rules={[
{ required: true, message: "请再次输入密码" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve()
}
return Promise.reject(new Error("两次输入的密码不一致"))
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
size="large"
>
注册
</Button>
</Form.Item>
</Form>
),
},
]
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
}}>
<Card
style={{
width: 400,
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
borderRadius: 12,
}}
>
<div style={{ textAlign: "center", marginBottom: 24 }}>
<Title level={3} style={{ margin: 0 }}>法律抢单系统</Title>
<Paragraph style={{ color: "#888" }}>中国法网智能业务办理</Paragraph>
</div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
centered
/>
<div style={{ textAlign: "center", marginTop: 16 }}>
<Paragraph style={{ color: "#aaa", fontSize: 12 }}>
管理员默认账号: admin / Admin@2026
</Paragraph>
</div>
</Card>
</div>
)
}
export default LoginPage
import React, { useState, useEffect } from "react"
import { Card, Tabs, Table, Tag, Button, Input, Space, Modal, Form, Input as AntInput, Select, message, Popconfirm, Tooltip } from "antd"
import {
SyncOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
} from "@ant-design/icons"
import api from "../api/client"
const { TextArea } = AntInput
function OrdersPage() {
const [activeTab, setActiveTab] = useState("grabbed")
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState([])
const [grabStats, setGrabStats] = useState({})
const [accounts, setAccounts] = useState([])
const [answerModalOpen, setAnswerModalOpen] = useState(false)
const [currentOrder, setCurrentOrder] = useState(null)
const [form] = Form.useForm()
const [answerLoading, setAnswerLoading] = useState(false)
// Pagination state - MUST be separate from data to avoid reset on refetch
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
})
// Load accounts map for display
useEffect(() => {
api.get("/accounts").then(res => {
setAccounts(res.data || [])
}).catch(() => {})
}, [])
const getAccountName = (accountId) => {
const acc = accounts.find(a => a.id === accountId)
if (!acc) return `账号${accountId}`
const parts = [acc.phone]
if (acc.group_name) parts.push(`(${acc.group_name})`)
return parts.join(" ")
}
const fetchOrders = async (page = pagination.current, pageSize = pagination.pageSize) => {
setLoading(true)
try {
const endpoint = activeTab === "grabbed" ? "/orders/grabbed" :
activeTab === "all" ? "/orders/all" :
"/orders/pending"
const res = await api.get(endpoint, { params: { page, page_size: pageSize } })
// Backend now returns { total, page, page_size, items }
const data = res.data
const items = Array.isArray(data.items) ? data.items : (Array.isArray(data) ? data : [])
const total = data.total || items.length
setOrders(items)
setPagination({ current: page, pageSize, total })
} catch (e) {
console.error("Fetch orders failed:", e)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchOrders(1, pagination.pageSize)
}, [activeTab])
const handleTableChange = (newPagination) => {
const { current, pageSize } = newPagination
setPagination(prev => ({ ...prev, current, pageSize }))
fetchOrders(current, pageSize)
}
const handleAnswer = (order) => {
setCurrentOrder({
...order,
account_id: order.account_id || 1,
order_id: order.order_id || order.id,
})
form.resetFields()
setAnswerModalOpen(true)
}
const handleSubmitAnswer = async () => {
setAnswerLoading(true)
try {
const values = await form.validateFields()
const res = await api.post("/orders/answer", {
account_id: currentOrder.account_id,
order_id: currentOrder.order_id,
answer_content: values.answer_content,
answer_title: values.answer_title || "",
begin_sentences: values.begin_sentences || "",
end_sentences: values.end_sentences || "",
business_id: values.business_id ? parseInt(values.business_id) : null,
answer_template_id: null,
order_tag_list: [],
})
if (res.data.success) {
message.success("解答提交成功")
setAnswerModalOpen(false)
fetchOrders(pagination.current, pagination.pageSize)
} else {
message.error(res.data.message || "解答失败")
}
} catch (e) {
if (e.errorFields) return // form validation error
message.error("解答失败")
} finally {
setAnswerLoading(false)
}
}
const statusColors = {
pending: "orange",
grabbed: "blue",
answered: "green",
completed: "default",
}
const statusTexts = {
pending: "待处理",
grabbed: "待处理",
answering: "处理中",
answered: "已处理",
completed: "已处理",
}
const columns = [
{ title: "订单号", dataIndex: "order_no", key: "order_no", width: 100 },
{ title: "标题", dataIndex: "title", key: "title", width: 200, ellipsis: true,
render: (v, r) => <Tooltip title={v}>{v}</Tooltip> },
{ title: "咨询内容", dataIndex: "consult_content", key: "consult_content", width: 300, ellipsis: true,
render: (v, r) => <Tooltip title={v || "无"}>{v || "无"}</Tooltip> },
{ title: "客户", dataIndex: "customer_name", key: "customer_name", width: 100 },
{ title: "账号", key: "account_display", width: 150, render: (_, r) => getAccountName(r.account_id) },
{ title: "状态", key: "status", width: 100, render: (_, r) => <Tag color={statusColors[r.status]}>{statusTexts[r.status] || r.status}</Tag> },
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 160, render: v => v ? new Date(v).toLocaleString() : "-" },
{
title: "操作", key: "action", width: 150,
render: (_, r) => (
<Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleAnswer(r)}>解答</Button>
</Space>
),
},
]
const tabItems = [
{ key: "grabbed", label: "待处理" },
{ key: "pending", label: "已处理" },
{ key: "all", label: "全部订单" },
]
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>订单管理</h2>
<Button icon={<SyncOutlined />} onClick={() => fetchOrders(pagination.current, pagination.pageSize)} loading={loading}>刷新</Button>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} style={{ marginBottom: 16 }} />
<Card bordered={false}>
<Table
dataSource={orders}
columns={columns}
rowKey={r => r.id || r.order_no}
loading={loading}
onChange={handleTableChange}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
pageSizeOptions: ["10", "20", "50", "100"],
showSizeChanger: true,
showQuickJumper: true,
showTotal: (tot) => `共 ${tot} 条`,
}}
/>
</Card>
<Modal
title={`解答订单: ${currentOrder?.order_no || ""} (${currentOrder?.title || ""})`}
open={answerModalOpen}
onCancel={() => setAnswerModalOpen(false)}
onOk={handleSubmitAnswer}
confirmLoading={answerLoading}
width={700}
>
{currentOrder && (
<div style={{ marginBottom: 16, padding: 12, background: "#f5f5f5", borderRadius: 4 }}>
<div style={{ fontWeight: "bold", marginBottom: 8 }}>咨询内容:</div>
<div style={{ whiteSpace: "pre-wrap", fontSize: 14 }}>{currentOrder.consult_content || "无内容"}</div>
</div>
)}
<Form form={form} layout="vertical"
initialValues={{
answer_title: "",
begin_sentences: "您好!感谢您的咨询,针对您提出的问题,我的解答如下:",
answer_content: "",
end_sentences: "如有其他法律问题,欢迎继续咨询。祝您生活愉快!"
}}
>
<Form.Item name="answer_title" label="解答标题">
<Input placeholder="输入解答标题" />
</Form.Item>
<Form.Item name="begin_sentences" label="开头语">
<TextArea rows={2} placeholder="输入开头语" />
</Form.Item>
<Form.Item name="answer_content" label="解答内容 *"
rules={[{ required: true, message: "请输入解答内容" }, { min: 10, message: "解答内容至少10个字" }]}
>
<TextArea rows={10} placeholder="在此处输入详细的解答内容..." />
</Form.Item>
<Form.Item name="end_sentences" label="结束语">
<TextArea rows={2} placeholder="输入结束语" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default OrdersPage
import React, { useState, useEffect } from "react"
import { Card, Table, Button, Space, Input, Tag, Modal, Form, message, Divider, Select, Collapse, Row, Col, Popconfirm } from "antd"
import {
SyncOutlined,
PlusOutlined,
LoginOutlined,
LogoutOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import api from "../api/client"
const { TextArea } = Input
function SettingsPage() {
const [accounts, setAccounts] = useState([])
const [sessions, setSessions] = useState([])
const [groups, setGroups] = useState([])
const [addModalOpen, setAddModalOpen] = useState(false)
const [form] = Form.useForm()
const [groupForm] = Form.useForm()
const [groupModalOpen, setGroupModalOpen] = useState(false)
const [selectedAccounts, setSelectedAccounts] = useState([])
const fetchAll = async () => {
try {
const [accountsRes, sessionsRes, groupsRes] = await Promise.all([
api.get("/accounts"),
api.get("/fw/sessions"),
api.get("/accounts/groups"),
])
setAccounts(accountsRes.data)
setSessions(sessionsRes.data)
setGroups(groupsRes.data)
} catch (e) {
console.error("Fetch failed:", e)
}
}
useEffect(() => {
fetchAll()
}, [])
const handleAddAccount = async () => {
try {
const values = await form.validateFields()
await api.post("/accounts", values)
message.success("账号添加成功")
setAddModalOpen(false)
form.resetFields()
fetchAll()
} catch (e) {
if (e.errorFields) return
message.error("添加失败")
}
}
const handleLogin = async (accountId) => {
try {
const res = await api.post("/fw/sessions/add", { account_id: accountId })
if (res.data.code === "1") {
message.success("登录成功")
fetchAll()
} else {
message.error(res.data.msg)
}
} catch (e) {
message.error("登录失败")
}
}
const handleLogout = async (accountId) => {
try {
await api.post("/fw/sessions/remove", { account_id: accountId })
message.success("已退出")
fetchAll()
} catch (e) {
message.error("退出失败")
}
}
const handleDeleteAccount = async (accountId) => {
try {
await api.delete(`/accounts/${accountId}`)
message.success("已删除")
fetchAll()
} catch (e) {
message.error("删除失败")
}
}
const handleAssignGroup = async (accountId) => {
try {
const { group_name } = await new Promise((resolve) => {
const input = document.createElement("input")
input.placeholder = "输入分组名称(如:第一组)"
input.style.cssText = "position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);padding:8px 16px;font-size:14px;border:1px solid #d9d9d9;border-radius:4px;outline:none;z-index:9999"
document.body.appendChild(input)
input.focus()
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { resolve({ group_name: input.value }); document.body.removeChild(input) }
if (e.key === "Escape") { resolve({ group_name: null }); document.body.removeChild(input) }
})
// Simple confirm via message
setTimeout(() => document.body.removeChild(input), 30000)
})
// Use a simpler approach - just update directly
if (!group_name) return
await api.put(`/accounts/${accountId}/group`, { group_name })
message.success("分组已更新")
fetchAll()
} catch (e) {
message.error("操作失败")
}
}
const handleBatchAssignGroup = async () => {
try {
const values = await groupForm.validateFields()
await api.post("/accounts/batch-group", {
account_ids: values.account_ids,
group_name: values.group_name,
})
message.success(`已将 ${values.account_ids.length} 个账号分配到「${values.group_name}」`)
setGroupModalOpen(false)
groupForm.resetFields()
fetchAll()
} catch (e) {
if (e.errorFields) return
message.error("操作失败")
}
}
const accountColumns = [
{
title: "ID",
dataIndex: "id",
width: 60,
},
{
title: "手机号",
dataIndex: "phone",
width: 150,
},
{
title: "分组",
dataIndex: "group_name",
width: 120,
render: (group, record) => {
if (!group) return <Tag color="default">未分组</Tag>
const colorMap = { "第一组": "blue", "第二组": "green", "第三组": "orange", "第四组": "purple" }
return <Tag color={colorMap[group] || "default"}>{group}</Tag>
},
},
{
title: "名称",
dataIndex: "name",
width: 150,
},
{
title: "状态",
dataIndex: "status",
width: 100,
render: (_, record) => {
const hasSession = sessions.find(s => s.account_id === record.id)
if (!hasSession) return <Tag color="default">离线</Tag>
if (hasSession.status === "connected") return <Tag color="success">在线</Tag>
return <Tag color="error">{hasSession.status}</Tag>
},
},
{
title: "操作",
key: "action",
width: 220,
render: (_, record) => {
const hasSession = sessions.find(s => s.account_id === record.id)
const isConnected = hasSession?.status === "connected"
return (
<Space size="small">
{!isConnected ? (
<Button
type="link"
size="small"
icon={<LoginOutlined />}
onClick={() => handleLogin(record.id)}
>
登录
</Button>
) : (
<Button
type="link"
size="small"
danger
icon={<LogoutOutlined />}
onClick={() => handleLogout(record.id)}
>
退出
</Button>
)}
<Popconfirm title="确定删除此账号?" onConfirm={() => handleDeleteAccount(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</Space>
)
},
},
]
const groupColumn = {
title: "分组操作",
key: "group",
width: 120,
render: (_, record) => (
<Button
type="link"
size="small"
onClick={() => {
// Quick group assignment via prompt
const groupName = prompt(`为账号 ${record.phone} 设置分组名称:`, record.group_name || "")
if (groupName !== null) {
api.put(`/accounts/${record.id}/group`, { group_name: groupName }).then(() => {
message.success("分组已更新")
fetchAll()
}).catch(() => message.error("操作失败"))
}
}}
>
{record.group_name ? "改分组" : "设分组"}
</Button>
),
}
const allColumns = [groupColumn, ...accountColumns]
const refreshAll = () => {
fetchAll()
message.success("已刷新")
}
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3 style={{ margin: 0 }}>账号管理</h3>
<Space>
<Button icon={<SyncOutlined />} onClick={refreshAll}>刷新</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddModalOpen(true)}>添加账号</Button>
<Button onClick={() => setGroupModalOpen(true)}>批量分配分组</Button>
</Space>
</div>
<Card title="账号列表" style={{ marginBottom: 24 }} bordered={false}>
<Table
dataSource={accounts}
columns={allColumns}
rowKey="id"
pagination={false}
/>
</Card>
<Card title="分组统计" style={{ marginBottom: 24 }} bordered={false}>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card size="small">
<div style={{ fontSize: 24, fontWeight: "bold" }}>{accounts.length}</div>
<div style={{ color: "#999" }}>总账号数</div>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<div style={{ fontSize: 24, fontWeight: "bold" }}>{groups.length}</div>
<div style={{ color: "#999" }}>分组数</div>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<div style={{ fontSize: 24, fontWeight: "bold" }}>{accounts.filter(a => a.group_name).length}</div>
<div style={{ color: "#999" }}>已分组</div>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<div style={{ fontSize: 24, fontWeight: "bold" }}>{accounts.filter(a => !a.group_name).length}</div>
<div style={{ color: "#999" }}>未分组</div>
</Card>
</Col>
</Row>
{groups.length > 0 && (
<div style={{ marginTop: 16 }}>
{groups.map(g => (
<Tag key={g.group_name} color="blue" style={{ fontSize: 14, marginRight: 8 }}>
{g.group_name} ({g.account_count}个)
</Tag>
))}
</div>
)}
</Card>
<Card title="活跃会话" bordered={false}>
<Table
dataSource={sessions}
columns={[
{ title: "手机号", dataIndex: "phone" },
{ title: "状态", dataIndex: "status", render: s => s === "connected" ? <Tag color="success">在线</Tag> : <Tag>{s}</Tag> },
{ title: "抢单数", dataIndex: "grabbed_count" },
{ title: "解答数", dataIndex: "answered_count" },
]}
rowKey="account_id"
pagination={false}
/>
</Card>
{/* Add Account Modal */}
<Modal
title="添加账号"
open={addModalOpen}
onCancel={() => { setAddModalOpen(false); form.resetFields() }}
onOk={handleAddAccount}
>
<Form form={form} layout="vertical">
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: "请输入手机号" }]}>
<Input placeholder="请输入手机号" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item name="group_name" label="分组(可选)">
<Input placeholder="如:第一组" />
</Form.Item>
</Form>
</Modal>
{/* Batch Assign Group Modal */}
<Modal
title="批量分配分组"
open={groupModalOpen}
onCancel={() => { setGroupModalOpen(false); groupForm.resetFields() }}
onOk={handleBatchAssignGroup}
>
<Form form={groupForm} layout="vertical">
<Form.Item name="account_ids" label="选择账号" rules={[{ required: true, message: "请选择账号" }]}>
<Select
mode="multiple"
placeholder="选择要分配的账号"
style={{ width: "100%" }}
>
{accounts.map(a => (
<Select.Option key={a.id} value={a.id}>
{a.phone} ({a.group_name || "未分组"})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="group_name" label="分组名称" rules={[{ required: true, message: "请输入分组名称" }]}>
<Input placeholder="如:第一组" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default SettingsPage
@echo off
cd /d %~dp0
echo Starting frontend server...
call D:\software\nodejs\npm.cmd run dev
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
})
server {
listen 80;
server_name _;
# Frontend — React SPA
root /usr/share/nginx/html;
index index.html;
# SPA routing — all non-API routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Backend API proxy
location /api/ {
proxy_pass http://law_order_backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
# WebSocket support (future SSE/log streaming)
location /ws/ {
proxy_pass http://law_order_backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
我们计划开发一个中国法网智能业务办理系统,功能描述如下:
一、登陆网址:http://sys.12348.gov.cn/resources/#/
二、登陆账号、密码
账号1:18917209703 密码:200663Ai@
账号2:19542820841 密码:Fabao@123
账号3:13764780450 密码:Fabao@123
账号4:13661598223 密码:Bst202606+
三、功能描述
1.账号管理和角色管理
系统提供手机号和密码注册功能,先不实现短信验证。这些注册的账号都是普通用户角色。
内置一个管理员账号admin 密码是 Admin@2026
管理员也可以手动添加普通用户的账号,添加的时候输入手机及对应的密码。密码可以由管理员进行重置。
浏览器可以同时登录多个账号,每个账号使用一个tab页。
2.会话管理
功能1注册的这些账号需要自动登录到 http://sys.12348.gov.cn/resources/#/
我分析了一下网络请求过程:
登录: http://sys.12348.gov.cn/resources/api/login
post带的内容 密码应该做了加密 需要分析下网页是做了什么加密转换
{
"username": "18917209703",
"password": "k/TQp2rpW2mZRQz2MrdQ5w==",
"terrace": 0,
"userAgent": "M5.0"
}
会话管理要有一个列表 管理员可以查看当前哪些账号登录了 可以主动断开这些会话
会话要维护一个状态 包括连接正常、断开、禁用、被阻塞等 状态需要做一个心跳保持 服务器可能会阻塞拉黑我们 如果发现这种情况 就显示 被阻塞
3.抢单管理
服务器端有一个“留言咨询” 通过下面的接口可以不断查询 我们的目标是当有留言了 就自动把这个订单抢下来
http://sys.12348.gov.cn/resources/api/message_order/query_order
可以利用功能2的会话 同时多个账号进行抢单 需要维护一下每个会话抢到的单数
当前这个系统 每次每个账号只能抢单一次 当抢到单后就必须处理 处理完毕后才能继续抢单
抢单模块要能设置频率 默认间隔500ms 长期驻留进行抢单
4.订单处理
普通账号登录后 如果有订单 就需要展示当前待处理的订单
当抢到订单后 sys.12348.gov.cn的“当前服务”就可以进行解答 我们需要自己做这个页面 首先研究下这个表单的工作逻辑
然后通过api把数据传给服务器 这样我们就能统计每个账号处理过的订单
处理过的订单要记录下来
5.管理功能
给管理员做一个dashboard 展示 会话状态 每个会话抢到的单数 每个账号的处理单数
为了避免对方服务器拉黑我们 看看有没有伪装的方法可以用
使用python做后台 react做前台 数据库用sqlite就行 先在本机测试 后面要能打包Docker部署到ubuntu 26.04上
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论