aps更新
This commit is contained in:
@@ -10,7 +10,7 @@ ALIYUN_APS_ORDER_START_DATE=2023-01-01
|
||||
ALIYUN_APS_BILL_START_MONTH=2023-01
|
||||
|
||||
|
||||
#如果需要从某月继续就配置如下
|
||||
#如果需要从某月继续就配置如<EFBFBD>?
|
||||
#ALIYUN_APS_RESUME_BILL_MONTH=2026-04
|
||||
#ALIYUN_APS_RESUME_BILL_PAGE=1790
|
||||
#如果不需要就
|
||||
@@ -23,18 +23,30 @@ ALIYUN_APS_SMTP_PORT=465
|
||||
ALIYUN_APS_SMTP_SECURE=true
|
||||
ALIYUN_APS_SMTP_USER=wang1416431931@163.com
|
||||
ALIYUN_APS_SMTP_PASS=VXS4FCeckMbtYz7r
|
||||
ALIYUN_APS_NOTIFY_EMAIL=1416431931@qq.com
|
||||
|
||||
# 浏览器关闭策略: true=执行完关闭(默认), false=保持浏览器不关闭
|
||||
ALIYUN_APS_NOTIFY_EMAIL=
|
||||
# 浏览器关闭策<E997AD>? true=执行完关<E5AE8C>?默认), false=保持浏览器不关闭
|
||||
ALIYUN_APS_CLOSE_BROWSER=true
|
||||
|
||||
# 全量同步: true=从起始日期遍历所有月份(默认), false=增量(订单查前一天,账单查当月)
|
||||
|
||||
# 全量同步: true=从起始日期遍历所有月<E69C89>?默认), false=增量(订单查前一<E5898D>?账单查当<E69FA5>?
|
||||
ALIYUN_APS_FULL_SYNC=true
|
||||
|
||||
ALIYUN_APS_DB_HOST=172.27.137.236
|
||||
ALIYUN_APS_DB_HOST=rm-2vcm693187i5p3z66ao.mysql.cn-chengdu.rds.aliyuncs.com
|
||||
ALIYUN_APS_DB_PORT=3306
|
||||
ALIYUN_APS_DB_USER=ray
|
||||
ALIYUN_APS_DB_PASSWORD=GV0C$ErephgQO7RQc7b6
|
||||
ALIYUN_APS_DB_NAME=crm-prod
|
||||
ALIYUN_APS_DB_USER=root
|
||||
ALIYUN_APS_DB_PASSWORD=Cdcc833!!!
|
||||
ALIYUN_APS_DB_NAME=goonseek-dev
|
||||
ALIYUN_APS_DB_CHARSET=utf8mb4
|
||||
ALIYUN_APS_DB_CONNECTION_LIMIT=5
|
||||
|
||||
ALIYUN_APS_SOURCE_ID=test
|
||||
ALIYUN_APS_HOT_MESSAGE_OVERLAP_MINUTES=15
|
||||
ALIYUN_APS_HOT_ORDER_STABLE_THRESHOLD=100
|
||||
ALIYUN_APS_HOT_ORDER_STABLE_PAGE_THRESHOLD=2
|
||||
ALIYUN_APS_HOT_ORDER_MAX_PAGES=20
|
||||
ALIYUN_APS_HOT_MESSAGE_MAX_PAGES=10
|
||||
ALIYUN_APS_HOT_ORDER_DETAIL_REFRESH_MINUTES=30
|
||||
ALIYUN_APS_HOT_FINAL_STATUSES=已完<EFBFBD>?已关<E5B7B2>?已取<E5B7B2>?已退款完<E6ACBE>?
|
||||
|
||||
ALIYUN_APS_SCHEDULE_MODE=hot
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e4]:
|
||||
- row "阿里云logo 中国站 " [ref=e6]:
|
||||
- gridcell "阿里云logo" [ref=e7]:
|
||||
- link "阿里云logo" [ref=e8] [cursor=pointer]:
|
||||
- /url: //www.aliyun.com
|
||||
- img "阿里云logo" [ref=e9]
|
||||
- gridcell "中国站 " [ref=e11]:
|
||||
- generic [ref=e12] [cursor=pointer]:
|
||||
- generic [ref=e13]: 中国站
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]: 全球生成式 AI “领导者”
|
||||
- generic [ref=e20]: 中国 AI 云市场份额 超过 2-4 名总和
|
||||
- generic [ref=e23]:
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]: 登录
|
||||
- generic [ref=e28]: 阿里云APP/支付宝/钉钉
|
||||
- img "二维码" [ref=e31]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]: 其他方式
|
||||
- generic [ref=e36]:
|
||||
- generic [ref=e38] [cursor=pointer]:
|
||||
- generic [ref=e40] [cursor=pointer]:
|
||||
- generic [ref=e42] [cursor=pointer]:
|
||||
- generic [ref=e44] [cursor=pointer]:
|
||||
- generic [ref=e46] [cursor=pointer]:
|
||||
- generic [ref=e47]:
|
||||
- generic [ref=e49]:
|
||||
- generic [ref=e50]:
|
||||
- generic [ref=e52] [cursor=pointer]: 账密登录
|
||||
- generic [ref=e54] [cursor=pointer]: 手机号登录
|
||||
- generic [ref=e56] [cursor=pointer]: 通行密钥
|
||||
- iframe [ref=e61]:
|
||||
- generic [ref=f1e6]:
|
||||
- generic [ref=f1e7]:
|
||||
- generic [ref=f1e10]: 账号名
|
||||
- textbox "请输入" [ref=f1e12]
|
||||
- generic [ref=f1e13]:
|
||||
- generic [ref=f1e16]: 密码
|
||||
- textbox "请输入" [ref=f1e18]
|
||||
- text:
|
||||
- button "立即登录" [ref=f1e22] [cursor=pointer]
|
||||
- generic [ref=e62] [cursor=pointer]:
|
||||
- text: 前往注册
|
||||
- img [ref=e63]
|
||||
- generic [ref=e65]:
|
||||
- generic [ref=e66]:
|
||||
- generic [ref=e67] [cursor=pointer]:
|
||||
- text: 忘记登录名
|
||||
- img [ref=e68]
|
||||
- generic [ref=e70] [cursor=pointer]:
|
||||
- text: 忘记密码
|
||||
- img [ref=e71]
|
||||
- generic [ref=e74] [cursor=pointer]:
|
||||
- text: RAM登录
|
||||
- img [ref=e75]
|
||||
- contentinfo "PC 端页尾" [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]:
|
||||
- link "法律声明" [ref=e83] [cursor=pointer]:
|
||||
- /url: https://help.aliyun.com/product/67275.html
|
||||
- link "Cookies政策" [ref=e84] [cursor=pointer]:
|
||||
- /url: https://terms.alicdn.com/legal-agreement/terms/platform_service/20220906101446934/20220906101446934.html
|
||||
- link "廉正举报" [ref=e85] [cursor=pointer]:
|
||||
- /url: https://aliyun.jubao.alibaba.com
|
||||
- link "安全举报" [ref=e86] [cursor=pointer]:
|
||||
- /url: https://report.aliyun.com
|
||||
- link "联系我们" [ref=e87] [cursor=pointer]:
|
||||
- /url: https://www.aliyun.com/contact
|
||||
- link "加入我们" [ref=e88] [cursor=pointer]:
|
||||
- /url: https://careers.aliyun.com/
|
||||
- generic [ref=e89]:
|
||||
- link "阿里巴巴集团" [ref=e90] [cursor=pointer]:
|
||||
- /url: https://www.alibabagroup.com/cn/global/home
|
||||
- link "淘宝网" [ref=e91] [cursor=pointer]:
|
||||
- /url: https://www.taobao.com/
|
||||
- link "天猫" [ref=e92] [cursor=pointer]:
|
||||
- /url: https://www.tmall.com/
|
||||
- link "全球速卖通" [ref=e93] [cursor=pointer]:
|
||||
- /url: https://www.aliexpress.com/
|
||||
- link "阿里巴巴国际交易市场" [ref=e94] [cursor=pointer]:
|
||||
- /url: https://www.alibaba.com/
|
||||
- link "1688" [ref=e95] [cursor=pointer]:
|
||||
- /url: https://www.1688.com/
|
||||
- link "阿里妈妈" [ref=e96] [cursor=pointer]:
|
||||
- /url: https://www.alimama.com/index.htm
|
||||
- link "飞猪" [ref=e97] [cursor=pointer]:
|
||||
- /url: https://www.fliggy.com/
|
||||
- link "阿里云计算" [ref=e98] [cursor=pointer]:
|
||||
- /url: https://www.aliyun.com/
|
||||
- link "万网" [ref=e99] [cursor=pointer]:
|
||||
- /url: https://wanwang.aliyun.com/
|
||||
- link "高德" [ref=e100] [cursor=pointer]:
|
||||
- /url: https://mobile.amap.com/
|
||||
- link "UC" [ref=e101] [cursor=pointer]:
|
||||
- /url: https://www.uc.cn/
|
||||
- link "友盟" [ref=e102] [cursor=pointer]:
|
||||
- /url: https://www.umeng.com/
|
||||
- link "优酷" [ref=e103] [cursor=pointer]:
|
||||
- /url: https://www.youku.com/
|
||||
- link "钉钉" [ref=e104] [cursor=pointer]:
|
||||
- /url: https://www.dingtalk.com/
|
||||
- link "支付宝" [ref=e105] [cursor=pointer]:
|
||||
- /url: https://www.alipay.com/
|
||||
- link "达摩院" [ref=e106] [cursor=pointer]:
|
||||
- /url: https://damo.alibaba.com/
|
||||
- link "淘宝海外" [ref=e107] [cursor=pointer]:
|
||||
- /url: "https://world.taobao.com/ "
|
||||
- link "阿里云盘" [ref=e108] [cursor=pointer]:
|
||||
- /url: "https://www.aliyundrive.com/ "
|
||||
- link "淘宝闪购" [ref=e109] [cursor=pointer]:
|
||||
- /url: https://www.ele.me/
|
||||
- paragraph [ref=e110]:
|
||||
- text: © 2009-现在 Aliyun.com 版权所有 增值电信业务经营许可证:
|
||||
- link "浙B2-20080101" [ref=e111] [cursor=pointer]:
|
||||
- /url: http://beian.miit.gov.cn/
|
||||
- text: 域名注册服务机构许可:
|
||||
- link "浙D3-20210002" [ref=e112] [cursor=pointer]:
|
||||
- /url: "https://domain.miit.gov.cn/域名注册服务机构/互联网域名/阿里云计算有限公司 "
|
||||
- paragraph [ref=e113]:
|
||||
- link [ref=e114] [cursor=pointer]:
|
||||
- /url: https://zzlz.gsxt.gov.cn/businessCheck/verifKey.do?showType=p&serial=91330106673959654P-SAIC_SHOW_10000091330106673959654P1710919400712&signData=MEUCIQDEkCd8cK7/yqe6BNMWvoMPtAnsgKa7FZetfPkjZMsvhAIgOX1G9YC6FKyndE7o7hL0KaBVn4f+V/iof3iAgpsV09o=
|
||||
- link "浙公网安备 33010602009975号 浙公网安备 33010602009975号" [ref=e115] [cursor=pointer]:
|
||||
- /url: http://www.beian.gov.cn/portal/registerSystemInfo
|
||||
- img "浙公网安备 33010602009975号" [ref=e116]
|
||||
- text: 浙公网安备 33010602009975号
|
||||
- link "浙B2-20080101-4" [ref=e117] [cursor=pointer]:
|
||||
- /url: https://beian.miit.gov.cn/
|
||||
@@ -0,0 +1,104 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e4]:
|
||||
- row "阿里云logo 中国站 " [ref=e6]:
|
||||
- gridcell "阿里云logo" [ref=e7]:
|
||||
- link "阿里云logo" [ref=e8] [cursor=pointer]:
|
||||
- /url: //www.aliyun.com
|
||||
- img "阿里云logo" [ref=e9]
|
||||
- gridcell "中国站 " [ref=e11]:
|
||||
- generic [ref=e12] [cursor=pointer]:
|
||||
- generic [ref=e13]: 中国站
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]: 全球生成式 AI “领导者”
|
||||
- generic [ref=e20]: 中国 AI 云市场份额 超过 2-4 名总和
|
||||
- generic [ref=e23]:
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]: 登录
|
||||
- generic [ref=e28]: 阿里云APP/支付宝/钉钉
|
||||
- img "二维码" [ref=e31]
|
||||
- generic [ref=e33]:
|
||||
- generic [ref=e34]: 其他方式
|
||||
- generic [ref=e36]:
|
||||
- generic [ref=e38] [cursor=pointer]:
|
||||
- generic [ref=e40] [cursor=pointer]:
|
||||
- generic [ref=e42] [cursor=pointer]:
|
||||
- generic [ref=e44] [cursor=pointer]:
|
||||
- generic [ref=e46] [cursor=pointer]:
|
||||
- generic [ref=e47]:
|
||||
- generic [ref=e49]:
|
||||
- generic [ref=e50]:
|
||||
- generic [ref=e52] [cursor=pointer]: 账密登录
|
||||
- generic [ref=e54] [cursor=pointer]: 手机号登录
|
||||
- generic [ref=e56] [cursor=pointer]: 通行密钥
|
||||
- iframe [ref=e61]:
|
||||
- generic [ref=f1e6]:
|
||||
- generic [ref=f1e7]:
|
||||
- generic [ref=f1e10]: 账号名
|
||||
- textbox "请输入" [ref=f1e12]
|
||||
- generic [ref=f1e13]:
|
||||
- generic [ref=f1e16]: 密码
|
||||
- textbox "请输入" [ref=f1e18]
|
||||
- text:
|
||||
- button "立即登录" [ref=f1e22] [cursor=pointer]
|
||||
- generic [ref=e62] [cursor=pointer]:
|
||||
- text: 前往注册
|
||||
- img [ref=e63]
|
||||
- generic [ref=e65]:
|
||||
- generic [ref=e66]:
|
||||
- generic [ref=e67] [cursor=pointer]:
|
||||
- text: 忘记登录名
|
||||
- img [ref=e68]
|
||||
- generic [ref=e70] [cursor=pointer]:
|
||||
- text: 忘记密码
|
||||
- img [ref=e71]
|
||||
- generic [ref=e74] [cursor=pointer]:
|
||||
- text: RAM登录
|
||||
- img [ref=e75]
|
||||
- contentinfo "移动端页尾" [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e84]:
|
||||
- generic [ref=e85]: 关注我们:
|
||||
- link "新浪微博" [ref=e86] [cursor=pointer]:
|
||||
- /url: https://weibo.com/u/1644971875
|
||||
- text: 新浪微博
|
||||
- link "联系我们" [ref=e88] [cursor=pointer]:
|
||||
- /url: https://www.aliyun.com/contact
|
||||
- generic [ref=e89]:
|
||||
- link "文档" [ref=e90] [cursor=pointer]:
|
||||
- /url: https://help.aliyun.com/
|
||||
- text: "|"
|
||||
- link "开发者社区" [ref=e91] [cursor=pointer]:
|
||||
- /url: https://developer.aliyun.com/
|
||||
- text: "|"
|
||||
- link "天池大赛" [ref=e92] [cursor=pointer]:
|
||||
- /url: https://tianchi.aliyun.com/
|
||||
- text: "|"
|
||||
- link "培训与认证" [ref=e93] [cursor=pointer]:
|
||||
- /url: https://edu.aliyun.com/
|
||||
- img "阿里云" [ref=e94]
|
||||
- generic [ref=e95]:
|
||||
- link "法律声明及隐私权政策" [ref=e96] [cursor=pointer]:
|
||||
- /url: http://terms.aliyun.com/legal-agreement/terms/suit_bu1_ali_cloud/suit_bu1_ali_cloud201902141711_54837.html
|
||||
- text: "|"
|
||||
- link "Cookies政策" [ref=e97] [cursor=pointer]:
|
||||
- /url: https://terms.alicdn.com/legal-agreement/terms/platform_service/20220906101446934/20220906101446934.html
|
||||
- generic [ref=e98]:
|
||||
- text: © 2009-现在 Aliyun.com 版权所有
|
||||
- text: 增值电信业务经营许可证:
|
||||
- link "浙B2-20080101" [ref=e99] [cursor=pointer]:
|
||||
- /url: http://beian.miit.gov.cn/
|
||||
- text: 域名注册服务机构许可:
|
||||
- link "浙D3-20210002" [ref=e100] [cursor=pointer]:
|
||||
- /url: "https://domain.miit.gov.cn/域名注册服务机构/互联网域名/阿里云计算有限公司 "
|
||||
- generic [ref=e101]:
|
||||
- link [ref=e102] [cursor=pointer]:
|
||||
- /url: https://zzlz.gsxt.gov.cn/businessCheck/verifKey.do?showType=p&serial=91330106673959654P-SAIC_SHOW_10000091330106673959654P1710919400712&signData=MEUCIQDEkCd8cK7/yqe6BNMWvoMPtAnsgKa7FZetfPkjZMsvhAIgOX1G9YC6FKyndE7o7hL0KaBVn4f+V/iof3iAgpsV09o=
|
||||
- img [ref=e103]
|
||||
- link "浙公网安备 33010602009975号" [ref=e104] [cursor=pointer]:
|
||||
- /url: http://www.beian.gov.cn/portal/registerSystemInfo
|
||||
- img [ref=e105]
|
||||
- text: 浙公网安备 33010602009975号
|
||||
- link "浙B2-20080101-4" [ref=e106] [cursor=pointer]:
|
||||
- /url: https://beian.miit.gov.cn/
|
||||
@@ -92,6 +92,32 @@ npm run login
|
||||
- `.browser/`
|
||||
- `.browser/storage-state.json`
|
||||
|
||||
## 可视化控制台
|
||||
|
||||
启动本地管理页面:
|
||||
|
||||
```bash
|
||||
npm run console
|
||||
```
|
||||
|
||||
默认打开地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3210
|
||||
```
|
||||
|
||||
能力:
|
||||
|
||||
- 可视化编辑 `.env`
|
||||
- 一键启动 login / sync / incremental / hot / bills / orders / messages / schedule
|
||||
- 查看最近运行历史和输出日志
|
||||
|
||||
如需修改端口,可设置:
|
||||
|
||||
```env
|
||||
ALIYUN_APS_CONSOLE_PORT=3210
|
||||
```
|
||||
|
||||
## 账单
|
||||
|
||||
### 单独抓账单
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"console": "node src/console-server.js",
|
||||
"login": "node src/index.js login",
|
||||
"sync": "node src/index.js sync",
|
||||
"incremental": "node src/index.js incremental",
|
||||
|
||||
@@ -236,8 +236,8 @@ export const datasets = {
|
||||
uniqueKey: (record) => record.msgId || record.__hash,
|
||||
normalize: (record) => ({
|
||||
msgId: pickFirst(record, ['消息ID', 'msg_id', '消息id', 'ID', 'id']),
|
||||
title: pickFirst(record, ['消息标题', '标题', 'title']),
|
||||
content: pickFirst(record, ['消息内容', '内容', 'content']),
|
||||
title: pickFirst(record, ['消息标题', '标题', 'title', 'detailTitle', 'column_1']),
|
||||
content: pickFirst(record, ['消息内容', '内容', 'content', 'detailContent']),
|
||||
msgType: pickFirst(record, ['消息类型', 'type', 'msg_type']),
|
||||
fromApp: pickFirst(record, ['来源应用', 'from_app', '应用']),
|
||||
bizCode: pickFirst(record, ['业务编码', 'biz_code']),
|
||||
@@ -247,12 +247,23 @@ export const datasets = {
|
||||
lv1CategoryId: pickFirst(record, ['一级分类ID', 'lv1_category_id']),
|
||||
lv2CategoryId: pickFirst(record, ['二级分类ID', 'lv2_category_id']),
|
||||
lv3CategoryId: pickFirst(record, ['三级分类ID', 'lv3_category_id']),
|
||||
messageClassification: pickFirst(record, ['归类结果', 'message_classification']),
|
||||
customerName: pickFirst(record, ['客户名称', 'customer_name']),
|
||||
orderNo: pickFirst(record, ['订单号', 'order_no']),
|
||||
status: pickFirst(record, ['消息状态', '状态', 'status']),
|
||||
gmtCreated: pickFirst(record, ['消息创建时间', '创建时间', 'gmt_created']),
|
||||
gmtModified: pickFirst(record, ['消息修改时间', '修改时间', 'gmt_modified']),
|
||||
messageClassification: pickFirst(record, ['messageClassification', '归类结果', 'message_classification']) || classifyMessage(record),
|
||||
customerName: pickFirst(record, ['客户名称', 'customer_name', 'customerName', '客户账号']),
|
||||
customerNo: pickFirst(record, ['customerNo', '客户编号', '客户账号', '账号ID', 'UID']),
|
||||
orderNo: pickFirst(record, ['订单号', 'orderNo', 'order_no', 'refundOrderNo', '退款订单号']),
|
||||
status: pickFirst(record, ['消息状态', '状态', 'status']) || '未读',
|
||||
gmtCreated: pickFirst(record, ['消息创建时间', '创建时间', 'gmt_created', '接收时间', 'received_at', 'receivedAt', 'column_2']),
|
||||
gmtModified: pickFirst(record, ['消息修改时间', '修改时间', 'gmt_modified', '接收时间', 'received_at', 'receivedAt', 'column_2']),
|
||||
receivedAt: pickFirst(record, ['接收时间', 'received_at', 'receivedAt']),
|
||||
orderAmount: pickFirst(record, ['订单金额', 'order_amount', 'orderAmount']),
|
||||
customerOrderTime: pickFirst(record, ['客户下单时间', 'customer_order_time', 'customerOrderTime']),
|
||||
refundOrderNo: pickFirst(record, ['退款订单号', 'refundOrderNo']),
|
||||
refundAmount: pickFirst(record, ['退款金额', 'refundAmount']),
|
||||
refundTime: pickFirst(record, ['退款时间', 'refundTime']),
|
||||
invitedRegisterUid: pickFirst(record, ['受邀注册UID', 'invitedRegisterUid']),
|
||||
accountIds: pickFirst(record, ['accountIds', '账号ID列表']),
|
||||
detailTitle: pickFirst(record, ['detailTitle', '详情标题']),
|
||||
detailContent: pickFirst(record, ['detailContent', '详情内容']),
|
||||
extraData: record,
|
||||
}),
|
||||
},
|
||||
@@ -274,3 +285,26 @@ function pickFirst(record, keys) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function classifyMessage(record) {
|
||||
const text = [
|
||||
record?.title,
|
||||
record?.detailTitle,
|
||||
record?.content,
|
||||
record?.detailContent,
|
||||
record?.column_1,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (/退款/.test(text)) return 'refund';
|
||||
if (/释放预警/.test(text) || /预计于【.*】释放/.test(text)) return 'release_warning';
|
||||
if (/释放通知/.test(text) || /已释放/.test(text)) return 'release_notice';
|
||||
if (/未支付提醒/.test(text) || /未支付/.test(text)) return 'unpaid_reminder';
|
||||
if (/取消通知/.test(text) || /取消了一笔未支付订单/.test(text)) return 'order_cancel';
|
||||
if (/余额-预警通知/.test(text) || /账户现金余额/.test(text)) return 'balance_warning';
|
||||
if (/关联成功/.test(text) || /关联关系已完成建立/.test(text)) return 'association_success';
|
||||
if (/注册成功/.test(text) || /受邀注册UID/.test(text)) return 'registration_success';
|
||||
if (/变更已超期/.test(text) || /变更申请已超期/.test(text)) return 'change_overdue';
|
||||
return 'general';
|
||||
}
|
||||
|
||||
593
aliyun-sync/aliyun-aps-sync/src/console-server.js
Normal file
593
aliyun-sync/aliyun-aps-sync/src/console-server.js
Normal file
@@ -0,0 +1,593 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const envPath = path.join(rootDir, '.env');
|
||||
const staticDir = path.join(rootDir, 'web-console');
|
||||
const scheduleEventFile = path.join(rootDir, 'data', 'runs', 'schedule-events.jsonl');
|
||||
const port = Number.parseInt(process.env.ALIYUN_APS_CONSOLE_PORT || '3210', 10) || 3210;
|
||||
|
||||
const commandCatalog = [
|
||||
{ key: 'login', label: '登录校验', command: 'node', args: ['src/index.js', 'login'], description: '打开浏览器并保存登录态' },
|
||||
{ key: 'sync', label: '全量同步', command: 'node', args: ['src/index.js', 'sync'], description: '执行完整同步流程' },
|
||||
{ key: 'incremental', label: '增量同步', command: 'node', args: ['src/index.js', 'incremental'], description: '按数据库水位增量同步' },
|
||||
{ key: 'hot', label: '高频同步', command: 'node', args: ['src/index.js', 'hot'], description: '只抓当天订单与消息' },
|
||||
{ key: 'bills', label: '账单同步', command: 'node', args: ['src/index.js', 'bills'], description: '只同步账单' },
|
||||
{ key: 'orders', label: '订单同步', command: 'node', args: ['src/index.js', 'orders'], description: '同步订单与订单详情' },
|
||||
{ key: 'messages', label: '消息同步', command: 'node', args: ['src/index.js', 'messages'], description: '同步消息与消息详情' },
|
||||
{ key: 'schedule', label: '定时任务', command: 'node', args: ['src/index.js', 'schedule'], description: '启动定时同步守护' },
|
||||
];
|
||||
|
||||
const envSchema = [
|
||||
{ key: 'ALIYUN_APS_BASE_URL', label: 'APS 基础地址', group: '基础配置', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_SOURCE_ID', label: '数据来源标识', group: '基础配置', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_HEADLESS', label: '无头浏览器', group: '浏览器', type: 'boolean' },
|
||||
{ key: 'ALIYUN_APS_BROWSER_MODE', label: '浏览器模式', group: '浏览器', type: 'select', options: ['launch', 'cdp'] },
|
||||
{ key: 'ALIYUN_APS_BROWSER_CHANNEL', label: '浏览器渠道', group: '浏览器', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_BROWSER_EXECUTABLE_PATH', label: '浏览器可执行文件', group: '浏览器', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_CDP_URL', label: 'CDP 调试地址', group: '浏览器', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_CLOSE_BROWSER', label: '运行后关闭浏览器', group: '浏览器', type: 'boolean' },
|
||||
{ key: 'ALIYUN_APS_TIMEZONE', label: '时区', group: '调度', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_CRON', label: '普通任务 Cron', group: '调度', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_HOT_CRON', label: '高频任务 Cron', group: '调度', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_SCHEDULE_MODE', label: '定时模式', group: '调度', type: 'select', options: ['incremental', 'full', 'hot'] },
|
||||
{ key: 'ALIYUN_APS_FULL_SYNC', label: '默认全量模式', group: '调度', type: 'boolean' },
|
||||
{ key: 'ALIYUN_APS_ORDER_START_DATE', label: '订单起始日期', group: '同步范围', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_INCREMENTAL_ORDER_START_DATE', label: '增量订单起始日期', group: '同步范围', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_BILL_START_MONTH', label: '账单起始月份', group: '同步范围', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_ORDER_INCREMENTAL_OVERLAP_DAYS', label: '订单增量回退天数', group: '窗口配置', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_BILL_INCREMENTAL_OVERLAP_DAYS', label: '账单增量回退天数', group: '窗口配置', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_MESSAGE_INCREMENTAL_OVERLAP_DAYS', label: '消息增量回退天数', group: '窗口配置', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_HOT_MESSAGE_OVERLAP_MINUTES', label: '消息高频回退分钟', group: '窗口配置', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_HOT_ORDER_STABLE_THRESHOLD', label: '订单稳定行阈值', group: '高频优化', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_HOT_ORDER_STABLE_PAGE_THRESHOLD', label: '订单稳定页阈值', group: '高频优化', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_HOT_ORDER_MAX_PAGES', label: '订单最大页数', group: '高频优化', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_HOT_MESSAGE_MAX_PAGES', label: '消息最大页数', group: '高频优化', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_HOT_ORDER_DETAIL_REFRESH_MINUTES', label: '详情刷新分钟', group: '高频优化', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_HOT_FINAL_STATUSES', label: '终态状态列表', group: '高频优化', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_DB_HOST', label: 'MySQL 主机', group: '数据库', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_DB_PORT', label: 'MySQL 端口', group: '数据库', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_DB_USER', label: 'MySQL 用户', group: '数据库', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_DB_PASSWORD', label: 'MySQL 密码', group: '数据库', type: 'password' },
|
||||
{ key: 'ALIYUN_APS_DB_NAME', label: 'MySQL 库名', group: '数据库', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_DB_CHARSET', label: 'MySQL 字符集', group: '数据库', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_DB_CONNECTION_LIMIT', label: '连接池上限', group: '数据库', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_DB_CONNECT_TIMEOUT', label: '连接超时(ms)', group: '数据库', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_SMTP_HOST', label: 'SMTP 主机', group: '通知', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_SMTP_PORT', label: 'SMTP 端口', group: '通知', type: 'number' },
|
||||
{ key: 'ALIYUN_APS_SMTP_SECURE', label: 'SMTP 安全连接', group: '通知', type: 'boolean' },
|
||||
{ key: 'ALIYUN_APS_SMTP_USER', label: 'SMTP 用户', group: '通知', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_SMTP_PASS', label: 'SMTP 密码', group: '通知', type: 'password' },
|
||||
{ key: 'ALIYUN_APS_NOTIFY_EMAIL', label: '通知邮箱', group: '通知', type: 'text' },
|
||||
{ key: 'ALIYUN_APS_CONSOLE_PORT', label: '控制台端口', group: '控制台', type: 'number' },
|
||||
];
|
||||
|
||||
let activeRun = null;
|
||||
let runCounter = 0;
|
||||
const runHistory = [];
|
||||
const sseClients = new Set();
|
||||
|
||||
function parseEnvFile(raw) {
|
||||
const values = {};
|
||||
const lines = raw.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const index = line.indexOf('=');
|
||||
if (index < 0) continue;
|
||||
const key = line.slice(0, index).trim();
|
||||
const value = line.slice(index + 1);
|
||||
values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function readEnvState() {
|
||||
const raw = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
|
||||
return {
|
||||
raw,
|
||||
values: parseEnvFile(raw),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeEnv(values, existingRaw) {
|
||||
const existingLines = existingRaw.split(/\r?\n/);
|
||||
const seen = new Set();
|
||||
const updated = existingLines.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || !line.includes('=')) {
|
||||
return line;
|
||||
}
|
||||
const index = line.indexOf('=');
|
||||
const key = line.slice(0, index).trim();
|
||||
if (!(key in values)) {
|
||||
return line;
|
||||
}
|
||||
seen.add(key);
|
||||
return `${key}=${values[key] ?? ''}`;
|
||||
});
|
||||
|
||||
for (const field of envSchema) {
|
||||
if (seen.has(field.key)) continue;
|
||||
if (!(field.key in values)) continue;
|
||||
updated.push(`${field.key}=${values[field.key] ?? ''}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (seen.has(key)) continue;
|
||||
if (envSchema.some((field) => field.key === key)) continue;
|
||||
updated.push(`${key}=${value ?? ''}`);
|
||||
}
|
||||
|
||||
return `${updated.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function sendText(res, statusCode, text, contentType = 'text/plain; charset=utf-8') {
|
||||
res.writeHead(statusCode, { 'Content-Type': contentType });
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function readRequestBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
if (data.length > 2_000_000) {
|
||||
reject(new Error('payload_too_large'));
|
||||
}
|
||||
});
|
||||
req.on('end', () => resolve(data));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function getCommandArgs(commandKey, options = {}) {
|
||||
const command = commandCatalog.find((item) => item.key === commandKey);
|
||||
if (!command) {
|
||||
throw new Error(`unsupported_command:${commandKey}`);
|
||||
}
|
||||
const args = [...command.args];
|
||||
if (options.resume) args.push('--resume');
|
||||
if (options.incremental) args.push('--incremental');
|
||||
if (options.incrementalOrderStartDate) args.push(`--incremental-order-start-date=${options.incrementalOrderStartDate}`);
|
||||
return { command: command.command, args, label: command.label };
|
||||
}
|
||||
|
||||
function snapshotRun(run) {
|
||||
return {
|
||||
id: run.id,
|
||||
commandKey: run.commandKey,
|
||||
label: run.label,
|
||||
startedAt: run.startedAt,
|
||||
finishedAt: run.finishedAt,
|
||||
status: run.status,
|
||||
options: run.options,
|
||||
output: run.output,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastRunState() {
|
||||
const payload = JSON.stringify(getRunState());
|
||||
for (const client of sseClients) {
|
||||
client.write(`event: runState\n`);
|
||||
client.write(`data: ${payload}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function startCommand(commandKey, options = {}) {
|
||||
if (activeRun && activeRun.status === 'running') {
|
||||
throw new Error('command_already_running');
|
||||
}
|
||||
|
||||
const commandInfo = getCommandArgs(commandKey, options);
|
||||
const envState = readEnvState();
|
||||
runCounter += 1;
|
||||
const run = {
|
||||
id: `run_${Date.now()}_${runCounter}`,
|
||||
commandKey,
|
||||
label: commandInfo.label,
|
||||
options,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
status: 'running',
|
||||
output: [`[system] starting ${commandInfo.command} ${commandInfo.args.join(' ')}`],
|
||||
};
|
||||
|
||||
const child = spawn(commandInfo.command, commandInfo.args, {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...envState.values,
|
||||
},
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
run.child = child;
|
||||
activeRun = run;
|
||||
runHistory.unshift(run);
|
||||
runHistory.splice(10);
|
||||
|
||||
const append = (prefix, chunk) => {
|
||||
const text = String(chunk || '').trimEnd();
|
||||
if (!text) return;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
run.output.push(prefix ? `${prefix}${line}` : line);
|
||||
}
|
||||
if (run.output.length > 4000) {
|
||||
run.output = run.output.slice(-4000);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', (chunk) => append('', chunk));
|
||||
child.stderr.on('data', (chunk) => append('[stderr] ', chunk));
|
||||
child.stdout.on('data', () => broadcastRunState());
|
||||
child.stderr.on('data', () => broadcastRunState());
|
||||
child.on('close', (code, signal) => {
|
||||
run.finishedAt = new Date().toISOString();
|
||||
run.status = code === 0 ? 'completed' : signal ? 'stopped' : 'failed';
|
||||
run.output.push(`[system] finished code=${code ?? 'null'} signal=${signal ?? 'null'}`);
|
||||
delete run.child;
|
||||
if (activeRun?.id === run.id) {
|
||||
activeRun = run;
|
||||
}
|
||||
broadcastRunState();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
run.finishedAt = new Date().toISOString();
|
||||
run.status = 'failed';
|
||||
run.output.push(`[system] spawn error: ${error.message}`);
|
||||
delete run.child;
|
||||
if (activeRun?.id === run.id) {
|
||||
activeRun = run;
|
||||
}
|
||||
broadcastRunState();
|
||||
});
|
||||
|
||||
broadcastRunState();
|
||||
return snapshotRun(run);
|
||||
}
|
||||
|
||||
function stopActiveRun() {
|
||||
if (!activeRun || activeRun.status !== 'running' || !activeRun.child) {
|
||||
return false;
|
||||
}
|
||||
activeRun.child.kill('SIGTERM');
|
||||
activeRun.output.push('[system] stop requested from console');
|
||||
return true;
|
||||
}
|
||||
|
||||
function getRunState() {
|
||||
return {
|
||||
activeRun: activeRun ? snapshotRun(activeRun) : null,
|
||||
history: runHistory.map(snapshotRun),
|
||||
};
|
||||
}
|
||||
|
||||
function loadRecentRunSummaries(limit = 6) {
|
||||
const runsDir = path.join(rootDir, 'data', 'runs');
|
||||
if (!fs.existsSync(runsDir)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readdirSync(runsDir)
|
||||
.filter((fileName) => fileName.endsWith('.json'))
|
||||
.map((fileName) => {
|
||||
const filePath = path.join(runsDir, fileName);
|
||||
const stat = fs.statSync(filePath);
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
return { fileName, filePath, mtimeMs: stat.mtimeMs, payload };
|
||||
})
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.slice(0, limit)
|
||||
.map(({ payload }) => payload);
|
||||
}
|
||||
|
||||
function buildSyncSummaryCards() {
|
||||
const summaries = loadRecentRunSummaries(8);
|
||||
const latest = summaries[0] || null;
|
||||
const datasetTotals = latest?.datasets
|
||||
? Object.entries(latest.datasets).map(([key, value]) => ({
|
||||
key,
|
||||
total: value?.stats?.total ?? 0,
|
||||
added: value?.stats?.added ?? 0,
|
||||
updated: value?.stats?.updated ?? 0,
|
||||
removed: value?.stats?.removed ?? 0,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
latestStartedAt: latest?.startedAt || '',
|
||||
latestFinishedAt: latest?.finishedAt || '',
|
||||
datasetTotals,
|
||||
runCount: summaries.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSchedulerMeta() {
|
||||
const envState = readEnvState();
|
||||
const values = envState.values;
|
||||
const mode = values.ALIYUN_APS_SCHEDULE_MODE || 'incremental';
|
||||
const billsCron = values.ALIYUN_APS_CRON || '0 6 * * *';
|
||||
const hotCron = values.ALIYUN_APS_HOT_CRON || '*/5 * * * *';
|
||||
const running = Boolean(activeRun && activeRun.status === 'running' && activeRun.commandKey === 'schedule');
|
||||
let recentEvents = [];
|
||||
if (fs.existsSync(scheduleEventFile)) {
|
||||
const lines = fs.readFileSync(scheduleEventFile, 'utf8').split(/\r?\n/).filter(Boolean).slice(-12).reverse();
|
||||
recentEvents = lines.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
billsCron,
|
||||
hotCron,
|
||||
timezone: values.ALIYUN_APS_TIMEZONE || 'Asia/Shanghai',
|
||||
browserClose: values.ALIYUN_APS_CLOSE_BROWSER || 'true',
|
||||
running,
|
||||
activeRunLabel: running ? activeRun.label : '',
|
||||
strategy: mode === 'hot'
|
||||
? '每日 6 点账单增量 + 每 5 分钟高频订单/消息,同享调度锁且浏览器保持常驻'
|
||||
: '单轨模式:按 scheduleMode 执行 full 或 incremental',
|
||||
recentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
async function testDbConnection() {
|
||||
const envState = readEnvState();
|
||||
const values = envState.values;
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: values.ALIYUN_APS_DB_HOST || '',
|
||||
port: Number.parseInt(values.ALIYUN_APS_DB_PORT || '3306', 10),
|
||||
user: values.ALIYUN_APS_DB_USER || '',
|
||||
password: values.ALIYUN_APS_DB_PASSWORD || '',
|
||||
database: values.ALIYUN_APS_DB_NAME || '',
|
||||
charset: values.ALIYUN_APS_DB_CHARSET || 'utf8mb4',
|
||||
});
|
||||
const [rows] = await connection.query('SELECT NOW() AS now_time');
|
||||
await connection.end();
|
||||
return { ok: true, now: rows[0]?.now_time || null };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: error.code || '',
|
||||
errno: error.errno || null,
|
||||
sqlState: error.sqlState || '',
|
||||
message: error.message,
|
||||
host: values.ALIYUN_APS_DB_HOST || '',
|
||||
database: values.ALIYUN_APS_DB_NAME || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkLoginState() {
|
||||
const statePath = path.join(rootDir, '.browser', 'storage-state.json');
|
||||
if (!fs.existsSync(statePath)) {
|
||||
return { ok: false, reason: 'storage_state_missing' };
|
||||
}
|
||||
const payload = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
const cookies = Array.isArray(payload.cookies) ? payload.cookies.length : 0;
|
||||
return {
|
||||
ok: cookies > 0,
|
||||
cookies,
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
function removeDirectoryContents(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return { cleared: false, path: dirPath };
|
||||
}
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
return { cleared: true, path: dirPath };
|
||||
}
|
||||
|
||||
async function clearMessageData() {
|
||||
const envState = readEnvState();
|
||||
const values = envState.values;
|
||||
const connection = await mysql.createConnection({
|
||||
host: values.ALIYUN_APS_DB_HOST || '',
|
||||
port: Number.parseInt(values.ALIYUN_APS_DB_PORT || '3306', 10),
|
||||
user: values.ALIYUN_APS_DB_USER || '',
|
||||
password: values.ALIYUN_APS_DB_PASSWORD || '',
|
||||
database: values.ALIYUN_APS_DB_NAME || '',
|
||||
charset: values.ALIYUN_APS_DB_CHARSET || 'utf8mb4',
|
||||
});
|
||||
try {
|
||||
const sourceId = values.ALIYUN_APS_SOURCE_ID || 'default';
|
||||
const [childResult] = await connection.execute('DELETE FROM aliyun_aps_message_accounts WHERE source_id = ?', [sourceId]);
|
||||
const [mainResult] = await connection.execute('DELETE FROM aliyun_aps_messages WHERE source_id = ?', [sourceId]);
|
||||
return {
|
||||
ok: true,
|
||||
sourceId,
|
||||
mainDeleted: mainResult.affectedRows ?? 0,
|
||||
childDeleted: childResult.affectedRows ?? 0,
|
||||
};
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessageArtifacts() {
|
||||
const paths = [
|
||||
path.join(rootDir, 'data', 'checkpoints', 'messages'),
|
||||
path.join(rootDir, 'data', 'history', 'messages'),
|
||||
path.join(rootDir, 'data', 'delta', 'messages'),
|
||||
].map(removeDirectoryContents);
|
||||
|
||||
const currentFile = path.join(rootDir, 'data', 'current', 'messages.json');
|
||||
if (fs.existsSync(currentFile)) {
|
||||
fs.rmSync(currentFile, { force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
paths,
|
||||
currentFileRemoved: !fs.existsSync(currentFile),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMessageAnalytics() {
|
||||
const envState = readEnvState();
|
||||
const values = envState.values;
|
||||
const connection = await mysql.createConnection({
|
||||
host: values.ALIYUN_APS_DB_HOST || '',
|
||||
port: Number.parseInt(values.ALIYUN_APS_DB_PORT || '3306', 10),
|
||||
user: values.ALIYUN_APS_DB_USER || '',
|
||||
password: values.ALIYUN_APS_DB_PASSWORD || '',
|
||||
database: values.ALIYUN_APS_DB_NAME || '',
|
||||
charset: values.ALIYUN_APS_DB_CHARSET || 'utf8mb4',
|
||||
});
|
||||
try {
|
||||
const sourceId = values.ALIYUN_APS_SOURCE_ID || 'default';
|
||||
const [classificationRows] = await connection.query(
|
||||
`SELECT COALESCE(message_classification, 'unknown') AS classification, COUNT(*) AS cnt
|
||||
FROM aliyun_aps_messages
|
||||
WHERE source_id = ?
|
||||
GROUP BY COALESCE(message_classification, 'unknown')
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10`,
|
||||
[sourceId],
|
||||
);
|
||||
const [accountRows] = await connection.query(
|
||||
`SELECT account_id, COUNT(*) AS cnt
|
||||
FROM aliyun_aps_message_accounts
|
||||
WHERE source_id = ?
|
||||
GROUP BY account_id
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10`,
|
||||
[sourceId],
|
||||
);
|
||||
return { classifications: classificationRows, topAccounts: accountRows };
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
function getContentType(filePath) {
|
||||
if (filePath.endsWith('.html')) return 'text/html; charset=utf-8';
|
||||
if (filePath.endsWith('.css')) return 'text/css; charset=utf-8';
|
||||
if (filePath.endsWith('.js')) return 'application/javascript; charset=utf-8';
|
||||
if (filePath.endsWith('.json')) return 'application/json; charset=utf-8';
|
||||
return 'text/plain; charset=utf-8';
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
||||
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/api/bootstrap') {
|
||||
const envState = readEnvState();
|
||||
return sendJson(res, 200, {
|
||||
envSchema,
|
||||
envValues: envState.values,
|
||||
commands: commandCatalog,
|
||||
runState: getRunState(),
|
||||
summaryCards: buildSyncSummaryCards(),
|
||||
schedulerMeta: buildSchedulerMeta(),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/env') {
|
||||
const rawBody = await readRequestBody(req);
|
||||
const payload = JSON.parse(rawBody || '{}');
|
||||
const envState = readEnvState();
|
||||
const nextRaw = serializeEnv(payload.values || {}, envState.raw);
|
||||
fs.writeFileSync(envPath, nextRaw, 'utf8');
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/run') {
|
||||
const rawBody = await readRequestBody(req);
|
||||
const payload = JSON.parse(rawBody || '{}');
|
||||
const run = startCommand(payload.commandKey, payload.options || {});
|
||||
return sendJson(res, 200, { ok: true, run });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
||||
return sendJson(res, 200, { ok: stopActiveRun() });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/runs') {
|
||||
return sendJson(res, 200, getRunState());
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/summary') {
|
||||
return sendJson(res, 200, buildSyncSummaryCards());
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/scheduler-meta') {
|
||||
return sendJson(res, 200, buildSchedulerMeta());
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/test-db') {
|
||||
return sendJson(res, 200, await testDbConnection());
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/login-state') {
|
||||
return sendJson(res, 200, checkLoginState());
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/analytics/messages') {
|
||||
return sendJson(res, 200, await buildMessageAnalytics());
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/maintenance/clear-message-checkpoints') {
|
||||
return sendJson(res, 200, clearMessageArtifacts());
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/maintenance/clear-message-data') {
|
||||
return sendJson(res, 200, await clearMessageData());
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/events') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write(`event: runState\n`);
|
||||
res.write(`data: ${JSON.stringify(getRunState())}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on('close', () => {
|
||||
sseClients.delete(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname.startsWith('/assets/'))) {
|
||||
const filePath = url.pathname === '/'
|
||||
? path.join(staticDir, 'index.html')
|
||||
: path.join(staticDir, url.pathname.replace(/^\//, ''));
|
||||
|
||||
if (!filePath.startsWith(staticDir) || !fs.existsSync(filePath)) {
|
||||
return sendText(res, 404, 'Not Found');
|
||||
}
|
||||
return sendText(res, 200, fs.readFileSync(filePath, 'utf8'), getContentType(filePath));
|
||||
}
|
||||
|
||||
return sendText(res, 404, 'Not Found');
|
||||
} catch (error) {
|
||||
return sendJson(res, 500, { ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`APS Console is running at http://127.0.0.1:${port}`);
|
||||
});
|
||||
@@ -22,7 +22,18 @@ CREATE TABLE IF NOT EXISTS aliyun_aps_messages (
|
||||
lv3_category_id varchar(64) NULL DEFAULT NULL COMMENT '三级分类ID',
|
||||
message_classification varchar(255) NULL DEFAULT NULL COMMENT '归类结果',
|
||||
customer_name varchar(255) NULL DEFAULT NULL COMMENT '客户名称',
|
||||
customer_no varchar(128) NULL DEFAULT NULL COMMENT '客户账号/编号/UID',
|
||||
order_no varchar(128) NULL DEFAULT NULL COMMENT '订单号',
|
||||
order_amount varchar(64) NULL DEFAULT NULL COMMENT '订单金额',
|
||||
customer_order_time varchar(64) NULL DEFAULT NULL COMMENT '客户下单时间',
|
||||
refund_order_no varchar(128) NULL DEFAULT NULL COMMENT '退款订单号',
|
||||
refund_amount varchar(64) NULL DEFAULT NULL COMMENT '退款金额',
|
||||
refund_time varchar(64) NULL DEFAULT NULL COMMENT '退款时间',
|
||||
invited_register_uid varchar(128) NULL DEFAULT NULL COMMENT '受邀注册UID',
|
||||
account_ids text NULL COMMENT '消息详情中的账号ID列表',
|
||||
received_at varchar(64) NULL DEFAULT NULL COMMENT '接收时间',
|
||||
detail_title text NULL COMMENT '详情标题',
|
||||
detail_content longtext NULL COMMENT '详情原文',
|
||||
status varchar(32) NULL DEFAULT NULL COMMENT '消息状态(已读/未读)',
|
||||
gmt_created varchar(64) NULL DEFAULT NULL COMMENT '消息创建时间',
|
||||
gmt_modified varchar(64) NULL DEFAULT NULL COMMENT '消息修改时间',
|
||||
@@ -34,6 +45,23 @@ CREATE TABLE IF NOT EXISTS aliyun_aps_messages (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='阿里云APS站内消息'
|
||||
`;
|
||||
|
||||
const MESSAGE_ACCOUNT_TABLE_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS aliyun_aps_message_accounts (
|
||||
id bigint NOT NULL AUTO_INCREMENT,
|
||||
source_id varchar(64) NOT NULL DEFAULT 'default' COMMENT '数据来源账号标识',
|
||||
msg_id varchar(128) NOT NULL COMMENT '消息原始ID',
|
||||
account_id varchar(128) NOT NULL COMMENT '账号ID',
|
||||
title text NULL COMMENT '消息标题',
|
||||
message_classification varchar(255) NULL DEFAULT NULL COMMENT '归类结果',
|
||||
received_at varchar(64) NULL DEFAULT NULL COMMENT '接收时间',
|
||||
crawl_time datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '爬取时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_source_msg_account (source_id, msg_id, account_id),
|
||||
KEY idx_source_account (source_id, account_id),
|
||||
KEY idx_source_msg (source_id, msg_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='阿里云APS消息关联账号明细'
|
||||
`;
|
||||
|
||||
let customerLifecycleEnsured = false;
|
||||
const ensuredSourceTables = new Set();
|
||||
|
||||
@@ -193,6 +221,34 @@ export async function getLatestMessageTimeFromDb() {
|
||||
return queryLatestValue("SELECT MAX(COALESCE(NULLIF(gmt_modified, ''), NULLIF(gmt_created, ''))) AS latest_time FROM aliyun_aps_messages WHERE source_id = ?", [config.sourceId]);
|
||||
}
|
||||
|
||||
export async function getExistingMessageIds(msgIds) {
|
||||
const normalizedIds = Array.from(new Set((msgIds || []).map((item) => safeString(item)).filter(Boolean)));
|
||||
if (normalizedIds.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
await ensureMessagesTable();
|
||||
const placeholders = normalizedIds.map(() => '?').join(',');
|
||||
const [rows] = await getPool().execute(
|
||||
`SELECT msg_id FROM aliyun_aps_messages WHERE source_id = ? AND msg_id IN (${placeholders})`,
|
||||
[config.sourceId, ...normalizedIds],
|
||||
);
|
||||
return new Set((Array.isArray(rows) ? rows : []).map((row) => safeString(row.msg_id)).filter(Boolean));
|
||||
}
|
||||
|
||||
export async function getExistingMessageFingerprints(receivedAtValues) {
|
||||
const receivedAtList = Array.from(new Set((receivedAtValues || []).map((item) => safeString(item)).filter(Boolean)));
|
||||
if (receivedAtList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
await ensureMessagesTable();
|
||||
const placeholders = receivedAtList.map(() => '?').join(',');
|
||||
const [rows] = await getPool().execute(
|
||||
`SELECT title, received_at, order_no FROM aliyun_aps_messages WHERE source_id = ? AND received_at IN (${placeholders})`,
|
||||
[config.sourceId, ...receivedAtList],
|
||||
);
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}
|
||||
|
||||
export async function closeDbPool() {
|
||||
if (!pool) {
|
||||
return;
|
||||
@@ -204,7 +260,58 @@ export async function closeDbPool() {
|
||||
|
||||
export async function ensureMessagesTable() {
|
||||
await getPool().query(MESSAGE_TABLE_DDL);
|
||||
await getPool().query(MESSAGE_ACCOUNT_TABLE_DDL);
|
||||
await ensureSourceColumn('aliyun_aps_messages');
|
||||
await ensureSourceColumn('aliyun_aps_message_accounts');
|
||||
await ensureColumnExists('aliyun_aps_messages', 'customer_no', "ALTER TABLE aliyun_aps_messages ADD COLUMN customer_no VARCHAR(128) NULL DEFAULT NULL COMMENT '客户账号/编号/UID'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'order_amount', "ALTER TABLE aliyun_aps_messages ADD COLUMN order_amount VARCHAR(64) NULL DEFAULT NULL COMMENT '订单金额'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'customer_order_time', "ALTER TABLE aliyun_aps_messages ADD COLUMN customer_order_time VARCHAR(64) NULL DEFAULT NULL COMMENT '客户下单时间'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'refund_order_no', "ALTER TABLE aliyun_aps_messages ADD COLUMN refund_order_no VARCHAR(128) NULL DEFAULT NULL COMMENT '退款订单号'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'refund_amount', "ALTER TABLE aliyun_aps_messages ADD COLUMN refund_amount VARCHAR(64) NULL DEFAULT NULL COMMENT '退款金额'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'refund_time', "ALTER TABLE aliyun_aps_messages ADD COLUMN refund_time VARCHAR(64) NULL DEFAULT NULL COMMENT '退款时间'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'invited_register_uid', "ALTER TABLE aliyun_aps_messages ADD COLUMN invited_register_uid VARCHAR(128) NULL DEFAULT NULL COMMENT '受邀注册UID'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'account_ids', "ALTER TABLE aliyun_aps_messages ADD COLUMN account_ids TEXT NULL COMMENT '消息详情中的账号ID列表'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'received_at', "ALTER TABLE aliyun_aps_messages ADD COLUMN received_at VARCHAR(64) NULL DEFAULT NULL COMMENT '接收时间'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'detail_title', "ALTER TABLE aliyun_aps_messages ADD COLUMN detail_title TEXT NULL COMMENT '详情标题'");
|
||||
await ensureColumnExists('aliyun_aps_messages', 'detail_content', "ALTER TABLE aliyun_aps_messages ADD COLUMN detail_content LONGTEXT NULL COMMENT '详情原文'");
|
||||
}
|
||||
|
||||
async function replaceMessageAccounts(record, msgId) {
|
||||
const accountIds = String(record.accountIds || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
await getPool().execute(
|
||||
'DELETE FROM aliyun_aps_message_accounts WHERE source_id = ? AND msg_id = ?',
|
||||
[config.sourceId, msgId],
|
||||
);
|
||||
|
||||
if (accountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO aliyun_aps_message_accounts (
|
||||
source_id, msg_id, account_id, title, message_classification, received_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title=VALUES(title),
|
||||
message_classification=VALUES(message_classification),
|
||||
received_at=VALUES(received_at),
|
||||
crawl_time=CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
await getPool().execute(sql, [
|
||||
config.sourceId,
|
||||
msgId,
|
||||
accountId,
|
||||
safeString(record.title),
|
||||
safeString(record.messageClassification),
|
||||
safeString(record.receivedAt),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCustomerLifecycleColumns() {
|
||||
@@ -317,7 +424,7 @@ export async function upsertCustomerDetails(records) {
|
||||
return { inserted: 0 };
|
||||
}
|
||||
await ensureCustomerLifecycleColumns();
|
||||
const sql = `
|
||||
const updateSql = `
|
||||
UPDATE aps_customer SET
|
||||
customer_name = ?,
|
||||
customer_type = ?,
|
||||
@@ -329,12 +436,28 @@ export async function upsertCustomerDetails(records) {
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE source_id = ? AND account_id = ?
|
||||
`;
|
||||
const insertSql = `
|
||||
INSERT INTO aps_customer (
|
||||
source_id, account_id, login_name, customer_name, customer_type, customer_source,
|
||||
email, phone, department, payment_notice_status,
|
||||
active, status, released_at, release_reason
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'active', NULL, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
customer_name=VALUES(customer_name),
|
||||
customer_type=VALUES(customer_type),
|
||||
customer_source=VALUES(customer_source),
|
||||
email=VALUES(email),
|
||||
phone=VALUES(phone),
|
||||
department=VALUES(department),
|
||||
payment_notice_status=COALESCE(VALUES(payment_notice_status), payment_notice_status),
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
`;
|
||||
for (const record of records) {
|
||||
const accountId = safeString(record.accountId);
|
||||
if (!accountId) {
|
||||
continue;
|
||||
}
|
||||
await getPool().execute(sql, [
|
||||
const [result] = await getPool().execute(updateSql, [
|
||||
safeString(record.customerName),
|
||||
safeString(record.customerType),
|
||||
safeString(record.customerSource),
|
||||
@@ -345,6 +468,20 @@ export async function upsertCustomerDetails(records) {
|
||||
config.sourceId,
|
||||
accountId,
|
||||
]);
|
||||
if (Number(result?.affectedRows || 0) === 0) {
|
||||
await getPool().execute(insertSql, [
|
||||
config.sourceId,
|
||||
accountId,
|
||||
safeString(record.customerAccount) || accountId,
|
||||
safeString(record.customerName),
|
||||
safeString(record.customerType),
|
||||
safeString(record.customerSource),
|
||||
safeString(record.email),
|
||||
safeString(record.phone),
|
||||
safeString(record.department),
|
||||
safeString(record.paymentNoticeStatus),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return { inserted: records.length };
|
||||
}
|
||||
@@ -630,9 +767,11 @@ export async function upsertMessages(records) {
|
||||
INSERT INTO aliyun_aps_messages (
|
||||
source_id, msg_id, title, content, msg_type, from_app, biz_code, msg_channel,
|
||||
category_id, category_name, lv1_category_id, lv2_category_id, lv3_category_id,
|
||||
message_classification, customer_name, order_no, status,
|
||||
message_classification, customer_name, customer_no, order_no, order_amount, customer_order_time,
|
||||
refund_order_no, refund_amount, refund_time, invited_register_uid, account_ids,
|
||||
received_at, detail_title, detail_content, status,
|
||||
gmt_created, gmt_modified, extra_data
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title=VALUES(title),
|
||||
content=VALUES(content),
|
||||
@@ -647,7 +786,18 @@ export async function upsertMessages(records) {
|
||||
lv3_category_id=VALUES(lv3_category_id),
|
||||
message_classification=VALUES(message_classification),
|
||||
customer_name=VALUES(customer_name),
|
||||
customer_no=VALUES(customer_no),
|
||||
order_no=VALUES(order_no),
|
||||
order_amount=VALUES(order_amount),
|
||||
customer_order_time=VALUES(customer_order_time),
|
||||
refund_order_no=VALUES(refund_order_no),
|
||||
refund_amount=VALUES(refund_amount),
|
||||
refund_time=VALUES(refund_time),
|
||||
invited_register_uid=VALUES(invited_register_uid),
|
||||
account_ids=VALUES(account_ids),
|
||||
received_at=VALUES(received_at),
|
||||
detail_title=VALUES(detail_title),
|
||||
detail_content=VALUES(detail_content),
|
||||
status=VALUES(status),
|
||||
gmt_created=VALUES(gmt_created),
|
||||
gmt_modified=VALUES(gmt_modified),
|
||||
@@ -675,12 +825,24 @@ export async function upsertMessages(records) {
|
||||
safeString(record.lv3CategoryId),
|
||||
safeString(record.messageClassification),
|
||||
safeString(record.customerName),
|
||||
safeString(record.customerNo),
|
||||
safeString(record.orderNo),
|
||||
safeString(record.status),
|
||||
safeString(record.orderAmount),
|
||||
safeString(record.customerOrderTime),
|
||||
safeString(record.refundOrderNo),
|
||||
safeString(record.refundAmount),
|
||||
safeString(record.refundTime),
|
||||
safeString(record.invitedRegisterUid),
|
||||
safeString(record.accountIds),
|
||||
safeString(record.receivedAt),
|
||||
safeString(record.detailTitle),
|
||||
safeString(record.detailContent),
|
||||
safeString(record.status) || '未读',
|
||||
safeString(record.gmtCreated),
|
||||
safeString(record.gmtModified),
|
||||
JSON.stringify(record.extraData || record),
|
||||
]);
|
||||
await replaceMessageAccounts(record, msgId);
|
||||
await applyCustomerLifecycleFromMessage(record);
|
||||
}
|
||||
return { inserted: records.length };
|
||||
|
||||
@@ -5,6 +5,7 @@ const command = args[0] || 'sync';
|
||||
const extraArgs = args.slice(1);
|
||||
const billsResume = extraArgs.includes('--resume');
|
||||
const ordersIncremental = extraArgs.includes('--incremental');
|
||||
const messagesIncremental = extraArgs.includes('--incremental');
|
||||
const messagesResume = extraArgs.includes('--resume');
|
||||
const hotResume = extraArgs.includes('--resume');
|
||||
|
||||
@@ -46,7 +47,7 @@ if (command === 'orders') {
|
||||
}
|
||||
|
||||
if (command === 'messages') {
|
||||
const summary = await syncMessagesOnly({ incremental: config.scheduleMode === 'incremental', resume: messagesResume });
|
||||
const summary = await syncMessagesOnly({ incremental: messagesIncremental, resume: messagesResume });
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { config, datasets } from './config.js';
|
||||
import { sendLoginAlert, sendRuntimeErrorAlert } from './notify.js';
|
||||
import {
|
||||
closeDbPool,
|
||||
getExistingMessageIds,
|
||||
getExistingMessageFingerprints,
|
||||
getLatestBillConsumptionTimeFromDb,
|
||||
getLatestMessageTimeFromDb,
|
||||
getLatestOrderTimeFromDb,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
} from './storage.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const scheduleEventFile = path.join(config.dataDir, 'runs', 'schedule-events.jsonl');
|
||||
|
||||
let _context = null;
|
||||
let _runtimeController = null;
|
||||
@@ -37,6 +40,15 @@ let _browser = null;
|
||||
let _isAttachedBrowser = false;
|
||||
const runningJobs = new Set();
|
||||
|
||||
function recordScheduleEvent(payload) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(scheduleEventFile), { recursive: true });
|
||||
fs.appendFileSync(scheduleEventFile, `${JSON.stringify({ at: new Date().toISOString(), ...payload })}\n`, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn(`[schedule-event] 写入失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_PAGE_KEYWORDS = [
|
||||
'RAM 用户登录',
|
||||
'主账号登录',
|
||||
@@ -318,6 +330,12 @@ function subtractDays(dateValue, days) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function subtractMonths(dateValue, months) {
|
||||
const next = new Date(dateValue);
|
||||
next.setMonth(next.getMonth() - months);
|
||||
return next;
|
||||
}
|
||||
|
||||
function randomIntBetween(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
@@ -505,7 +523,7 @@ async function captureErrorArtifacts(page, metadata = {}) {
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2));
|
||||
|
||||
let screenshotSaved = false;
|
||||
if (page) {
|
||||
if (page && !page.isClosed?.()) {
|
||||
try {
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true, timeout: 5000, animations: 'disabled' });
|
||||
screenshotSaved = true;
|
||||
@@ -679,7 +697,9 @@ export async function syncBillsOnly(options = {}) {
|
||||
await reportRuntimeError(error, page, { label: 'syncBillsOnly', dataset: 'bills', mode: options.incremental ? 'incremental' : 'full' });
|
||||
throw error;
|
||||
} finally {
|
||||
if (config.closeBrowser) {
|
||||
if (options.keepBrowserOpen === true) {
|
||||
console.log('浏览器保持运行(schedule bills)');
|
||||
} else if (config.closeBrowser) {
|
||||
await closeContextIfNeeded();
|
||||
} else {
|
||||
console.log('浏览器保持运行');
|
||||
@@ -755,13 +775,28 @@ export async function syncMessagesOnly(options = {}) {
|
||||
}
|
||||
|
||||
export async function scheduleSync() {
|
||||
console.log(`定时任务已启动: normal=${config.cron}, hot=${config.hotCron} (${config.timezone})`);
|
||||
console.log(`定时任务已启动: bills=${config.cron}, hot=${config.hotCron} (${config.timezone})`);
|
||||
setInterval(() => {
|
||||
console.log(`[${new Date().toISOString()}] 定时守护存活中: bills=${config.cron}, hot=${config.hotCron}, mode=${config.scheduleMode}`);
|
||||
}, 60 * 1000);
|
||||
cron.schedule(
|
||||
config.cron,
|
||||
async () => {
|
||||
if (config.scheduleMode === 'hot') {
|
||||
return;
|
||||
return runLockedJob('schedule-shared', async () => {
|
||||
try {
|
||||
recordScheduleEvent({ track: 'bills', status: 'started', mode: 'bills-incremental' });
|
||||
console.log(`[${new Date().toISOString()}] 开始执行账单定时同步 mode=bills-incremental`);
|
||||
const summary = await syncBillsOnly({ incremental: true, keepBrowserOpen: true });
|
||||
recordScheduleEvent({ track: 'bills', status: 'completed', mode: 'bills-incremental', summary });
|
||||
console.log(`[${new Date().toISOString()}] 账单定时同步完成`, JSON.stringify(summary, null, 2));
|
||||
} catch (error) {
|
||||
recordScheduleEvent({ track: 'bills', status: 'failed', mode: 'bills-incremental', error: error.message });
|
||||
console.error(`[${new Date().toISOString()}] 账单定时同步失败`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[${new Date().toISOString()}] 开始执行同步 mode=${config.scheduleMode}`);
|
||||
const summary = config.scheduleMode === 'full'
|
||||
@@ -781,13 +816,22 @@ export async function scheduleSync() {
|
||||
if (config.scheduleMode !== 'hot') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(`[${new Date().toISOString()}] 开始执行高频同步 mode=hot`);
|
||||
const summary = await syncHot();
|
||||
console.log(`[${new Date().toISOString()}] 高频同步完成`, JSON.stringify(summary, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`[${new Date().toISOString()}] 高频同步失败`, error);
|
||||
}
|
||||
return runLockedJob('schedule-shared', async () => {
|
||||
try {
|
||||
recordScheduleEvent({ track: 'hot', status: 'started', mode: 'hot' });
|
||||
console.log(`[${new Date().toISOString()}] 开始执行高频同步 mode=hot`);
|
||||
const summary = await syncHot({ keepBrowserOpen: true });
|
||||
if (summary?.skipped) {
|
||||
recordScheduleEvent({ track: 'hot', status: 'skipped', mode: 'hot', reason: summary.reason || 'already_running' });
|
||||
} else {
|
||||
recordScheduleEvent({ track: 'hot', status: 'completed', mode: 'hot', summary });
|
||||
}
|
||||
console.log(`[${new Date().toISOString()}] 高频同步完成`, JSON.stringify(summary, null, 2));
|
||||
} catch (error) {
|
||||
recordScheduleEvent({ track: 'hot', status: 'failed', mode: 'hot', error: error.message });
|
||||
console.error(`[${new Date().toISOString()}] 高频同步失败`, error);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ timezone: config.timezone },
|
||||
);
|
||||
@@ -826,7 +870,9 @@ export async function syncHot(options = {}) {
|
||||
await reportRuntimeError(error, page, { label: 'syncHot', dataset: 'hot', mode: 'hot' });
|
||||
throw error;
|
||||
} finally {
|
||||
if (config.closeBrowser) {
|
||||
if (options.keepBrowserOpen === true) {
|
||||
console.log('浏览器保持运行(schedule hot)');
|
||||
} else if (config.closeBrowser) {
|
||||
await closeContextIfNeeded();
|
||||
} else {
|
||||
console.log('浏览器保持运行');
|
||||
@@ -1274,6 +1320,7 @@ async function syncMessages(page, options = {}) {
|
||||
await runtimeCheckpoint('同步消息');
|
||||
const dataset = datasets.messages;
|
||||
const { incremental = false, resume = false, hot = false } = options;
|
||||
const fullSyncWatermark = !incremental && !hot ? subtractMonths(new Date(), 3) : null;
|
||||
await page.goto(dataset.url, { waitUntil: 'domcontentloaded' });
|
||||
await waitUntilReady(page, dataset.heading);
|
||||
await trySetPageSize(page, dataset.pageSize);
|
||||
@@ -1283,17 +1330,36 @@ async function syncMessages(page, options = {}) {
|
||||
let shouldContinueScrape = true;
|
||||
let allNormalizedRecords = Array.isArray(resumeCheckpoint?.records) ? resumeCheckpoint.records : [];
|
||||
|
||||
const shouldStopForFullSyncPage = (pageRows) => {
|
||||
if (!fullSyncWatermark) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPageRows = normalizeDatasetRecords(dataset, pageRows, {});
|
||||
const pageTimeStats = getMessagePageTimeStats(normalizedPageRows);
|
||||
console.log(`[全量模式] 当前页时间范围: parsed=${pageTimeStats.parsed}/${pageTimeStats.total}, earliest=${pageTimeStats.earliest || 'N/A'}, latest=${pageTimeStats.latest || 'N/A'}, watermark=${formatDateTime(fullSyncWatermark)}`);
|
||||
return normalizedPageRows.length > 0
|
||||
&& normalizedPageRows.every((record) => !isAfterLatestMessageTime(record, fullSyncWatermark));
|
||||
};
|
||||
|
||||
if (resumeFromPage > 0) {
|
||||
console.log(`[消息续爬] 从 checkpoint 恢复: page=${resumeFromPage}, records=${allNormalizedRecords.length}`);
|
||||
const moved = await moveMessagesToResumeStart(page, resumeFromPage);
|
||||
if (!moved) {
|
||||
console.log('[消息续爬] checkpoint 已在最后一页,无需继续抓取');
|
||||
shouldContinueScrape = false;
|
||||
} else if (fullSyncWatermark) {
|
||||
await waitForTableRows(page);
|
||||
const resumedPageData = await extractTable(page);
|
||||
if (shouldStopForFullSyncPage(resumedPageData.rows)) {
|
||||
console.log(`[全量模式] 当前续爬页已超出近三个月范围,停止继续抓取: page=${resumeFromPage + 1}, watermark=${formatDateTime(fullSyncWatermark)}`);
|
||||
shouldContinueScrape = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let records = [];
|
||||
let hotWatermark = null;
|
||||
let stopByExistingPage = false;
|
||||
if (hot && hasDbConfig()) {
|
||||
const latestMessageTime = await getLatestMessageTimeFromDb();
|
||||
const latest = parseDbDateTime(latestMessageTime);
|
||||
@@ -1304,11 +1370,39 @@ async function syncMessages(page, options = {}) {
|
||||
if (shouldContinueScrape) {
|
||||
records = await scrapePagedTable(page, dataset, {}, {
|
||||
onPage: async ({ pageNum, pageRows }) => {
|
||||
const normalizedPageRows = normalizeDatasetRecords(dataset, pageRows, {});
|
||||
allNormalizedRecords.push(...normalizedPageRows);
|
||||
if (hasDbConfig()) {
|
||||
await upsertMessages(normalizedPageRows);
|
||||
const detailedPageRows = await enrichMessageRowsWithDetails(page, pageRows, pageNum);
|
||||
const normalizedPageRows = normalizeDatasetRecords(dataset, detailedPageRows, {});
|
||||
const filteredPageRows = fullSyncWatermark
|
||||
? normalizedPageRows.filter((record) => isAfterLatestMessageTime(record, fullSyncWatermark))
|
||||
: normalizedPageRows;
|
||||
let pageRowsToPersist = filteredPageRows;
|
||||
if (hasDbConfig() && filteredPageRows.length > 0) {
|
||||
const pageMsgIds = filteredPageRows.map((record) => record.msgId).filter(Boolean);
|
||||
const existingIds = await getExistingMessageIds(pageMsgIds);
|
||||
const fingerprintCandidates = filteredPageRows
|
||||
.map((record) => String(record.receivedAt || record.gmtModified || record.gmtCreated || '').trim())
|
||||
.filter(Boolean);
|
||||
const existingFingerprintRows = await getExistingMessageFingerprints(fingerprintCandidates);
|
||||
const existingFingerprints = new Set(
|
||||
existingFingerprintRows.map((row) => buildMessageFingerprint({ title: row.title, receivedAt: row.received_at, orderNo: row.order_no })),
|
||||
);
|
||||
stopByExistingPage = filteredPageRows.length > 0 && filteredPageRows.every((record) => {
|
||||
if (record.msgId) {
|
||||
return existingIds.has(record.msgId);
|
||||
}
|
||||
return existingFingerprints.has(buildMessageFingerprint(record));
|
||||
});
|
||||
pageRowsToPersist = filteredPageRows.filter((record) => {
|
||||
if (record.msgId) {
|
||||
return !existingIds.has(record.msgId);
|
||||
}
|
||||
return !existingFingerprints.has(buildMessageFingerprint(record));
|
||||
});
|
||||
if (pageRowsToPersist.length > 0) {
|
||||
await upsertMessages(pageRowsToPersist);
|
||||
}
|
||||
}
|
||||
allNormalizedRecords.push(...pageRowsToPersist);
|
||||
await saveMessagesCheckpoint(dataset, pageNum, allNormalizedRecords);
|
||||
},
|
||||
skipInitialPage: resumeFromPage > 0,
|
||||
@@ -1317,13 +1411,22 @@ async function syncMessages(page, options = {}) {
|
||||
if (pageNum >= config.hotMessageMaxPagesPerRun) {
|
||||
return true;
|
||||
}
|
||||
if (stopByExistingPage) {
|
||||
return true;
|
||||
}
|
||||
if (!hotWatermark) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPageRows = normalizeDatasetRecords(dataset, pageRows, {});
|
||||
const detailedPageRows = await enrichMessageRowsWithDetails(page, pageRows, pageNum);
|
||||
const normalizedPageRows = normalizeDatasetRecords(dataset, detailedPageRows, {});
|
||||
return normalizedPageRows.length > 0
|
||||
&& normalizedPageRows.every((record) => !isAfterLatestMessageTime(record, hotWatermark));
|
||||
}
|
||||
: fullSyncWatermark
|
||||
? async ({ pageNum, pageRows }) => {
|
||||
const detailedPageRows = await enrichMessageRowsWithDetails(page, pageRows, pageNum);
|
||||
return stopByExistingPage || shouldStopForFullSyncPage(detailedPageRows);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -1331,6 +1434,11 @@ async function syncMessages(page, options = {}) {
|
||||
if (resumeFromPage === 0) {
|
||||
allNormalizedRecords = normalizeDatasetRecords(dataset, records, {});
|
||||
}
|
||||
if (fullSyncWatermark) {
|
||||
const before = allNormalizedRecords.length;
|
||||
allNormalizedRecords = allNormalizedRecords.filter((record) => isAfterLatestMessageTime(record, fullSyncWatermark));
|
||||
console.log(`[全量模式] 消息仅保留近三个月: ${before} -> ${allNormalizedRecords.length} (watermark=${formatDateTime(fullSyncWatermark)})`);
|
||||
}
|
||||
if ((incremental || hot) && hasDbConfig()) {
|
||||
try {
|
||||
const latestMessageTime = await getLatestMessageTimeFromDb();
|
||||
@@ -1350,7 +1458,8 @@ async function syncMessages(page, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return persistDataset(dataset, dedupeByHash(allNormalizedRecords), {});
|
||||
const previousState = loadCurrentState(dataset.name, dataset.uniqueKey);
|
||||
return persistNormalizedDataset(dataset, dedupeByHash([...(previousState.records || []), ...allNormalizedRecords]));
|
||||
}
|
||||
|
||||
async function saveMessagesCheckpoint(dataset, pageNum, normalizedRecords) {
|
||||
@@ -1629,15 +1738,53 @@ function isAfterLatestConsumptionDate(record, latestConsumptionDate) {
|
||||
function isAfterLatestMessageTime(record, watermarkDate) {
|
||||
const value = String(record['消息修改时间'] || record['修改时间'] || record.gmtModified || record['消息创建时间'] || record['创建时间'] || record.gmtCreated || '').trim();
|
||||
if (!value) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
const parsed = parseDbDateTime(value);
|
||||
if (!parsed) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
return parsed >= watermarkDate;
|
||||
}
|
||||
|
||||
function extractMessageTime(record) {
|
||||
const value = String(record['消息修改时间'] || record['修改时间'] || record.gmtModified || record['消息创建时间'] || record['创建时间'] || record.gmtCreated || '').trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return parseDbDateTime(value);
|
||||
}
|
||||
|
||||
function getMessagePageTimeStats(records) {
|
||||
const parsedTimes = records
|
||||
.map((record) => extractMessageTime(record))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
if (parsedTimes.length === 0) {
|
||||
return {
|
||||
total: records.length,
|
||||
parsed: 0,
|
||||
earliest: '',
|
||||
latest: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
total: records.length,
|
||||
parsed: parsedTimes.length,
|
||||
earliest: formatDateTime(parsedTimes[0]),
|
||||
latest: formatDateTime(parsedTimes[parsedTimes.length - 1]),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessageFingerprint(record) {
|
||||
const title = String(record.title || record.detailTitle || record.column_1 || '').trim();
|
||||
const receivedAt = String(record.receivedAt || record.gmtModified || record.gmtCreated || record.column_2 || '').trim();
|
||||
const orderNo = String(record.orderNo || record.refundOrderNo || '').trim();
|
||||
return `${title}__${receivedAt}__${orderNo}`;
|
||||
}
|
||||
|
||||
async function syncOrderDetails(page, cachedOrderIds, options = {}) {
|
||||
await runtimeCheckpoint('同步订单详情');
|
||||
const dataset = datasets.orderDetails;
|
||||
@@ -1806,6 +1953,20 @@ async function scrapePagedTable(page, dataset, context, options = {}) {
|
||||
const visited = new Set();
|
||||
let shouldSkipCurrentPage = skipInitialPage;
|
||||
|
||||
const describeStopReason = (reason) => {
|
||||
if (!reason) {
|
||||
return 'unknown';
|
||||
}
|
||||
const details = [];
|
||||
if (reason.beforePage != null) {
|
||||
details.push(`before=${reason.beforePage}`);
|
||||
}
|
||||
if (reason.afterPage != null) {
|
||||
details.push(`after=${reason.afterPage}`);
|
||||
}
|
||||
return details.length > 0 ? `${reason.code} (${details.join(', ')})` : reason.code;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
await runtimeCheckpoint(`抓取 ${dataset.name} 分页`);
|
||||
await waitForTableRows(page);
|
||||
@@ -1816,9 +1977,9 @@ async function scrapePagedTable(page, dataset, context, options = {}) {
|
||||
if (shouldSkipCurrentPage) {
|
||||
console.log(`[抓取] 跳过 checkpoint 已保存页: ${pageNum}`);
|
||||
shouldSkipCurrentPage = false;
|
||||
const moved = await gotoNextPage(page);
|
||||
const { moved, reason } = await gotoNextPage(page);
|
||||
if (!moved) {
|
||||
console.log(`[抓取] checkpoint 已位于最后一页,停止`);
|
||||
console.log(`[抓取] checkpoint 已停止续爬: ${describeStopReason(reason)}`);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
@@ -1839,9 +2000,9 @@ async function scrapePagedTable(page, dataset, context, options = {}) {
|
||||
break;
|
||||
}
|
||||
|
||||
const moved = await gotoNextPage(page);
|
||||
const { moved, reason } = await gotoNextPage(page);
|
||||
if (!moved) {
|
||||
console.log(`[抓取] 翻页失败或已到最后一页,停止`);
|
||||
console.log(`[抓取] 停止翻页: ${describeStopReason(reason)}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1875,39 +2036,101 @@ async function extractTable(page) {
|
||||
.replace(/\n\s+/g, '\n')
|
||||
.trim();
|
||||
|
||||
const headerTables = Array.from(document.querySelectorAll('table')).filter((table) => table.querySelectorAll('thead th').length > 1);
|
||||
const headerTable = headerTables.sort((a, b) => b.querySelectorAll('thead th').length - a.querySelectorAll('thead th').length)[0];
|
||||
if (!headerTable) return { headers: [], rows: [] };
|
||||
|
||||
const headers = Array.from(headerTable.querySelectorAll('thead th')).map((cell) => normalize(cell.textContent));
|
||||
const bodyTables = Array.from(document.querySelectorAll('table')).filter((table) => table.querySelectorAll('tbody tr').length > 0);
|
||||
const bodyTable = bodyTables.sort((a, b) => {
|
||||
const aSize = Math.max(...Array.from(a.querySelectorAll('tbody tr')).map((row) => row.querySelectorAll('td').length), 0);
|
||||
const bSize = Math.max(...Array.from(b.querySelectorAll('tbody tr')).map((row) => row.querySelectorAll('td').length), 0);
|
||||
return bSize - aSize;
|
||||
})[0];
|
||||
if (!bodyTable) return { headers, rows: [] };
|
||||
|
||||
const rows = Array.from(bodyTable.querySelectorAll('tbody tr'))
|
||||
.map((row) => Array.from(row.querySelectorAll('td')).map((cell) => normalize(cell.innerText || cell.textContent)))
|
||||
const toRecords = (headers, rows) => rows
|
||||
.map((cells) => cells.map((cell) => normalize(cell)))
|
||||
.filter((cells) => cells.some(Boolean))
|
||||
.map((cells) => {
|
||||
const record = {};
|
||||
headers.forEach((header, index) => {
|
||||
const keys = headers.length ? headers : cells.map((_, index) => `column_${index + 1}`);
|
||||
keys.forEach((header, index) => {
|
||||
record[header || `column_${index + 1}`] = cells[index] || '';
|
||||
});
|
||||
return record;
|
||||
});
|
||||
|
||||
return { headers, rows };
|
||||
const extractFromNativeTables = () => {
|
||||
const headerTables = Array.from(document.querySelectorAll('table')).filter((table) => table.querySelectorAll('thead th').length > 1);
|
||||
const headerTable = headerTables.sort((a, b) => b.querySelectorAll('thead th').length - a.querySelectorAll('thead th').length)[0];
|
||||
const headers = headerTable
|
||||
? Array.from(headerTable.querySelectorAll('thead th')).map((cell) => normalize(cell.textContent))
|
||||
: [];
|
||||
|
||||
const bodyTables = Array.from(document.querySelectorAll('table')).filter((table) => table.querySelectorAll('tbody tr').length > 0);
|
||||
const bodyTable = bodyTables.sort((a, b) => {
|
||||
const aSize = Math.max(...Array.from(a.querySelectorAll('tbody tr')).map((row) => row.querySelectorAll('td').length), 0);
|
||||
const bSize = Math.max(...Array.from(b.querySelectorAll('tbody tr')).map((row) => row.querySelectorAll('td').length), 0);
|
||||
return bSize - aSize;
|
||||
})[0];
|
||||
if (!bodyTable) {
|
||||
return { headers, rows: [] };
|
||||
}
|
||||
|
||||
const rows = Array.from(bodyTable.querySelectorAll('tbody tr'))
|
||||
.map((row) => Array.from(row.querySelectorAll('td')).map((cell) => normalize(cell.innerText || cell.textContent)));
|
||||
return { headers, rows: toRecords(headers, rows) };
|
||||
};
|
||||
|
||||
const extractFromNextTable = () => {
|
||||
const container = document.querySelector('.next-table, .next-table-inner, [class*="next-table"]');
|
||||
if (!container) {
|
||||
return { headers: [], rows: [] };
|
||||
}
|
||||
|
||||
const headers = Array.from(container.querySelectorAll('.next-table-header .next-table-cell, .next-table-header th, [role="columnheader"]'))
|
||||
.map((cell) => normalize(cell.innerText || cell.textContent))
|
||||
.filter(Boolean);
|
||||
|
||||
const rowCandidates = Array.from(container.querySelectorAll('.next-table-body .next-table-row, .next-table-row, [role="row"]'));
|
||||
const rows = rowCandidates
|
||||
.map((row) => {
|
||||
const cells = Array.from(row.querySelectorAll('.next-table-cell, [role="gridcell"], [role="cell"], td'))
|
||||
.map((cell) => normalize(cell.innerText || cell.textContent));
|
||||
return cells;
|
||||
})
|
||||
.filter((cells) => cells.length > 0 && cells.some(Boolean));
|
||||
|
||||
return { headers, rows: toRecords(headers, rows) };
|
||||
};
|
||||
|
||||
const nativeResult = extractFromNativeTables();
|
||||
if (nativeResult.rows.length > 0) {
|
||||
return nativeResult;
|
||||
}
|
||||
|
||||
const nextTableResult = extractFromNextTable();
|
||||
if (nextTableResult.rows.length > 0) {
|
||||
return nextTableResult;
|
||||
}
|
||||
|
||||
return nextTableResult.headers.length > 0 ? nextTableResult : nativeResult;
|
||||
});
|
||||
}
|
||||
|
||||
function isTargetClosedError(error) {
|
||||
const message = String(error?.message || error || '');
|
||||
return message.includes('Target page, context or browser has been closed');
|
||||
}
|
||||
|
||||
function assertPageAvailable(page, label) {
|
||||
if (!page || page.isClosed?.()) {
|
||||
throw new Error(`页面在${label}前已被关闭。请检查是否手动关闭了浏览器,或浏览器是否异常退出,然后重新执行同步。`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTableRows(page) {
|
||||
await runtimeCheckpoint('等待表格加载');
|
||||
assertPageAvailable(page, '等待表格加载');
|
||||
try {
|
||||
await page.waitForFunction(() => document.querySelectorAll('table tbody tr').length > 0, null, { timeout: 120000 });
|
||||
await page.waitForFunction(() => {
|
||||
const nativeRows = document.querySelectorAll('table tbody tr').length;
|
||||
const nextRows = document.querySelectorAll('.next-table-body .next-table-row, .next-table-row, [role="row"]').length;
|
||||
const emptyState = document.querySelector('.next-table-empty, .next-empty, [class*="empty"], [class*="no-data"]');
|
||||
return nativeRows > 0 || nextRows > 0 || Boolean(emptyState);
|
||||
}, null, { timeout: 120000 });
|
||||
} catch (error) {
|
||||
if (isTargetClosedError(error)) {
|
||||
throw new Error('等待消息表格加载时,浏览器页面已被关闭。请勿手动关闭浏览器窗口,并检查浏览器是否异常退出后重试。');
|
||||
}
|
||||
await raiseIfSessionExpired(page, '等待表格加载');
|
||||
throw error;
|
||||
}
|
||||
@@ -1915,12 +2138,21 @@ async function waitForTableRows(page) {
|
||||
}
|
||||
|
||||
async function currentPageNumber(page) {
|
||||
const active = page.locator('.next-pagination-item.next-current');
|
||||
if ((await active.count()) === 0) return 1;
|
||||
return Number.parseInt((await active.first().innerText()).trim(), 10) || 1;
|
||||
assertPageAvailable(page, '读取当前页码');
|
||||
try {
|
||||
const active = page.locator('.next-pagination-item.next-current');
|
||||
if ((await active.count()) === 0) return 1;
|
||||
return Number.parseInt((await active.first().innerText()).trim(), 10) || 1;
|
||||
} catch (error) {
|
||||
if (isTargetClosedError(error)) {
|
||||
throw new Error('读取分页页码时,浏览器页面已被关闭。请勿手动关闭浏览器窗口,并检查浏览器是否异常退出后重试。');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function jumpToPage(page, targetPage) {
|
||||
async function jumpToPage(page, targetPage, options = {}) {
|
||||
const { allowSequentialFallback = true } = options;
|
||||
if (targetPage <= 1) {
|
||||
return true;
|
||||
}
|
||||
@@ -1949,19 +2181,24 @@ async function jumpToPage(page, targetPage) {
|
||||
await sleep(1500);
|
||||
const afterJump = await currentPageNumber(page);
|
||||
if (afterJump === targetPage) {
|
||||
console.log(`[账单续爬] 已跳转到第 ${targetPage} 页`);
|
||||
console.log(`[跳页] 已跳转到第 ${targetPage} 页`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`[账单续爬] 未找到可用跳页输入框,尝试顺序翻到第 ${targetPage} 页`);
|
||||
if (!allowSequentialFallback) {
|
||||
console.warn(`[跳页] 未找到可用跳页输入框,且当前模式禁止顺序兜底: target=${targetPage}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.warn(`[跳页] 未找到可用跳页输入框,尝试顺序翻到第 ${targetPage} 页`);
|
||||
let guard = 0;
|
||||
while (guard < targetPage + 5) {
|
||||
const currentPage = await currentPageNumber(page);
|
||||
if (currentPage >= targetPage) {
|
||||
return currentPage === targetPage;
|
||||
}
|
||||
const moved = await gotoNextPage(page);
|
||||
const { moved } = await gotoNextPage(page);
|
||||
if (!moved) {
|
||||
return false;
|
||||
}
|
||||
@@ -1972,38 +2209,71 @@ async function jumpToPage(page, targetPage) {
|
||||
|
||||
async function gotoNextPage(page) {
|
||||
await runtimeCheckpoint('翻页');
|
||||
assertPageAvailable(page, '翻页');
|
||||
const before = await currentPageNumber(page);
|
||||
|
||||
// 用 Playwright locator 定位"下一页"按钮
|
||||
const nextBtn = page.locator('button.next-pagination-item.next-next');
|
||||
if ((await nextBtn.count()) === 0) {
|
||||
console.log('[翻页] 未找到下一页按钮');
|
||||
return false;
|
||||
try {
|
||||
// 用 Playwright locator 定位"下一页"按钮
|
||||
const nextBtn = page.locator('button.next-pagination-item.next-next');
|
||||
if ((await nextBtn.count()) === 0) {
|
||||
return {
|
||||
moved: false,
|
||||
reason: { code: 'next_button_missing', beforePage: before },
|
||||
};
|
||||
}
|
||||
|
||||
const disabled = (await nextBtn.getAttribute('disabled')) != null;
|
||||
if (disabled) {
|
||||
return {
|
||||
moved: false,
|
||||
reason: { code: 'next_button_disabled', beforePage: before },
|
||||
};
|
||||
}
|
||||
|
||||
// 用 Playwright click(而非 DOM click),确保 React 事件正常触发
|
||||
await nextBtn.click();
|
||||
await sleep(2000);
|
||||
await raiseIfSessionExpired(page, `翻页 ${before} -> next`);
|
||||
|
||||
const after = await currentPageNumber(page);
|
||||
console.log(`[翻页] ${before} -> ${after}`);
|
||||
|
||||
if (before > 1 && after === 1) {
|
||||
throw new Error(`分页从第 ${before} 页异常回退到第 1 页,疑似登录态失效或页面会话已重置。请重新执行 npm run login 后再继续同步。`);
|
||||
}
|
||||
|
||||
if (after < before) {
|
||||
throw new Error(`分页从第 ${before} 页异常回退到第 ${after} 页,疑似登录态失效或页面状态被重置。请重新执行 npm run login 后再继续同步。`);
|
||||
}
|
||||
|
||||
if (before === after) {
|
||||
const fallbackTarget = before + 1;
|
||||
console.warn(`[翻页] next 点击后页码未推进,尝试跳页到 ${fallbackTarget}`);
|
||||
const jumped = await jumpToPage(page, fallbackTarget, { allowSequentialFallback: false });
|
||||
if (jumped) {
|
||||
const afterJump = await currentPageNumber(page);
|
||||
console.log(`[翻页] fallback jump ${before} -> ${afterJump}`);
|
||||
return {
|
||||
moved: true,
|
||||
reason: { code: 'advanced_via_jump', beforePage: before, afterPage: afterJump },
|
||||
};
|
||||
}
|
||||
return {
|
||||
moved: false,
|
||||
reason: { code: 'page_number_not_advanced', beforePage: before, afterPage: after },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
moved: true,
|
||||
reason: { code: 'advanced', beforePage: before, afterPage: after },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isTargetClosedError(error)) {
|
||||
throw new Error(`翻页到下一页时,浏览器页面在第 ${before} 页之后被关闭。请勿手动关闭浏览器窗口,并检查浏览器是否异常退出后重试。`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const disabled = (await nextBtn.getAttribute('disabled')) != null;
|
||||
if (disabled) {
|
||||
console.log('[翻页] 下一页按钮已禁用');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 用 Playwright click(而非 DOM click),确保 React 事件正常触发
|
||||
await nextBtn.click();
|
||||
await sleep(2000);
|
||||
await raiseIfSessionExpired(page, `翻页 ${before} -> next`);
|
||||
|
||||
const after = await currentPageNumber(page);
|
||||
console.log(`[翻页] ${before} -> ${after}`);
|
||||
|
||||
if (before > 1 && after === 1) {
|
||||
throw new Error(`分页从第 ${before} 页异常回退到第 1 页,疑似登录态失效或页面会话已重置。请重新执行 npm run login 后再继续同步。`);
|
||||
}
|
||||
|
||||
if (after < before) {
|
||||
throw new Error(`分页从第 ${before} 页异常回退到第 ${after} 页,疑似登录态失效或页面状态被重置。请重新执行 npm run login 后再继续同步。`);
|
||||
}
|
||||
|
||||
return before !== after;
|
||||
}
|
||||
|
||||
async function trySetPageSize(page, pageSize) {
|
||||
@@ -2455,6 +2725,217 @@ async function waitForStableOrderList(page) {
|
||||
await waitForTableRows(page).catch(() => null);
|
||||
}
|
||||
|
||||
async function clickMessageDetailButton(page, rowText, rowIndex) {
|
||||
const clicked = await page.evaluate(({ rowTextValue, rowIndexValue }) => {
|
||||
const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
const target = normalize(rowTextValue);
|
||||
|
||||
const rows = Array.from(document.querySelectorAll('.next-table-row, table tbody tr, [role="row"]'))
|
||||
.filter((row) => normalize(row.innerText || row.textContent || ''));
|
||||
const row = rows[rowIndexValue];
|
||||
if (!row) {
|
||||
return { clicked: false, reason: 'row_not_found', rowCount: rows.length };
|
||||
}
|
||||
|
||||
const rowTextActual = normalize(row.innerText || row.textContent || '');
|
||||
const clickableNodes = Array.from(row.querySelectorAll('button, a, [role="button"], .next-btn-text'));
|
||||
const preferred = clickableNodes.find((node) => {
|
||||
const text = normalize(node.innerText || node.textContent || '');
|
||||
return text && rowTextActual.includes(text);
|
||||
}) || clickableNodes[0];
|
||||
|
||||
if (!preferred) {
|
||||
return { clicked: false, reason: 'clickable_node_not_found', rowTextActual };
|
||||
}
|
||||
|
||||
preferred.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
||||
preferred.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
return {
|
||||
clicked: true,
|
||||
buttonText: normalize(preferred.innerText || preferred.textContent || ''),
|
||||
rowTextActual,
|
||||
matched: rowTextActual.includes(target),
|
||||
};
|
||||
}, { rowTextValue: rowText, rowIndexValue: rowIndex }).catch(() => ({ clicked: false, reason: 'evaluate_failed' }));
|
||||
|
||||
if (clicked.clicked) {
|
||||
await sleep(1200);
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
async function waitForMessageDetailDrawer(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const header = document.querySelector('.next-drawer-header');
|
||||
const body = document.querySelector('.next-drawer-body');
|
||||
return !!header && !!body && String(header.textContent || '').includes('消息详情');
|
||||
}, null, { timeout: 15000 });
|
||||
await sleep(600);
|
||||
}
|
||||
|
||||
async function extractMessageDetail(page) {
|
||||
return page.evaluate(() => {
|
||||
const normalize = (value) => String(value || '').replace(/\u00a0/g, ' ').trim();
|
||||
const header = normalize(document.querySelector('.next-drawer-header')?.innerText || '');
|
||||
const body = normalize(document.querySelector('.next-drawer-body')?.innerText || '');
|
||||
const lines = body.split(/\r?\n/).map((line) => normalize(line)).filter(Boolean);
|
||||
const firstLine = lines[0] || '';
|
||||
|
||||
const extract = (label) => {
|
||||
const line = lines.find((item) => item.startsWith(`${label}:`) || item.startsWith(`${label}:`));
|
||||
if (!line) return '';
|
||||
return normalize(line.replace(`${label}:`, '').replace(`${label}:`, ''));
|
||||
};
|
||||
|
||||
const match = (pattern) => {
|
||||
const matched = body.match(pattern);
|
||||
return matched?.[1] ? normalize(matched[1]) : '';
|
||||
};
|
||||
|
||||
const accountIdMatches = Array.from(body.matchAll(/账号ID[::]?(\d{6,})/g)).map((item) => normalize(item[1])).filter(Boolean);
|
||||
|
||||
const classification = (() => {
|
||||
if (/退款/.test(header) || /退款/.test(body)) return 'refund';
|
||||
if (/释放预警/.test(header) || /预计于【.*】释放/.test(body)) return 'release_warning';
|
||||
if (/释放通知/.test(header) || /已释放/.test(body)) return 'release_notice';
|
||||
if (/未支付提醒/.test(header) || /未支付/.test(body)) return 'unpaid_reminder';
|
||||
if (/取消通知/.test(header) || /取消了一笔未支付订单/.test(body)) return 'order_cancel';
|
||||
if (/余额-预警通知/.test(header) || /账户现金余额/.test(body)) return 'balance_warning';
|
||||
if (/关联成功/.test(header) || /关联关系已完成建立/.test(body)) return 'association_success';
|
||||
if (/注册成功/.test(header) || /受邀注册UID/.test(body)) return 'registration_success';
|
||||
if (/变更已超期/.test(header) || /变更申请已超期/.test(body)) return 'change_overdue';
|
||||
return 'general';
|
||||
})();
|
||||
|
||||
const detailContent = lines.filter((line) => !/^(接收时间|客户账号|订单号|退款订单号|订单金额|退款金额|客户下单时间|退款时间|受邀注册UID)[::]/.test(line));
|
||||
|
||||
return {
|
||||
detailTitle: firstLine || header,
|
||||
detailContent: body,
|
||||
receivedAt: extract('接收时间'),
|
||||
customerName: extract('客户账号'),
|
||||
customerNo: extract('客户账号') || match(/贵司的代付(?:关联)?客户【[^/]+\/(\d{6,})】/) || match(/受邀注册UID[::]?(\d{6,})/) || accountIdMatches[0] || '',
|
||||
orderNo: extract('订单号') || extract('退款订单号'),
|
||||
orderAmount: extract('订单金额'),
|
||||
customerOrderTime: extract('客户下单时间'),
|
||||
refundOrderNo: extract('退款订单号'),
|
||||
refundAmount: extract('退款金额'),
|
||||
refundTime: extract('退款时间'),
|
||||
invitedRegisterUid: extract('受邀注册UID') || match(/受邀注册UID[::]?(\d{6,})/),
|
||||
accountIds: accountIdMatches.join(','),
|
||||
messageClassification: classification,
|
||||
status: '未读',
|
||||
title: firstLine || header,
|
||||
content: detailContent.join('\n'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function closeMessageDetailDrawer(page) {
|
||||
const closeButton = page.locator('.next-drawer-close, .next-dialog-close, .next-icon-close').first();
|
||||
if ((await closeButton.count()) > 0) {
|
||||
await closeButton.click().catch(() => null);
|
||||
} else {
|
||||
await page.keyboard.press('Escape').catch(() => null);
|
||||
}
|
||||
await page.waitForFunction(() => !document.querySelector('.next-drawer-header'), null, { timeout: 10000 }).catch(() => null);
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
async function fetchMessageApiRows(page, pageNum, pageSize) {
|
||||
return page.evaluate(async ({ currentPage, currentPageSize }) => {
|
||||
const response = await fetch(`/api/taskapi/msgbox/queryUserMsg.json?lv2CategoryId=0&pageNo=${currentPage}&pageSize=${currentPageSize}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload?.data?.list) ? payload.data.list : [];
|
||||
}, { currentPage: pageNum, currentPageSize: pageSize }).catch(() => []);
|
||||
}
|
||||
|
||||
function stripHtmlTags(value) {
|
||||
return String(value || '')
|
||||
.replace(/<br\s*\/?>(\r?\n)?/gi, '\n')
|
||||
.replace(/<\/div>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function mapApiMessageRecord(record) {
|
||||
if (!record || typeof record !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
msgId: String(record.id || '').trim(),
|
||||
title: String(record.title || '').trim(),
|
||||
detailTitle: String(record.title || '').trim(),
|
||||
detailContent: stripHtmlTags(record.htmlContent || record.content || ''),
|
||||
content: stripHtmlTags(record.htmlContent || record.content || ''),
|
||||
fromApp: String(record.fromApp || '').trim(),
|
||||
bizCode: String(record.bizCode || '').trim(),
|
||||
msgChannel: String(record.msgChannel || '').trim(),
|
||||
categoryName: String(record.categoryName || '').trim(),
|
||||
categoryId: String(record.lv3CategoryId || '').trim(),
|
||||
lv1CategoryId: String(record.lv1CategoryId || '').trim(),
|
||||
lv2CategoryId: String(record.lv2CategoryId || '').trim(),
|
||||
lv3CategoryId: String(record.lv3CategoryId || '').trim(),
|
||||
gmtCreated: record.createDate ? formatDateTime(new Date(record.createDate)) : '',
|
||||
gmtModified: record.updateDate ? formatDateTime(new Date(record.updateDate)) : '',
|
||||
status: Number(record.isRead) === 1 ? '已读' : '未读',
|
||||
};
|
||||
}
|
||||
|
||||
async function enrichMessageRowsWithDetails(page, pageRows, pageNum) {
|
||||
const enrichedRows = [];
|
||||
let detailSuccess = 0;
|
||||
let detailFailed = 0;
|
||||
const apiRows = await fetchMessageApiRows(page, pageNum, datasets.messages.pageSize);
|
||||
for (let index = 0; index < pageRows.length; index += 1) {
|
||||
const row = pageRows[index];
|
||||
const rowText = String(row['消息标题'] || row['标题'] || row.title || row.column_1 || '').trim();
|
||||
if (!rowText) {
|
||||
enrichedRows.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
const apiDetail = mapApiMessageRecord(apiRows[index]);
|
||||
if (apiDetail?.msgId) {
|
||||
detailSuccess += 1;
|
||||
enrichedRows.push({ ...row, ...apiDetail });
|
||||
continue;
|
||||
}
|
||||
|
||||
const clicked = await clickMessageDetailButton(page, rowText, index);
|
||||
if (!clicked.clicked) {
|
||||
detailFailed += 1;
|
||||
console.warn(`[消息详情] 打开失败: pageRow=${index + 1}, title="${rowText}", reason=${clicked.reason || 'unknown'}`);
|
||||
enrichedRows.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForMessageDetailDrawer(page);
|
||||
const detail = await extractMessageDetail(page);
|
||||
detailSuccess += 1;
|
||||
enrichedRows.push({ ...row, ...detail });
|
||||
} catch (error) {
|
||||
detailFailed += 1;
|
||||
console.warn(`[消息详情] 提取失败: pageRow=${index + 1}, title="${rowText}", error=${error.message}`);
|
||||
enrichedRows.push(row);
|
||||
} finally {
|
||||
await closeMessageDetailDrawer(page);
|
||||
await waitForTableRows(page).catch(() => null);
|
||||
}
|
||||
}
|
||||
console.log(`[消息详情] 本页详情提取: success=${detailSuccess}, failed=${detailFailed}, total=${pageRows.length}`);
|
||||
return enrichedRows;
|
||||
}
|
||||
|
||||
async function restoreOrderWindow(page, windowStart, windowEnd) {
|
||||
await waitUntilReady(page, datasets.orders.heading).catch(() => null);
|
||||
await setDateRange(page, windowStart, windowEnd);
|
||||
@@ -2587,6 +3068,9 @@ async function extractCustomerDetail(page) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!department) {
|
||||
department = extract('所属部门');
|
||||
}
|
||||
|
||||
return {
|
||||
customerAccount: extract('客户账号'),
|
||||
@@ -2595,10 +3079,10 @@ async function extractCustomerDetail(page) {
|
||||
tradeMode: extract('交易模式'),
|
||||
customerSource: extract('客户来源'),
|
||||
realNameStatus: extract('实名状态'),
|
||||
email: extract('邮箱'),
|
||||
email: extract('邮箱') || extract('Email') || extract('电子邮箱'),
|
||||
relationDate: extract('关联日期'),
|
||||
phone: extract('手机号'),
|
||||
remark: extract('备注'),
|
||||
phone: extract('手机号') || extract('手机') || extract('联系电话') || extract('联系手机'),
|
||||
remark: extract('备注') || extract('客户备注'),
|
||||
paymentNoticeStatus: extract('代为支付告知状态'),
|
||||
department,
|
||||
lastMonthPayableTotalCny: extractAmountFromSection(lastMonthSection, '上月应付总金额(CNY)'),
|
||||
|
||||
465
aliyun-sync/aliyun-aps-sync/web-console/assets/app.js
Normal file
465
aliyun-sync/aliyun-aps-sync/web-console/assets/app.js
Normal file
@@ -0,0 +1,465 @@
|
||||
const state = {
|
||||
envSchema: [],
|
||||
envValues: {},
|
||||
commands: [],
|
||||
runState: { activeRun: null, history: [] },
|
||||
};
|
||||
|
||||
const commandGrid = document.getElementById('commandGrid');
|
||||
const configGroups = document.getElementById('configGroups');
|
||||
const historyList = document.getElementById('historyList');
|
||||
const runOutput = document.getElementById('runOutput');
|
||||
const runStatusPill = document.getElementById('runStatusPill');
|
||||
const activeRunSummary = document.getElementById('activeRunSummary');
|
||||
const saveEnvBtn = document.getElementById('saveEnvBtn');
|
||||
const stopRunBtn = document.getElementById('stopRunBtn');
|
||||
const summaryCards = document.getElementById('summaryCards');
|
||||
const testDbBtn = document.getElementById('testDbBtn');
|
||||
const checkLoginBtn = document.getElementById('checkLoginBtn');
|
||||
const utilityFeedback = document.getElementById('utilityFeedback');
|
||||
const clearCheckpointBtn = document.getElementById('clearCheckpointBtn');
|
||||
const clearMessageDataBtn = document.getElementById('clearMessageDataBtn');
|
||||
const messageClassificationStats = document.getElementById('messageClassificationStats');
|
||||
const messageAccountStats = document.getElementById('messageAccountStats');
|
||||
const maintenanceResultCard = document.getElementById('maintenanceResultCard');
|
||||
const autoScrollToggle = document.getElementById('autoScrollToggle');
|
||||
const logFilterInput = document.getElementById('logFilterInput');
|
||||
const exportLogBtn = document.getElementById('exportLogBtn');
|
||||
const schedulerMeta = document.getElementById('schedulerMeta');
|
||||
let eventSource = null;
|
||||
|
||||
document.querySelectorAll('.nav-link').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-link').forEach((item) => item.classList.remove('active'));
|
||||
document.querySelectorAll('.panel').forEach((panel) => panel.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
document.querySelector(`[data-panel="${button.dataset.tab}"]`)?.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
function setRunStatus(status) {
|
||||
runStatusPill.textContent = status || 'idle';
|
||||
runStatusPill.className = `pill ${status || 'idle'}`;
|
||||
}
|
||||
|
||||
function renderRunState() {
|
||||
const run = state.runState.activeRun;
|
||||
if (!run) {
|
||||
activeRunSummary.textContent = '当前无运行任务';
|
||||
setRunStatus('idle');
|
||||
runOutput.textContent = '等待任务启动…';
|
||||
return;
|
||||
}
|
||||
activeRunSummary.textContent = `${run.label} · ${run.status} · ${new Date(run.startedAt).toLocaleString()}`;
|
||||
setRunStatus(run.status);
|
||||
const filter = logFilterInput?.value?.trim();
|
||||
const lines = run.output || [];
|
||||
const filtered = filter ? lines.filter((line) => line.toLowerCase().includes(filter.toLowerCase())) : lines;
|
||||
runOutput.textContent = filtered.join('\n') || '暂无输出';
|
||||
if (autoScrollToggle?.checked) {
|
||||
runOutput.scrollTop = runOutput.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function showMaintenanceResult(kind, text) {
|
||||
maintenanceResultCard.classList.remove('hidden', 'success', 'error');
|
||||
maintenanceResultCard.classList.add(kind);
|
||||
maintenanceResultCard.textContent = text;
|
||||
}
|
||||
|
||||
function renderAnalytics(payload) {
|
||||
messageClassificationStats.innerHTML = '';
|
||||
messageAccountStats.innerHTML = '';
|
||||
|
||||
for (const item of payload.classifications || []) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'analytics-item';
|
||||
row.innerHTML = `<strong>${item.classification}</strong><span>${item.cnt}</span>`;
|
||||
messageClassificationStats.appendChild(row);
|
||||
}
|
||||
|
||||
for (const item of payload.topAccounts || []) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'analytics-item';
|
||||
row.innerHTML = `<strong>${item.account_id}</strong><span>${item.cnt}</span>`;
|
||||
messageAccountStats.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSchedulerMeta(meta) {
|
||||
schedulerMeta.innerHTML = '';
|
||||
const items = [
|
||||
['调度模式', meta.mode],
|
||||
['账单 Cron', meta.billsCron],
|
||||
['高频 Cron', meta.hotCron],
|
||||
['时区', meta.timezone],
|
||||
['浏览器关闭策略', meta.browserClose === 'true' ? '默认关闭' : '常驻' ],
|
||||
['运行状态', meta.running ? `运行中:${meta.activeRunLabel}` : '当前未运行'],
|
||||
['调度策略', meta.strategy],
|
||||
];
|
||||
|
||||
for (const [label, value] of items) {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'item';
|
||||
node.innerHTML = `<div class="label">${label}</div><div class="value">${value || '-'}</div>`;
|
||||
schedulerMeta.appendChild(node);
|
||||
}
|
||||
|
||||
if ((meta.recentEvents || []).length > 0) {
|
||||
for (const event of meta.recentEvents) {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'item';
|
||||
const detail = event.error || event.reason || event.mode || '-';
|
||||
node.innerHTML = `<div class="label">${event.track} · ${event.status}</div><div class="value">${new Date(event.at).toLocaleString()}<br />${detail}</div>`;
|
||||
schedulerMeta.appendChild(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fieldValue(key) {
|
||||
return state.envValues[key] ?? '';
|
||||
}
|
||||
|
||||
function renderCommands() {
|
||||
commandGrid.innerHTML = '';
|
||||
const template = document.getElementById('commandCardTemplate');
|
||||
for (const command of state.commands) {
|
||||
const fragment = template.content.cloneNode(true);
|
||||
fragment.querySelector('.command-title').textContent = command.label;
|
||||
fragment.querySelector('.command-description').textContent = command.description;
|
||||
fragment.querySelector('.command-key').textContent = command.key;
|
||||
const optionContainer = fragment.querySelector('.command-options');
|
||||
|
||||
if (['sync', 'bills', 'orders', 'messages', 'hot'].includes(command.key)) {
|
||||
optionContainer.appendChild(buildCheckboxField(`${command.key}-resume`, '继续上次进度', 'resume'));
|
||||
}
|
||||
if (['orders', 'messages'].includes(command.key)) {
|
||||
optionContainer.appendChild(buildCheckboxField(`${command.key}-incremental`, '增量参数', 'incremental'));
|
||||
}
|
||||
if (command.key === 'orders') {
|
||||
optionContainer.appendChild(buildInputField(`${command.key}-incremental-order-start-date`, '增量订单起始日期', '例如 2026-01-01'));
|
||||
}
|
||||
|
||||
fragment.querySelector('.run-command-btn').addEventListener('click', async () => {
|
||||
const payload = {
|
||||
commandKey: command.key,
|
||||
options: {
|
||||
resume: document.getElementById(`${command.key}-resume`)?.checked || false,
|
||||
incremental: document.getElementById(`${command.key}-incremental`)?.checked || false,
|
||||
incrementalOrderStartDate: document.getElementById(`${command.key}-incremental-order-start-date`)?.value?.trim() || '',
|
||||
},
|
||||
};
|
||||
await fetchJson('/api/run', { method: 'POST', body: JSON.stringify(payload) });
|
||||
await refreshRuns();
|
||||
});
|
||||
|
||||
commandGrid.appendChild(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCheckboxField(id, title, subtitle) {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'checkbox-field';
|
||||
wrapper.innerHTML = `
|
||||
<div>
|
||||
<strong>${title}</strong>
|
||||
<div>${subtitle}</div>
|
||||
</div>
|
||||
<input id="${id}" type="checkbox" />
|
||||
`;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function buildInputField(id, title, placeholder) {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'field-label';
|
||||
wrapper.innerHTML = `
|
||||
<strong>${title}</strong>
|
||||
<input id="${id}" type="text" placeholder="${placeholder}" />
|
||||
`;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function renderConfig() {
|
||||
configGroups.innerHTML = '';
|
||||
const groups = new Map();
|
||||
for (const field of state.envSchema) {
|
||||
if (!groups.has(field.group)) groups.set(field.group, []);
|
||||
groups.get(field.group).push(field);
|
||||
}
|
||||
|
||||
for (const [groupName, fields] of groups) {
|
||||
const group = document.createElement('section');
|
||||
group.className = 'config-group';
|
||||
group.innerHTML = `<h4>${groupName}</h4>`;
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'config-grid';
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.type === 'boolean') {
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'checkbox-field';
|
||||
wrapper.innerHTML = `
|
||||
<div>
|
||||
<strong>${field.label}</strong>
|
||||
<div>${field.key}</div>
|
||||
</div>
|
||||
<input data-env-key="${field.key}" type="checkbox" ${String(fieldValue(field.key)).toLowerCase() === 'true' ? 'checked' : ''} />
|
||||
`;
|
||||
grid.appendChild(wrapper);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'field-label';
|
||||
wrapper.innerHTML = `<strong>${field.label}</strong>`;
|
||||
let input;
|
||||
if (field.type === 'select') {
|
||||
input = document.createElement('select');
|
||||
input.dataset.envKey = field.key;
|
||||
for (const option of field.options || []) {
|
||||
const item = document.createElement('option');
|
||||
item.value = option;
|
||||
item.textContent = option;
|
||||
if (fieldValue(field.key) === option) item.selected = true;
|
||||
input.appendChild(item);
|
||||
}
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.dataset.envKey = field.key;
|
||||
input.type = field.type === 'password' ? 'password' : field.type === 'number' ? 'number' : 'text';
|
||||
input.value = fieldValue(field.key);
|
||||
input.placeholder = field.key;
|
||||
}
|
||||
wrapper.appendChild(input);
|
||||
grid.appendChild(wrapper);
|
||||
}
|
||||
|
||||
group.appendChild(grid);
|
||||
configGroups.appendChild(group);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
historyList.innerHTML = '';
|
||||
const template = document.getElementById('historyItemTemplate');
|
||||
for (const item of state.runState.history) {
|
||||
const fragment = template.content.cloneNode(true);
|
||||
fragment.querySelector('.history-title').textContent = item.label;
|
||||
fragment.querySelector('.history-meta').textContent = `${item.commandKey} · ${new Date(item.startedAt).toLocaleString()}`;
|
||||
const pill = fragment.querySelector('.history-status');
|
||||
pill.textContent = item.status;
|
||||
pill.className = `pill ${item.status}`;
|
||||
historyList.appendChild(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummaryCards(summary) {
|
||||
summaryCards.innerHTML = '';
|
||||
const cards = [
|
||||
{
|
||||
label: '最近运行数',
|
||||
value: String(summary.runCount || 0),
|
||||
meta: summary.latestFinishedAt ? `最后完成:${new Date(summary.latestFinishedAt).toLocaleString()}` : '暂无运行记录',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of summary.datasetTotals || []) {
|
||||
cards.push({
|
||||
label: item.key,
|
||||
value: String(item.total ?? 0),
|
||||
meta: `新增 ${item.added ?? 0} · 更新 ${item.updated ?? 0} · 删除 ${item.removed ?? 0}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const card of cards) {
|
||||
const element = document.createElement('article');
|
||||
element.className = 'stats-card';
|
||||
element.innerHTML = `
|
||||
<div class="label">${card.label}</div>
|
||||
<div class="value">${card.value}</div>
|
||||
<div class="meta">${card.meta}</div>
|
||||
`;
|
||||
summaryCards.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, init = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error(payload.error || 'request_failed');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function collectEnvValues() {
|
||||
const values = { ...state.envValues };
|
||||
document.querySelectorAll('[data-env-key]').forEach((input) => {
|
||||
const key = input.dataset.envKey;
|
||||
if (input.type === 'checkbox') {
|
||||
values[key] = input.checked ? 'true' : 'false';
|
||||
} else {
|
||||
values[key] = input.value;
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const payload = await fetchJson('/api/bootstrap');
|
||||
state.envSchema = payload.envSchema;
|
||||
state.envValues = payload.envValues;
|
||||
state.commands = payload.commands;
|
||||
state.runState = payload.runState;
|
||||
state.summaryCards = payload.summaryCards;
|
||||
state.schedulerMeta = payload.schedulerMeta;
|
||||
renderCommands();
|
||||
renderConfig();
|
||||
renderHistory();
|
||||
renderRunState();
|
||||
renderSummaryCards(payload.summaryCards || { datasetTotals: [], runCount: 0 });
|
||||
renderSchedulerMeta(payload.schedulerMeta || {});
|
||||
await refreshAnalytics();
|
||||
startRunStream();
|
||||
}
|
||||
|
||||
async function refreshRuns() {
|
||||
state.runState = await fetchJson('/api/runs');
|
||||
renderHistory();
|
||||
renderRunState();
|
||||
}
|
||||
|
||||
async function refreshSummary() {
|
||||
const summary = await fetchJson('/api/summary');
|
||||
state.summaryCards = summary;
|
||||
renderSummaryCards(summary);
|
||||
}
|
||||
|
||||
async function refreshSchedulerMeta() {
|
||||
const payload = await fetchJson('/api/scheduler-meta');
|
||||
state.schedulerMeta = payload;
|
||||
renderSchedulerMeta(payload);
|
||||
}
|
||||
|
||||
async function refreshAnalytics() {
|
||||
const payload = await fetchJson('/api/analytics/messages');
|
||||
renderAnalytics(payload);
|
||||
}
|
||||
|
||||
function startRunStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
eventSource = new EventSource('/api/events');
|
||||
eventSource.addEventListener('runState', (event) => {
|
||||
state.runState = JSON.parse(event.data);
|
||||
renderHistory();
|
||||
renderRunState();
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
utilityFeedback.textContent = '日志流连接断开,正在等待重连…';
|
||||
};
|
||||
}
|
||||
|
||||
saveEnvBtn.addEventListener('click', async () => {
|
||||
const values = collectEnvValues();
|
||||
await fetchJson('/api/env', { method: 'POST', body: JSON.stringify({ values }) });
|
||||
state.envValues = values;
|
||||
saveEnvBtn.textContent = '已保存';
|
||||
setTimeout(() => { saveEnvBtn.textContent = '保存配置'; }, 1600);
|
||||
});
|
||||
|
||||
stopRunBtn.addEventListener('click', async () => {
|
||||
await fetchJson('/api/stop', { method: 'POST', body: '{}' });
|
||||
await refreshRuns();
|
||||
});
|
||||
|
||||
testDbBtn.addEventListener('click', async () => {
|
||||
utilityFeedback.textContent = '正在测试数据库连接…';
|
||||
try {
|
||||
const result = await fetchJson('/api/test-db', { method: 'POST', body: '{}' });
|
||||
if (!result.ok) {
|
||||
utilityFeedback.textContent = `数据库连接失败:${result.code || ''} ${result.message}`.trim();
|
||||
showMaintenanceResult('error', `数据库连接失败\ncode: ${result.code || 'N/A'}\nmessage: ${result.message}\nhost: ${result.host || '-'}\ndatabase: ${result.database || '-'}`);
|
||||
return;
|
||||
}
|
||||
utilityFeedback.textContent = `数据库连接成功 · 服务时间 ${result.now}`;
|
||||
showMaintenanceResult('success', `数据库连接成功\n服务器时间:${result.now}`);
|
||||
} catch (error) {
|
||||
utilityFeedback.textContent = `数据库连接失败:${error.message}`;
|
||||
showMaintenanceResult('error', `数据库连接失败\n${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
checkLoginBtn.addEventListener('click', async () => {
|
||||
utilityFeedback.textContent = '正在检查登录态…';
|
||||
try {
|
||||
const result = await fetchJson('/api/login-state');
|
||||
utilityFeedback.textContent = result.ok
|
||||
? `登录态可用 · cookies=${result.cookies}`
|
||||
: `登录态异常:${result.reason || 'unknown'}`;
|
||||
showMaintenanceResult(result.ok ? 'success' : 'error', result.ok
|
||||
? `登录态可用\nCookies 数量:${result.cookies}\n文件:${result.statePath}`
|
||||
: `登录态异常\n原因:${result.reason || 'unknown'}`);
|
||||
} catch (error) {
|
||||
utilityFeedback.textContent = `登录态检查失败:${error.message}`;
|
||||
showMaintenanceResult('error', `登录态检查失败\n${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
clearCheckpointBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm('确定要清理消息的 checkpoint / history / delta / current 痕迹吗?');
|
||||
if (!confirmed) return;
|
||||
utilityFeedback.textContent = '正在清理消息 checkpoint…';
|
||||
try {
|
||||
const result = await fetchJson('/api/maintenance/clear-message-checkpoints', { method: 'POST', body: '{}' });
|
||||
utilityFeedback.textContent = `已清理消息本地痕迹 · currentFileRemoved=${result.currentFileRemoved}`;
|
||||
showMaintenanceResult('success', `消息本地痕迹已清理\ncurrentFileRemoved=${result.currentFileRemoved}`);
|
||||
} catch (error) {
|
||||
utilityFeedback.textContent = `清理 checkpoint 失败:${error.message}`;
|
||||
showMaintenanceResult('error', `清理消息 checkpoint 失败\n${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
clearMessageDataBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm('确定要清空当前 source_id 下的消息主表和账号明细表数据吗?');
|
||||
if (!confirmed) return;
|
||||
utilityFeedback.textContent = '正在清空消息数据库…';
|
||||
try {
|
||||
const result = await fetchJson('/api/maintenance/clear-message-data', { method: 'POST', body: '{}' });
|
||||
utilityFeedback.textContent = `已删除主表 ${result.mainDeleted} 行,明细表 ${result.childDeleted} 行`;
|
||||
showMaintenanceResult('success', `消息数据库已清理\nsource_id=${result.sourceId}\n主表删除 ${result.mainDeleted} 行\n明细表删除 ${result.childDeleted} 行`);
|
||||
await refreshAnalytics();
|
||||
await refreshSummary();
|
||||
} catch (error) {
|
||||
utilityFeedback.textContent = `清空消息数据失败:${error.message}`;
|
||||
showMaintenanceResult('error', `清空消息数据库失败\n${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
logFilterInput?.addEventListener('input', () => {
|
||||
renderRunState();
|
||||
});
|
||||
|
||||
exportLogBtn?.addEventListener('click', () => {
|
||||
const text = runOutput.textContent || '';
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `aps-console-log-${Date.now()}.txt`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
void refreshSummary();
|
||||
void refreshAnalytics();
|
||||
void refreshSchedulerMeta();
|
||||
}, 10000);
|
||||
|
||||
void bootstrap();
|
||||
355
aliyun-sync/aliyun-aps-sync/web-console/assets/styles.css
Normal file
355
aliyun-sync/aliyun-aps-sync/web-console/assets/styles.css
Normal file
@@ -0,0 +1,355 @@
|
||||
:root {
|
||||
--bg: #07111f;
|
||||
--panel: rgba(10, 20, 36, 0.84);
|
||||
--panel-strong: #0f1d33;
|
||||
--line: rgba(148, 163, 184, 0.18);
|
||||
--text: #edf4ff;
|
||||
--muted: #90a3bf;
|
||||
--brand: #61dafb;
|
||||
--brand-strong: #2dd4bf;
|
||||
--danger: #f87171;
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--shadow: 0 32px 80px rgba(0, 0, 0, 0.35);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
--font-display: 'Georgia', 'Times New Roman', serif;
|
||||
--font-body: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at top, #123456 0%, #07111f 42%, #020611 100%); color: var(--text); font-family: var(--font-body); }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 28px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(6, 11, 23, 0.96), rgba(6, 11, 23, 0.74));
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand { display: flex; gap: 16px; align-items: center; margin-bottom: 36px; }
|
||||
.brand-badge {
|
||||
width: 56px; height: 56px; border-radius: 18px;
|
||||
display: grid; place-items: center;
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-strong));
|
||||
color: #02111d; font-weight: 800; letter-spacing: 0.1em;
|
||||
box-shadow: 0 18px 40px rgba(45, 212, 191, 0.25);
|
||||
}
|
||||
.brand h1 { margin: 0; font-family: var(--font-display); font-size: 1.5rem; }
|
||||
.brand p { margin: 4px 0 0; color: var(--muted); font-size: 0.92rem; }
|
||||
|
||||
.sidebar-nav { display: flex; flex-direction: column; gap: 10px; }
|
||||
.nav-link {
|
||||
text-align: left; border: 1px solid transparent; border-radius: 14px;
|
||||
padding: 14px 16px; background: transparent; color: var(--muted);
|
||||
transition: 160ms ease;
|
||||
}
|
||||
.nav-link.active,
|
||||
.nav-link:hover {
|
||||
background: rgba(97, 218, 251, 0.12);
|
||||
border-color: rgba(97, 218, 251, 0.22);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace { padding: 28px; display: flex; flex-direction: column; gap: 22px; }
|
||||
|
||||
.hero-card,
|
||||
.stats-card,
|
||||
.panel,
|
||||
.log-card,
|
||||
.command-card,
|
||||
.history-item,
|
||||
.config-group {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 28px 30px;
|
||||
display: flex; justify-content: space-between; gap: 24px; align-items: center;
|
||||
}
|
||||
.eyebrow { color: var(--brand); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.2em; }
|
||||
.hero-card h2 { margin: 10px 0 12px; font-family: var(--font-display); font-size: 2rem; max-width: 760px; }
|
||||
.hero-card p { margin: 0; color: var(--muted); max-width: 760px; line-height: 1.7; }
|
||||
.hero-status {
|
||||
min-width: 220px; align-self: stretch; border-radius: 22px;
|
||||
background: linear-gradient(160deg, rgba(45, 212, 191, 0.18), rgba(96, 165, 250, 0.08));
|
||||
border: 1px solid rgba(45, 212, 191, 0.2); padding: 20px; display: flex; align-items: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.stats-card .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.stats-card .value {
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.stats-card .meta {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.utility-strip {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.analytics-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.scheduler-strip {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.scheduler-card {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.scheduler-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.scheduler-meta .item {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(3, 10, 20, 0.72);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.scheduler-meta .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.scheduler-meta .value {
|
||||
margin-top: 8px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.analytics-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.analytics-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.analytics-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.analytics-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(3, 10, 20, 0.72);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.analytics-item strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.analytics-item span {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.utility-feedback {
|
||||
color: var(--muted);
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.result-strip {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
width: 100%;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(3, 10, 20, 0.72);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.result-card.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-card.success {
|
||||
border-color: rgba(52, 211, 153, 0.24);
|
||||
background: rgba(6, 78, 59, 0.2);
|
||||
}
|
||||
|
||||
.result-card.error {
|
||||
border-color: rgba(248, 113, 113, 0.24);
|
||||
background: rgba(127, 29, 29, 0.2);
|
||||
}
|
||||
|
||||
.log-tools {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mini-toggle {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.log-filter-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.panel { display: none; padding: 24px; }
|
||||
.panel.active { display: block; }
|
||||
.panel-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-bottom: 20px; }
|
||||
.panel-header h3 { margin: 0; font-family: var(--font-display); font-size: 1.5rem; }
|
||||
.panel-header p { margin: 6px 0 0; color: var(--muted); }
|
||||
|
||||
.command-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 18px; margin-bottom: 20px; }
|
||||
.command-card { padding: 20px; border-radius: var(--radius-lg); }
|
||||
.command-card-head { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
||||
.command-title { margin: 0; font-size: 1.1rem; }
|
||||
.command-description { margin: 8px 0 0; color: var(--muted); line-height: 1.6; min-height: 46px; }
|
||||
.command-key { font-size: 0.78rem; color: var(--brand); text-transform: uppercase; letter-spacing: 0.16em; }
|
||||
.command-options { display: grid; gap: 10px; margin-bottom: 16px; }
|
||||
|
||||
.field-label { display: grid; gap: 6px; color: var(--muted); font-size: 0.88rem; }
|
||||
.field-label strong { color: var(--text); font-size: 0.94rem; }
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(3, 10, 20, 0.72); color: var(--text); padding: 12px 14px;
|
||||
}
|
||||
|
||||
.primary, .ghost {
|
||||
border-radius: 14px; border: 1px solid transparent; padding: 12px 16px;
|
||||
transition: 160ms ease; font-weight: 600;
|
||||
}
|
||||
.primary {
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-strong));
|
||||
color: #07111f;
|
||||
}
|
||||
.primary:hover { transform: translateY(-1px); }
|
||||
.ghost {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
color: var(--text);
|
||||
}
|
||||
.ghost.danger { color: #ffd7d7; border-color: rgba(248, 113, 113, 0.24); background: rgba(127, 29, 29, 0.2); }
|
||||
|
||||
.log-card { padding: 18px; border-radius: var(--radius-lg); }
|
||||
.log-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.log-output {
|
||||
margin: 0; min-height: 280px; max-height: 460px; overflow: auto;
|
||||
padding: 18px; border-radius: 18px; background: rgba(1, 6, 14, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12); line-height: 1.55; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 92px; border-radius: 999px; padding: 8px 12px; font-size: 0.82rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
.pill.idle { background: rgba(148, 163, 184, 0.16); color: var(--muted); }
|
||||
.pill.running { background: rgba(251, 191, 36, 0.18); color: #fde68a; }
|
||||
.pill.completed { background: rgba(52, 211, 153, 0.18); color: #bbf7d0; }
|
||||
.pill.failed, .pill.stopped { background: rgba(248, 113, 113, 0.16); color: #fecaca; }
|
||||
|
||||
.config-groups { display: grid; gap: 18px; }
|
||||
.config-group { padding: 20px; border-radius: var(--radius-lg); }
|
||||
.config-group h4 { margin: 0 0 14px; font-size: 1.08rem; }
|
||||
.config-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
|
||||
|
||||
.checkbox-field {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 10px; padding: 14px 16px; border-radius: 14px;
|
||||
background: rgba(3, 10, 20, 0.72); border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.history-list { display: grid; gap: 12px; }
|
||||
.history-item { padding: 16px 18px; border-radius: var(--radius-lg); display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.history-title { margin: 0; font-size: 1rem; }
|
||||
.history-meta { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.sidebar-nav { flex-direction: row; flex-wrap: wrap; }
|
||||
.hero-card { flex-direction: column; align-items: flex-start; }
|
||||
.hero-status { min-width: 0; width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.workspace { padding: 18px; }
|
||||
.panel, .hero-card, .sidebar { padding: 18px; }
|
||||
.hero-card h2 { font-size: 1.5rem; }
|
||||
.panel-header { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
152
aliyun-sync/aliyun-aps-sync/web-console/index.html
Normal file
152
aliyun-sync/aliyun-aps-sync/web-console/index.html
Normal file
@@ -0,0 +1,152 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>APS Console</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-badge">APS</div>
|
||||
<div>
|
||||
<h1>Control Console</h1>
|
||||
<p>配置、命令与日志统一管理</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<button data-tab="commands" class="nav-link active">运行命令</button>
|
||||
<button data-tab="config" class="nav-link">环境配置</button>
|
||||
<button data-tab="history" class="nav-link">运行记录</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<section class="hero-card">
|
||||
<div>
|
||||
<span class="eyebrow">Local orchestration</span>
|
||||
<h2>把 `.env`、启动命令和运行日志整合进一个页面</h2>
|
||||
<p>用于本地管理 APS 抓取工具:保存配置、执行常用命令、实时查看输出,不再来回切换命令行和配置文件。</p>
|
||||
</div>
|
||||
<div class="hero-status" id="activeRunSummary">当前无运行任务</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-grid" id="summaryCards"></section>
|
||||
|
||||
<section class="scheduler-strip">
|
||||
<article class="scheduler-card">
|
||||
<div class="analytics-header">
|
||||
<h3>定时任务专区</h3>
|
||||
<p>专门展示 bills 与 hot 双轨调度的运行策略和配置。</p>
|
||||
</div>
|
||||
<div id="schedulerMeta" class="scheduler-meta"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="analytics-strip">
|
||||
<article class="analytics-card">
|
||||
<div class="analytics-header">
|
||||
<h3>消息分类统计</h3>
|
||||
<p>最近数据库中的消息分类分布</p>
|
||||
</div>
|
||||
<div id="messageClassificationStats" class="analytics-list"></div>
|
||||
</article>
|
||||
<article class="analytics-card">
|
||||
<div class="analytics-header">
|
||||
<h3>账号 ID 命中统计</h3>
|
||||
<p>释放/预警类消息中出现最多的账号 ID</p>
|
||||
</div>
|
||||
<div id="messageAccountStats" class="analytics-list"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="utility-strip">
|
||||
<button id="testDbBtn" class="ghost">测试数据库连接</button>
|
||||
<button id="checkLoginBtn" class="ghost">检查登录态</button>
|
||||
<button id="clearCheckpointBtn" class="ghost">清消息 Checkpoint</button>
|
||||
<button id="clearMessageDataBtn" class="ghost danger">清消息数据库</button>
|
||||
<div id="utilityFeedback" class="utility-feedback">等待检测…</div>
|
||||
</section>
|
||||
|
||||
<section class="result-strip">
|
||||
<div id="maintenanceResultCard" class="result-card hidden"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel active" data-panel="commands">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3>命令控制台</h3>
|
||||
<p>常用同步任务一键启动,可选 resume / incremental 参数。</p>
|
||||
</div>
|
||||
<button id="stopRunBtn" class="ghost danger">停止当前任务</button>
|
||||
</div>
|
||||
<div class="command-grid" id="commandGrid"></div>
|
||||
<div class="log-card">
|
||||
<div class="log-card-header">
|
||||
<h4>运行输出</h4>
|
||||
<div class="log-tools">
|
||||
<label class="mini-toggle">
|
||||
<input id="autoScrollToggle" type="checkbox" checked />
|
||||
<span>自动滚动</span>
|
||||
</label>
|
||||
<input id="logFilterInput" class="log-filter-input" type="text" placeholder="过滤日志关键词" />
|
||||
<button id="exportLogBtn" class="ghost">导出日志</button>
|
||||
<span id="runStatusPill" class="pill idle">idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="runOutput" class="log-output">等待任务启动…</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="config">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3>环境变量配置</h3>
|
||||
<p>按分组查看和编辑 `.env`,保存后下次启动任务会读取新值。</p>
|
||||
</div>
|
||||
<button id="saveEnvBtn" class="primary">保存配置</button>
|
||||
</div>
|
||||
<div id="configGroups" class="config-groups"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="history">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3>运行历史</h3>
|
||||
<p>保留最近任务状态与命令参数,便于快速回看。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="historyList" class="history-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="commandCardTemplate">
|
||||
<article class="command-card">
|
||||
<div class="command-card-head">
|
||||
<div>
|
||||
<h4 class="command-title"></h4>
|
||||
<p class="command-description"></p>
|
||||
</div>
|
||||
<span class="command-key"></span>
|
||||
</div>
|
||||
<div class="command-options"></div>
|
||||
<button class="primary run-command-btn">启动</button>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<template id="historyItemTemplate">
|
||||
<article class="history-item">
|
||||
<div>
|
||||
<h4 class="history-title"></h4>
|
||||
<p class="history-meta"></p>
|
||||
</div>
|
||||
<span class="pill history-status"></span>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user