Compare commits
7 Commits
595e4c62f9
...
7d2f98b7c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d2f98b7c9 | |||
| 8bc8e81e95 | |||
| 54daa923da | |||
| 463d3e2315 | |||
| 559d59b4f8 | |||
| d4f0d559cf | |||
| cbb451d18c |
+3
-3
@@ -44,13 +44,13 @@ type (
|
||||
Balance int64 `json:"balance"`
|
||||
Commission int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable bool `json:"enable"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
UpdateUserNotifySettingRequest {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "recovery API"
|
||||
desc: "API for order recovery"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
RecoverySendCodeRequest {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
RecoverOrderRequest {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
RecoverOrderResponse {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/public/recovery
|
||||
group: public/recovery
|
||||
middleware: DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Send recovery verification code"
|
||||
@handler SendCode
|
||||
post /send_code (RecoverySendCodeRequest) returns (SendCodeResponse)
|
||||
|
||||
@doc "Recover order subscription"
|
||||
@handler RecoverOrder
|
||||
post /order (RecoverOrderRequest) returns (RecoverOrderResponse)
|
||||
}
|
||||
+20
-134
@@ -6,9 +6,9 @@
|
||||
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
||||
#
|
||||
# 网络说明:
|
||||
# ppanel-server 使用 host 网络(可出外网,访问 MySQL/Redis/Tempo 用 127.0.0.1)
|
||||
# 监控服务(MySQL/Redis/Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
||||
# MySQL(3306)/Redis(6379)/Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
||||
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS/ElastiCache 私网地址)
|
||||
# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
||||
# Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
||||
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
|
||||
#
|
||||
# 未来多开 ppanel-server 时:
|
||||
@@ -18,7 +18,7 @@
|
||||
services:
|
||||
# ----------------------------------------------------
|
||||
# 1. 业务后端 (PPanel Server)
|
||||
# host 网络:可出外网,通过 127.0.0.1 访问 MySQL/Redis/Tempo
|
||||
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
||||
# ----------------------------------------------------
|
||||
ppanel-server:
|
||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
||||
@@ -37,10 +37,6 @@ services:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
tempo:
|
||||
condition: service_started
|
||||
logging:
|
||||
@@ -50,82 +46,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 2. MySQL Database
|
||||
# ----------------------------------------------------
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: ppanel-mysql
|
||||
restart: always
|
||||
ports:
|
||||
- "3306:3306" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?请在 .env 文件中设置 MYSQL_ROOT_PASSWORD}"
|
||||
MYSQL_DATABASE: "ppanel"
|
||||
TZ: Asia/Shanghai
|
||||
command:
|
||||
- --default-authentication-plugin=mysql_native_password
|
||||
- --innodb_buffer_pool_size=16G
|
||||
- --innodb_buffer_pool_instances=16
|
||||
- --innodb_log_file_size=2G
|
||||
- --innodb_flush_log_at_trx_commit=2
|
||||
- --innodb_io_capacity=5000
|
||||
- --max_connections=5000
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
networks:
|
||||
- ppanel_net
|
||||
healthcheck:
|
||||
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 3. Redis
|
||||
# ----------------------------------------------------
|
||||
redis:
|
||||
image: redis:8.2.1
|
||||
container_name: ppanel-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
|
||||
command:
|
||||
- redis-server
|
||||
- --tcp-backlog 65535
|
||||
- --maxmemory-policy allkeys-lru
|
||||
- --requirepass hifast67yj
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
networks:
|
||||
- ppanel_net
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 4. Tempo (链路追踪存储)
|
||||
# 2. Tempo (链路追踪存储)
|
||||
# ----------------------------------------------------
|
||||
tempo:
|
||||
image: grafana/tempo:2.4.1
|
||||
@@ -149,7 +70,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 5. Loki (日志存储)
|
||||
# 3. Loki (日志存储)
|
||||
# ----------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
@@ -169,7 +90,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 6. Promtail (日志采集)
|
||||
# 4. Promtail (日志采集)
|
||||
# ----------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
@@ -193,7 +114,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 7. Grafana (可观测面板)
|
||||
# 5. Grafana (可观测面板)
|
||||
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
||||
# 或配置 Nginx 反代(建议加认证)
|
||||
# ----------------------------------------------------
|
||||
@@ -202,11 +123,18 @@ services:
|
||||
container_name: ppanel-grafana
|
||||
restart: always
|
||||
ports:
|
||||
- "3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
|
||||
- "127.0.0.1:3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-logsx.hifast.biz}
|
||||
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-https://logsx.hifast.biz}
|
||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
||||
- AWS_REGION=${AWS_REGION:-ap-east-1}
|
||||
- AWS_DEFAULT_REGION=${AWS_REGION:-ap-east-1}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
@@ -223,7 +151,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 8. Prometheus (指标采集)
|
||||
# 6. Prometheus (指标采集)
|
||||
# ----------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
@@ -248,26 +176,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 9. Redis Exporter
|
||||
# ----------------------------------------------------
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:latest
|
||||
container_name: ppanel-redis-exporter
|
||||
restart: always
|
||||
environment:
|
||||
- REDIS_ADDR=redis://redis:6379
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- redis
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 10. Nginx Exporter (监控宿主机 Nginx)
|
||||
# 7. Nginx Exporter (监控宿主机 Nginx)
|
||||
# ----------------------------------------------------
|
||||
nginx-exporter:
|
||||
image: nginx/nginx-prometheus-exporter:latest
|
||||
@@ -286,28 +195,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 11. MySQL Exporter
|
||||
# ----------------------------------------------------
|
||||
mysql-exporter:
|
||||
image: prom/mysqld-exporter:latest
|
||||
container_name: ppanel-mysql-exporter
|
||||
restart: always
|
||||
command:
|
||||
- --config.my-cnf=/etc/.my.cnf
|
||||
volumes:
|
||||
- ./mysql/.my.cnf:/etc/.my.cnf:ro
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- mysql
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 12. Node Exporter (宿主机监控)
|
||||
# 8. Node Exporter (宿主机监控)
|
||||
# ----------------------------------------------------
|
||||
node-exporter:
|
||||
image: prom/node-exporter:latest
|
||||
@@ -330,7 +218,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 13. cAdvisor (容器监控)
|
||||
# 9. cAdvisor (容器监控)
|
||||
# ----------------------------------------------------
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:latest
|
||||
@@ -351,8 +239,6 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
prometheus_data:
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ Logger: # 日志配置
|
||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||
|
||||
MySQL:
|
||||
Addr: 103.150.215.44:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||
Username: root # MySQL用户名
|
||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||
Dbname: hifast # MySQL数据库名
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
apiVersion: 1
|
||||
|
||||
groups:
|
||||
- orgId: 1
|
||||
name: ppanel-core
|
||||
folder: PPanel
|
||||
interval: 1m
|
||||
rules:
|
||||
- uid: ppanel-target-down
|
||||
title: PPanel monitoring target down
|
||||
condition: C
|
||||
for: 2m
|
||||
noDataState: Alerting
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Monitoring target is down"
|
||||
description: "{{ $labels.job }} on {{ $labels.instance }} has been down for more than 2 minutes."
|
||||
labels:
|
||||
severity: critical
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 300
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: 'up{job=~"grafana|prometheus|node-exporter|cadvisor|nginx-exporter|loki|tempo"}'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 1
|
||||
type: lt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-disk-high
|
||||
title: PPanel host disk usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host disk usage is high"
|
||||
description: "{{ $labels.instance }} {{ $labels.mountpoint }} disk usage is above 85% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '100 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} * 100)'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 85
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-memory-high
|
||||
title: PPanel host memory usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host memory usage is high"
|
||||
description: "{{ $labels.instance }} memory usage is above 90% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 90
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-cpu-high
|
||||
title: PPanel host CPU usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host CPU usage is high"
|
||||
description: "{{ $labels.instance }} CPU usage is above 90% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 90
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-container-restarts
|
||||
title: PPanel container restarted
|
||||
condition: C
|
||||
for: 1m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Container restarted"
|
||||
description: "{{ $labels.name }} restarted or changed start time in the last hour."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 3600
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: 'sum by (name) (changes(container_start_time_seconds{name!=""}[1h]))'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"content": "<b>AWS CloudWatch overview</b><br/>Region: ap-east-1 (Hong Kong)<br/>RDS DBInstanceIdentifier: database-1<br/>Redis ReplicationGroupId: hifastapp-redis<br/><br/>Note: Redis panels are configured using <code>ReplicationGroupId</code>. If a panel shows no data in your account, switch that dimension to the concrete <code>CacheClusterId</code> in Grafana query editor.",
|
||||
"mode": "html"
|
||||
},
|
||||
"pluginVersion": "11.0.0",
|
||||
"title": "Read Me",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "database-1"
|
||||
},
|
||||
"metricName": "CPUUtilization",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 3
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "database-1"
|
||||
},
|
||||
"metricName": "DatabaseConnections",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Connections",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 3
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "database-1"
|
||||
},
|
||||
"metricName": "FreeStorageSpace",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Minimum"
|
||||
}
|
||||
],
|
||||
"title": "RDS Free Storage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 11
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "database-1"
|
||||
},
|
||||
"metricName": "ReadLatency",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "database-1"
|
||||
},
|
||||
"metricName": "WriteLatency",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "B",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Read / Write Latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "iops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 11
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "database-1"
|
||||
},
|
||||
"metricName": "ReadIOPS",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "database-1"
|
||||
},
|
||||
"metricName": "WriteIOPS",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "B",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Read / Write IOPS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 19
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "CPUUtilization",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Host CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 19
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "EngineCPUUtilization",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Engine CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 19
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "CurrConnections",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Connections",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 19
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "DatabaseMemoryUsagePercentage",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Memory Usage %",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"aws",
|
||||
"cloudwatch",
|
||||
"rds",
|
||||
"redis"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "AWS RDS & Redis Overview",
|
||||
"uid": "aws-rds-redis-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": {
|
||||
"text": "DOWN"
|
||||
},
|
||||
"1": {
|
||||
"text": "UP"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "13.0.1",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "up{job=~\"prometheus|grafana|node-exporter|cadvisor|nginx-exporter|loki|tempo\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "{{job}}",
|
||||
"range": false,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Service Availability",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 75
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "{{instance}} CPU",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host CPU Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 4
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"legendFormat": "{{instance}} memory",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host Memory Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 4
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} * 100)",
|
||||
"legendFormat": "{{mountpoint}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host Disk Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 12
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 12
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container Memory",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 12
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (changes(container_start_time_seconds{name!=\"\"}[1h]))",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container Restarts / Changes",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 20
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "rate(nginx_http_requests_total[5m])",
|
||||
"legendFormat": "requests",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Nginx Requests",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "loki"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 20
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "loki"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{job=~\"ppanel-server|nginx|docker\"} |~ \"(?i)(error|panic|fatal|timeout|exception|failed)\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Recent Errors",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 20
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (service_name) (rate(traces_spanmetrics_calls_total[5m]))",
|
||||
"legendFormat": "{{service_name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Trace Span Calls",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 42,
|
||||
"tags": [
|
||||
"ppanel",
|
||||
"ops",
|
||||
"prometheus",
|
||||
"loki",
|
||||
"tempo"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PPanel Ops Overview",
|
||||
"uid": "ppanel-ops-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({container=\"ppanel-server\"} |~ \"${user_id:raw}\" |~ \"${email:raw}\" |~ \"${order:raw}\" |~ \"${keyword:raw}\" |~ \"${level:raw}\" [5m]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Matched Log Volume",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({container=\"ppanel-server\"} |~ \"${user_id:raw}\" |~ \"${email:raw}\" |~ \"${order:raw}\" |~ \"${keyword:raw}\" |~ \"(?i)(error|panic|fatal)\" [5m]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Matched Error Volume",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 12,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{container=\"ppanel-server\"} |~ \"${user_id:raw}\" |~ \"${email:raw}\" |~ \"${order:raw}\" |~ \"${keyword:raw}\" |~ \"${level:raw}\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Filtered Server Logs",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 19
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{container=\"ppanel-server\"} |~ \"${user_id:raw}\" |~ \"${email:raw}\" |~ \"${order:raw}\" |~ \"${keyword:raw}\" |~ \"(?i)(error|panic|fatal)\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Filtered Server Errors",
|
||||
"type": "logs"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 42,
|
||||
"tags": [
|
||||
"ppanel",
|
||||
"logs",
|
||||
"server",
|
||||
"loki"
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入用户 ID;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "用户ID",
|
||||
"name": "user_id",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入邮箱或邮箱片段;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "邮箱",
|
||||
"name": "email",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入订单号/支付单号/交易号片段;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "订单",
|
||||
"name": "order",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": "."
|
||||
},
|
||||
"description": "日志等级",
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "等级",
|
||||
"multi": false,
|
||||
"name": "level",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": "."
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "debug",
|
||||
"value": "debug"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "info",
|
||||
"value": "info"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "warn",
|
||||
"value": "warn"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "error",
|
||||
"value": "error"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "slow",
|
||||
"value": "slow"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "panic",
|
||||
"value": "panic"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "fatal",
|
||||
"value": "fatal"
|
||||
}
|
||||
],
|
||||
"query": "All : .,debug,info,warn,error,slow,panic,fatal",
|
||||
"queryValue": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "任意关键字;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "关键字",
|
||||
"name": "keyword",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-7d",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PPanel Server Logs",
|
||||
"uid": "ppanel-server-logs",
|
||||
"version": 5,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -46,3 +46,12 @@ datasources:
|
||||
datasourceUid: prometheus
|
||||
serviceMap:
|
||||
datasourceUid: prometheus
|
||||
|
||||
- name: CloudWatch
|
||||
uid: cloudwatch
|
||||
type: cloudwatch
|
||||
access: proxy
|
||||
editable: true
|
||||
jsonData:
|
||||
authType: default
|
||||
defaultRegion: ap-east-1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `order_recovery_claims`;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS `order_recovery_claims` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`order_no` VARCHAR(255) NOT NULL COMMENT 'Recovered Order No',
|
||||
`email` VARCHAR(255) NOT NULL COMMENT 'Claim Email',
|
||||
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User ID',
|
||||
`subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe ID',
|
||||
`order_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Recovered Order ID',
|
||||
`user_subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User Subscribe ID',
|
||||
`claimed_at` DATETIME(3) NOT NULL COMMENT 'Claimed Time',
|
||||
`created_at` DATETIME(3) DEFAULT NULL COMMENT 'Creation Time',
|
||||
`updated_at` DATETIME(3) DEFAULT NULL COMMENT 'Update Time',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_order_recovery_claim_order_no` (`order_no`),
|
||||
KEY `idx_order_recovery_claim_email` (`email`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,25 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/recovery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func RecoverOrderHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.RecoverOrderRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := recovery.NewRecoverOrderLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.RecoverOrder(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/recovery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func SendCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.RecoverySendCodeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := recovery.NewSendCodeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.SendCode(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
||||
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
||||
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
||||
publicRecovery "github.com/perfect-panel/server/internal/handler/public/recovery"
|
||||
publicRedemption "github.com/perfect-panel/server/internal/handler/public/redemption"
|
||||
publicSubscribe "github.com/perfect-panel/server/internal/handler/public/subscribe"
|
||||
publicTicket "github.com/perfect-panel/server/internal/handler/public/ticket"
|
||||
@@ -932,6 +933,17 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicRecoveryGroupRouter := router.Group("/v1/public/recovery")
|
||||
publicRecoveryGroupRouter.Use(middleware.DeviceMiddleware(serverCtx))
|
||||
|
||||
{
|
||||
// Send recovery verification code
|
||||
publicRecoveryGroupRouter.POST("/send_code", publicRecovery.SendCodeHandler(serverCtx))
|
||||
|
||||
// Recover order subscription
|
||||
publicRecoveryGroupRouter.POST("/order", publicRecovery.RecoverOrderHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
|
||||
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
|
||||
|
||||
@@ -119,14 +119,28 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
}
|
||||
userInfo.Commission = req.Commission
|
||||
}
|
||||
userInfo.Avatar = req.Avatar
|
||||
userInfo.ReferCode = req.ReferCode
|
||||
if req.Avatar != "" {
|
||||
userInfo.Avatar = req.Avatar
|
||||
}
|
||||
if req.ReferCode != "" {
|
||||
userInfo.ReferCode = req.ReferCode
|
||||
}
|
||||
userInfo.RefererId = req.RefererId
|
||||
userInfo.Enable = &req.Enable
|
||||
userInfo.IsAdmin = &req.IsAdmin
|
||||
userInfo.Remark = req.Remark
|
||||
userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase
|
||||
userInfo.ReferralPercentage = req.ReferralPercentage
|
||||
if req.Enable != nil {
|
||||
userInfo.Enable = req.Enable
|
||||
}
|
||||
if req.IsAdmin != nil {
|
||||
userInfo.IsAdmin = req.IsAdmin
|
||||
}
|
||||
if req.Remark != "" {
|
||||
userInfo.Remark = req.Remark
|
||||
}
|
||||
if req.OnlyFirstPurchase != nil {
|
||||
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
|
||||
}
|
||||
if req.ReferralPercentage != 0 {
|
||||
userInfo.ReferralPercentage = req.ReferralPercentage
|
||||
}
|
||||
|
||||
if req.Password != "" {
|
||||
if userInfo.Id == 2 && isDemo {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
ordermodel "github.com/perfect-panel/server/internal/model/order"
|
||||
recoverymodel "github.com/perfect-panel/server/internal/model/recovery"
|
||||
subscribemodel "github.com/perfect-panel/server/internal/model/subscribe"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
recoveryconfig "github.com/perfect-panel/server/internal/recovery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/authmethod"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
recoverySuccessMessage = "恢复完成,请使用该邮箱验证码登录查看订阅。"
|
||||
recoveryAlreadyDoneMessage = "该订单已恢复,请使用恢复时绑定的邮箱登录查看订阅。"
|
||||
recoveryInvalidOrderMessage = "订单信息无效,请确认订单号是否正确。"
|
||||
recoveryInvalidCodeMessage = "邮箱验证码错误或已过期,请重新获取验证码。"
|
||||
recoverySystemErrorMessage = "恢复暂时失败,请稍后再试或联系客服处理。"
|
||||
)
|
||||
|
||||
type RecoverOrderLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewRecoverOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RecoverOrderLogic {
|
||||
return &RecoverOrderLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RecoverOrderLogic) RecoverOrder(req *types.RecoverOrderRequest) (*types.RecoverOrderResponse, error) {
|
||||
orderNo := strings.TrimSpace(req.OrderNo)
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
code := strings.TrimSpace(req.Code)
|
||||
|
||||
if orderNo == "" || email == "" || code == "" {
|
||||
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||
}
|
||||
|
||||
if err := l.verifyEmailCode(email, code); err != nil {
|
||||
return recoveryFailed(recoveryInvalidCodeMessage), nil
|
||||
}
|
||||
|
||||
item, ok, err := recoveryconfig.FindOrder(orderNo)
|
||||
if err != nil {
|
||||
l.Errorw("[RecoverOrder] load recovery config failed", logger.Field("error", err.Error()))
|
||||
return recoveryFailed(recoverySystemErrorMessage), nil
|
||||
}
|
||||
if !ok {
|
||||
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||
}
|
||||
|
||||
if item.OrderStatus != "" && item.OrderStatus != "success" {
|
||||
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||
}
|
||||
if item.Quantity <= 0 || item.OrderMoneyCents <= 0 {
|
||||
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||
}
|
||||
claimOrderNo := item.OutOrderNo
|
||||
if claimOrderNo == "" {
|
||||
claimOrderNo = item.OrderNo
|
||||
}
|
||||
|
||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, item.SubscribeId)
|
||||
if err != nil {
|
||||
l.Errorw("[RecoverOrder] subscribe not found", logger.Field("subscribe_id", item.SubscribeId), logger.Field("error", err.Error()))
|
||||
return recoveryFailed(recoverySystemErrorMessage), nil
|
||||
}
|
||||
|
||||
var (
|
||||
claim recoverymodel.OrderRecoveryClaim
|
||||
order ordermodel.Order
|
||||
userSub usermodel.Subscribe
|
||||
alreadyRecovered bool
|
||||
)
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&recoverymodel.OrderRecoveryClaim{}).
|
||||
Where("order_no = ?", claimOrderNo).
|
||||
First(&claim).Error; err == nil {
|
||||
alreadyRecovered = true
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := l.findOrCreateUser(tx, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
claim = recoverymodel.OrderRecoveryClaim{
|
||||
OrderNo: claimOrderNo,
|
||||
Email: email,
|
||||
UserId: u.Id,
|
||||
SubscribeId: item.SubscribeId,
|
||||
ClaimedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Model(&recoverymodel.OrderRecoveryClaim{}).Create(&claim).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
order, err = l.createRecoveredOrder(tx, u.Id, item, sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userSub, err = l.createOrExtendSubscribe(tx, u.Id, order.Id, item, sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
claim.UserSubscribeId = userSub.Id
|
||||
return tx.Model(&recoverymodel.OrderRecoveryClaim{}).
|
||||
Where("id = ?", claim.Id).
|
||||
Updates(map[string]any{
|
||||
"order_id": order.Id,
|
||||
"user_subscribe_id": userSub.Id,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("[RecoverOrder] recover failed", logger.Field("order_no", orderNo), logger.Field("error", err.Error()))
|
||||
return recoveryFailed(recoverySystemErrorMessage), nil
|
||||
}
|
||||
|
||||
if alreadyRecovered {
|
||||
return &types.RecoverOrderResponse{
|
||||
Success: true,
|
||||
Message: recoveryAlreadyDoneMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
l.clearCaches(&userSub, item.SubscribeId, email)
|
||||
l.Infof("[RecoverOrder] recovered order subscription order_no=%s email=%s user_id=%d subscribe_id=%d user_subscribe_id=%d",
|
||||
orderNo, email, claim.UserId, claim.SubscribeId, claim.UserSubscribeId)
|
||||
|
||||
return &types.RecoverOrderResponse{
|
||||
Success: true,
|
||||
Message: recoverySuccessMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func recoveryFailed(message string) *types.RecoverOrderResponse {
|
||||
return &types.RecoverOrderResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RecoverOrderLogic) createRecoveredOrder(tx *gorm.DB, userId int64, item recoveryconfig.Order, sub *subscribemodel.Subscribe) (ordermodel.Order, error) {
|
||||
var existing ordermodel.Order
|
||||
orderNo := item.OutOrderNo
|
||||
if orderNo == "" {
|
||||
orderNo = item.OrderNo
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&ordermodel.Order{}).
|
||||
Where("order_no = ?", orderNo).
|
||||
First(&existing).Error; err == nil {
|
||||
if existing.UserId != userId {
|
||||
return ordermodel.Order{}, errors.New("recovered order number already belongs to another user")
|
||||
}
|
||||
return existing, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ordermodel.Order{}, err
|
||||
}
|
||||
|
||||
paidAt := time.Now()
|
||||
if item.PaidAt > 0 {
|
||||
paidAt = time.Unix(item.PaidAt, 0)
|
||||
}
|
||||
|
||||
quantity := item.Quantity
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
price := sub.UnitPrice * quantity
|
||||
amount := item.OrderMoneyCents
|
||||
if amount <= 0 {
|
||||
amount = price
|
||||
}
|
||||
|
||||
o := ordermodel.Order{
|
||||
UserId: userId,
|
||||
SubscriptionUserId: userId,
|
||||
OrderNo: orderNo,
|
||||
Type: 1,
|
||||
Quantity: quantity,
|
||||
Price: price,
|
||||
Amount: amount,
|
||||
Discount: price - amount,
|
||||
PaymentId: 2,
|
||||
Method: "EPay",
|
||||
TradeNo: strings.TrimSpace(item.PaymentOrderNo),
|
||||
Status: 5,
|
||||
SubscribeId: item.SubscribeId,
|
||||
IsNew: true,
|
||||
CreatedAt: paidAt,
|
||||
UpdatedAt: paidAt,
|
||||
}
|
||||
if o.Discount < 0 {
|
||||
o.Discount = 0
|
||||
}
|
||||
if err := tx.Model(&ordermodel.Order{}).Create(&o).Error; err != nil {
|
||||
return ordermodel.Order{}, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (l *RecoverOrderLogic) createOrExtendSubscribe(tx *gorm.DB, userId int64, orderId int64, item recoveryconfig.Order, sub *subscribemodel.Subscribe) (usermodel.Subscribe, error) {
|
||||
now := time.Now()
|
||||
var existing usermodel.Subscribe
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&usermodel.Subscribe{}).
|
||||
Where("user_id = ? AND subscribe_id = ?", userId, item.SubscribeId).
|
||||
Order("expire_time DESC").
|
||||
Order("id DESC").
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
base := now
|
||||
if existing.ExpireTime.After(now) {
|
||||
base = existing.ExpireTime
|
||||
}
|
||||
existing.OrderId = orderId
|
||||
existing.ExpireTime = base.Add(time.Duration(item.Quantity) * 24 * time.Hour)
|
||||
existing.Status = 1
|
||||
existing.FinishedAt = nil
|
||||
if strings.TrimSpace(item.Note) != "" {
|
||||
existing.Note = strings.TrimSpace(item.Note)
|
||||
}
|
||||
if err := tx.Model(&usermodel.Subscribe{}).Where("id = ?", existing.Id).Save(&existing).Error; err != nil {
|
||||
return usermodel.Subscribe{}, err
|
||||
}
|
||||
return existing, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return usermodel.Subscribe{}, err
|
||||
}
|
||||
|
||||
orderNo := item.OutOrderNo
|
||||
if orderNo == "" {
|
||||
orderNo = item.OrderNo
|
||||
}
|
||||
userSub := usermodel.Subscribe{
|
||||
UserId: userId,
|
||||
OrderId: orderId,
|
||||
SubscribeId: item.SubscribeId,
|
||||
NodeGroupId: sub.NodeGroupId,
|
||||
StartTime: now,
|
||||
ExpireTime: now.Add(time.Duration(item.Quantity) * 24 * time.Hour),
|
||||
Traffic: sub.Traffic,
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
Token: uuidx.SubscribeToken(orderNo),
|
||||
UUID: uuid.New().String(),
|
||||
Status: 1,
|
||||
Note: strings.TrimSpace(item.Note),
|
||||
}
|
||||
if err := tx.Model(&usermodel.Subscribe{}).Create(&userSub).Error; err != nil {
|
||||
return usermodel.Subscribe{}, err
|
||||
}
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
func (l *RecoverOrderLogic) verifyEmailCode(email string, code string) error {
|
||||
checker := commonLogic.NewCheckVerificationCodeLogic(l.ctx, l.svcCtx)
|
||||
resp, err := checker.CheckVerificationCodeWithBehavior(&types.CheckVerificationCodeRequest{
|
||||
Method: authmethod.Email,
|
||||
Account: email,
|
||||
Code: code,
|
||||
Type: uint8(constant.Security),
|
||||
}, commonLogic.VerifyCodeCheckBehavior{
|
||||
Source: "order_recovery",
|
||||
Consume: true,
|
||||
AllowSceneFallback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil || !resp.Status {
|
||||
return errors.New("invalid verification code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *RecoverOrderLogic) findOrCreateUser(tx *gorm.DB, email string) (*usermodel.User, error) {
|
||||
var auth usermodel.AuthMethods
|
||||
if err := tx.Model(&usermodel.AuthMethods{}).
|
||||
Where("auth_type = ? AND auth_identifier = ?", authmethod.Email, email).
|
||||
First(&auth).Error; err == nil {
|
||||
var u usermodel.User
|
||||
if err := tx.Model(&usermodel.User{}).Where("id = ?", auth.UserId).First(&u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.AuthMethods = []usermodel.AuthMethods{auth}
|
||||
return &u, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u := &usermodel.User{
|
||||
Password: tool.EncodePassWord(uuid.NewString()),
|
||||
Algo: "default",
|
||||
AuthMethods: []usermodel.AuthMethods{
|
||||
{
|
||||
AuthType: authmethod.Email,
|
||||
AuthIdentifier: email,
|
||||
Verified: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := tx.Create(u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
referCode := uuidx.UserInviteCode(u.Id)
|
||||
if err := tx.Model(&usermodel.User{}).Where("id = ?", u.Id).Update("refer_code", referCode).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ReferCode = referCode
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (l *RecoverOrderLogic) clearCaches(userSub *usermodel.Subscribe, subscribeId int64, email string) {
|
||||
if userSub != nil && userSub.Id > 0 {
|
||||
if err := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, userSub); err != nil {
|
||||
l.Errorw("[RecoverOrder] clear user subscribe cache failed", logger.Field("error", err.Error()))
|
||||
}
|
||||
if userSub.UserId > 0 {
|
||||
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, &usermodel.User{
|
||||
Id: userSub.UserId,
|
||||
AuthMethods: []usermodel.AuthMethods{
|
||||
{
|
||||
UserId: userSub.UserId,
|
||||
AuthType: authmethod.Email,
|
||||
AuthIdentifier: email,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
l.Errorw("[RecoverOrder] clear user cache failed", logger.Field("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
if subscribeId > 0 {
|
||||
if err := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeId); err != nil {
|
||||
l.Errorw("[RecoverOrder] clear subscribe cache failed", logger.Field("error", err.Error()))
|
||||
}
|
||||
}
|
||||
if l.svcCtx.NodeModel != nil {
|
||||
if err := l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
|
||||
l.Errorw("[RecoverOrder] clear server cache failed", logger.Field("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/limit"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/random"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
queue "github.com/perfect-panel/server/queue/types"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type SendCodeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewSendCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SendCodeLogic {
|
||||
return &SendCodeLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SendCodeLogic) SendCode(req *types.RecoverySendCodeRequest) (*types.SendCodeResponse, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if email == "" {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "email is required")
|
||||
}
|
||||
|
||||
scene := constant.Security.String()
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||
limiter := limit.NewPeriodLimit(60, 1, l.svcCtx.Redis, fmt.Sprintf("%s:%s:%s", config.SendIntervalKeyPrefix, "email", scene))
|
||||
permit, err := limiter.Take(email)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to take limit")
|
||||
}
|
||||
if !limiter.ParsePermitState(permit) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "send email too many requests")
|
||||
}
|
||||
permit, err = l.svcCtx.AuthLimiter.Take(fmt.Sprintf("%s:%s:%s", "email", scene, email))
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to take limit")
|
||||
}
|
||||
if !l.svcCtx.AuthLimiter.ParsePermitState(permit) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TodaySendCountExceedsLimit), "send email too many requests")
|
||||
}
|
||||
|
||||
code := random.Key(6, 0)
|
||||
expireTime := l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime
|
||||
if expireTime == 0 {
|
||||
expireTime = 900
|
||||
}
|
||||
val, _ := json.Marshal(commonLogic.CacheKeyPayload{
|
||||
Code: code,
|
||||
LastAt: time.Now().Unix(),
|
||||
})
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*time.Duration(expireTime)).Err(); err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code")
|
||||
}
|
||||
|
||||
expireMinutes := expireTime / 60
|
||||
taskPayload := queue.SendEmailPayload{
|
||||
Type: queue.EmailTypeVerify,
|
||||
Scene: scene,
|
||||
Email: email,
|
||||
Subject: "Verification code",
|
||||
Content: map[string]interface{}{
|
||||
"Type": uint8(constant.Security),
|
||||
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
|
||||
"SiteName": l.svcCtx.Config.Site.SiteName,
|
||||
"Expire": expireMinutes,
|
||||
"Code": code,
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(taskPayload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to marshal task payload")
|
||||
}
|
||||
task := asynq.NewTask(queue.ForthwithSendEmail, payload, asynq.MaxRetry(3))
|
||||
if _, err = l.svcCtx.Queue.Enqueue(task); err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to enqueue task")
|
||||
}
|
||||
|
||||
resp := &types.SendCodeResponse{Status: true}
|
||||
if l.svcCtx.Config.Model == constant.DevMode {
|
||||
resp.Code = code
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -57,6 +57,9 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
var verified bool
|
||||
if req.Code == "202511" {
|
||||
verified = true
|
||||
} else if req.Code == "000000" && req.Email == "hifastday@hifast.com" {
|
||||
// 000000 仅允许 hifastday@hifast.com 使用
|
||||
verified = true
|
||||
}
|
||||
scenes := []string{constant.Security.String(), constant.Register.String()}
|
||||
for _, scene := range scenes {
|
||||
|
||||
@@ -2,6 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -9,8 +10,13 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// restrictedOwnerUserId 受限家主用户ID(hifastday@hifast.com)
|
||||
// 该用户本人及其家庭成员只允许返回第一个设备
|
||||
const restrictedOwnerUserId int64 = 37498
|
||||
|
||||
type GetDeviceListLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
@@ -28,18 +34,59 @@ func NewGetDeviceListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Get
|
||||
|
||||
func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse, err error) {
|
||||
userInfo := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||
scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
|
||||
|
||||
restricted, err := l.isRestrictedScope(userInfo.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
|
||||
userRespList := make([]types.UserDevice, 0)
|
||||
tool.DeepCopy(&userRespList, list)
|
||||
for i, d := range list {
|
||||
if i < len(userRespList) {
|
||||
userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id)
|
||||
|
||||
if restricted {
|
||||
// 受限逻辑:hifastday@hifast.com (userId=37498) 本人及其家庭成员
|
||||
// 只返回当前用户自己的第一个设备,其余丢弃
|
||||
// 原始代码(保留以供对照):
|
||||
// scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||
// scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
|
||||
// if err != nil { return nil, err }
|
||||
// list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
|
||||
// ...(for 循环为所有设备赋 DeviceNo)
|
||||
var ownList []*user.Device
|
||||
ownList, _, err = l.svcCtx.UserModel.QueryDeviceList(l.ctx, userInfo.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userRespList := make([]types.UserDevice, 0, 1)
|
||||
if len(ownList) > 0 {
|
||||
var item types.UserDevice
|
||||
tool.DeepCopy(&item, ownList[0])
|
||||
item.DeviceNo = tool.DeviceIdToHash(ownList[0].Id)
|
||||
userRespList = append(userRespList, item)
|
||||
}
|
||||
resp = &types.GetDeviceListResponse{
|
||||
Total: int64(len(userRespList)),
|
||||
List: userRespList,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 正常逻辑:返回家庭范围内所有成员的设备(含 DeviceNo)
|
||||
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||
var scopeUserIds []int64
|
||||
scopeUserIds, err = scopeHelper.resolveScopedUserIds(userInfo.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list []*user.Device
|
||||
var count int64
|
||||
list, count, err = l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userRespList := make([]types.UserDevice, 0, len(list))
|
||||
for _, d := range list {
|
||||
var item types.UserDevice
|
||||
tool.DeepCopy(&item, d)
|
||||
item.DeviceNo = tool.DeviceIdToHash(d.Id)
|
||||
userRespList = append(userRespList, item)
|
||||
}
|
||||
resp = &types.GetDeviceListResponse{
|
||||
Total: count,
|
||||
@@ -47,3 +94,27 @@ func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// isRestrictedScope 判断当前用户是否在受限范围内:
|
||||
// 1. 当前用户本身即 restrictedOwnerUserId(hifastday@hifast.com)
|
||||
// 2. 当前用户所在家庭的家主是 restrictedOwnerUserId(即通过该邮箱登录的其他设备用户)
|
||||
func (l *GetDeviceListLogic) isRestrictedScope(currentUserId int64) (bool, error) {
|
||||
if currentUserId == restrictedOwnerUserId {
|
||||
return true, nil
|
||||
}
|
||||
var family user.UserFamily
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.UserFamily{}).
|
||||
Joins("JOIN user_family_member ON user_family_member.family_id = user_family.id AND user_family_member.user_id = ? AND user_family_member.status = ?",
|
||||
currentUserId, user.FamilyMemberActive).
|
||||
Where("user_family.owner_user_id = ? AND user_family.status = ? AND user_family.deleted_at IS NULL",
|
||||
restrictedOwnerUserId, user.FamilyStatusActive).
|
||||
First(&family).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package recovery
|
||||
|
||||
import "time"
|
||||
|
||||
type OrderRecoveryClaim struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
OrderNo string `gorm:"type:varchar(255);not null;uniqueIndex:idx_order_recovery_claim_order_no;comment:Recovered Order No"`
|
||||
Email string `gorm:"type:varchar(255);not null;index:idx_order_recovery_claim_email;comment:Claim Email"`
|
||||
UserId int64 `gorm:"type:bigint;not null;default:0;comment:User ID"`
|
||||
SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe ID"`
|
||||
OrderId int64 `gorm:"type:bigint;not null;default:0;comment:Recovered Order ID"`
|
||||
UserSubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:User Subscribe ID"`
|
||||
ClaimedAt time.Time `gorm:"comment:Claimed Time"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
func (OrderRecoveryClaim) TableName() string {
|
||||
return "order_recovery_claims"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed recovery_orders.json
|
||||
var recoveryOrdersJSON []byte
|
||||
|
||||
type Config struct {
|
||||
Orders []Order `json:"orders"`
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
OutOrderNo string `json:"out_order_no,omitempty"`
|
||||
OrderNo string `json:"order_no"`
|
||||
OrderStatus string `json:"order_status,omitempty"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
LegacyID string `json:"订阅id,omitempty"`
|
||||
ExpireAt int64 `json:"expire_at"`
|
||||
Quantity int64 `json:"quantity,omitempty"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"`
|
||||
PaymentOrderNo string `json:"payment_order_no,omitempty"`
|
||||
OrderMoneyCents int64 `json:"order_money_cents,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
var (
|
||||
loadOnce sync.Once
|
||||
orders map[string]Order
|
||||
loadErr error
|
||||
)
|
||||
|
||||
func FindOrder(orderNo string) (Order, bool, error) {
|
||||
loadOnce.Do(func() {
|
||||
orders, loadErr = loadOrders()
|
||||
})
|
||||
if loadErr != nil {
|
||||
return Order{}, false, loadErr
|
||||
}
|
||||
item, ok := orders[strings.TrimSpace(orderNo)]
|
||||
return item, ok, nil
|
||||
}
|
||||
|
||||
func loadOrders() (map[string]Order, error) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(recoveryOrdersJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse recovery orders json: %w", err)
|
||||
}
|
||||
|
||||
result := make(map[string]Order, len(cfg.Orders))
|
||||
for i, item := range cfg.Orders {
|
||||
item.OutOrderNo = strings.TrimSpace(item.OutOrderNo)
|
||||
item.OrderNo = strings.TrimSpace(item.OrderNo)
|
||||
item.OrderStatus = strings.ToLower(strings.TrimSpace(item.OrderStatus))
|
||||
if item.OutOrderNo == "" {
|
||||
item.OutOrderNo = item.OrderNo
|
||||
}
|
||||
if item.OrderNo == "" {
|
||||
item.OrderNo = item.OutOrderNo
|
||||
}
|
||||
if item.OrderStatus == "" {
|
||||
item.OrderStatus = "success"
|
||||
}
|
||||
if item.SubscribeId == 0 && strings.TrimSpace(item.LegacyID) != "" {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(item.LegacyID), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("orders[%d] legacy subscribe id is invalid", i)
|
||||
}
|
||||
item.SubscribeId = id
|
||||
}
|
||||
if item.OutOrderNo == "" {
|
||||
return nil, fmt.Errorf("orders[%d] out_order_no is required", i)
|
||||
}
|
||||
if item.SubscribeId <= 0 {
|
||||
return nil, fmt.Errorf("orders[%d] subscribe_id is required", i)
|
||||
}
|
||||
if item.ExpireAt <= 0 {
|
||||
return nil, fmt.Errorf("orders[%d] expire_at is required", i)
|
||||
}
|
||||
if item.Quantity <= 0 {
|
||||
item.Quantity = quantityFromAmount(item.OrderMoneyCents)
|
||||
}
|
||||
if item.Quantity <= 0 {
|
||||
return nil, fmt.Errorf("orders[%d] quantity is required", i)
|
||||
}
|
||||
if item.PaidAt < 0 {
|
||||
return nil, fmt.Errorf("orders[%d] paid_at is invalid", i)
|
||||
}
|
||||
if item.OrderMoneyCents < 0 {
|
||||
return nil, fmt.Errorf("orders[%d] order_money_cents is invalid", i)
|
||||
}
|
||||
if _, exists := result[item.OutOrderNo]; exists {
|
||||
return nil, fmt.Errorf("duplicate recovery out_order_no %s", item.OutOrderNo)
|
||||
}
|
||||
result[item.OutOrderNo] = item
|
||||
if item.OrderNo != "" && item.OrderNo != item.OutOrderNo {
|
||||
if _, exists := result[item.OrderNo]; exists {
|
||||
return nil, fmt.Errorf("duplicate recovery order_no %s", item.OrderNo)
|
||||
}
|
||||
result[item.OrderNo] = item
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func quantityFromAmount(amount int64) int64 {
|
||||
switch amount {
|
||||
case 1953:
|
||||
return 7
|
||||
case 4193:
|
||||
return 30
|
||||
case 9093:
|
||||
return 90
|
||||
case 31500:
|
||||
return 365
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,7 @@ func initServer(svc *svc.ServiceContext) *gin.Engine {
|
||||
|
||||
// register handlers
|
||||
handler.RegisterHandlers(r, svc)
|
||||
r.StaticFile("/order-recovery.html", "./public/order-recovery.html")
|
||||
// register subscribe handler
|
||||
handler.RegisterSubscribeHandlers(r, svc)
|
||||
// register telegram handler
|
||||
|
||||
+18
-3
@@ -2235,6 +2235,17 @@ type RechargeOrderResponse struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
|
||||
type RecoverOrderRequest struct {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
type RecoverOrderResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RedeemCodeRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
@@ -2359,6 +2370,10 @@ type ResetTrafficOrderResponse struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
|
||||
type RecoverySendCodeRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
type ResetUserSubscribeTokenRequest struct {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
@@ -3089,13 +3104,13 @@ type UpdateUserBasiceInfoRequest struct {
|
||||
Balance int64 `json:"balance"`
|
||||
Commission int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable bool `json:"enable"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,20 @@ scrape_configs:
|
||||
job: ppanel-server
|
||||
service_name: ppanel
|
||||
__path__: /var/log/ppanel-server/*.log
|
||||
pipeline_stages:
|
||||
- json:
|
||||
expressions:
|
||||
caller: caller
|
||||
content: content
|
||||
level: level
|
||||
timestamp: timestamp
|
||||
- timestamp:
|
||||
source: timestamp
|
||||
format: "2006-01-02 15:04:05.000"
|
||||
location: Asia/Shanghai
|
||||
- labels:
|
||||
caller:
|
||||
level:
|
||||
|
||||
- job_name: nginx-file
|
||||
static_configs:
|
||||
|
||||
+1
-1
@@ -34,6 +34,7 @@ import (
|
||||
"apis/public/user.api"
|
||||
"apis/public/subscribe.api"
|
||||
"apis/public/redemption.api"
|
||||
"apis/public/recovery.api"
|
||||
"apis/public/order.api"
|
||||
"apis/public/announcement.api"
|
||||
"apis/public/ticket.api"
|
||||
@@ -42,4 +43,3 @@ import (
|
||||
"apis/public/portal.api"
|
||||
"apis/public/iap.api"
|
||||
)
|
||||
|
||||
|
||||
@@ -24,16 +24,6 @@ scrape_configs:
|
||||
- targets:
|
||||
- cadvisor:8080
|
||||
|
||||
- job_name: redis-exporter
|
||||
static_configs:
|
||||
- targets:
|
||||
- redis-exporter:9121
|
||||
|
||||
- job_name: mysql-exporter
|
||||
static_configs:
|
||||
- targets:
|
||||
- mysql-exporter:9104
|
||||
|
||||
- job_name: nginx-exporter
|
||||
static_configs:
|
||||
- targets:
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no,email=no" />
|
||||
<title>订单订阅恢复</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--text: #162033;
|
||||
--muted: #667085;
|
||||
--line: #d8dee9;
|
||||
--primary: #2563eb;
|
||||
--primary-pressed: #1d4ed8;
|
||||
--danger: #d92d20;
|
||||
--success: #047857;
|
||||
--shadow: 0 18px 48px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 50% -10%, rgba(37, 99, 235, 0.16), transparent 34rem),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: max(20px, env(safe-area-inset-top)) 16px
|
||||
max(24px, env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(100%, 440px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid rgba(216, 222, 233, 0.86);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0 13px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 116px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 46px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
background 0.12s ease,
|
||||
opacity 0.12s ease;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--primary-pressed);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e9eefb;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 14px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: none;
|
||||
margin: 14px 0 0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.status.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
border: 1px solid rgba(4, 120, 87, 0.24);
|
||||
background: rgba(4, 120, 87, 0.08);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
border: 1px solid rgba(217, 45, 32, 0.24);
|
||||
background: rgba(217, 45, 32, 0.08);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.page {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="shell" aria-labelledby="page-title">
|
||||
<div class="brand">
|
||||
<h1 id="page-title">订单订阅恢复</h1>
|
||||
<p>填写订单号并验证邮箱,系统会为该邮箱恢复对应订阅。</p>
|
||||
</div>
|
||||
|
||||
<form class="card" id="recovery-form">
|
||||
<div class="field">
|
||||
<label for="order-no">订单号</label>
|
||||
<input
|
||||
id="order-no"
|
||||
name="order_no"
|
||||
autocomplete="off"
|
||||
inputmode="text"
|
||||
placeholder="请输入订单号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">邮箱</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
inputmode="email"
|
||||
placeholder="请输入接收验证码的邮箱"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="code">邮箱验证码</label>
|
||||
<div class="code-row">
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="8"
|
||||
placeholder="请输入验证码"
|
||||
required
|
||||
/>
|
||||
<button class="btn-secondary" id="send-code" type="button">
|
||||
获取验证码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" id="submit-btn" type="submit">
|
||||
恢复订阅
|
||||
</button>
|
||||
|
||||
<div class="status" id="status" role="status"></div>
|
||||
|
||||
<p class="hint">
|
||||
恢复成功后,请使用该邮箱验证码登录账号查看订阅。每个订单号只能恢复一次。
|
||||
</p>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById("recovery-form");
|
||||
const sendCodeButton = document.getElementById("send-code");
|
||||
const submitButton = document.getElementById("submit-btn");
|
||||
const statusBox = document.getElementById("status");
|
||||
const emailInput = document.getElementById("email");
|
||||
const orderInput = document.getElementById("order-no");
|
||||
const codeInput = document.getElementById("code");
|
||||
const API_BASE = "https://tapi.hifast.biz";
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
const ERROR_MESSAGE_MAP = {
|
||||
"Param Error": "请检查订单号、邮箱和验证码是否填写正确。",
|
||||
"Verify code error": "邮箱验证码错误或已过期,请重新获取验证码。",
|
||||
"Too Many Requests": "验证码发送太频繁,请稍后再试。",
|
||||
"This account has reached the limit of sending times today":
|
||||
"该邮箱今天获取验证码次数已达上限,请明天再试。"
|
||||
};
|
||||
|
||||
function friendlyMessage(message, fallback) {
|
||||
return ERROR_MESSAGE_MAP[message] || message || fallback;
|
||||
}
|
||||
|
||||
function showStatus(type, message) {
|
||||
statusBox.className = `status show ${type}`;
|
||||
statusBox.textContent = message;
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
statusBox.className = "status";
|
||||
statusBox.textContent = "";
|
||||
}
|
||||
|
||||
function normalizeEmail() {
|
||||
emailInput.value = emailInput.value.trim().toLowerCase();
|
||||
return emailInput.value;
|
||||
}
|
||||
|
||||
async function postJson(path, body) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.code !== 200) {
|
||||
throw new Error(
|
||||
friendlyMessage(payload.msg, "请求失败,请稍后再试。")
|
||||
);
|
||||
}
|
||||
return payload.data || {};
|
||||
}
|
||||
|
||||
function startCountdown(seconds) {
|
||||
let remaining = seconds;
|
||||
sendCodeButton.disabled = true;
|
||||
sendCodeButton.textContent = `${remaining}s`;
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(countdownTimer);
|
||||
sendCodeButton.disabled = false;
|
||||
sendCodeButton.textContent = "获取验证码";
|
||||
return;
|
||||
}
|
||||
sendCodeButton.textContent = `${remaining}s`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
sendCodeButton.addEventListener("click", async () => {
|
||||
clearStatus();
|
||||
const email = normalizeEmail();
|
||||
if (!emailInput.reportValidity()) return;
|
||||
|
||||
sendCodeButton.disabled = true;
|
||||
sendCodeButton.textContent = "发送中";
|
||||
try {
|
||||
await postJson("/v1/public/recovery/send_code", { email });
|
||||
showStatus("success", "验证码已发送,请查看邮箱。");
|
||||
startCountdown(60);
|
||||
} catch (error) {
|
||||
sendCodeButton.disabled = false;
|
||||
sendCodeButton.textContent = "获取验证码";
|
||||
showStatus("error", error.message || "验证码发送失败,请稍后再试。");
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
clearStatus();
|
||||
const orderNo = orderInput.value.trim();
|
||||
const email = normalizeEmail();
|
||||
const code = codeInput.value.trim();
|
||||
if (!form.reportValidity()) return;
|
||||
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "恢复中";
|
||||
try {
|
||||
const result = await postJson("/v1/public/recovery/order", {
|
||||
order_no: orderNo,
|
||||
email,
|
||||
code
|
||||
});
|
||||
const message =
|
||||
result.message || "恢复完成,请使用该邮箱验证码登录查看订阅。";
|
||||
if (result.success === false) {
|
||||
showStatus("error", message);
|
||||
return;
|
||||
}
|
||||
showStatus("success", message);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
showStatus(
|
||||
"error",
|
||||
error.message || "恢复失败,请检查信息后重试。"
|
||||
);
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = "恢复订阅";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type remoteOrder struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
OutOrderNo string `json:"out_order_no"`
|
||||
OrderMoney json.Number `json:"order_money"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
PaymentTime *int64 `json:"payment_time"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
}
|
||||
|
||||
type recoveryFile struct {
|
||||
Orders []recoveryOrder `json:"orders"`
|
||||
}
|
||||
|
||||
type recoveryOrder struct {
|
||||
OutOrderNo string `json:"out_order_no"`
|
||||
OrderNo string `json:"order_no"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
ExpireAt int64 `json:"expire_at"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
PaymentOrderNo string `json:"payment_order_no,omitempty"`
|
||||
OrderMoneyCents int64 `json:"order_money_cents,omitempty"`
|
||||
}
|
||||
|
||||
type adminOrderResponse struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Total int64 `json:"total"`
|
||||
List []adminOrder `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type adminOrder struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
input string
|
||||
output string
|
||||
adminOrders string
|
||||
subscribeID int64
|
||||
onlyMissing bool
|
||||
)
|
||||
flag.StringVar(&input, "in", "aaa.json", "input payment platform orders JSON")
|
||||
flag.StringVar(&output, "out", "internal/recovery/recovery_orders.json", "output recovery orders JSON")
|
||||
flag.StringVar(&adminOrders, "admin-orders", "", "optional admin order list response JSON; when provided, matched out_order_no values are skipped")
|
||||
flag.Int64Var(&subscribeID, "subscribe-id", 1, "subscribe id to use for recovered subscriptions")
|
||||
flag.BoolVar(&onlyMissing, "only-missing", true, "only output payment success orders missing from admin order list when -admin-orders is set")
|
||||
flag.Parse()
|
||||
|
||||
orders, err := readOrders(input)
|
||||
must(err)
|
||||
existingOrders := map[string]struct{}{}
|
||||
if adminOrders != "" {
|
||||
existingOrders, err = readAdminOrderSet(adminOrders)
|
||||
must(err)
|
||||
}
|
||||
|
||||
daysByAmount := map[int64]int64{
|
||||
1953: 7,
|
||||
4193: 30,
|
||||
9093: 90,
|
||||
31500: 365,
|
||||
}
|
||||
|
||||
out := recoveryFile{Orders: make([]recoveryOrder, 0, len(orders))}
|
||||
var skipped []string
|
||||
var matchedExisting int
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range orders {
|
||||
if strings.ToLower(strings.TrimSpace(item.OrderStatus)) != "success" {
|
||||
continue
|
||||
}
|
||||
localOrderNo := strings.TrimSpace(item.OutOrderNo)
|
||||
if localOrderNo == "" {
|
||||
skipped = append(skipped, fmt.Sprintf("%s missing out_order_no", item.OrderNo))
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[localOrderNo]; ok {
|
||||
skipped = append(skipped, fmt.Sprintf("%s duplicate out_order_no", localOrderNo))
|
||||
continue
|
||||
}
|
||||
if _, exists := existingOrders[localOrderNo]; exists && onlyMissing {
|
||||
matchedExisting++
|
||||
seen[localOrderNo] = struct{}{}
|
||||
continue
|
||||
}
|
||||
amount := decimalYuanToCents(item.OrderMoney)
|
||||
days, ok := daysByAmount[amount]
|
||||
if !ok {
|
||||
skipped = append(skipped, fmt.Sprintf("%s unknown amount %s", localOrderNo, item.OrderMoney.String()))
|
||||
continue
|
||||
}
|
||||
base := item.CreateTime
|
||||
if item.PaymentTime != nil && *item.PaymentTime > 0 {
|
||||
base = *item.PaymentTime
|
||||
}
|
||||
if base <= 0 {
|
||||
skipped = append(skipped, fmt.Sprintf("%s missing payment/create time", localOrderNo))
|
||||
continue
|
||||
}
|
||||
seen[localOrderNo] = struct{}{}
|
||||
out.Orders = append(out.Orders, recoveryOrder{
|
||||
OutOrderNo: localOrderNo,
|
||||
OrderNo: strings.TrimSpace(item.OrderNo),
|
||||
OrderStatus: "success",
|
||||
SubscribeId: subscribeID,
|
||||
ExpireAt: time.Unix(base, 0).Add(time.Duration(days) * 24 * time.Hour).UnixMilli(),
|
||||
Quantity: days,
|
||||
PaidAt: base,
|
||||
Note: "data recovery",
|
||||
PaymentOrderNo: strings.TrimSpace(item.OrderNo),
|
||||
OrderMoneyCents: amount,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(out.Orders, func(i, j int) bool {
|
||||
return out.Orders[i].OutOrderNo < out.Orders[j].OutOrderNo
|
||||
})
|
||||
|
||||
must(writeJSON(output, out))
|
||||
fmt.Printf("converted recovery orders=%d matched_existing=%d skipped=%d output=%s\n", len(out.Orders), matchedExisting, len(skipped), output)
|
||||
for _, msg := range skipped {
|
||||
fmt.Printf("skip: %s\n", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func readAdminOrderSet(path string) (map[string]struct{}, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp adminOrderResponse
|
||||
if err := json.Unmarshal(data, &resp); err == nil && resp.Data.List != nil {
|
||||
if resp.Code != 0 && resp.Code != 200 {
|
||||
return nil, fmt.Errorf("admin order response failed: code=%d msg=%s", resp.Code, resp.Msg)
|
||||
}
|
||||
return adminOrdersToSet(resp.Data.List), nil
|
||||
}
|
||||
|
||||
var list []adminOrder
|
||||
if err := json.Unmarshal(data, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adminOrdersToSet(list), nil
|
||||
}
|
||||
|
||||
func adminOrdersToSet(list []adminOrder) map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(list))
|
||||
for _, item := range list {
|
||||
orderNo := strings.TrimSpace(item.OrderNo)
|
||||
if orderNo != "" {
|
||||
set[orderNo] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func readOrders(path string) ([]remoteOrder, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var orders []remoteOrder
|
||||
if err := json.Unmarshal(data, &orders); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func writeJSON(path string, data recoveryFile) error {
|
||||
bytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bytes = append(bytes, '\n')
|
||||
return os.WriteFile(path, bytes, 0644)
|
||||
}
|
||||
|
||||
func decimalYuanToCents(n json.Number) int64 {
|
||||
s := strings.TrimSpace(n.String())
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
parts := strings.SplitN(s, ".", 2)
|
||||
yuan, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
cents := yuan * 100
|
||||
if len(parts) == 1 {
|
||||
return cents
|
||||
}
|
||||
frac := parts[1]
|
||||
if len(frac) > 2 {
|
||||
frac = frac[:2]
|
||||
}
|
||||
for len(frac) < 2 {
|
||||
frac += "0"
|
||||
}
|
||||
f, _ := strconv.ParseInt(frac, 10, 64)
|
||||
if strings.HasPrefix(parts[0], "-") {
|
||||
return cents - f
|
||||
}
|
||||
return cents + f
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/model/payment"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
queueTypes "github.com/perfect-panel/server/queue/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://gfiuseui.lkfezg.cn"
|
||||
|
||||
// Hard-code Mihapay V2 credentials here when this reconciliation script should
|
||||
// not depend on payment.config. Leave them empty to read pid/key/url from DB.
|
||||
hardcodedMerchantID = "80567"
|
||||
hardcodedMerchantKey = "475951745395f05a3be2ba7ca8235ba3"
|
||||
hardcodedBaseURL = defaultBaseURL
|
||||
)
|
||||
|
||||
type options struct {
|
||||
configPath string
|
||||
baseURL string
|
||||
paymentID int64
|
||||
method string
|
||||
start string
|
||||
end string
|
||||
page int
|
||||
limit int
|
||||
maxPages int
|
||||
apply bool
|
||||
amountDiff int64
|
||||
verbose bool
|
||||
printNotFound bool
|
||||
}
|
||||
|
||||
type merchantConfig struct {
|
||||
merchantID string
|
||||
key string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type orderListResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
List []remoteOrder `json:"list"`
|
||||
Total int `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type remoteOrder struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
OutOrderNo string `json:"out_order_no"`
|
||||
OrderMoney json.Number `json:"order_money"`
|
||||
UserMoney json.Number `json:"user_money"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
NotifyStatus string `json:"notify_status"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
PaymentTime int64 `json:"payment_time"`
|
||||
NotifyTime int64 `json:"notify_time"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Device string `json:"device"`
|
||||
OrderRemark string `json:"order_remark"`
|
||||
}
|
||||
|
||||
type matchedOrder struct {
|
||||
Remote remoteOrder
|
||||
Local order.Order
|
||||
RemoteCents int64
|
||||
Audit subscriptionAudit
|
||||
Action string
|
||||
Reason string
|
||||
PaymentAudit string
|
||||
}
|
||||
|
||||
type subscriptionAudit struct {
|
||||
Checked bool
|
||||
Status string
|
||||
UserSubID int64
|
||||
UserID int64
|
||||
StartTime time.Time
|
||||
ExpireTime time.Time
|
||||
ExpectedExpire time.Time
|
||||
SubscribeUnit string
|
||||
Quantity int64
|
||||
Reason string
|
||||
}
|
||||
|
||||
type reconcileStats struct {
|
||||
RemotePages int
|
||||
RemoteOrders int
|
||||
RemotePaid int
|
||||
RemoteMissingNo int
|
||||
DuplicateRemote int
|
||||
LocalNotFound int
|
||||
LocalDateFiltered int
|
||||
LocalMethodSkip int
|
||||
Matched int
|
||||
StatusCounts map[string]int
|
||||
LocalNotFoundOrders []remoteOrder
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
opt := parseFlags()
|
||||
|
||||
var cfg config.Config
|
||||
conf.MustLoad(opt.configPath, &cfg)
|
||||
|
||||
db, err := orm.ConnectMysql(orm.Mysql{Config: cfg.MySQL})
|
||||
must(err, "connect mysql")
|
||||
sqlDB, err := db.DB()
|
||||
must(err, "get sql db")
|
||||
defer sqlDB.Close()
|
||||
|
||||
var redisClient *redis.Client
|
||||
var queueClient *asynq.Client
|
||||
if opt.apply {
|
||||
redisClient = redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Host,
|
||||
Password: cfg.Redis.Pass,
|
||||
DB: cfg.Redis.DB,
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
queueClient = asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: cfg.Redis.Host,
|
||||
Password: cfg.Redis.Pass,
|
||||
DB: 5,
|
||||
})
|
||||
defer queueClient.Close()
|
||||
}
|
||||
|
||||
merchant, paymentInfo, err := resolveMerchantConfig(ctx, db, opt)
|
||||
must(err, "resolve mihapay config")
|
||||
if opt.paymentID > 0 {
|
||||
opt.method = paymentInfo.Platform
|
||||
}
|
||||
baseURL := opt.baseURL
|
||||
if baseURL == defaultBaseURL && merchant.baseURL != "" {
|
||||
baseURL = merchant.baseURL
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
matches, stats, err := reconcile(ctx, client, db, redisClient, queueClient, opt, baseURL, merchant, paymentInfo)
|
||||
must(err, "reconcile orders")
|
||||
|
||||
printSummary(matches, stats, opt)
|
||||
}
|
||||
|
||||
func parseFlags() options {
|
||||
opt := options{}
|
||||
flag.StringVar(&opt.configPath, "config", "etc/ppanel.yaml", "ppanel config path")
|
||||
flag.StringVar(&opt.baseURL, "base-url", defaultBaseURL, "Mihapay base URL")
|
||||
flag.Int64Var(&opt.paymentID, "payment-id", 0, "payment table id to use for merchant credentials")
|
||||
flag.StringVar(&opt.method, "method", "epay", "local order method/platform to match when payment-id is not set")
|
||||
flag.StringVar(&opt.start, "start", "", "local order created_at start, e.g. 2026-05-05 00:00:00")
|
||||
flag.StringVar(&opt.end, "end", "", "local order created_at end, e.g. 2026-05-06 00:00:00")
|
||||
flag.IntVar(&opt.page, "page", 1, "remote start page")
|
||||
flag.IntVar(&opt.limit, "limit", 100, "remote page size")
|
||||
flag.IntVar(&opt.maxPages, "max-pages", 50, "maximum remote pages to fetch")
|
||||
flag.BoolVar(&opt.apply, "apply", false, "apply fixes; default is dry-run")
|
||||
flag.Int64Var(&opt.amountDiff, "amount-diff-cents", 1, "allowed amount difference in cents")
|
||||
flag.BoolVar(&opt.verbose, "v", false, "print diagnostic details")
|
||||
flag.BoolVar(&opt.printNotFound, "print-not-found", false, "print remote paid orders that have no matching local order")
|
||||
flag.Parse()
|
||||
|
||||
if opt.start == "" {
|
||||
now := time.Now()
|
||||
opt.start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if opt.end == "" {
|
||||
start, err := time.ParseInLocation("2006-01-02 15:04:05", opt.start, time.Local)
|
||||
if err == nil {
|
||||
opt.end = start.AddDate(0, 0, 1).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
opt.baseURL = strings.TrimRight(opt.baseURL, "/")
|
||||
if opt.page <= 0 {
|
||||
opt.page = 1
|
||||
}
|
||||
if opt.limit <= 0 || opt.limit > 500 {
|
||||
opt.limit = 100
|
||||
}
|
||||
if opt.maxPages <= 0 {
|
||||
opt.maxPages = 1
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
func resolveMerchantConfig(ctx context.Context, db *gorm.DB, opt options) (merchantConfig, *payment.Payment, error) {
|
||||
if hardcodedMerchantID != "" && hardcodedMerchantKey != "" {
|
||||
return merchantConfig{
|
||||
merchantID: hardcodedMerchantID,
|
||||
key: hardcodedMerchantKey,
|
||||
baseURL: strings.TrimRight(hardcodedBaseURL, "/"),
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
var p payment.Payment
|
||||
query := db.WithContext(ctx).Model(&payment.Payment{})
|
||||
if opt.paymentID > 0 {
|
||||
query = query.Where("id = ?", opt.paymentID)
|
||||
} else {
|
||||
query = query.Where("LOWER(REPLACE(platform, '_', '')) = LOWER(REPLACE(?, '_', ''))", opt.method)
|
||||
}
|
||||
if err := query.First(&p).Error; err != nil {
|
||||
return merchantConfig{}, nil, err
|
||||
}
|
||||
|
||||
var epayCfg payment.EPayConfig
|
||||
if err := epayCfg.Unmarshal([]byte(p.Config)); err != nil {
|
||||
return merchantConfig{}, nil, err
|
||||
}
|
||||
if epayCfg.Pid == "" || epayCfg.Key == "" {
|
||||
return merchantConfig{}, nil, fmt.Errorf("payment id %d has empty pid/key", p.Id)
|
||||
}
|
||||
return merchantConfig{merchantID: epayCfg.Pid, key: epayCfg.Key, baseURL: strings.TrimRight(epayCfg.Url, "/")}, &p, nil
|
||||
}
|
||||
|
||||
func reconcile(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
db *gorm.DB,
|
||||
redisClient *redis.Client,
|
||||
queueClient *asynq.Client,
|
||||
opt options,
|
||||
baseURL string,
|
||||
merchant merchantConfig,
|
||||
paymentInfo *payment.Payment,
|
||||
) ([]matchedOrder, reconcileStats, error) {
|
||||
var matches []matchedOrder
|
||||
var stats reconcileStats
|
||||
stats.StatusCounts = make(map[string]int)
|
||||
seen := make(map[string]struct{})
|
||||
page := opt.page
|
||||
|
||||
for fetchedPages := 0; fetchedPages < opt.maxPages; fetchedPages++ {
|
||||
resp, err := fetchRemoteOrders(ctx, client, baseURL, merchant, page, opt.limit)
|
||||
if err != nil {
|
||||
return matches, stats, err
|
||||
}
|
||||
if resp.Code != 1 {
|
||||
return matches, stats, fmt.Errorf("remote orderlist failed: code=%d msg=%s", resp.Code, resp.Msg)
|
||||
}
|
||||
stats.RemotePages++
|
||||
stats.RemoteOrders += len(resp.Data.List)
|
||||
if opt.verbose {
|
||||
fmt.Printf("remote page=%d list=%d total=%d\n", page, len(resp.Data.List), resp.Data.Total)
|
||||
}
|
||||
if len(resp.Data.List) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, remote := range resp.Data.List {
|
||||
status := strings.TrimSpace(remote.OrderStatus)
|
||||
if status == "" {
|
||||
status = "<empty>"
|
||||
}
|
||||
stats.StatusCounts[status]++
|
||||
if !isRemotePaidStatus(remote.OrderStatus) {
|
||||
continue
|
||||
}
|
||||
stats.RemotePaid++
|
||||
if remote.OutOrderNo == "" {
|
||||
stats.RemoteMissingNo++
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[remote.OutOrderNo]; ok {
|
||||
stats.DuplicateRemote++
|
||||
continue
|
||||
}
|
||||
seen[remote.OutOrderNo] = struct{}{}
|
||||
|
||||
item, ok := matchRemoteOrder(ctx, db, opt, &stats, remote)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stats.Matched++
|
||||
matches = append(matches, item)
|
||||
if opt.apply && (item.Action == "补单" || item.Action == "重投队列") {
|
||||
if err := applyOrderFix(ctx, db, redisClient, queueClient, paymentInfo, item); err != nil {
|
||||
return matches, stats, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page++
|
||||
if resp.Data.Total > 0 && page*opt.limit >= resp.Data.Total+opt.limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return matches, stats, nil
|
||||
}
|
||||
|
||||
func isRemotePaidStatus(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "paid", "success", "succeeded", "trade_success", "completed", "complete", "1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRemoteOrders(ctx context.Context, client *http.Client, baseURL string, merchant merchantConfig, page, limit int) (*orderListResponse, error) {
|
||||
params := map[string]string{
|
||||
"merchantId": merchant.merchantID,
|
||||
"page": strconv.Itoa(page),
|
||||
"limit": strconv.Itoa(limit),
|
||||
"order": "create_time desc",
|
||||
}
|
||||
params["sign"] = sign(params, merchant.key)
|
||||
|
||||
form := url.Values{}
|
||||
for k, v := range params {
|
||||
form.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/v2/orderlist", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("remote status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
var out orderListResponse
|
||||
if err := decoder.Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode remote response: %w body=%s", err, string(body))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func matchRemoteOrder(ctx context.Context, db *gorm.DB, opt options, stats *reconcileStats, remote remoteOrder) (matchedOrder, bool) {
|
||||
var local order.Order
|
||||
if err := db.WithContext(ctx).Model(&order.Order{}).Where("order_no = ?", remote.OutOrderNo).First(&local).Error; err != nil {
|
||||
stats.LocalNotFound++
|
||||
stats.LocalNotFoundOrders = append(stats.LocalNotFoundOrders, remote)
|
||||
if opt.verbose {
|
||||
fmt.Printf("skip local_not_found out_order_no=%s remote_no=%s status=%s money=%s\n", remote.OutOrderNo, remote.OrderNo, remote.OrderStatus, remote.OrderMoney)
|
||||
}
|
||||
return matchedOrder{}, false
|
||||
}
|
||||
if opt.method != "" && local.Method != opt.method {
|
||||
stats.LocalMethodSkip++
|
||||
if opt.verbose {
|
||||
fmt.Printf("skip method out_order_no=%s local_method=%s want=%s\n", remote.OutOrderNo, local.Method, opt.method)
|
||||
}
|
||||
return matchedOrder{}, false
|
||||
}
|
||||
if opt.start != "" && local.CreatedAt.Format("2006-01-02 15:04:05") < opt.start {
|
||||
stats.LocalDateFiltered++
|
||||
return matchedOrder{}, false
|
||||
}
|
||||
if opt.end != "" && local.CreatedAt.Format("2006-01-02 15:04:05") >= opt.end {
|
||||
stats.LocalDateFiltered++
|
||||
return matchedOrder{}, false
|
||||
}
|
||||
|
||||
remoteCents := decimalYuanToCents(remote.OrderMoney)
|
||||
item := matchedOrder{
|
||||
Remote: remote,
|
||||
Local: local,
|
||||
RemoteCents: remoteCents,
|
||||
PaymentAudit: "远端已支付",
|
||||
}
|
||||
item.Audit = auditSubscription(ctx, db, local)
|
||||
|
||||
switch {
|
||||
case local.Status == 5:
|
||||
item.Action = "跳过"
|
||||
item.Reason = "本地已完成"
|
||||
case remoteCents > 0 && abs64(local.Amount-remoteCents) > opt.amountDiff:
|
||||
item.Action = "跳过"
|
||||
item.Reason = fmt.Sprintf("金额不匹配 本地=%d 远端=%d", local.Amount, remoteCents)
|
||||
case local.Status == 2:
|
||||
item.Action = "重投队列"
|
||||
item.Reason = "本地已支付但未完成"
|
||||
case local.Status != 1 && local.Status != 3:
|
||||
item.Action = "跳过"
|
||||
item.Reason = fmt.Sprintf("本地状态 %d 不适合自动补单", local.Status)
|
||||
default:
|
||||
item.Action = "补单"
|
||||
item.Reason = "远端已支付,本地未支付/已关闭"
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
|
||||
func auditSubscription(ctx context.Context, db *gorm.DB, local order.Order) subscriptionAudit {
|
||||
audit := subscriptionAudit{
|
||||
Checked: true,
|
||||
Quantity: local.Quantity,
|
||||
}
|
||||
if local.Type != 1 && local.Type != 2 {
|
||||
audit.Status = "跳过"
|
||||
audit.Reason = fmt.Sprintf("订单类型 %d 不核对订阅时间", local.Type)
|
||||
return audit
|
||||
}
|
||||
|
||||
var plan subscribe.Subscribe
|
||||
if err := db.WithContext(ctx).Model(&subscribe.Subscribe{}).Where("id = ?", local.SubscribeId).First(&plan).Error; err != nil {
|
||||
audit.Status = "异常"
|
||||
audit.Reason = "套餐不存在: " + err.Error()
|
||||
return audit
|
||||
}
|
||||
audit.SubscribeUnit = plan.UnitTime
|
||||
|
||||
var userSub user.Subscribe
|
||||
err := db.WithContext(ctx).Model(&user.Subscribe{}).Where("order_id = ?", local.Id).Order("id DESC").First(&userSub).Error
|
||||
if err == nil {
|
||||
audit.UserSubID = userSub.Id
|
||||
audit.UserID = userSub.UserId
|
||||
audit.StartTime = userSub.StartTime
|
||||
audit.ExpireTime = userSub.ExpireTime
|
||||
audit.ExpectedExpire = userSub.ExpireTime
|
||||
audit.Status = "已核对"
|
||||
audit.Reason = "订阅已绑定当前订单"
|
||||
return audit
|
||||
}
|
||||
|
||||
if local.Type == 2 && local.SubscribeToken != "" {
|
||||
err = db.WithContext(ctx).Model(&user.Subscribe{}).Where("token = ?", local.SubscribeToken).First(&userSub).Error
|
||||
if err == nil {
|
||||
base := subscriptionRenewalBaseTime(time.Now(), userSub.ExpireTime, userSub.FinishedAt)
|
||||
audit.UserSubID = userSub.Id
|
||||
audit.UserID = userSub.UserId
|
||||
audit.StartTime = userSub.StartTime
|
||||
audit.ExpireTime = userSub.ExpireTime
|
||||
audit.ExpectedExpire = tool.AddTime(plan.UnitTime, local.Quantity, base)
|
||||
if local.Status == 5 {
|
||||
audit.Status = "异常"
|
||||
audit.Reason = "订单已完成但订阅 order_id 未指向该订单"
|
||||
} else {
|
||||
audit.Status = "待激活"
|
||||
audit.Reason = "续费订单未完成,预计激活后续期"
|
||||
}
|
||||
return audit
|
||||
}
|
||||
}
|
||||
|
||||
if local.Status == 5 {
|
||||
audit.Status = "异常"
|
||||
audit.Reason = "订单已完成但未找到对应订阅"
|
||||
return audit
|
||||
}
|
||||
|
||||
audit.Status = "待激活"
|
||||
audit.ExpectedExpire = tool.AddTime(plan.UnitTime, local.Quantity, time.Now())
|
||||
audit.Reason = "订单未完成,订阅时间需激活队列生成"
|
||||
return audit
|
||||
}
|
||||
|
||||
func subscriptionRenewalBaseTime(now time.Time, expire time.Time, finishedAt *time.Time) time.Time {
|
||||
base := expire
|
||||
if finishedAt != nil && finishedAt.After(base) {
|
||||
base = *finishedAt
|
||||
}
|
||||
if base.Before(now) {
|
||||
base = now
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func applyOrderFix(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
redisClient *redis.Client,
|
||||
queueClient *asynq.Client,
|
||||
paymentInfo *payment.Payment,
|
||||
item matchedOrder,
|
||||
) error {
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updates := map[string]interface{}{
|
||||
"status": 2,
|
||||
"trade_no": item.Remote.OrderNo,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if paymentInfo != nil {
|
||||
updates["payment_id"] = paymentInfo.Id
|
||||
updates["method"] = paymentInfo.Platform
|
||||
}
|
||||
result := tx.Model(&order.Order{}).
|
||||
Where("order_no = ? AND status IN ?", item.Local.OrderNo, []uint8{1, 2, 3}).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("order %s was not updated; status changed concurrently", item.Local.OrderNo)
|
||||
}
|
||||
clearOrderCache(ctx, redisClient, item.Local)
|
||||
return enqueueActivation(ctx, queueClient, item.Local.OrderNo)
|
||||
})
|
||||
}
|
||||
|
||||
func enqueueActivation(ctx context.Context, queueClient *asynq.Client, orderNo string) error {
|
||||
payload, err := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: orderNo})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = queueClient.EnqueueContext(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5)))
|
||||
return err
|
||||
}
|
||||
|
||||
func clearOrderCache(ctx context.Context, redisClient *redis.Client, local order.Order) {
|
||||
keys := []string{
|
||||
fmt.Sprintf("cache:order:id:%d", local.Id),
|
||||
fmt.Sprintf("cache:order:no:%s", local.OrderNo),
|
||||
}
|
||||
_ = redisClient.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
func sign(params map[string]string, key string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k != "sign" && k != "sign_type" && v != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+params[k])
|
||||
}
|
||||
sum := md5.Sum([]byte(strings.Join(parts, "&") + key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func decimalYuanToCents(n json.Number) int64 {
|
||||
s := strings.TrimSpace(n.String())
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
parts := strings.SplitN(s, ".", 2)
|
||||
yuan, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
cents := yuan * 100
|
||||
if len(parts) == 1 {
|
||||
return cents
|
||||
}
|
||||
frac := parts[1]
|
||||
if len(frac) > 2 {
|
||||
frac = frac[:2]
|
||||
}
|
||||
for len(frac) < 2 {
|
||||
frac += "0"
|
||||
}
|
||||
f, _ := strconv.ParseInt(frac, 10, 64)
|
||||
if strings.HasPrefix(parts[0], "-") {
|
||||
return cents - f
|
||||
}
|
||||
return cents + f
|
||||
}
|
||||
|
||||
func printSummary(matches []matchedOrder, stats reconcileStats, opt options) {
|
||||
var fix, requeue, skip int
|
||||
for _, item := range matches {
|
||||
switch item.Action {
|
||||
case "补单":
|
||||
fix++
|
||||
case "重投队列":
|
||||
requeue++
|
||||
default:
|
||||
skip++
|
||||
}
|
||||
fmt.Printf("%s order_no=%s remote_no=%s local_status=%d amount=%d remote_amount=%d reason=%s\n",
|
||||
item.Action,
|
||||
item.Local.OrderNo,
|
||||
item.Remote.OrderNo,
|
||||
item.Local.Status,
|
||||
item.Local.Amount,
|
||||
item.RemoteCents,
|
||||
item.Reason,
|
||||
)
|
||||
if item.Audit.Checked {
|
||||
fmt.Printf(" payment=%s subscription=%s sub_id=%d user_id=%d expire=%s expected=%s note=%s\n",
|
||||
item.PaymentAudit,
|
||||
item.Audit.Status,
|
||||
item.Audit.UserSubID,
|
||||
item.Audit.UserID,
|
||||
formatTime(item.Audit.ExpireTime),
|
||||
formatTime(item.Audit.ExpectedExpire),
|
||||
item.Audit.Reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
mode := "DRY-RUN"
|
||||
if opt.apply {
|
||||
mode = "APPLIED"
|
||||
}
|
||||
fmt.Printf("\nremote pages=%d orders=%d paid=%d missing_out_order_no=%d duplicates=%d\n",
|
||||
stats.RemotePages, stats.RemoteOrders, stats.RemotePaid, stats.RemoteMissingNo, stats.DuplicateRemote)
|
||||
if len(stats.StatusCounts) > 0 {
|
||||
keys := make([]string, 0, len(stats.StatusCounts))
|
||||
for key := range stats.StatusCounts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Print("remote status_counts=")
|
||||
for i, key := range keys {
|
||||
if i > 0 {
|
||||
fmt.Print(", ")
|
||||
}
|
||||
fmt.Printf("%s:%d", key, stats.StatusCounts[key])
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Printf("local not_found=%d method_skipped=%d date_filtered=%d matched=%d\n",
|
||||
stats.LocalNotFound, stats.LocalMethodSkip, stats.LocalDateFiltered, stats.Matched)
|
||||
if opt.printNotFound && len(stats.LocalNotFoundOrders) > 0 {
|
||||
printNotFoundOrders(stats.LocalNotFoundOrders)
|
||||
}
|
||||
fmt.Printf("\n%s summary: matched=%d fixable=%d requeue=%d skipped=%d\n", mode, len(matches), fix, requeue, skip)
|
||||
}
|
||||
|
||||
func printNotFoundOrders(items []remoteOrder) {
|
||||
fmt.Println("\nlocal_not_found orders:")
|
||||
fmt.Println("out_order_no\tremote_no\tstatus\torder_money\tuser_money\tcreate_time\tpayment_time\tclient_ip\tdevice\tremark")
|
||||
for _, item := range items {
|
||||
fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
item.OutOrderNo,
|
||||
item.OrderNo,
|
||||
item.OrderStatus,
|
||||
item.OrderMoney.String(),
|
||||
item.UserMoney.String(),
|
||||
formatUnixTime(item.CreateTime),
|
||||
formatUnixTime(item.PaymentTime),
|
||||
oneLine(item.ClientIP),
|
||||
oneLine(item.Device),
|
||||
oneLine(item.OrderRemark),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func formatUnixTime(v int64) string {
|
||||
if v <= 0 {
|
||||
return "-"
|
||||
}
|
||||
if v > 1_000_000_000_000 {
|
||||
return time.UnixMilli(v).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return time.Unix(v, 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func must(err error, step string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", step, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user