Compare commits
13 Commits
d14ed90597
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4ed1f14ee | ||
|
|
9bb5ab35af | ||
|
|
4af439a6c9 | ||
|
|
e5d4b027b2 | ||
| 87f629ac29 | |||
| f4854a2630 | |||
| 3a7f91419e | |||
| a06cdc70f1 | |||
| bbba33cab0 | |||
| f8d6274674 | |||
| 5355d8b7d2 | |||
| c4e7f8dd58 | |||
| 1c92478867 |
@@ -31,7 +31,7 @@ npm run login
|
|||||||
npm run sync
|
npm run sync
|
||||||
```
|
```
|
||||||
|
|
||||||
如果要从已有 checkpoint 继续全量流程(当前主要覆盖 orders + bills):
|
如果要从已有 checkpoint 继续全量流程(覆盖 customers / customerDetails / orders / orderDetails / bills / messages):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run sync -- --resume
|
npm run sync -- --resume
|
||||||
@@ -84,6 +84,8 @@ npm run messages
|
|||||||
npm run orders
|
npm run orders
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:会同时抓取订单列表与订单详情。
|
||||||
|
|
||||||
订单增量:
|
订单增量:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ ALIYUN_APS_ORDER_START_DATE=2023-01-01
|
|||||||
ALIYUN_APS_BILL_START_MONTH=2023-01
|
ALIYUN_APS_BILL_START_MONTH=2023-01
|
||||||
|
|
||||||
|
|
||||||
#如果需要从某月继续就配置如下
|
#如果需要从某月继续就配置如<EFBFBD>?
|
||||||
#ALIYUN_APS_RESUME_BILL_MONTH=2026-04
|
#ALIYUN_APS_RESUME_BILL_MONTH=2026-04
|
||||||
#ALIYUN_APS_RESUME_BILL_PAGE=1790
|
#ALIYUN_APS_RESUME_BILL_PAGE=1790
|
||||||
#如果不需要就
|
#如果不需要就
|
||||||
@@ -23,18 +23,30 @@ ALIYUN_APS_SMTP_PORT=465
|
|||||||
ALIYUN_APS_SMTP_SECURE=true
|
ALIYUN_APS_SMTP_SECURE=true
|
||||||
ALIYUN_APS_SMTP_USER=wang1416431931@163.com
|
ALIYUN_APS_SMTP_USER=wang1416431931@163.com
|
||||||
ALIYUN_APS_SMTP_PASS=VXS4FCeckMbtYz7r
|
ALIYUN_APS_SMTP_PASS=VXS4FCeckMbtYz7r
|
||||||
ALIYUN_APS_NOTIFY_EMAIL=1416431931@qq.com
|
ALIYUN_APS_NOTIFY_EMAIL=
|
||||||
|
# 浏览器关闭策<E997AD>? true=执行完关<E5AE8C>?默认), false=保持浏览器不关闭
|
||||||
# 浏览器关闭策略: true=执行完关闭(默认), false=保持浏览器不关闭
|
|
||||||
ALIYUN_APS_CLOSE_BROWSER=true
|
ALIYUN_APS_CLOSE_BROWSER=true
|
||||||
|
|
||||||
# 全量同步: true=从起始日期遍历所有月份(默认), false=增量(订单查前一天,账单查当月)
|
|
||||||
|
# 全量同步: true=从起始日期遍历所有月<E69C89>?默认), false=增量(订单查前一<E5898D>?账单查当<E69FA5>?
|
||||||
ALIYUN_APS_FULL_SYNC=true
|
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_PORT=3306
|
||||||
ALIYUN_APS_DB_USER=ray
|
ALIYUN_APS_DB_USER=root
|
||||||
ALIYUN_APS_DB_PASSWORD=GV0C$ErephgQO7RQc7b6
|
ALIYUN_APS_DB_PASSWORD=Cdcc833!!!
|
||||||
ALIYUN_APS_DB_NAME=crm-prod
|
ALIYUN_APS_DB_NAME=goonseek-dev
|
||||||
ALIYUN_APS_DB_CHARSET=utf8mb4
|
ALIYUN_APS_DB_CHARSET=utf8mb4
|
||||||
ALIYUN_APS_DB_CONNECTION_LIMIT=5
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
ALIYUN_APS_BASE_URL=https://aps.aliyun.com
|
ALIYUN_APS_BASE_URL=https://aps.aliyun.com
|
||||||
|
ALIYUN_APS_SOURCE_ID=default
|
||||||
ALIYUN_APS_HEADLESS=false
|
ALIYUN_APS_HEADLESS=false
|
||||||
ALIYUN_APS_BROWSER_MODE=launch
|
ALIYUN_APS_BROWSER_MODE=launch
|
||||||
ALIYUN_APS_BROWSER_CHANNEL=
|
ALIYUN_APS_BROWSER_CHANNEL=
|
||||||
@@ -6,6 +7,7 @@ ALIYUN_APS_BROWSER_EXECUTABLE_PATH=
|
|||||||
ALIYUN_APS_CDP_URL=http://127.0.0.1:9222
|
ALIYUN_APS_CDP_URL=http://127.0.0.1:9222
|
||||||
ALIYUN_APS_TIMEZONE=Asia/Shanghai
|
ALIYUN_APS_TIMEZONE=Asia/Shanghai
|
||||||
ALIYUN_APS_CRON=0 6 * * *
|
ALIYUN_APS_CRON=0 6 * * *
|
||||||
|
ALIYUN_APS_HOT_CRON=*/5 * * * *
|
||||||
ALIYUN_APS_SCHEDULE_MODE=incremental
|
ALIYUN_APS_SCHEDULE_MODE=incremental
|
||||||
ALIYUN_APS_CLOSE_BROWSER=true
|
ALIYUN_APS_CLOSE_BROWSER=true
|
||||||
ALIYUN_APS_FULL_SYNC=true
|
ALIYUN_APS_FULL_SYNC=true
|
||||||
@@ -15,6 +17,13 @@ ALIYUN_APS_BILL_START_MONTH=2024-01
|
|||||||
ALIYUN_APS_ORDER_INCREMENTAL_OVERLAP_DAYS=2
|
ALIYUN_APS_ORDER_INCREMENTAL_OVERLAP_DAYS=2
|
||||||
ALIYUN_APS_BILL_INCREMENTAL_OVERLAP_DAYS=7
|
ALIYUN_APS_BILL_INCREMENTAL_OVERLAP_DAYS=7
|
||||||
ALIYUN_APS_MESSAGE_INCREMENTAL_OVERLAP_DAYS=7
|
ALIYUN_APS_MESSAGE_INCREMENTAL_OVERLAP_DAYS=7
|
||||||
|
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=已完成,已关闭,已取消,已退款完成
|
||||||
ALIYUN_APS_DB_HOST=
|
ALIYUN_APS_DB_HOST=
|
||||||
ALIYUN_APS_DB_PORT=3306
|
ALIYUN_APS_DB_PORT=3306
|
||||||
ALIYUN_APS_DB_USER=
|
ALIYUN_APS_DB_USER=
|
||||||
@@ -22,6 +31,7 @@ ALIYUN_APS_DB_PASSWORD=
|
|||||||
ALIYUN_APS_DB_NAME=
|
ALIYUN_APS_DB_NAME=
|
||||||
ALIYUN_APS_DB_CHARSET=utf8mb4
|
ALIYUN_APS_DB_CHARSET=utf8mb4
|
||||||
ALIYUN_APS_DB_CONNECTION_LIMIT=5
|
ALIYUN_APS_DB_CONNECTION_LIMIT=5
|
||||||
|
ALIYUN_APS_DB_CONNECT_TIMEOUT=20000
|
||||||
ALIYUN_APS_SMTP_HOST=smtp.163.com
|
ALIYUN_APS_SMTP_HOST=smtp.163.com
|
||||||
ALIYUN_APS_SMTP_PORT=465
|
ALIYUN_APS_SMTP_PORT=465
|
||||||
ALIYUN_APS_SMTP_SECURE=true
|
ALIYUN_APS_SMTP_SECURE=true
|
||||||
|
|||||||
27
aliyun-sync/aliyun-aps-sync/.github/workflows/playwright.yml
vendored
Normal file
27
aliyun-sync/aliyun-aps-sync/.github/workflows/playwright.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npx playwright test
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
8
aliyun-sync/aliyun-aps-sync/.gitignore
vendored
8
aliyun-sync/aliyun-aps-sync/.gitignore
vendored
@@ -3,3 +3,11 @@ node_modules/
|
|||||||
.browser/
|
.browser/
|
||||||
data/
|
data/
|
||||||
downloads/
|
downloads/
|
||||||
|
.sisyphus/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -28,7 +28,7 @@ Node 版阿里云 APS 同步工具。
|
|||||||
npm run sync
|
npm run sync
|
||||||
```
|
```
|
||||||
|
|
||||||
如果要让 full sync 从已有 checkpoint 继续(当前主要覆盖 orders + bills):
|
如果要让 full sync 从已有 checkpoint 继续(覆盖 customers / customerDetails / orders / orderDetails / bills / messages):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run sync -- --resume
|
npm run sync -- --resume
|
||||||
@@ -54,6 +54,28 @@ npm run incremental
|
|||||||
- 抓 orders / orderDetails / bills / messages
|
- 抓 orders / orderDetails / bills / messages
|
||||||
- 以数据库 watermark + overlap 为增量窗口
|
- 以数据库 watermark + overlap 为增量窗口
|
||||||
|
|
||||||
|
### Hot 模式
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run hot
|
||||||
|
```
|
||||||
|
|
||||||
|
行为:
|
||||||
|
|
||||||
|
- 每次只抓**当天订单**
|
||||||
|
- 从订单第一页开始扫描
|
||||||
|
- 订单列表按“连续稳定行 / 连续稳定页 / 最大页数”提前停止
|
||||||
|
- 订单详情只抓:新增订单、列表有变化订单、缺失详情订单、非终态且到达兜底刷新时间的订单
|
||||||
|
- 消息按数据库最新时间回退分钟 overlap 后抓取,并在旧页提前停止
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 白天高频追当天订单
|
||||||
|
- 订单量较大,不希望每 5 分钟重复扫完整个当天分页
|
||||||
|
- 需要兼顾详情完整性和抓取效率
|
||||||
|
|
||||||
## 登录
|
## 登录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -70,6 +92,32 @@ npm run login
|
|||||||
- `.browser/`
|
- `.browser/`
|
||||||
- `.browser/storage-state.json`
|
- `.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
|
||||||
|
```
|
||||||
|
|
||||||
## 账单
|
## 账单
|
||||||
|
|
||||||
### 单独抓账单
|
### 单独抓账单
|
||||||
@@ -92,6 +140,11 @@ npm run bills -- --resume
|
|||||||
npm run orders
|
npm run orders
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:该命令会同时抓取:
|
||||||
|
|
||||||
|
- orders(订单列表)
|
||||||
|
- orderDetails(订单详情)
|
||||||
|
|
||||||
订单增量:
|
订单增量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -112,6 +165,26 @@ npm run orders -- --resume
|
|||||||
npm run messages
|
npm run messages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 高频同步
|
||||||
|
|
||||||
|
手动执行一次高频同步:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run hot
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 PowerShell 禁止 `npm.ps1`,可以直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node src/index.js hot
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `hot` 只覆盖当天订单、订单详情、消息
|
||||||
|
- 不抓 customer / customerDetails / bills
|
||||||
|
- 适合作为工作时间内的高频轮询任务
|
||||||
|
|
||||||
## 定时任务
|
## 定时任务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -126,6 +199,26 @@ ALIYUN_APS_SCHEDULE_MODE=incremental
|
|||||||
|
|
||||||
执行日增量。
|
执行日增量。
|
||||||
|
|
||||||
|
如果要执行 5 分钟高频同步,可以设置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALIYUN_APS_SCHEDULE_MODE=hot
|
||||||
|
ALIYUN_APS_HOT_CRON=*/5 * * * *
|
||||||
|
```
|
||||||
|
|
||||||
|
然后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `incremental`:按现有增量策略抓 orders / orderDetails / bills / messages
|
||||||
|
- `full`:按全量策略执行
|
||||||
|
- `hot`:每轮只抓当天 orders / orderDetails / messages
|
||||||
|
- hot 模式内置任务锁;如果上一轮还没结束,会跳过下一轮,避免重叠执行
|
||||||
|
|
||||||
## 增量窗口
|
## 增量窗口
|
||||||
|
|
||||||
### orders
|
### orders
|
||||||
@@ -152,11 +245,47 @@ ALIYUN_APS_BILL_INCREMENTAL_OVERLAP_DAYS=7
|
|||||||
ALIYUN_APS_MESSAGE_INCREMENTAL_OVERLAP_DAYS=7
|
ALIYUN_APS_MESSAGE_INCREMENTAL_OVERLAP_DAYS=7
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 高频模式配置
|
||||||
|
|
||||||
|
推荐配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALIYUN_APS_SCHEDULE_MODE=hot
|
||||||
|
ALIYUN_APS_HOT_CRON=*/5 * * * *
|
||||||
|
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=已完成,已关闭,已取消,已退款完成
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- `ALIYUN_APS_HOT_CRON`:高频任务 cron,默认每 5 分钟一次
|
||||||
|
- `ALIYUN_APS_HOT_MESSAGE_OVERLAP_MINUTES`:消息高频模式的回扫分钟数
|
||||||
|
- `ALIYUN_APS_HOT_ORDER_STABLE_THRESHOLD`:订单扫描中连续多少条稳定记录后停止
|
||||||
|
- `ALIYUN_APS_HOT_ORDER_STABLE_PAGE_THRESHOLD`:订单扫描中连续多少页无新增/变更后停止
|
||||||
|
- `ALIYUN_APS_HOT_ORDER_MAX_PAGES`:订单每轮最多扫描页数,防止高峰期跑太久
|
||||||
|
- `ALIYUN_APS_HOT_MESSAGE_MAX_PAGES`:消息每轮最多扫描页数
|
||||||
|
- `ALIYUN_APS_HOT_ORDER_DETAIL_REFRESH_MINUTES`:非终态订单详情兜底刷新间隔
|
||||||
|
- `ALIYUN_APS_HOT_FINAL_STATUSES`:视为终态的订单状态,终态订单在无变化时会尽量跳过详情抓取
|
||||||
|
|
||||||
|
默认策略:
|
||||||
|
|
||||||
|
- 订单按最新到最旧扫描
|
||||||
|
- 新订单或列表字段变化的订单会进入详情抓取
|
||||||
|
- 已抓过且无变化的终态订单会直接跳过详情
|
||||||
|
- 非终态订单会按兜底刷新时间周期性重抓详情
|
||||||
|
- 消息使用 watermark + overlap,避免 5 分钟轮询时漏边界消息
|
||||||
|
|
||||||
## 数据库配置
|
## 数据库配置
|
||||||
|
|
||||||
`.env` 需要配置:
|
`.env` 需要配置:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
ALIYUN_APS_SOURCE_ID=default
|
||||||
ALIYUN_APS_DB_HOST=
|
ALIYUN_APS_DB_HOST=
|
||||||
ALIYUN_APS_DB_PORT=3306
|
ALIYUN_APS_DB_PORT=3306
|
||||||
ALIYUN_APS_DB_USER=
|
ALIYUN_APS_DB_USER=
|
||||||
@@ -166,6 +295,39 @@ ALIYUN_APS_DB_CHARSET=utf8mb4
|
|||||||
ALIYUN_APS_DB_CONNECTION_LIMIT=5
|
ALIYUN_APS_DB_CONNECTION_LIMIT=5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 多账号 source_id
|
||||||
|
|
||||||
|
如果两个 APS 账号写入同一个数据库,每个账号必须配置不同的 `ALIYUN_APS_SOURCE_ID`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 账号 A
|
||||||
|
ALIYUN_APS_SOURCE_ID=aliyun_account_a
|
||||||
|
|
||||||
|
# 账号 B
|
||||||
|
ALIYUN_APS_SOURCE_ID=aliyun_account_b
|
||||||
|
```
|
||||||
|
|
||||||
|
同步写库时会把 `source_id` 写入:
|
||||||
|
|
||||||
|
- `aps_customer`
|
||||||
|
- `aps_order`
|
||||||
|
- `aps_order_detail`
|
||||||
|
- `aps_bill`
|
||||||
|
- `aliyun_aps_messages`
|
||||||
|
|
||||||
|
增量水位也会按 `source_id` 查询,避免两个账号互相影响。
|
||||||
|
|
||||||
|
建议两个账号使用不同项目目录或不同 `data/.browser` 目录,避免本地登录态和 checkpoint 互相覆盖。
|
||||||
|
|
||||||
|
生产库建议把唯一键调整为 `source_id + 业务唯一键`,例如:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 示例,实际约束名以生产库为准
|
||||||
|
-- aps_order: UNIQUE(source_id, order_id)
|
||||||
|
-- aps_order_detail: UNIQUE(source_id, order_id)
|
||||||
|
-- aliyun_aps_messages: UNIQUE(source_id, msg_id)
|
||||||
|
```
|
||||||
|
|
||||||
## 浏览器配置
|
## 浏览器配置
|
||||||
|
|
||||||
默认不再强制使用 Google Chrome。
|
默认不再强制使用 Google Chrome。
|
||||||
|
|||||||
46
aliyun-sync/aliyun-aps-sync/package-lock.json
generated
46
aliyun-sync/aliyun-aps-sync/package-lock.json
generated
@@ -13,14 +13,31 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"playwright": "^1.58.2"
|
"playwright": "^1.58.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/node": "^25.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.0",
|
"version": "25.6.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
@@ -174,12 +191,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.58.2",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.2"
|
"playwright-core": "1.59.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -192,10 +208,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.58.2",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
@@ -228,8 +243,7 @@
|
|||||||
"version": "7.19.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"console": "node src/console-server.js",
|
||||||
"login": "node src/index.js login",
|
"login": "node src/index.js login",
|
||||||
"sync": "node src/index.js sync",
|
"sync": "node src/index.js sync",
|
||||||
"incremental": "node src/index.js incremental",
|
"incremental": "node src/index.js incremental",
|
||||||
|
"hot": "node src/index.js hot",
|
||||||
"bills": "node src/index.js bills",
|
"bills": "node src/index.js bills",
|
||||||
"orders": "node src/index.js orders",
|
"orders": "node src/index.js orders",
|
||||||
"messages": "node src/index.js messages",
|
"messages": "node src/index.js messages",
|
||||||
@@ -15,8 +17,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"mysql2": "^3.15.2",
|
"mysql2": "^3.15.2",
|
||||||
"nodemailer": "^6.10.1",
|
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
"playwright": "^1.58.2"
|
"playwright": "^1.58.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/node": "^25.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
aliyun-sync/aliyun-aps-sync/playwright.config.ts
Normal file
79
aliyun-sync/aliyun-aps-sync/playwright.config.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('')`. */
|
||||||
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// url: 'http://localhost:3000',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
@@ -11,6 +11,24 @@ const toBool = (value, fallback) => {
|
|||||||
return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).trim().toLowerCase());
|
return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).trim().toLowerCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toInt = (value, fallback, min = Number.MIN_SAFE_INTEGER) => {
|
||||||
|
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Math.max(min, parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toList = (value, fallback = []) => {
|
||||||
|
if (value == null || String(value).trim() === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
const ensureDir = (dirPath) => {
|
const ensureDir = (dirPath) => {
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
return dirPath;
|
return dirPath;
|
||||||
@@ -18,6 +36,7 @@ const ensureDir = (dirPath) => {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
rootDir,
|
rootDir,
|
||||||
|
sourceId: process.env.ALIYUN_APS_SOURCE_ID || 'default',
|
||||||
baseUrl: process.env.ALIYUN_APS_BASE_URL || 'https://aps.aliyun.com',
|
baseUrl: process.env.ALIYUN_APS_BASE_URL || 'https://aps.aliyun.com',
|
||||||
headless: toBool(process.env.ALIYUN_APS_HEADLESS, false),
|
headless: toBool(process.env.ALIYUN_APS_HEADLESS, false),
|
||||||
browserMode: (process.env.ALIYUN_APS_BROWSER_MODE || 'launch').trim().toLowerCase(),
|
browserMode: (process.env.ALIYUN_APS_BROWSER_MODE || 'launch').trim().toLowerCase(),
|
||||||
@@ -26,12 +45,20 @@ export const config = {
|
|||||||
cdpUrl: (process.env.ALIYUN_APS_CDP_URL || 'http://127.0.0.1:9222').trim(),
|
cdpUrl: (process.env.ALIYUN_APS_CDP_URL || 'http://127.0.0.1:9222').trim(),
|
||||||
timezone: process.env.ALIYUN_APS_TIMEZONE || 'Asia/Shanghai',
|
timezone: process.env.ALIYUN_APS_TIMEZONE || 'Asia/Shanghai',
|
||||||
cron: process.env.ALIYUN_APS_CRON || '0 6 * * *',
|
cron: process.env.ALIYUN_APS_CRON || '0 6 * * *',
|
||||||
|
hotCron: process.env.ALIYUN_APS_HOT_CRON || '*/5 * * * *',
|
||||||
orderStartDate: process.env.ALIYUN_APS_ORDER_START_DATE || '2024-01-01',
|
orderStartDate: process.env.ALIYUN_APS_ORDER_START_DATE || '2024-01-01',
|
||||||
incrementalOrderStartDate: process.env.ALIYUN_APS_INCREMENTAL_ORDER_START_DATE || '',
|
incrementalOrderStartDate: process.env.ALIYUN_APS_INCREMENTAL_ORDER_START_DATE || '',
|
||||||
billStartMonth: process.env.ALIYUN_APS_BILL_START_MONTH || '2024-01',
|
billStartMonth: process.env.ALIYUN_APS_BILL_START_MONTH || '2024-01',
|
||||||
orderIncrementalOverlapDays: Math.max(0, Number.parseInt(process.env.ALIYUN_APS_ORDER_INCREMENTAL_OVERLAP_DAYS || '2', 10) || 2),
|
orderIncrementalOverlapDays: toInt(process.env.ALIYUN_APS_ORDER_INCREMENTAL_OVERLAP_DAYS || '2', 2, 0),
|
||||||
billIncrementalOverlapDays: Math.max(0, Number.parseInt(process.env.ALIYUN_APS_BILL_INCREMENTAL_OVERLAP_DAYS || '7', 10) || 7),
|
billIncrementalOverlapDays: toInt(process.env.ALIYUN_APS_BILL_INCREMENTAL_OVERLAP_DAYS || '7', 7, 0),
|
||||||
messageIncrementalOverlapDays: Math.max(0, Number.parseInt(process.env.ALIYUN_APS_MESSAGE_INCREMENTAL_OVERLAP_DAYS || '7', 10) || 7),
|
messageIncrementalOverlapDays: toInt(process.env.ALIYUN_APS_MESSAGE_INCREMENTAL_OVERLAP_DAYS || '7', 7, 0),
|
||||||
|
hotMessageOverlapMinutes: toInt(process.env.ALIYUN_APS_HOT_MESSAGE_OVERLAP_MINUTES || '15', 15, 0),
|
||||||
|
hotOrderStableThreshold: toInt(process.env.ALIYUN_APS_HOT_ORDER_STABLE_THRESHOLD || '100', 100, 1),
|
||||||
|
hotOrderStablePageThreshold: toInt(process.env.ALIYUN_APS_HOT_ORDER_STABLE_PAGE_THRESHOLD || '2', 2, 1),
|
||||||
|
hotOrderMaxPagesPerRun: toInt(process.env.ALIYUN_APS_HOT_ORDER_MAX_PAGES || '20', 20, 1),
|
||||||
|
hotMessageMaxPagesPerRun: toInt(process.env.ALIYUN_APS_HOT_MESSAGE_MAX_PAGES || '10', 10, 1),
|
||||||
|
hotOrderDetailRefreshMinutes: toInt(process.env.ALIYUN_APS_HOT_ORDER_DETAIL_REFRESH_MINUTES || '30', 30, 1),
|
||||||
|
hotFinalStatuses: toList(process.env.ALIYUN_APS_HOT_FINAL_STATUSES, ['已完成', '已关闭', '已取消', '已退款完成']),
|
||||||
scheduleMode: process.env.ALIYUN_APS_SCHEDULE_MODE || 'incremental',
|
scheduleMode: process.env.ALIYUN_APS_SCHEDULE_MODE || 'incremental',
|
||||||
smtp: {
|
smtp: {
|
||||||
host: process.env.ALIYUN_APS_SMTP_HOST || 'smtp.qq.com',
|
host: process.env.ALIYUN_APS_SMTP_HOST || 'smtp.qq.com',
|
||||||
@@ -58,6 +85,7 @@ export const config = {
|
|||||||
database: process.env.ALIYUN_APS_DB_NAME || '',
|
database: process.env.ALIYUN_APS_DB_NAME || '',
|
||||||
charset: process.env.ALIYUN_APS_DB_CHARSET || 'utf8mb4',
|
charset: process.env.ALIYUN_APS_DB_CHARSET || 'utf8mb4',
|
||||||
connectionLimit: Math.max(1, Number.parseInt(process.env.ALIYUN_APS_DB_CONNECTION_LIMIT || '5', 10) || 5),
|
connectionLimit: Math.max(1, Number.parseInt(process.env.ALIYUN_APS_DB_CONNECTION_LIMIT || '5', 10) || 5),
|
||||||
|
connectTimeout: Math.max(1000, Number.parseInt(process.env.ALIYUN_APS_DB_CONNECT_TIMEOUT || '20000', 10) || 20000),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,12 +96,13 @@ export const datasets = {
|
|||||||
heading: '我的客户',
|
heading: '我的客户',
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
uniqueKey: (record) => record.accountId || record.loginName || record.__hash,
|
uniqueKey: (record) => record.accountId || record.loginName || record.__hash,
|
||||||
normalize: (record) => {
|
normalize: (record, context = {}) => {
|
||||||
const loginAndUid = record['登录名称/账号ID'] || '';
|
const loginAndUid = record['登录名称/账号ID'] || '';
|
||||||
const [loginName = '', accountId = ''] = splitLines(loginAndUid);
|
const [loginName = '', accountId = ''] = splitLines(loginAndUid);
|
||||||
return {
|
return {
|
||||||
loginName: loginName.replace(/\s+/g, ''),
|
loginName: loginName.replace(/\s+/g, ''),
|
||||||
accountId,
|
accountId,
|
||||||
|
listPageNum: context.pageNum || '',
|
||||||
realName: record['UID实名认证名称'] || '',
|
realName: record['UID实名认证名称'] || '',
|
||||||
reportSource: record['报备来源'] || '',
|
reportSource: record['报备来源'] || '',
|
||||||
reportType: record['报备类型'] || '',
|
reportType: record['报备类型'] || '',
|
||||||
@@ -101,10 +130,11 @@ export const datasets = {
|
|||||||
name: 'orders',
|
name: 'orders',
|
||||||
url: `${config.baseUrl}/#/detail/order/~/costCenter/order`,
|
url: `${config.baseUrl}/#/detail/order/~/costCenter/order`,
|
||||||
heading: '订单查询',
|
heading: '订单查询',
|
||||||
pageSize: 100,
|
pageSize: 20,
|
||||||
uniqueKey: (record) => record.orderId || record.__hash,
|
uniqueKey: (record) => record.orderId || record.__hash,
|
||||||
normalize: (record, context) => ({
|
normalize: (record, context) => ({
|
||||||
orderId: record['订单号'] || '',
|
orderId: record['订单号'] || '',
|
||||||
|
listPageNum: context.pageNum || '',
|
||||||
customerAccount: (record['客户账号'] || '').replace(/\s+/g, ''),
|
customerAccount: (record['客户账号'] || '').replace(/\s+/g, ''),
|
||||||
customerCategory: record['客户分类'] || '',
|
customerCategory: record['客户分类'] || '',
|
||||||
orderType: record['订单类型'] || '',
|
orderType: record['订单类型'] || '',
|
||||||
@@ -143,6 +173,7 @@ export const datasets = {
|
|||||||
couponAmountCny: record.couponAmountCny || '',
|
couponAmountCny: record.couponAmountCny || '',
|
||||||
windowStart: context.windowStart || '',
|
windowStart: context.windowStart || '',
|
||||||
windowEnd: context.windowEnd || '',
|
windowEnd: context.windowEnd || '',
|
||||||
|
detailSyncedAt: context.detailSyncedAt || record.detailSyncedAt || '',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
customerDetails: {
|
customerDetails: {
|
||||||
@@ -205,8 +236,8 @@ export const datasets = {
|
|||||||
uniqueKey: (record) => record.msgId || record.__hash,
|
uniqueKey: (record) => record.msgId || record.__hash,
|
||||||
normalize: (record) => ({
|
normalize: (record) => ({
|
||||||
msgId: pickFirst(record, ['消息ID', 'msg_id', '消息id', 'ID', 'id']),
|
msgId: pickFirst(record, ['消息ID', 'msg_id', '消息id', 'ID', 'id']),
|
||||||
title: pickFirst(record, ['消息标题', '标题', 'title']),
|
title: pickFirst(record, ['消息标题', '标题', 'title', 'detailTitle', 'column_1']),
|
||||||
content: pickFirst(record, ['消息内容', '内容', 'content']),
|
content: pickFirst(record, ['消息内容', '内容', 'content', 'detailContent']),
|
||||||
msgType: pickFirst(record, ['消息类型', 'type', 'msg_type']),
|
msgType: pickFirst(record, ['消息类型', 'type', 'msg_type']),
|
||||||
fromApp: pickFirst(record, ['来源应用', 'from_app', '应用']),
|
fromApp: pickFirst(record, ['来源应用', 'from_app', '应用']),
|
||||||
bizCode: pickFirst(record, ['业务编码', 'biz_code']),
|
bizCode: pickFirst(record, ['业务编码', 'biz_code']),
|
||||||
@@ -216,12 +247,23 @@ export const datasets = {
|
|||||||
lv1CategoryId: pickFirst(record, ['一级分类ID', 'lv1_category_id']),
|
lv1CategoryId: pickFirst(record, ['一级分类ID', 'lv1_category_id']),
|
||||||
lv2CategoryId: pickFirst(record, ['二级分类ID', 'lv2_category_id']),
|
lv2CategoryId: pickFirst(record, ['二级分类ID', 'lv2_category_id']),
|
||||||
lv3CategoryId: pickFirst(record, ['三级分类ID', 'lv3_category_id']),
|
lv3CategoryId: pickFirst(record, ['三级分类ID', 'lv3_category_id']),
|
||||||
messageClassification: pickFirst(record, ['归类结果', 'message_classification']),
|
messageClassification: pickFirst(record, ['messageClassification', '归类结果', 'message_classification']) || classifyMessage(record),
|
||||||
customerName: pickFirst(record, ['客户名称', 'customer_name']),
|
customerName: pickFirst(record, ['客户名称', 'customer_name', 'customerName', '客户账号']),
|
||||||
orderNo: pickFirst(record, ['订单号', 'order_no']),
|
customerNo: pickFirst(record, ['customerNo', '客户编号', '客户账号', '账号ID', 'UID']),
|
||||||
status: pickFirst(record, ['消息状态', '状态', 'status']),
|
orderNo: pickFirst(record, ['订单号', 'orderNo', 'order_no', 'refundOrderNo', '退款订单号']),
|
||||||
gmtCreated: pickFirst(record, ['消息创建时间', '创建时间', 'gmt_created']),
|
status: pickFirst(record, ['消息状态', '状态', 'status']) || '未读',
|
||||||
gmtModified: pickFirst(record, ['消息修改时间', '修改时间', 'gmt_modified']),
|
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,
|
extraData: record,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -243,3 +285,26 @@ function pickFirst(record, keys) {
|
|||||||
}
|
}
|
||||||
return '';
|
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}`);
|
||||||
|
});
|
||||||
@@ -2,10 +2,12 @@ import mysql from 'mysql2/promise';
|
|||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
|
||||||
let pool = null;
|
let pool = null;
|
||||||
|
let customerMapCache = null;
|
||||||
|
|
||||||
const MESSAGE_TABLE_DDL = `
|
const MESSAGE_TABLE_DDL = `
|
||||||
CREATE TABLE IF NOT EXISTS aliyun_aps_messages (
|
CREATE TABLE IF NOT EXISTS aliyun_aps_messages (
|
||||||
id bigint NOT NULL AUTO_INCREMENT,
|
id bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
source_id varchar(64) NOT NULL DEFAULT 'default' COMMENT '数据来源账号标识',
|
||||||
msg_id varchar(128) NULL DEFAULT NULL COMMENT '消息原始ID',
|
msg_id varchar(128) NULL DEFAULT NULL COMMENT '消息原始ID',
|
||||||
title text NULL COMMENT '消息标题',
|
title text NULL COMMENT '消息标题',
|
||||||
content text NULL COMMENT '消息内容',
|
content text NULL COMMENT '消息内容',
|
||||||
@@ -20,18 +22,48 @@ CREATE TABLE IF NOT EXISTS aliyun_aps_messages (
|
|||||||
lv3_category_id varchar(64) NULL DEFAULT NULL COMMENT '三级分类ID',
|
lv3_category_id varchar(64) NULL DEFAULT NULL COMMENT '三级分类ID',
|
||||||
message_classification varchar(255) NULL DEFAULT NULL COMMENT '归类结果',
|
message_classification varchar(255) NULL DEFAULT NULL COMMENT '归类结果',
|
||||||
customer_name 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_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 '消息状态(已读/未读)',
|
status varchar(32) NULL DEFAULT NULL COMMENT '消息状态(已读/未读)',
|
||||||
gmt_created varchar(64) NULL DEFAULT NULL COMMENT '消息创建时间',
|
gmt_created varchar(64) NULL DEFAULT NULL COMMENT '消息创建时间',
|
||||||
gmt_modified varchar(64) NULL DEFAULT NULL COMMENT '消息修改时间',
|
gmt_modified varchar(64) NULL DEFAULT NULL COMMENT '消息修改时间',
|
||||||
extra_data json NULL COMMENT '其他字段(原始JSON)',
|
extra_data json NULL COMMENT '其他字段(原始JSON)',
|
||||||
crawl_time datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '爬取时间',
|
crawl_time datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '爬取时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_msg_id (msg_id)
|
UNIQUE KEY uk_source_msg_id (source_id, msg_id),
|
||||||
|
KEY idx_source_message_time (source_id, gmt_modified, gmt_created)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='阿里云APS站内消息'
|
) 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;
|
let customerLifecycleEnsured = false;
|
||||||
|
const ensuredSourceTables = new Set();
|
||||||
|
|
||||||
function hasDbConfig() {
|
function hasDbConfig() {
|
||||||
return Boolean(config.db.host && config.db.user && config.db.database);
|
return Boolean(config.db.host && config.db.user && config.db.database);
|
||||||
@@ -52,6 +84,7 @@ function getPool() {
|
|||||||
database: config.db.database,
|
database: config.db.database,
|
||||||
charset: config.db.charset,
|
charset: config.db.charset,
|
||||||
connectionLimit: config.db.connectionLimit,
|
connectionLimit: config.db.connectionLimit,
|
||||||
|
connectTimeout: config.db.connectTimeout,
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
});
|
});
|
||||||
return pool;
|
return pool;
|
||||||
@@ -130,7 +163,11 @@ function safeDateTime(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getCustomerMap() {
|
async function getCustomerMap() {
|
||||||
const [rows] = await getPool().query('SELECT account_id, login_name FROM aps_customer');
|
if (customerMapCache) {
|
||||||
|
return customerMapCache;
|
||||||
|
}
|
||||||
|
await ensureSourceColumn('aps_customer');
|
||||||
|
const [rows] = await getPool().execute('SELECT account_id, login_name FROM aps_customer WHERE source_id = ?', [config.sourceId]);
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const loginName = safeString(row.login_name);
|
const loginName = safeString(row.login_name);
|
||||||
@@ -141,7 +178,8 @@ async function getCustomerMap() {
|
|||||||
map.set(loginName, accountId);
|
map.set(loginName, accountId);
|
||||||
map.set(loginName.replace(/\s+/g, ''), accountId);
|
map.set(loginName.replace(/\s+/g, ''), accountId);
|
||||||
}
|
}
|
||||||
return map;
|
customerMapCache = map;
|
||||||
|
return customerMapCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCustomerAccountId(customerMap, customerAccount) {
|
function resolveCustomerAccountId(customerMap, customerAccount) {
|
||||||
@@ -152,8 +190,8 @@ function resolveCustomerAccountId(customerMap, customerAccount) {
|
|||||||
return customerMap.get(normalized) || customerMap.get(normalized.replace(/\s+/g, '')) || null;
|
return customerMap.get(normalized) || customerMap.get(normalized.replace(/\s+/g, '')) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queryLatestValue(sql) {
|
async function queryLatestValue(sql, params = []) {
|
||||||
const [rows] = await getPool().query(sql);
|
const [rows] = await getPool().execute(sql, params);
|
||||||
const row = Array.isArray(rows) ? rows[0] : null;
|
const row = Array.isArray(rows) ? rows[0] : null;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return null;
|
return null;
|
||||||
@@ -169,15 +207,59 @@ async function queryLatestValue(sql) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestOrderTimeFromDb() {
|
export async function getLatestOrderTimeFromDb() {
|
||||||
return queryLatestValue('SELECT MAX(order_time) AS latest_time FROM aps_order');
|
await ensureSourceColumn('aps_order');
|
||||||
|
return queryLatestValue('SELECT MAX(order_time) AS latest_time FROM aps_order WHERE source_id = ?', [config.sourceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function customerExists(accountId) {
|
||||||
|
const normalized = safeString(accountId);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await ensureCustomerLifecycleColumns();
|
||||||
|
const [rows] = await getPool().execute(
|
||||||
|
'SELECT 1 AS matched FROM aps_customer WHERE source_id = ? AND account_id = ? LIMIT 1',
|
||||||
|
[config.sourceId, normalized],
|
||||||
|
);
|
||||||
|
return Array.isArray(rows) && rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestBillConsumptionTimeFromDb() {
|
export async function getLatestBillConsumptionTimeFromDb() {
|
||||||
return queryLatestValue('SELECT MAX(consumption_time) AS latest_time FROM aps_bill');
|
await ensureSourceColumn('aps_bill');
|
||||||
|
return queryLatestValue('SELECT MAX(consumption_time) AS latest_time FROM aps_bill WHERE source_id = ?', [config.sourceId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestMessageTimeFromDb() {
|
export async function getLatestMessageTimeFromDb() {
|
||||||
return queryLatestValue("SELECT MAX(COALESCE(NULLIF(gmt_modified, ''), NULLIF(gmt_created, ''))) AS latest_time FROM aliyun_aps_messages");
|
await ensureMessagesTable();
|
||||||
|
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() {
|
export async function closeDbPool() {
|
||||||
@@ -186,16 +268,70 @@ export async function closeDbPool() {
|
|||||||
}
|
}
|
||||||
await pool.end();
|
await pool.end();
|
||||||
pool = null;
|
pool = null;
|
||||||
|
customerMapCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureMessagesTable() {
|
export async function ensureMessagesTable() {
|
||||||
await getPool().query(MESSAGE_TABLE_DDL);
|
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() {
|
export async function ensureCustomerLifecycleColumns() {
|
||||||
if (customerLifecycleEnsured) {
|
if (customerLifecycleEnsured) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await ensureSourceColumn('aps_customer');
|
||||||
await ensureColumnExists('aps_customer', 'active', "ALTER TABLE aps_customer ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否有效 1=有效 0=释放'");
|
await ensureColumnExists('aps_customer', 'active', "ALTER TABLE aps_customer ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否有效 1=有效 0=释放'");
|
||||||
await ensureColumnExists('aps_customer', 'status', "ALTER TABLE aps_customer ADD COLUMN status VARCHAR(32) DEFAULT 'active' COMMENT '客户状态'");
|
await ensureColumnExists('aps_customer', 'status', "ALTER TABLE aps_customer ADD COLUMN status VARCHAR(32) DEFAULT 'active' COMMENT '客户状态'");
|
||||||
await ensureColumnExists('aps_customer', 'released_at', "ALTER TABLE aps_customer ADD COLUMN released_at DATETIME NULL COMMENT '释放时间'");
|
await ensureColumnExists('aps_customer', 'released_at', "ALTER TABLE aps_customer ADD COLUMN released_at DATETIME NULL COMMENT '释放时间'");
|
||||||
@@ -203,6 +339,14 @@ export async function ensureCustomerLifecycleColumns() {
|
|||||||
customerLifecycleEnsured = true;
|
customerLifecycleEnsured = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSourceColumn(tableName) {
|
||||||
|
if (ensuredSourceTables.has(tableName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureColumnExists(tableName, 'source_id', `ALTER TABLE ${tableName} ADD COLUMN source_id VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '数据来源账号标识'`);
|
||||||
|
ensuredSourceTables.add(tableName);
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureColumnExists(tableName, columnName, alterSql) {
|
async function ensureColumnExists(tableName, columnName, alterSql) {
|
||||||
const [rows] = await getPool().execute(
|
const [rows] = await getPool().execute(
|
||||||
`SELECT COUNT(*) AS cnt
|
`SELECT COUNT(*) AS cnt
|
||||||
@@ -226,13 +370,13 @@ export async function upsertCustomers(records) {
|
|||||||
await ensureCustomerLifecycleColumns();
|
await ensureCustomerLifecycleColumns();
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO aps_customer (
|
INSERT INTO aps_customer (
|
||||||
account_id, login_name, real_name, report_source, report_type, trade_mode,
|
source_id, account_id, login_name, real_name, report_source, report_type, trade_mode,
|
||||||
real_name_status, relation_date, follow_staff, payment_notice_status,
|
real_name_status, relation_date, follow_staff, payment_notice_status,
|
||||||
invite_register_type, is_new_customer, performance_start_point_reached,
|
invite_register_type, is_new_customer, performance_start_point_reached,
|
||||||
customer_category, remark, no_consumption_months,
|
customer_category, remark, no_consumption_months,
|
||||||
planned_release_time, planned_release_reason,
|
planned_release_time, planned_release_reason,
|
||||||
active, status, released_at, release_reason
|
active, status, released_at, release_reason
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'active', NULL, NULL)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'active', NULL, NULL)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
login_name=VALUES(login_name),
|
login_name=VALUES(login_name),
|
||||||
real_name=VALUES(real_name),
|
real_name=VALUES(real_name),
|
||||||
@@ -264,6 +408,7 @@ export async function upsertCustomers(records) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await getPool().execute(sql, [
|
await getPool().execute(sql, [
|
||||||
|
config.sourceId,
|
||||||
accountId,
|
accountId,
|
||||||
loginName,
|
loginName,
|
||||||
safeString(record.realName),
|
safeString(record.realName),
|
||||||
@@ -292,7 +437,7 @@ export async function upsertCustomerDetails(records) {
|
|||||||
return { inserted: 0 };
|
return { inserted: 0 };
|
||||||
}
|
}
|
||||||
await ensureCustomerLifecycleColumns();
|
await ensureCustomerLifecycleColumns();
|
||||||
const sql = `
|
const updateSql = `
|
||||||
UPDATE aps_customer SET
|
UPDATE aps_customer SET
|
||||||
customer_name = ?,
|
customer_name = ?,
|
||||||
customer_type = ?,
|
customer_type = ?,
|
||||||
@@ -302,14 +447,30 @@ export async function upsertCustomerDetails(records) {
|
|||||||
department = ?,
|
department = ?,
|
||||||
payment_notice_status = COALESCE(?, payment_notice_status),
|
payment_notice_status = COALESCE(?, payment_notice_status),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE account_id = ?
|
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) {
|
for (const record of records) {
|
||||||
const accountId = safeString(record.accountId);
|
const accountId = safeString(record.accountId);
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await getPool().execute(sql, [
|
const [result] = await getPool().execute(updateSql, [
|
||||||
safeString(record.customerName),
|
safeString(record.customerName),
|
||||||
safeString(record.customerType),
|
safeString(record.customerType),
|
||||||
safeString(record.customerSource),
|
safeString(record.customerSource),
|
||||||
@@ -317,8 +478,23 @@ export async function upsertCustomerDetails(records) {
|
|||||||
safeString(record.phone),
|
safeString(record.phone),
|
||||||
safeString(record.department),
|
safeString(record.department),
|
||||||
safeString(record.paymentNoticeStatus),
|
safeString(record.paymentNoticeStatus),
|
||||||
|
config.sourceId,
|
||||||
accountId,
|
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 };
|
return { inserted: records.length };
|
||||||
}
|
}
|
||||||
@@ -329,8 +505,8 @@ async function findCustomerAccountIdByName(customerName) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const [rows] = await getPool().execute(
|
const [rows] = await getPool().execute(
|
||||||
'SELECT account_id FROM aps_customer WHERE customer_name = ? OR real_name = ? OR login_name = ? LIMIT 1',
|
'SELECT account_id FROM aps_customer WHERE source_id = ? AND (customer_name = ? OR real_name = ? OR login_name = ?) LIMIT 1',
|
||||||
[normalized, normalized, normalized],
|
[config.sourceId, normalized, normalized, normalized],
|
||||||
);
|
);
|
||||||
return Array.isArray(rows) && rows.length > 0 ? safeString(rows[0].account_id) : null;
|
return Array.isArray(rows) && rows.length > 0 ? safeString(rows[0].account_id) : null;
|
||||||
}
|
}
|
||||||
@@ -342,8 +518,8 @@ async function markCustomerReleased(customerName, reason, releasedAt) {
|
|||||||
}
|
}
|
||||||
await ensureCustomerLifecycleColumns();
|
await ensureCustomerLifecycleColumns();
|
||||||
await getPool().execute(
|
await getPool().execute(
|
||||||
'UPDATE aps_customer SET active = 0, status = ?, released_at = ?, release_reason = ? WHERE account_id = ?',
|
'UPDATE aps_customer SET active = 0, status = ?, released_at = ?, release_reason = ? WHERE source_id = ? AND account_id = ?',
|
||||||
['released', releasedAt || new Date().toISOString().slice(0, 19).replace('T', ' '), safeString(reason), accountId],
|
['released', releasedAt || new Date().toISOString().slice(0, 19).replace('T', ' '), safeString(reason), config.sourceId, accountId],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -355,8 +531,8 @@ async function markCustomerActive(customerName) {
|
|||||||
}
|
}
|
||||||
await ensureCustomerLifecycleColumns();
|
await ensureCustomerLifecycleColumns();
|
||||||
await getPool().execute(
|
await getPool().execute(
|
||||||
'UPDATE aps_customer SET active = 1, status = ?, released_at = NULL, release_reason = NULL WHERE account_id = ?',
|
'UPDATE aps_customer SET active = 1, status = ?, released_at = NULL, release_reason = NULL WHERE source_id = ? AND account_id = ?',
|
||||||
['active', accountId],
|
['active', config.sourceId, accountId],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -382,13 +558,14 @@ export async function upsertOrders(records) {
|
|||||||
if (!records?.length) {
|
if (!records?.length) {
|
||||||
return { inserted: 0 };
|
return { inserted: 0 };
|
||||||
}
|
}
|
||||||
|
await ensureSourceColumn('aps_order');
|
||||||
const customerMap = await getCustomerMap();
|
const customerMap = await getCustomerMap();
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO aps_order (
|
INSERT INTO aps_order (
|
||||||
order_id, customer_account_id, customer_login_name,
|
source_id, order_id, customer_account_id, customer_login_name,
|
||||||
customer_category, order_type, original_price_cny, paid_amount_cny,
|
customer_category, order_type, original_price_cny, paid_amount_cny,
|
||||||
status, order_time, order_month
|
status, order_time, order_month
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
customer_account_id=VALUES(customer_account_id),
|
customer_account_id=VALUES(customer_account_id),
|
||||||
customer_login_name=VALUES(customer_login_name),
|
customer_login_name=VALUES(customer_login_name),
|
||||||
@@ -408,6 +585,7 @@ export async function upsertOrders(records) {
|
|||||||
const customerLoginName = safeString(record.customerAccount) || '';
|
const customerLoginName = safeString(record.customerAccount) || '';
|
||||||
const accountId = resolveCustomerAccountId(customerMap, customerLoginName);
|
const accountId = resolveCustomerAccountId(customerMap, customerLoginName);
|
||||||
await getPool().execute(sql, [
|
await getPool().execute(sql, [
|
||||||
|
config.sourceId,
|
||||||
orderId,
|
orderId,
|
||||||
accountId,
|
accountId,
|
||||||
customerLoginName,
|
customerLoginName,
|
||||||
@@ -427,14 +605,15 @@ export async function upsertOrderDetails(records) {
|
|||||||
if (!records?.length) {
|
if (!records?.length) {
|
||||||
return { inserted: 0 };
|
return { inserted: 0 };
|
||||||
}
|
}
|
||||||
|
await ensureSourceColumn('aps_order_detail');
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO aps_order_detail (
|
INSERT INTO aps_order_detail (
|
||||||
order_id, order_type, status, trade_type, customer_category,
|
source_id, order_id, order_type, status, trade_type, customer_category,
|
||||||
dealer_name, dealer_uid, customer_type, opportunity_id,
|
dealer_name, dealer_uid, customer_type, opportunity_id,
|
||||||
payment_time, order_time, product_name, product_code,
|
payment_time, order_time, product_name, product_code,
|
||||||
original_price_cny, paid_amount_cny, discount,
|
original_price_cny, paid_amount_cny, discount,
|
||||||
payable_amount_cny, coupon_amount_cny
|
payable_amount_cny, coupon_amount_cny
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
order_type=VALUES(order_type),
|
order_type=VALUES(order_type),
|
||||||
status=VALUES(status),
|
status=VALUES(status),
|
||||||
@@ -460,6 +639,7 @@ export async function upsertOrderDetails(records) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await getPool().execute(sql, [
|
await getPool().execute(sql, [
|
||||||
|
config.sourceId,
|
||||||
orderId,
|
orderId,
|
||||||
safeString(record.orderType),
|
safeString(record.orderType),
|
||||||
safeString(record.status),
|
safeString(record.status),
|
||||||
@@ -469,8 +649,8 @@ export async function upsertOrderDetails(records) {
|
|||||||
safeString(record.dealerUid),
|
safeString(record.dealerUid),
|
||||||
safeString(record.customerType),
|
safeString(record.customerType),
|
||||||
safeString(record.opportunityId),
|
safeString(record.opportunityId),
|
||||||
safeString(record.paymentTime),
|
safeDateTime(record.paymentTime),
|
||||||
safeString(record.orderTime),
|
safeDateTime(record.orderTime),
|
||||||
safeString(record.productName),
|
safeString(record.productName),
|
||||||
safeString(record.productCode),
|
safeString(record.productCode),
|
||||||
safeNumber(record.originalPriceCny),
|
safeNumber(record.originalPriceCny),
|
||||||
@@ -487,10 +667,12 @@ export async function upsertBills(records) {
|
|||||||
if (!records?.length) {
|
if (!records?.length) {
|
||||||
return { inserted: 0 };
|
return { inserted: 0 };
|
||||||
}
|
}
|
||||||
|
await ensureSourceColumn('aps_bill');
|
||||||
const customerMap = await getCustomerMap();
|
const customerMap = await getCustomerMap();
|
||||||
const selectSql = `
|
const selectSql = `
|
||||||
SELECT id FROM aps_bill
|
SELECT id FROM aps_bill
|
||||||
WHERE billing_month = ?
|
WHERE source_id = ?
|
||||||
|
AND billing_month = ?
|
||||||
AND commission_month = ?
|
AND commission_month = ?
|
||||||
AND customer_login_name = ?
|
AND customer_login_name = ?
|
||||||
AND consumption_time = ?
|
AND consumption_time = ?
|
||||||
@@ -500,7 +682,7 @@ export async function upsertBills(records) {
|
|||||||
`;
|
`;
|
||||||
const insertSql = `
|
const insertSql = `
|
||||||
INSERT INTO aps_bill (
|
INSERT INTO aps_bill (
|
||||||
billing_month, customer_account_id, customer_login_name,
|
source_id, billing_month, customer_account_id, customer_login_name,
|
||||||
bill_type, consumption_time,
|
bill_type, consumption_time,
|
||||||
customer_category,
|
customer_category,
|
||||||
product_name, product_category,
|
product_name, product_category,
|
||||||
@@ -510,7 +692,7 @@ export async function upsertBills(records) {
|
|||||||
rebated,
|
rebated,
|
||||||
invite_register_type,
|
invite_register_type,
|
||||||
service_start_time, service_end_time
|
service_start_time, service_end_time
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
const updateSql = `
|
const updateSql = `
|
||||||
UPDATE aps_bill SET
|
UPDATE aps_bill SET
|
||||||
@@ -539,6 +721,7 @@ export async function upsertBills(records) {
|
|||||||
const consumptionTime = safeString(record.consumeDate);
|
const consumptionTime = safeString(record.consumeDate);
|
||||||
const originalPrice = safeNumber(record.originalPriceCny);
|
const originalPrice = safeNumber(record.originalPriceCny);
|
||||||
const [rows] = await getPool().execute(selectSql, [
|
const [rows] = await getPool().execute(selectSql, [
|
||||||
|
config.sourceId,
|
||||||
billingMonth,
|
billingMonth,
|
||||||
commissionMonth,
|
commissionMonth,
|
||||||
customerLoginName,
|
customerLoginName,
|
||||||
@@ -565,6 +748,7 @@ export async function upsertBills(records) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await getPool().execute(insertSql, [
|
await getPool().execute(insertSql, [
|
||||||
|
config.sourceId,
|
||||||
billingMonth,
|
billingMonth,
|
||||||
accountId,
|
accountId,
|
||||||
customerLoginName,
|
customerLoginName,
|
||||||
@@ -594,11 +778,13 @@ export async function upsertMessages(records) {
|
|||||||
await ensureMessagesTable();
|
await ensureMessagesTable();
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO aliyun_aps_messages (
|
INSERT INTO aliyun_aps_messages (
|
||||||
msg_id, title, content, msg_type, from_app, biz_code, msg_channel,
|
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,
|
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
|
gmt_created, gmt_modified, extra_data
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
title=VALUES(title),
|
title=VALUES(title),
|
||||||
content=VALUES(content),
|
content=VALUES(content),
|
||||||
@@ -613,7 +799,18 @@ export async function upsertMessages(records) {
|
|||||||
lv3_category_id=VALUES(lv3_category_id),
|
lv3_category_id=VALUES(lv3_category_id),
|
||||||
message_classification=VALUES(message_classification),
|
message_classification=VALUES(message_classification),
|
||||||
customer_name=VALUES(customer_name),
|
customer_name=VALUES(customer_name),
|
||||||
|
customer_no=VALUES(customer_no),
|
||||||
order_no=VALUES(order_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),
|
status=VALUES(status),
|
||||||
gmt_created=VALUES(gmt_created),
|
gmt_created=VALUES(gmt_created),
|
||||||
gmt_modified=VALUES(gmt_modified),
|
gmt_modified=VALUES(gmt_modified),
|
||||||
@@ -626,6 +823,7 @@ export async function upsertMessages(records) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await getPool().execute(sql, [
|
await getPool().execute(sql, [
|
||||||
|
config.sourceId,
|
||||||
msgId,
|
msgId,
|
||||||
safeString(record.title),
|
safeString(record.title),
|
||||||
safeString(record.content),
|
safeString(record.content),
|
||||||
@@ -640,12 +838,24 @@ export async function upsertMessages(records) {
|
|||||||
safeString(record.lv3CategoryId),
|
safeString(record.lv3CategoryId),
|
||||||
safeString(record.messageClassification),
|
safeString(record.messageClassification),
|
||||||
safeString(record.customerName),
|
safeString(record.customerName),
|
||||||
|
safeString(record.customerNo),
|
||||||
safeString(record.orderNo),
|
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.gmtCreated),
|
||||||
safeString(record.gmtModified),
|
safeString(record.gmtModified),
|
||||||
JSON.stringify(record.extraData || record),
|
JSON.stringify(record.extraData || record),
|
||||||
]);
|
]);
|
||||||
|
await replaceMessageAccounts(record, msgId);
|
||||||
await applyCustomerLifecycleFromMessage(record);
|
await applyCustomerLifecycleFromMessage(record);
|
||||||
}
|
}
|
||||||
return { inserted: records.length };
|
return { inserted: records.length };
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const command = args[0] || 'sync';
|
|||||||
const extraArgs = args.slice(1);
|
const extraArgs = args.slice(1);
|
||||||
const billsResume = extraArgs.includes('--resume');
|
const billsResume = extraArgs.includes('--resume');
|
||||||
const ordersIncremental = extraArgs.includes('--incremental');
|
const ordersIncremental = extraArgs.includes('--incremental');
|
||||||
|
const messagesIncremental = extraArgs.includes('--incremental');
|
||||||
|
const messagesResume = extraArgs.includes('--resume');
|
||||||
|
const hotResume = extraArgs.includes('--resume');
|
||||||
|
|
||||||
for (const arg of extraArgs) {
|
for (const arg of extraArgs) {
|
||||||
if (arg.startsWith('--incremental-order-start-date=')) {
|
if (arg.startsWith('--incremental-order-start-date=')) {
|
||||||
@@ -12,7 +15,7 @@ for (const arg of extraArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { login, scheduleSync, syncAll, syncAllIncremental, syncBillsOnly, syncMessagesOnly, syncOrdersOnly } = await import('./sync.js');
|
const { login, scheduleSync, syncAll, syncAllIncremental, syncBillsOnly, syncMessagesOnly, syncOrdersOnly, syncHot } = await import('./sync.js');
|
||||||
|
|
||||||
if (command === 'login') {
|
if (command === 'login') {
|
||||||
await login();
|
await login();
|
||||||
@@ -44,7 +47,13 @@ if (command === 'orders') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'messages') {
|
if (command === 'messages') {
|
||||||
const summary = await syncMessagesOnly({ incremental: config.scheduleMode === 'incremental' });
|
const summary = await syncMessagesOnly({ incremental: messagesIncremental, resume: messagesResume });
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'hot') {
|
||||||
|
const summary = await syncHot({ resume: hotResume });
|
||||||
console.log(JSON.stringify(summary, null, 2));
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
18
aliyun-sync/aliyun-aps-sync/tests/example.spec.ts
Normal file
18
aliyun-sync/aliyun-aps-sync/tests/example.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('has title', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/Playwright/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get started link', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Click the get started link.
|
||||||
|
await page.getByRole('link', { name: 'Get started' }).click();
|
||||||
|
|
||||||
|
// Expects page to have a heading with the name of Installation.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||||
|
});
|
||||||
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