Compare commits
1 Commits
aslan_prod
...
atyou_prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f2f8b0f6d |
@ -4,35 +4,26 @@ set -euo pipefail
|
|||||||
BASE_DIR="${BASE_DIR:-/opt/aslan-deploy}"
|
BASE_DIR="${BASE_DIR:-/opt/aslan-deploy}"
|
||||||
SRC_DIR="${SRC_DIR:-$BASE_DIR/source/likei-services}"
|
SRC_DIR="${SRC_DIR:-$BASE_DIR/source/likei-services}"
|
||||||
KUBECONFIG="${KUBECONFIG:-$BASE_DIR/kubeconfig}"
|
KUBECONFIG="${KUBECONFIG:-$BASE_DIR/kubeconfig}"
|
||||||
LOCK_FILE="${LOCK_FILE:-$BASE_DIR/deploy.lock}"
|
|
||||||
NAMESPACE="${NAMESPACE:-prod}"
|
NAMESPACE="${NAMESPACE:-prod}"
|
||||||
MODE="${MODE:-preload}"
|
MODE="${MODE:-preload}"
|
||||||
SKIP_BUILD="${SKIP_BUILD:-0}"
|
SKIP_BUILD="${SKIP_BUILD:-0}"
|
||||||
USE_GIT_SOURCE="${USE_GIT_SOURCE:-0}"
|
USE_GIT_SOURCE="${USE_GIT_SOURCE:-0}"
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-git@gitea.haiyihy.com:hy/aslan-server.git}"
|
GIT_REMOTE_URL="${GIT_REMOTE_URL:-git@gitea.haiyihy.com:hy/aslan-server.git}"
|
||||||
GIT_REF="${GIT_REF:-aslan_prod}"
|
GIT_REF="${GIT_REF:-atyou_prod}"
|
||||||
MAVEN_GOALS="${MAVEN_GOALS:-clean package}"
|
|
||||||
MAVEN_PROFILE="${MAVEN_PROFILE:-prod}"
|
|
||||||
MAVEN_EXTRA_ARGS="${MAVEN_EXTRA_ARGS:-}"
|
MAVEN_EXTRA_ARGS="${MAVEN_EXTRA_ARGS:-}"
|
||||||
BUILD_TS="${BUILD_TS:-$(date +%Y%m%dv%H%M%S)}"
|
BUILD_TS="${BUILD_TS:-$(date +%Y%m%dv%H%M%S)}"
|
||||||
IMAGE_REGISTRY="${IMAGE_REGISTRY:-tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com}"
|
|
||||||
IMAGE_PROJECT="${IMAGE_PROJECT:-atyou-prod}"
|
|
||||||
BASE_IMAGE="tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/public_mirror_images/eclipse-temurin:17-jdk-jammy"
|
BASE_IMAGE="tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/public_mirror_images/eclipse-temurin:17-jdk-jammy"
|
||||||
FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}"
|
FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [--fast] [gateway|wallet|order|other|external|console ...]
|
deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [other|external|console ...]
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh status
|
/opt/aslan-deploy/deploy-likei-services.sh status
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh --mode preload wallet
|
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh --mode preload order
|
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh --mode preload gateway order
|
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh --mode preload other external console
|
/opt/aslan-deploy/deploy-likei-services.sh --mode preload other external console
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh --fast wallet
|
USE_GIT_SOURCE=1 GIT_REF=atyou_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other
|
||||||
USE_GIT_SOURCE=1 GIT_REF=aslan_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other
|
|
||||||
MODE=push ALIYUN_USER=xxx ALIYUN_PASS=xxx /opt/aslan-deploy/deploy-likei-services.sh other
|
MODE=push ALIYUN_USER=xxx ALIYUN_PASS=xxx /opt/aslan-deploy/deploy-likei-services.sh other
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@ -53,23 +44,6 @@ need_cmd() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
acquire_deploy_lock() {
|
|
||||||
need_cmd flock
|
|
||||||
exec 9>"$LOCK_FILE"
|
|
||||||
# Only one deploy should mutate source, build images, preload nodes, and roll deployments at a time.
|
|
||||||
flock -n 9 || {
|
|
||||||
echo "another deploy is already running: $LOCK_FILE" >&2
|
|
||||||
exit 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_git_ref() {
|
|
||||||
if [[ ! "$GIT_REF" =~ ^[A-Za-z0-9._/-]{1,80}$ || "$GIT_REF" == -* || "$GIT_REF" == *..* ]]; then
|
|
||||||
echo "invalid GIT_REF: $GIT_REF" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_base_image() {
|
ensure_base_image() {
|
||||||
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
|
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
|
||||||
return
|
return
|
||||||
@ -88,7 +62,6 @@ update_source_from_git() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
need_cmd git
|
need_cmd git
|
||||||
validate_git_ref
|
|
||||||
mkdir -p "$(dirname "$SRC_DIR")"
|
mkdir -p "$(dirname "$SRC_DIR")"
|
||||||
if [[ ! -d "$SRC_DIR/.git" ]]; then
|
if [[ ! -d "$SRC_DIR/.git" ]]; then
|
||||||
rm -rf "$SRC_DIR"
|
rm -rf "$SRC_DIR"
|
||||||
@ -100,15 +73,12 @@ update_source_from_git() {
|
|||||||
log "git reset source to origin/$GIT_REF from $GIT_REMOTE_URL"
|
log "git reset source to origin/$GIT_REF from $GIT_REMOTE_URL"
|
||||||
git -C "$SRC_DIR" remote set-url origin "$GIT_REMOTE_URL"
|
git -C "$SRC_DIR" remote set-url origin "$GIT_REMOTE_URL"
|
||||||
git -C "$SRC_DIR" fetch origin "$GIT_REF"
|
git -C "$SRC_DIR" fetch origin "$GIT_REF"
|
||||||
git -C "$SRC_DIR" checkout -B "$GIT_REF" "origin/$GIT_REF"
|
git -C "$SRC_DIR" checkout "$GIT_REF"
|
||||||
git -C "$SRC_DIR" reset --hard "origin/$GIT_REF"
|
git -C "$SRC_DIR" reset --hard "origin/$GIT_REF"
|
||||||
}
|
}
|
||||||
|
|
||||||
service_module() {
|
service_module() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
gateway) echo "rc-gateway" ;;
|
|
||||||
wallet) echo "rc-service/rc-service-wallet/wallet-start" ;;
|
|
||||||
order) echo "rc-service/rc-service-order/order-start" ;;
|
|
||||||
other) echo "rc-service/rc-service-other/other-start" ;;
|
other) echo "rc-service/rc-service-other/other-start" ;;
|
||||||
external) echo "rc-service/rc-service-external/external-start" ;;
|
external) echo "rc-service/rc-service-external/external-start" ;;
|
||||||
console) echo "rc-service/rc-service-console/console-start" ;;
|
console) echo "rc-service/rc-service-console/console-start" ;;
|
||||||
@ -118,9 +88,6 @@ service_module() {
|
|||||||
|
|
||||||
service_dir() {
|
service_dir() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
gateway) echo "rc-gateway" ;;
|
|
||||||
wallet) echo "rc-service/rc-service-wallet" ;;
|
|
||||||
order) echo "rc-service/rc-service-order" ;;
|
|
||||||
other) echo "rc-service/rc-service-other" ;;
|
other) echo "rc-service/rc-service-other" ;;
|
||||||
external) echo "rc-service/rc-service-external" ;;
|
external) echo "rc-service/rc-service-external" ;;
|
||||||
console) echo "rc-service/rc-service-console" ;;
|
console) echo "rc-service/rc-service-console" ;;
|
||||||
@ -130,12 +97,9 @@ service_dir() {
|
|||||||
|
|
||||||
image_repo() {
|
image_repo() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
gateway) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/gateway" ;;
|
other) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/other" ;;
|
||||||
wallet) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/wallet" ;;
|
external) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/external" ;;
|
||||||
order) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/order" ;;
|
console) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/console" ;;
|
||||||
other) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/other" ;;
|
|
||||||
external) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/external" ;;
|
|
||||||
console) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/console" ;;
|
|
||||||
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@ -157,14 +121,13 @@ build_service() {
|
|||||||
if [[ "$SKIP_BUILD" != "1" ]]; then
|
if [[ "$SKIP_BUILD" != "1" ]]; then
|
||||||
log "maven package $svc ($module)"
|
log "maven package $svc ($module)"
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
# MAVEN_GOALS=package keeps the existing target cache for code-only fast builds; use the default clean package for safer full builds.
|
mvn clean package -pl "$module" -am -P prod -Dmaven.test.skip=true $MAVEN_EXTRA_ARGS
|
||||||
mvn $MAVEN_GOALS -pl "$module" -am -P "$MAVEN_PROFILE" -Dmaven.test.skip=true $MAVEN_EXTRA_ARGS
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_base_image
|
ensure_base_image
|
||||||
log "docker build $image"
|
log "docker build $image"
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg "SERVICE_VERSION=${IMAGE_PROJECT}:${tag}" \
|
--build-arg "SERVICE_VERSION=atyou-prod:${tag}" \
|
||||||
-f "$dir/Dockerfile" \
|
-f "$dir/Dockerfile" \
|
||||||
-t "$image" \
|
-t "$image" \
|
||||||
"$dir"
|
"$dir"
|
||||||
@ -303,7 +266,7 @@ rollout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy gateway wallet order other external console -o wide
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy other external console -o wide
|
||||||
kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide
|
kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,7 +274,6 @@ main() {
|
|||||||
while [[ "$#" -gt 0 ]]; do
|
while [[ "$#" -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--mode)
|
--mode)
|
||||||
[[ -n "${2:-}" ]] || { echo "--mode requires an argument" >&2; exit 2; }
|
|
||||||
MODE="$2"
|
MODE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@ -319,11 +281,6 @@ main() {
|
|||||||
SKIP_BUILD=1
|
SKIP_BUILD=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--fast)
|
|
||||||
# Code-only wallet changes can keep Maven's target cache instead of forcing clean package.
|
|
||||||
MAVEN_GOALS=package
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@ -347,12 +304,10 @@ main() {
|
|||||||
need_cmd kubectl
|
need_cmd kubectl
|
||||||
[[ -f "$KUBECONFIG" ]] || { echo "missing kubeconfig: $KUBECONFIG" >&2; exit 2; }
|
[[ -f "$KUBECONFIG" ]] || { echo "missing kubeconfig: $KUBECONFIG" >&2; exit 2; }
|
||||||
|
|
||||||
acquire_deploy_lock
|
|
||||||
update_source_from_git
|
update_source_from_git
|
||||||
|
|
||||||
local services=("$@")
|
local services=("$@")
|
||||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||||
# 保留既有无参数发布范围;gateway/order 只在显式指定时发布,避免老运维命令意外扩大生产变更面。
|
|
||||||
services=(other external console)
|
services=(other external console)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -15,12 +15,12 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
.deploy/prod-deploy/sync-and-deploy.sh [remote deploy args...]
|
.deploy/aslan-deploy/sync-and-deploy.sh [remote deploy args...]
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
.deploy/prod-deploy/sync-and-deploy.sh status
|
.deploy/aslan-deploy/sync-and-deploy.sh status
|
||||||
.deploy/prod-deploy/sync-and-deploy.sh --mode preload other
|
.deploy/aslan-deploy/sync-and-deploy.sh --mode preload other
|
||||||
.deploy/prod-deploy/sync-and-deploy.sh --mode preload other external console
|
.deploy/aslan-deploy/sync-and-deploy.sh --mode preload other external console
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
DEPLOY_HOST default 43.160.219.15
|
DEPLOY_HOST default 43.160.219.15
|
||||||
@ -29,7 +29,7 @@ Environment:
|
|||||||
|
|
||||||
Git source on deploy host:
|
Git source on deploy host:
|
||||||
ssh -i ~/.ssh/aslan-deploy-sg-ed25519 ubuntu@43.160.219.15 \
|
ssh -i ~/.ssh/aslan-deploy-sg-ed25519 ubuntu@43.160.219.15 \
|
||||||
'USE_GIT_SOURCE=1 GIT_REF=aslan_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other'
|
'USE_GIT_SOURCE=1 GIT_REF=atyou_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other'
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,10 +58,6 @@ rsync -az \
|
|||||||
-e "$rsync_ssh" \
|
-e "$rsync_ssh" \
|
||||||
"$SCRIPT_DIR/deploy-likei-services.sh" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/deploy-likei-services.sh"
|
"$SCRIPT_DIR/deploy-likei-services.sh" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/deploy-likei-services.sh"
|
||||||
|
|
||||||
rsync -az --delete \
|
|
||||||
-e "$rsync_ssh" \
|
|
||||||
"$SCRIPT_DIR/ops/" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/ops/"
|
|
||||||
|
|
||||||
for cache_group in $M2_CACHE_GROUPS; do
|
for cache_group in $M2_CACHE_GROUPS; do
|
||||||
local_cache_path="$LOCAL_M2_ROOT/$cache_group"
|
local_cache_path="$LOCAL_M2_ROOT/$cache_group"
|
||||||
remote_cache_parent="$REMOTE_M2_ROOT/$(dirname "$cache_group")"
|
remote_cache_parent="$REMOTE_M2_ROOT/$(dirname "$cache_group")"
|
||||||
@ -1,21 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Aslan deployment operations panel
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=ubuntu
|
|
||||||
Group=ubuntu
|
|
||||||
WorkingDirectory=/opt/aslan-deploy
|
|
||||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
||||||
Environment=HOME=/home/ubuntu
|
|
||||||
Environment=ASLAN_ALLOWED_BRANCHES=aslan_prod
|
|
||||||
EnvironmentFile=/etc/aslan-ops.env
|
|
||||||
ExecStart=/usr/bin/python3 /opt/aslan-deploy/ops/aslan_ops.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
NoNewPrivileges=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@ -1,984 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import base64
|
|
||||||
import fcntl
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(os.environ.get("ASLAN_DEPLOY_BASE", "/opt/aslan-deploy"))
|
|
||||||
RUN_DIR = Path(os.environ.get("ASLAN_OPS_RUN_DIR", str(BASE_DIR / "ops" / "runs")))
|
|
||||||
KUBECONFIG = Path(os.environ.get("KUBECONFIG", str(BASE_DIR / "kubeconfig")))
|
|
||||||
DEPLOY_SCRIPT = Path(os.environ.get("ASLAN_DEPLOY_SCRIPT", str(BASE_DIR / "deploy-likei-services.sh")))
|
|
||||||
LOCK_FILE = Path(os.environ.get("ASLAN_DEPLOY_LOCK_FILE", str(BASE_DIR / "deploy.lock")))
|
|
||||||
SOURCE_DIR = Path(os.environ.get("ASLAN_SOURCE_DIR", str(BASE_DIR / "source" / "likei-services")))
|
|
||||||
NAMESPACE = os.environ.get("ASLAN_NAMESPACE", "prod")
|
|
||||||
GIT_REF = os.environ.get("ASLAN_GIT_REF", "aslan_prod")
|
|
||||||
REMOTE_URL = os.environ.get("ASLAN_GIT_REMOTE_URL", "git@gitea.haiyihy.com:hy/aslan-server.git")
|
|
||||||
HOST = os.environ.get("ASLAN_OPS_HOST", "0.0.0.0")
|
|
||||||
PORT = int(os.environ.get("ASLAN_OPS_PORT", "18080"))
|
|
||||||
USERNAME = os.environ.get("ASLAN_OPS_USER", "admin")
|
|
||||||
PASSWORD = os.environ.get("ASLAN_OPS_PASSWORD", "")
|
|
||||||
SESSION_SECRET = os.environ.get("ASLAN_OPS_SESSION_SECRET", PASSWORD or secrets.token_hex(32))
|
|
||||||
ALLOWED_BRANCHES = {
|
|
||||||
branch.strip()
|
|
||||||
for branch in os.environ.get("ASLAN_ALLOWED_BRANCHES", GIT_REF).split(",")
|
|
||||||
if branch.strip()
|
|
||||||
}
|
|
||||||
|
|
||||||
SERVICES = {
|
|
||||||
"wallet": {
|
|
||||||
"label": "Wallet",
|
|
||||||
"port": "9000",
|
|
||||||
"health": "/actuator/health",
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"label": "Other",
|
|
||||||
"port": "5800",
|
|
||||||
"health": "/actuator/health",
|
|
||||||
},
|
|
||||||
"external": {
|
|
||||||
"label": "External",
|
|
||||||
"port": "5200",
|
|
||||||
"health": "/actuator/health",
|
|
||||||
},
|
|
||||||
"console": {
|
|
||||||
"label": "Console",
|
|
||||||
"port": "5300",
|
|
||||||
"health": "/console/actuator/health",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,80}$")
|
|
||||||
ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
||||||
SENSITIVE_ENV_RE = re.compile(r"(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL)", re.IGNORECASE)
|
|
||||||
|
|
||||||
job_lock = threading.Lock()
|
|
||||||
active_job = None
|
|
||||||
login_failures = {}
|
|
||||||
|
|
||||||
|
|
||||||
def now_ts():
|
|
||||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
|
|
||||||
def json_response(handler, status, payload):
|
|
||||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
handler.send_response(status)
|
|
||||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
handler.send_header("Cache-Control", "no-store")
|
|
||||||
handler.send_header("X-Frame-Options", "DENY")
|
|
||||||
handler.send_header("Content-Length", str(len(body)))
|
|
||||||
handler.end_headers()
|
|
||||||
handler.wfile.write(body)
|
|
||||||
|
|
||||||
|
|
||||||
def text_response(handler, status, body, content_type="text/plain; charset=utf-8"):
|
|
||||||
data = body.encode("utf-8")
|
|
||||||
handler.send_response(status)
|
|
||||||
handler.send_header("Content-Type", content_type)
|
|
||||||
handler.send_header("Cache-Control", "no-store")
|
|
||||||
handler.send_header("X-Frame-Options", "DENY")
|
|
||||||
handler.send_header("Content-Length", str(len(data)))
|
|
||||||
handler.end_headers()
|
|
||||||
handler.wfile.write(data)
|
|
||||||
|
|
||||||
|
|
||||||
def redirect_response(handler, location, cookies=None):
|
|
||||||
handler.send_response(302)
|
|
||||||
for cookie in cookies or []:
|
|
||||||
handler.send_header("Set-Cookie", cookie)
|
|
||||||
handler.send_header("Location", location)
|
|
||||||
handler.send_header("Content-Length", "0")
|
|
||||||
handler.end_headers()
|
|
||||||
|
|
||||||
|
|
||||||
def sign_session(value):
|
|
||||||
mac = hmac.new(SESSION_SECRET.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
return f"{value}.{mac}"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_session(signed):
|
|
||||||
if not signed or "." not in signed:
|
|
||||||
return False
|
|
||||||
value, mac = signed.rsplit(".", 1)
|
|
||||||
expected = hmac.new(SESSION_SECRET.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
if not hmac.compare_digest(mac, expected):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
username, issued = value.split(":", 1)
|
|
||||||
return username == USERNAME and time.time() - int(issued) < 86400
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cookies(header):
|
|
||||||
cookies = {}
|
|
||||||
for part in (header or "").split(";"):
|
|
||||||
if "=" not in part:
|
|
||||||
continue
|
|
||||||
key, value = part.strip().split("=", 1)
|
|
||||||
cookies[key] = value
|
|
||||||
return cookies
|
|
||||||
|
|
||||||
|
|
||||||
def too_many_login_failures(ip):
|
|
||||||
now = time.time()
|
|
||||||
window = [ts for ts in login_failures.get(ip, []) if now - ts < 300]
|
|
||||||
login_failures[ip] = window
|
|
||||||
return len(window) >= 5
|
|
||||||
|
|
||||||
|
|
||||||
def record_login_failure(ip):
|
|
||||||
window = login_failures.get(ip, [])
|
|
||||||
window.append(time.time())
|
|
||||||
login_failures[ip] = window[-10:]
|
|
||||||
|
|
||||||
|
|
||||||
def run_json(cmd, timeout=20):
|
|
||||||
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"command failed: {cmd[0]}")
|
|
||||||
return json.loads(result.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def run_text(cmd, timeout=10):
|
|
||||||
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return ""
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def run_checked_text(cmd, input_text=None, timeout=30):
|
|
||||||
result = subprocess.run(cmd, input=input_text, text=True, capture_output=True, timeout=timeout)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"command failed: {cmd[0]}")
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_service(svc):
|
|
||||||
if svc not in SERVICES:
|
|
||||||
raise ValueError(f"unsupported service: {svc}")
|
|
||||||
return svc
|
|
||||||
|
|
||||||
|
|
||||||
def deployment_json(svc):
|
|
||||||
ensure_service(svc)
|
|
||||||
return run_json([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"get",
|
|
||||||
"deployment",
|
|
||||||
svc,
|
|
||||||
"-o",
|
|
||||||
"json",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def deployment_yaml(svc):
|
|
||||||
ensure_service(svc)
|
|
||||||
return run_checked_text([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"get",
|
|
||||||
"deployment",
|
|
||||||
svc,
|
|
||||||
"-o",
|
|
||||||
"yaml",
|
|
||||||
"--show-managed-fields=false",
|
|
||||||
], timeout=20)
|
|
||||||
|
|
||||||
|
|
||||||
def service_container(deployment, svc):
|
|
||||||
containers = deployment.get("spec", {}).get("template", {}).get("spec", {}).get("containers", [])
|
|
||||||
for idx, container in enumerate(containers):
|
|
||||||
if container.get("name") == svc:
|
|
||||||
return idx, container
|
|
||||||
raise ValueError(f"deployment/{svc} does not contain container {svc}")
|
|
||||||
|
|
||||||
|
|
||||||
def single_kubernetes_object(payload):
|
|
||||||
if payload.get("kind") == "List":
|
|
||||||
items = payload.get("items", [])
|
|
||||||
if len(items) != 1:
|
|
||||||
raise ValueError("yaml must contain exactly one object")
|
|
||||||
return items[0]
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def protected_env_unchanged(svc, current_deployment, new_deployment):
|
|
||||||
_, current_container = service_container(current_deployment, svc)
|
|
||||||
_, new_container = service_container(new_deployment, svc)
|
|
||||||
if new_container.get("envFrom", []) != current_container.get("envFrom", []):
|
|
||||||
raise ValueError("envFrom cannot be changed here")
|
|
||||||
new_by_name = {
|
|
||||||
entry.get("name"): entry
|
|
||||||
for entry in new_container.get("env", [])
|
|
||||||
if isinstance(entry, dict)
|
|
||||||
}
|
|
||||||
for entry in current_container.get("env", []):
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
name = entry.get("name")
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
if ("valueFrom" in entry or SENSITIVE_ENV_RE.search(name)) and new_by_name.get(name) != entry:
|
|
||||||
raise ValueError(f"protected env {name} cannot be changed here")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_deployment_yaml(svc, yaml_text):
|
|
||||||
ensure_service(svc)
|
|
||||||
if not yaml_text.strip():
|
|
||||||
raise ValueError("yaml is empty")
|
|
||||||
if len(yaml_text.encode("utf-8")) > 600000:
|
|
||||||
raise ValueError("yaml is too large")
|
|
||||||
client_dry_run = run_checked_text([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"apply",
|
|
||||||
"--dry-run=client",
|
|
||||||
"-f",
|
|
||||||
"-",
|
|
||||||
"-o",
|
|
||||||
"json",
|
|
||||||
], input_text=yaml_text, timeout=30)
|
|
||||||
obj = single_kubernetes_object(json.loads(client_dry_run))
|
|
||||||
metadata = obj.get("metadata", {})
|
|
||||||
if obj.get("apiVersion") != "apps/v1" or obj.get("kind") != "Deployment" or metadata.get("name") != svc:
|
|
||||||
raise ValueError(f"yaml must be deployment/{svc}")
|
|
||||||
if metadata.get("namespace") not in (None, "", NAMESPACE):
|
|
||||||
raise ValueError(f"yaml namespace must be {NAMESPACE}")
|
|
||||||
current = deployment_json(svc)
|
|
||||||
if metadata.get("resourceVersion") != current.get("metadata", {}).get("resourceVersion"):
|
|
||||||
raise ValueError("deployment changed since YAML was loaded; reload before saving")
|
|
||||||
if obj.get("spec", {}).get("selector") != current.get("spec", {}).get("selector"):
|
|
||||||
raise ValueError("deployment selector cannot be changed")
|
|
||||||
protected_env_unchanged(svc, current, obj)
|
|
||||||
run_checked_text([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"apply",
|
|
||||||
"--dry-run=server",
|
|
||||||
"-f",
|
|
||||||
"-",
|
|
||||||
"-o",
|
|
||||||
"json",
|
|
||||||
], input_text=yaml_text, timeout=30)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def apply_deployment_yaml(svc, yaml_text):
|
|
||||||
marker = begin_mutation("yaml", svc)
|
|
||||||
try:
|
|
||||||
verify_deployment_yaml(svc, yaml_text)
|
|
||||||
# Apply only after server-side dry-run confirms the YAML still targets the whitelisted deployment.
|
|
||||||
output = run_checked_text([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"apply",
|
|
||||||
"-f",
|
|
||||||
"-",
|
|
||||||
], input_text=yaml_text, timeout=60)
|
|
||||||
return output.strip()
|
|
||||||
finally:
|
|
||||||
finish_mutation(marker)
|
|
||||||
|
|
||||||
|
|
||||||
def env_config(svc):
|
|
||||||
deployment = deployment_json(svc)
|
|
||||||
_, container = service_container(deployment, svc)
|
|
||||||
return {
|
|
||||||
"service": svc,
|
|
||||||
"namespace": NAMESPACE,
|
|
||||||
"env": container.get("env", []),
|
|
||||||
"envFrom": container.get("envFrom", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_env_entries(entries, existing_entries):
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
raise ValueError("env must be a list")
|
|
||||||
existing_by_name = {entry.get("name"): entry for entry in existing_entries if isinstance(entry, dict)}
|
|
||||||
normalized = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise ValueError("env entries must be objects")
|
|
||||||
name = entry.get("name")
|
|
||||||
if not isinstance(name, str) or not ENV_NAME_RE.match(name):
|
|
||||||
raise ValueError(f"invalid env name: {name}")
|
|
||||||
item = {"name": name}
|
|
||||||
has_value = "value" in entry
|
|
||||||
has_value_from = "valueFrom" in entry
|
|
||||||
if has_value and has_value_from:
|
|
||||||
raise ValueError(f"env {name} cannot contain both value and valueFrom")
|
|
||||||
if has_value:
|
|
||||||
if not isinstance(entry["value"], str):
|
|
||||||
raise ValueError(f"env {name} value must be a string")
|
|
||||||
item["value"] = entry["value"]
|
|
||||||
if has_value_from:
|
|
||||||
if not isinstance(entry["valueFrom"], dict):
|
|
||||||
raise ValueError(f"env {name} valueFrom must be an object")
|
|
||||||
item["valueFrom"] = entry["valueFrom"]
|
|
||||||
if has_value_from and item != existing_by_name.get(name):
|
|
||||||
raise ValueError(f"env {name} valueFrom cannot be changed here")
|
|
||||||
if SENSITIVE_ENV_RE.search(name) and item != existing_by_name.get(name):
|
|
||||||
raise ValueError(f"sensitive env {name} cannot be changed here")
|
|
||||||
normalized.append(item)
|
|
||||||
normalized_by_name = {entry["name"]: entry for entry in normalized}
|
|
||||||
for entry in existing_entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
name = entry.get("name")
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
if ("valueFrom" in entry or SENSITIVE_ENV_RE.search(name)) and normalized_by_name.get(name) != entry:
|
|
||||||
raise ValueError(f"protected env {name} cannot be removed here")
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_env_from_entries(entries):
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
raise ValueError("envFrom must be a list")
|
|
||||||
allowed_keys = {"configMapRef", "prefix", "secretRef"}
|
|
||||||
normalized = []
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise ValueError("envFrom entries must be objects")
|
|
||||||
extra = set(entry) - allowed_keys
|
|
||||||
if extra:
|
|
||||||
raise ValueError(f"unsupported envFrom keys: {', '.join(sorted(extra))}")
|
|
||||||
normalized.append(entry)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def apply_env_config(svc, payload):
|
|
||||||
marker = begin_mutation("env", svc)
|
|
||||||
try:
|
|
||||||
deployment = deployment_json(svc)
|
|
||||||
idx, container = service_container(deployment, svc)
|
|
||||||
existing_env = container.get("env", [])
|
|
||||||
existing_env_from = container.get("envFrom", [])
|
|
||||||
env = normalize_env_entries(payload.get("env", []), existing_env)
|
|
||||||
env_from = normalize_env_from_entries(payload.get("envFrom", existing_env_from))
|
|
||||||
if env_from != existing_env_from:
|
|
||||||
raise ValueError("envFrom cannot be changed here")
|
|
||||||
patch = []
|
|
||||||
env_path = f"/spec/template/spec/containers/{idx}/env"
|
|
||||||
patch.append({"op": "replace" if "env" in container else "add", "path": env_path, "value": env})
|
|
||||||
run_checked_text([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"patch",
|
|
||||||
"deployment",
|
|
||||||
svc,
|
|
||||||
"--type=json",
|
|
||||||
"-p",
|
|
||||||
json.dumps(patch, ensure_ascii=False),
|
|
||||||
], timeout=30)
|
|
||||||
return env_config(svc)
|
|
||||||
finally:
|
|
||||||
finish_mutation(marker)
|
|
||||||
|
|
||||||
|
|
||||||
def deployment_status():
|
|
||||||
names = list(SERVICES)
|
|
||||||
payload = run_json([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"get",
|
|
||||||
"deploy",
|
|
||||||
*names,
|
|
||||||
"-o",
|
|
||||||
"json",
|
|
||||||
])
|
|
||||||
items = []
|
|
||||||
for item in payload.get("items", []):
|
|
||||||
name = item["metadata"]["name"]
|
|
||||||
spec = item.get("spec", {})
|
|
||||||
status = item.get("status", {})
|
|
||||||
containers = item.get("spec", {}).get("template", {}).get("spec", {}).get("containers", [])
|
|
||||||
image = next((c.get("image", "") for c in containers if c.get("name") == name), containers[0].get("image", "") if containers else "")
|
|
||||||
conditions = {c.get("type"): c.get("status") for c in status.get("conditions", [])}
|
|
||||||
items.append({
|
|
||||||
"name": name,
|
|
||||||
"label": SERVICES.get(name, {}).get("label", name),
|
|
||||||
"ready": f"{status.get('readyReplicas', 0)}/{spec.get('replicas', 0)}",
|
|
||||||
"updated": status.get("updatedReplicas", 0),
|
|
||||||
"available": status.get("availableReplicas", 0),
|
|
||||||
"image": image,
|
|
||||||
"port": SERVICES.get(name, {}).get("port", ""),
|
|
||||||
"health": SERVICES.get(name, {}).get("health", ""),
|
|
||||||
"healthy": conditions.get("Available") == "True",
|
|
||||||
})
|
|
||||||
order = {name: idx for idx, name in enumerate(names)}
|
|
||||||
return sorted(items, key=lambda x: order.get(x["name"], 99))
|
|
||||||
|
|
||||||
|
|
||||||
def pod_status():
|
|
||||||
selector = "app in (" + ",".join(SERVICES) + ")"
|
|
||||||
payload = run_json([
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig",
|
|
||||||
str(KUBECONFIG),
|
|
||||||
"-n",
|
|
||||||
NAMESPACE,
|
|
||||||
"get",
|
|
||||||
"pods",
|
|
||||||
"-l",
|
|
||||||
selector,
|
|
||||||
"-o",
|
|
||||||
"json",
|
|
||||||
])
|
|
||||||
pods = []
|
|
||||||
for item in payload.get("items", []):
|
|
||||||
status = item.get("status", {})
|
|
||||||
containers = status.get("containerStatuses", [])
|
|
||||||
ready_count = sum(1 for c in containers if c.get("ready"))
|
|
||||||
pods.append({
|
|
||||||
"name": item["metadata"]["name"],
|
|
||||||
"app": item["metadata"].get("labels", {}).get("app", ""),
|
|
||||||
"ready": f"{ready_count}/{len(containers)}",
|
|
||||||
"phase": status.get("phase", ""),
|
|
||||||
"restarts": sum(c.get("restartCount", 0) for c in containers),
|
|
||||||
"node": item.get("spec", {}).get("nodeName", ""),
|
|
||||||
"ip": status.get("podIP", ""),
|
|
||||||
"age": item["metadata"].get("creationTimestamp", ""),
|
|
||||||
})
|
|
||||||
return pods
|
|
||||||
|
|
||||||
|
|
||||||
def git_status():
|
|
||||||
head = run_text(["git", "-C", str(SOURCE_DIR), "rev-parse", "HEAD"])
|
|
||||||
branch = run_text(["git", "-C", str(SOURCE_DIR), "branch", "--show-current"])
|
|
||||||
short = run_text(["git", "-C", str(SOURCE_DIR), "status", "--short", "--branch"])
|
|
||||||
return {"head": head, "branch": branch, "status": short}
|
|
||||||
|
|
||||||
|
|
||||||
def list_jobs():
|
|
||||||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
jobs = []
|
|
||||||
for meta_path in sorted(RUN_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:20]:
|
|
||||||
try:
|
|
||||||
jobs.append(json.loads(meta_path.read_text()))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
return jobs
|
|
||||||
|
|
||||||
|
|
||||||
def save_job(job):
|
|
||||||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
(RUN_DIR / f"{job['id']}.json").write_text(json.dumps(job, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def audit_config_change(action, svc, client_ip, result):
|
|
||||||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
line = json.dumps({
|
|
||||||
"time": now_ts(),
|
|
||||||
"ip": client_ip,
|
|
||||||
"action": action,
|
|
||||||
"service": svc,
|
|
||||||
"namespace": NAMESPACE,
|
|
||||||
"result": result,
|
|
||||||
}, ensure_ascii=False)
|
|
||||||
with (RUN_DIR / "config-audit.log").open("a", buffering=1) as log:
|
|
||||||
log.write(line + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def begin_mutation(operation, svc):
|
|
||||||
global active_job
|
|
||||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lock_handle = LOCK_FILE.open("a")
|
|
||||||
try:
|
|
||||||
# Share the same flock file with deploy-likei-services.sh so manual SSH deploys and config edits cannot interleave.
|
|
||||||
fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except BlockingIOError as exc:
|
|
||||||
lock_handle.close()
|
|
||||||
raise RuntimeError(f"deploy lock is already held: {LOCK_FILE}") from exc
|
|
||||||
with job_lock:
|
|
||||||
if active_job:
|
|
||||||
fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
|
|
||||||
lock_handle.close()
|
|
||||||
raise RuntimeError(f"operation already running: {active_job['id']}")
|
|
||||||
active_job = {"id": f"{operation}-{svc}-{int(time.time())}", "status": "running", "_lock": lock_handle}
|
|
||||||
return active_job
|
|
||||||
|
|
||||||
|
|
||||||
def finish_mutation(marker):
|
|
||||||
global active_job
|
|
||||||
with job_lock:
|
|
||||||
if active_job is marker:
|
|
||||||
active_job = None
|
|
||||||
lock_handle = marker.get("_lock")
|
|
||||||
if lock_handle:
|
|
||||||
fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
|
|
||||||
lock_handle.close()
|
|
||||||
|
|
||||||
|
|
||||||
def stream_process(job, cmd, env):
|
|
||||||
log_path = RUN_DIR / f"{job['id']}.log"
|
|
||||||
try:
|
|
||||||
with log_path.open("a", buffering=1) as log:
|
|
||||||
log.write(f"[{now_ts()}] start {' '.join(cmd)}\n")
|
|
||||||
process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
cwd=str(BASE_DIR),
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
)
|
|
||||||
job["pid"] = process.pid
|
|
||||||
job["status"] = "running"
|
|
||||||
save_job(job)
|
|
||||||
for line in process.stdout:
|
|
||||||
log.write(line)
|
|
||||||
rc = process.wait()
|
|
||||||
job["status"] = "success" if rc == 0 else "failed"
|
|
||||||
job["returnCode"] = rc
|
|
||||||
job["finishedAt"] = now_ts()
|
|
||||||
save_job(job)
|
|
||||||
log.write(f"[{now_ts()}] finished rc={rc}\n")
|
|
||||||
except Exception as exc:
|
|
||||||
job["status"] = "failed"
|
|
||||||
job["returnCode"] = -1
|
|
||||||
job["finishedAt"] = now_ts()
|
|
||||||
job["error"] = str(exc)
|
|
||||||
save_job(job)
|
|
||||||
with log_path.open("a", buffering=1) as log:
|
|
||||||
log.write(f"[{now_ts()}] runner error: {exc}\n")
|
|
||||||
finally:
|
|
||||||
global active_job
|
|
||||||
with job_lock:
|
|
||||||
active_job = None
|
|
||||||
|
|
||||||
|
|
||||||
def start_deploy(services, branch, fast):
|
|
||||||
global active_job
|
|
||||||
invalid = [svc for svc in services if svc not in SERVICES]
|
|
||||||
if invalid:
|
|
||||||
raise ValueError(f"unsupported services: {', '.join(invalid)}")
|
|
||||||
if not services:
|
|
||||||
raise ValueError("select at least one service")
|
|
||||||
if not BRANCH_RE.match(branch):
|
|
||||||
raise ValueError("invalid branch name")
|
|
||||||
if branch not in ALLOWED_BRANCHES:
|
|
||||||
raise ValueError(f"branch is not allowed: {branch}")
|
|
||||||
if not DEPLOY_SCRIPT.exists():
|
|
||||||
raise ValueError(f"missing deploy script: {DEPLOY_SCRIPT}")
|
|
||||||
|
|
||||||
with job_lock:
|
|
||||||
if active_job:
|
|
||||||
raise RuntimeError(f"deploy already running: {active_job['id']}")
|
|
||||||
job_id = time.strftime("%Y%m%d%H%M%S") + "-" + secrets.token_hex(3)
|
|
||||||
job = {
|
|
||||||
"id": job_id,
|
|
||||||
"services": services,
|
|
||||||
"branch": branch,
|
|
||||||
"fast": fast,
|
|
||||||
"status": "queued",
|
|
||||||
"createdAt": now_ts(),
|
|
||||||
"finishedAt": "",
|
|
||||||
"returnCode": None,
|
|
||||||
}
|
|
||||||
active_job = job
|
|
||||||
save_job(job)
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.update({
|
|
||||||
"USE_GIT_SOURCE": "1",
|
|
||||||
"GIT_REF": branch,
|
|
||||||
"GIT_REMOTE_URL": REMOTE_URL,
|
|
||||||
"MODE": "preload",
|
|
||||||
"KUBECONFIG": str(KUBECONFIG),
|
|
||||||
"NAMESPACE": NAMESPACE,
|
|
||||||
"ASLAN_NAMESPACE": NAMESPACE,
|
|
||||||
"HOME": str(Path.home()),
|
|
||||||
})
|
|
||||||
if fast:
|
|
||||||
# Fast mode avoids Maven clean so unchanged modules and already downloaded dependencies can reuse local target output.
|
|
||||||
env["MAVEN_GOALS"] = "package"
|
|
||||||
cmd = [str(DEPLOY_SCRIPT), "--mode", "preload", *services]
|
|
||||||
thread = threading.Thread(target=stream_process, args=(job, cmd, env), daemon=True)
|
|
||||||
thread.start()
|
|
||||||
return job
|
|
||||||
|
|
||||||
|
|
||||||
def login_page(error=""):
|
|
||||||
message = f"<div class='error'>{html.escape(error)}</div>" if error else ""
|
|
||||||
return f"""<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Aslan Ops</title>
|
|
||||||
<style>
|
|
||||||
:root {{ color-scheme: light; --ink:#172026; --muted:#65727f; --line:#d9e1e7; --brand:#0f766e; --bg:#f6f8fa; --danger:#b42318; }}
|
|
||||||
* {{ box-sizing:border-box; }}
|
|
||||||
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; background:var(--bg); color:var(--ink); }}
|
|
||||||
main {{ min-height:100vh; display:grid; place-items:center; padding:24px; }}
|
|
||||||
form {{ width:min(360px,100%); background:#fff; border:1px solid var(--line); border-radius:8px; padding:24px; box-shadow:0 10px 28px rgba(16,24,40,.08); }}
|
|
||||||
h1 {{ margin:0 0 20px; font-size:22px; letter-spacing:0; }}
|
|
||||||
label {{ display:block; margin:12px 0 6px; color:var(--muted); font-size:13px; }}
|
|
||||||
input {{ width:100%; height:40px; border:1px solid var(--line); border-radius:6px; padding:0 10px; font-size:15px; }}
|
|
||||||
button {{ width:100%; height:40px; border:0; border-radius:6px; background:var(--brand); color:white; font-weight:600; margin-top:18px; cursor:pointer; }}
|
|
||||||
.error {{ color:var(--danger); font-size:13px; margin-bottom:8px; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body><main><form method="post" action="/login"><h1>Aslan Ops</h1>{message}<label>账号</label><input name="username" autocomplete="username" autofocus><label>密码</label><input name="password" type="password" autocomplete="current-password"><button type="submit">登录</button></form></main></body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
INDEX_HTML = """<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Aslan Ops</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light; --bg:#f4f7f8; --panel:#fff; --ink:#14212b; --muted:#647382; --line:#d8e2e8; --brand:#0f766e; --brand-strong:#0b5f59; --warn:#b54708; --ok:#067647; --bad:#b42318; --chip:#e6f4f1; }
|
|
||||||
* { box-sizing:border-box; }
|
|
||||||
body { margin:0; background:var(--bg); color:var(--ink); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; letter-spacing:0; }
|
|
||||||
header { height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:1; }
|
|
||||||
h1 { margin:0; font-size:18px; }
|
|
||||||
header nav { display:flex; gap:12px; align-items:center; }
|
|
||||||
button, .link { height:34px; border-radius:6px; border:1px solid var(--line); background:#fff; color:var(--ink); padding:0 12px; cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
|
|
||||||
button.primary { background:var(--brand); border-color:var(--brand); color:white; }
|
|
||||||
button.primary:hover { background:var(--brand-strong); }
|
|
||||||
button:disabled { opacity:.55; cursor:not-allowed; }
|
|
||||||
main { max-width:1220px; margin:0 auto; padding:24px; display:grid; gap:16px; }
|
|
||||||
section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; }
|
|
||||||
.bar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; }
|
|
||||||
.bar h2 { margin:0; font-size:16px; }
|
|
||||||
.muted { color:var(--muted); font-size:13px; }
|
|
||||||
.grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
|
||||||
.svc { border:1px solid var(--line); border-radius:8px; padding:14px; display:grid; gap:10px; min-height:172px; }
|
|
||||||
.svc-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
|
||||||
.svc h3 { margin:0; font-size:16px; }
|
|
||||||
.badge { display:inline-flex; align-items:center; height:24px; border-radius:999px; padding:0 9px; font-size:12px; background:var(--chip); color:var(--brand-strong); white-space:nowrap; }
|
|
||||||
.badge.warn { background:#fff1e7; color:var(--warn); }
|
|
||||||
.badge.bad { background:#fee4e2; color:var(--bad); }
|
|
||||||
.meta { display:grid; grid-template-columns:90px 1fr; gap:6px; font-size:13px; }
|
|
||||||
.meta span:nth-child(odd) { color:var(--muted); }
|
|
||||||
.image { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
|
||||||
.deploy-row { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
|
|
||||||
.checks { display:flex; flex-wrap:wrap; gap:12px; }
|
|
||||||
label.check { display:inline-flex; align-items:center; gap:6px; color:var(--ink); font-size:14px; }
|
|
||||||
input[type="checkbox"] { width:16px; height:16px; accent-color:var(--brand); }
|
|
||||||
input.branch { width:180px; height:34px; border:1px solid var(--line); border-radius:6px; padding:0 10px; }
|
|
||||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
||||||
th, td { text-align:left; padding:10px; border-top:1px solid var(--line); vertical-align:top; }
|
|
||||||
th { color:var(--muted); font-weight:600; }
|
|
||||||
pre { margin:0; height:360px; overflow:auto; background:#101820; color:#e6edf3; padding:14px; border-radius:8px; font-size:12px; line-height:1.45; white-space:pre-wrap; }
|
|
||||||
textarea.editor { width:100%; min-height:420px; resize:vertical; border:1px solid var(--line); border-radius:8px; padding:12px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; line-height:1.45; background:#fbfdfe; color:var(--ink); }
|
|
||||||
select { height:34px; border:1px solid var(--line); border-radius:6px; background:#fff; color:var(--ink); padding:0 10px; }
|
|
||||||
.config-actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
|
|
||||||
.split { display:grid; grid-template-columns:2fr 1fr; gap:16px; }
|
|
||||||
@media (max-width: 900px) { header { padding:0 14px; } main { padding:14px; } .grid, .split { grid-template-columns:1fr; } .bar { align-items:flex-start; flex-direction:column; } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>Aslan Ops</h1>
|
|
||||||
<nav><span class="muted" id="git"></span><button id="refresh">刷新</button><a class="link" href="/logout">退出</a></nav>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>微服务</h2><span class="muted" id="updated"></span></div>
|
|
||||||
<div id="services" class="grid"></div>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>部署</h2><span class="muted">固定 preload 到 TKE 节点,不走 registry push</span></div>
|
|
||||||
<div class="deploy-row">
|
|
||||||
<div class="checks" id="serviceChecks"></div>
|
|
||||||
<label class="check">快速构建 <input id="fast" type="checkbox" checked></label>
|
|
||||||
<input id="branch" class="branch" value="aslan_prod">
|
|
||||||
<button class="primary" id="deploy">部署选中服务</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>配置</h2><span class="muted" id="configState"></span></div>
|
|
||||||
<div class="config-actions">
|
|
||||||
<select id="configService"></select>
|
|
||||||
<button id="loadYaml">YAML</button>
|
|
||||||
<button id="saveYaml">保存 YAML</button>
|
|
||||||
<button id="loadEnv">ENV</button>
|
|
||||||
<button id="saveEnv">保存 ENV</button>
|
|
||||||
</div>
|
|
||||||
<textarea id="configEditor" class="editor" spellcheck="false"></textarea>
|
|
||||||
</section>
|
|
||||||
<div class="split">
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>部署日志</h2><span class="muted" id="jobState">无运行任务</span></div>
|
|
||||||
<pre id="log"></pre>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>Pod</h2></div>
|
|
||||||
<table><thead><tr><th>名称</th><th>状态</th><th>节点</th></tr></thead><tbody id="pods"></tbody></table>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>最近任务</h2></div>
|
|
||||||
<table><thead><tr><th>ID</th><th>服务</th><th>分支</th><th>状态</th><th>时间</th></tr></thead><tbody id="jobs"></tbody></table>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
const serviceNames = ["wallet", "other", "external", "console"];
|
|
||||||
let currentJob = "";
|
|
||||||
let configMode = "yaml";
|
|
||||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
||||||
function badge(ok, text) { return `<span class="badge ${ok ? "" : "warn"}">${esc(text)}</span>`; }
|
|
||||||
async function api(path, opts) {
|
|
||||||
const res = await fetch(path, Object.assign({headers:{'Content-Type':'application/json','X-Aslan-Ops':'1'}}, opts || {}));
|
|
||||||
if (!res.ok) throw new Error((await res.text()) || res.statusText);
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
function renderChecks() {
|
|
||||||
document.getElementById("serviceChecks").innerHTML = serviceNames.map(s => `<label class="check"><input type="checkbox" value="${s}" checked> ${s}</label>`).join("");
|
|
||||||
document.getElementById("configService").innerHTML = serviceNames.map(s => `<option value="${s}">${s}</option>`).join("");
|
|
||||||
}
|
|
||||||
async function loadStatus() {
|
|
||||||
const data = await api("/api/status");
|
|
||||||
document.getElementById("updated").textContent = new Date().toLocaleString();
|
|
||||||
document.getElementById("git").textContent = `${data.git.branch || "unknown"} ${String(data.git.head || "").slice(0,8)}`;
|
|
||||||
document.getElementById("services").innerHTML = data.deployments.map(s => `
|
|
||||||
<article class="svc">
|
|
||||||
<div class="svc-head"><h3>${esc(s.label)}</h3>${badge(s.healthy, s.ready + " Ready")}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span>端口</span><span>${esc(s.port)}</span>
|
|
||||||
<span>健康检查</span><span>${esc(s.health)}</span>
|
|
||||||
<span>镜像</span><span class="image" title="${esc(s.image)}">${esc(s.image)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="deploy-row"><button data-service="${esc(s.name)}">部署 ${esc(s.name)}</button><button data-yaml="${esc(s.name)}">YAML</button><button data-env="${esc(s.name)}">ENV</button></div>
|
|
||||||
</article>`).join("");
|
|
||||||
document.querySelectorAll("[data-service]").forEach(btn => btn.onclick = () => deploy([btn.dataset.service]));
|
|
||||||
document.querySelectorAll("[data-yaml]").forEach(btn => btn.onclick = () => { document.getElementById("configService").value = btn.dataset.yaml; loadYaml(); });
|
|
||||||
document.querySelectorAll("[data-env]").forEach(btn => btn.onclick = () => { document.getElementById("configService").value = btn.dataset.env; loadEnv(); });
|
|
||||||
document.getElementById("pods").innerHTML = data.pods.map(p => `<tr><td>${esc(p.name)}<div class="muted">${esc(p.ip)}</div></td><td>${esc(p.ready)} ${esc(p.phase)}<div class="muted">restart ${esc(p.restarts)}</div></td><td>${esc(p.node)}</td></tr>`).join("");
|
|
||||||
document.getElementById("jobs").innerHTML = data.jobs.map(j => `<tr><td><button data-job="${esc(j.id)}">${esc(j.id)}</button></td><td>${esc((j.services || []).join(","))}</td><td>${esc(j.branch)}</td><td>${esc(j.status)}</td><td>${esc(j.createdAt)}</td></tr>`).join("");
|
|
||||||
document.querySelectorAll("[data-job]").forEach(btn => btn.onclick = () => { currentJob = btn.dataset.job; loadLog(); });
|
|
||||||
const running = data.jobs.find(j => j.status === "running" || j.status === "queued");
|
|
||||||
if (running && !currentJob) currentJob = running.id;
|
|
||||||
if (currentJob) loadLog();
|
|
||||||
}
|
|
||||||
async function deploy(services) {
|
|
||||||
const selected = services || Array.from(document.querySelectorAll("#serviceChecks input:checked")).map(i => i.value);
|
|
||||||
document.getElementById("deploy").disabled = true;
|
|
||||||
try {
|
|
||||||
const data = await api("/api/deploy", {method:"POST", body:JSON.stringify({services:selected, branch:document.getElementById("branch").value, fast:document.getElementById("fast").checked})});
|
|
||||||
currentJob = data.job.id;
|
|
||||||
await loadStatus();
|
|
||||||
await loadLog();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
document.getElementById("deploy").disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function loadLog() {
|
|
||||||
if (!currentJob) return;
|
|
||||||
const data = await api(`/api/jobs/${currentJob}/log`);
|
|
||||||
document.getElementById("jobState").textContent = `${currentJob} ${data.job.status}`;
|
|
||||||
const log = document.getElementById("log");
|
|
||||||
log.textContent = data.log || "";
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
async function loadYaml() {
|
|
||||||
const svc = document.getElementById("configService").value;
|
|
||||||
const data = await api(`/api/config/${svc}/yaml`);
|
|
||||||
configMode = "yaml";
|
|
||||||
document.getElementById("configEditor").value = data.yaml || "";
|
|
||||||
document.getElementById("configState").textContent = `${svc} YAML`;
|
|
||||||
}
|
|
||||||
async function saveYaml() {
|
|
||||||
const svc = document.getElementById("configService").value;
|
|
||||||
const yaml = document.getElementById("configEditor").value;
|
|
||||||
const data = await api(`/api/config/${svc}/yaml`, {method:"POST", body:JSON.stringify({yaml})});
|
|
||||||
document.getElementById("configState").textContent = data.message || `${svc} YAML saved`;
|
|
||||||
await loadStatus();
|
|
||||||
}
|
|
||||||
async function loadEnv() {
|
|
||||||
const svc = document.getElementById("configService").value;
|
|
||||||
const data = await api(`/api/config/${svc}/env`);
|
|
||||||
configMode = "env";
|
|
||||||
document.getElementById("configEditor").value = JSON.stringify({env:data.env || [], envFrom:data.envFrom || []}, null, 2);
|
|
||||||
document.getElementById("configState").textContent = `${svc} ENV`;
|
|
||||||
}
|
|
||||||
async function saveEnv() {
|
|
||||||
const svc = document.getElementById("configService").value;
|
|
||||||
const payload = JSON.parse(document.getElementById("configEditor").value || "{}");
|
|
||||||
await api(`/api/config/${svc}/env`, {method:"POST", body:JSON.stringify(payload)});
|
|
||||||
document.getElementById("configState").textContent = `${svc} ENV saved`;
|
|
||||||
await loadStatus();
|
|
||||||
}
|
|
||||||
renderChecks();
|
|
||||||
document.getElementById("refresh").onclick = loadStatus;
|
|
||||||
document.getElementById("deploy").onclick = () => deploy();
|
|
||||||
document.getElementById("loadYaml").onclick = () => loadYaml().catch(err => alert(err.message));
|
|
||||||
document.getElementById("saveYaml").onclick = () => saveYaml().catch(err => alert(err.message));
|
|
||||||
document.getElementById("loadEnv").onclick = () => loadEnv().catch(err => alert(err.message));
|
|
||||||
document.getElementById("saveEnv").onclick = () => saveEnv().catch(err => alert(err.message));
|
|
||||||
loadStatus().catch(err => document.getElementById("log").textContent = err.message);
|
|
||||||
setInterval(() => loadStatus().catch(() => {}), 5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
server_version = "AslanOps/1.0"
|
|
||||||
|
|
||||||
def authenticated(self):
|
|
||||||
cookies = parse_cookies(self.headers.get("Cookie"))
|
|
||||||
return verify_session(cookies.get("aslan_ops"))
|
|
||||||
|
|
||||||
def require_auth(self):
|
|
||||||
if self.authenticated():
|
|
||||||
return True
|
|
||||||
redirect_response(self, "/login")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read_body(self, max_bytes=65536):
|
|
||||||
size = int(self.headers.get("Content-Length", "0"))
|
|
||||||
if size > max_bytes:
|
|
||||||
raise ValueError("request body too large")
|
|
||||||
return self.rfile.read(size).decode("utf-8")
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
if parsed.path == "/login":
|
|
||||||
return text_response(self, 200, login_page(), "text/html; charset=utf-8")
|
|
||||||
if parsed.path == "/logout":
|
|
||||||
redirect_response(self, "/login", ["aslan_ops=; Max-Age=0; HttpOnly; SameSite=Lax; Path=/"])
|
|
||||||
return
|
|
||||||
if not self.require_auth():
|
|
||||||
return
|
|
||||||
if parsed.path == "/":
|
|
||||||
return text_response(self, 200, INDEX_HTML, "text/html; charset=utf-8")
|
|
||||||
if parsed.path == "/api/status":
|
|
||||||
try:
|
|
||||||
return json_response(self, 200, {
|
|
||||||
"deployments": deployment_status(),
|
|
||||||
"pods": pod_status(),
|
|
||||||
"git": git_status(),
|
|
||||||
"jobs": list_jobs(),
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
return json_response(self, 500, {"error": str(exc)})
|
|
||||||
match = re.match(r"^/api/config/([A-Za-z0-9_-]+)/(yaml|env)$", parsed.path)
|
|
||||||
if match:
|
|
||||||
svc, kind = match.groups()
|
|
||||||
try:
|
|
||||||
if kind == "yaml":
|
|
||||||
return json_response(self, 200, {"service": svc, "namespace": NAMESPACE, "yaml": deployment_yaml(svc)})
|
|
||||||
return json_response(self, 200, env_config(svc))
|
|
||||||
except Exception as exc:
|
|
||||||
return json_response(self, 400, {"error": str(exc)})
|
|
||||||
match = re.match(r"^/api/jobs/([A-Za-z0-9-]+)/log$", parsed.path)
|
|
||||||
if match:
|
|
||||||
job_id = match.group(1)
|
|
||||||
meta_path = RUN_DIR / f"{job_id}.json"
|
|
||||||
log_path = RUN_DIR / f"{job_id}.log"
|
|
||||||
if not meta_path.exists():
|
|
||||||
return json_response(self, 404, {"error": "job not found"})
|
|
||||||
job = json.loads(meta_path.read_text())
|
|
||||||
log = log_path.read_text(errors="replace")[-120000:] if log_path.exists() else ""
|
|
||||||
return json_response(self, 200, {"job": job, "log": log})
|
|
||||||
return text_response(self, 404, "not found")
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
if parsed.path == "/login":
|
|
||||||
if too_many_login_failures(self.client_address[0]):
|
|
||||||
return text_response(self, 429, login_page("登录失败次数过多,稍后再试"), "text/html; charset=utf-8")
|
|
||||||
try:
|
|
||||||
form = parse_qs(self.read_body(max_bytes=4096))
|
|
||||||
except ValueError as exc:
|
|
||||||
return text_response(self, 413, str(exc))
|
|
||||||
username = form.get("username", [""])[0]
|
|
||||||
password = form.get("password", [""])[0]
|
|
||||||
if hmac.compare_digest(username, USERNAME) and hmac.compare_digest(password, PASSWORD):
|
|
||||||
login_failures.pop(self.client_address[0], None)
|
|
||||||
session = sign_session(f"{USERNAME}:{int(time.time())}")
|
|
||||||
redirect_response(self, "/", [f"aslan_ops={session}; HttpOnly; SameSite=Lax; Path=/"])
|
|
||||||
return
|
|
||||||
record_login_failure(self.client_address[0])
|
|
||||||
return text_response(self, 401, login_page("账号或密码错误"), "text/html; charset=utf-8")
|
|
||||||
if not self.require_auth():
|
|
||||||
return
|
|
||||||
if parsed.path == "/api/deploy":
|
|
||||||
try:
|
|
||||||
if self.headers.get("X-Aslan-Ops") != "1":
|
|
||||||
return json_response(self, 403, {"error": "missing deploy header"})
|
|
||||||
payload = json.loads(self.read_body(max_bytes=4096) or "{}")
|
|
||||||
services = payload.get("services") or []
|
|
||||||
branch = payload.get("branch") or GIT_REF
|
|
||||||
fast = bool(payload.get("fast", True))
|
|
||||||
job = start_deploy(services, branch, fast)
|
|
||||||
return json_response(self, 202, {"job": job})
|
|
||||||
except Exception as exc:
|
|
||||||
return json_response(self, 400, {"error": str(exc)})
|
|
||||||
match = re.match(r"^/api/config/([A-Za-z0-9_-]+)/(yaml|env)$", parsed.path)
|
|
||||||
if match:
|
|
||||||
svc, kind = match.groups()
|
|
||||||
try:
|
|
||||||
if self.headers.get("X-Aslan-Ops") != "1":
|
|
||||||
return json_response(self, 403, {"error": "missing deploy header"})
|
|
||||||
payload = json.loads(self.read_body(max_bytes=900000) or "{}")
|
|
||||||
if kind == "yaml":
|
|
||||||
message = apply_deployment_yaml(svc, payload.get("yaml", ""))
|
|
||||||
audit_config_change("yaml", svc, self.client_address[0], message)
|
|
||||||
return json_response(self, 200, {"message": message})
|
|
||||||
result = apply_env_config(svc, payload)
|
|
||||||
audit_config_change("env", svc, self.client_address[0], "patched")
|
|
||||||
return json_response(self, 200, result)
|
|
||||||
except Exception as exc:
|
|
||||||
return json_response(self, 400, {"error": str(exc)})
|
|
||||||
return text_response(self, 404, "not found")
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
|
||||||
print(f"[{now_ts()}] {self.address_string()} {fmt % args}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not PASSWORD:
|
|
||||||
raise SystemExit("ASLAN_OPS_PASSWORD is required")
|
|
||||||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
||||||
print(f"aslan ops listening on {HOST}:{PORT}", flush=True)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
-- Webconsole salary settlement records menu.
|
|
||||||
-- Adds the second-level menu under 主播中心 and binds the list API resource.
|
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
|
|
||||||
INSERT INTO sys_resource (id, resource_name, mapping, method, auth_type, perm, update_time)
|
|
||||||
VALUES (
|
|
||||||
'team:salary:settlement:list',
|
|
||||||
'工资记录列表',
|
|
||||||
'/team/salary/settlement/list',
|
|
||||||
'GET',
|
|
||||||
3,
|
|
||||||
'team:salary:record:list',
|
|
||||||
NOW()
|
|
||||||
)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
resource_name = VALUES(resource_name),
|
|
||||||
mapping = VALUES(mapping),
|
|
||||||
method = VALUES(method),
|
|
||||||
auth_type = VALUES(auth_type),
|
|
||||||
perm = VALUES(perm),
|
|
||||||
update_time = NOW();
|
|
||||||
|
|
||||||
INSERT INTO sys_menu (
|
|
||||||
parent_id,
|
|
||||||
menu_name,
|
|
||||||
path,
|
|
||||||
router,
|
|
||||||
menu_type,
|
|
||||||
icon,
|
|
||||||
alias,
|
|
||||||
sort,
|
|
||||||
status,
|
|
||||||
create_user,
|
|
||||||
update_user,
|
|
||||||
create_time,
|
|
||||||
update_time
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
parent.id,
|
|
||||||
'工资记录',
|
|
||||||
'team/salary-record/index',
|
|
||||||
'team/salary-record',
|
|
||||||
2,
|
|
||||||
'form',
|
|
||||||
'team:salary:record',
|
|
||||||
880,
|
|
||||||
0,
|
|
||||||
'0',
|
|
||||||
'0',
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
FROM sys_menu parent
|
|
||||||
LEFT JOIN sys_menu existing ON existing.alias = 'team:salary:record'
|
|
||||||
WHERE (parent.alias = 'team:manaber' OR parent.router = '/team' OR parent.menu_name = '主播中心')
|
|
||||||
AND parent.menu_type = 1
|
|
||||||
AND existing.id IS NULL
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
UPDATE sys_menu salary_menu
|
|
||||||
JOIN (
|
|
||||||
SELECT id
|
|
||||||
FROM sys_menu
|
|
||||||
WHERE (alias = 'team:manaber' OR router = '/team' OR menu_name = '主播中心')
|
|
||||||
AND menu_type = 1
|
|
||||||
LIMIT 1
|
|
||||||
) parent ON 1 = 1
|
|
||||||
SET
|
|
||||||
salary_menu.parent_id = parent.id,
|
|
||||||
salary_menu.menu_name = '工资记录',
|
|
||||||
salary_menu.path = 'team/salary-record/index',
|
|
||||||
salary_menu.router = 'team/salary-record',
|
|
||||||
salary_menu.menu_type = 2,
|
|
||||||
salary_menu.icon = 'form',
|
|
||||||
salary_menu.sort = 880,
|
|
||||||
salary_menu.status = 0,
|
|
||||||
salary_menu.update_user = '0',
|
|
||||||
salary_menu.update_time = NOW()
|
|
||||||
WHERE salary_menu.alias = 'team:salary:record';
|
|
||||||
|
|
||||||
INSERT INTO sys_menu_resource (menu_id, resource_id)
|
|
||||||
SELECT menu.id, resource.id
|
|
||||||
FROM sys_menu menu
|
|
||||||
JOIN sys_resource resource ON resource.id = 'team:salary:settlement:list'
|
|
||||||
LEFT JOIN sys_menu_resource existing
|
|
||||||
ON existing.menu_id = menu.id
|
|
||||||
AND existing.resource_id = resource.id
|
|
||||||
WHERE menu.alias = 'team:salary:record'
|
|
||||||
AND existing.id IS NULL;
|
|
||||||
|
|
||||||
-- Salary records include admin salary, so auto-grant only to roles that can manage system permissions.
|
|
||||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
|
||||||
SELECT DISTINCT source_role.role_id, salary_menu.id
|
|
||||||
FROM sys_role_menu source_role
|
|
||||||
JOIN sys_menu source_menu ON source_menu.id = source_role.menu_id
|
|
||||||
JOIN sys_menu salary_menu ON salary_menu.alias = 'team:salary:record'
|
|
||||||
LEFT JOIN sys_role_menu existing
|
|
||||||
ON existing.role_id = source_role.role_id
|
|
||||||
AND existing.menu_id = salary_menu.id
|
|
||||||
WHERE source_menu.alias IN (
|
|
||||||
'sys:menu:list',
|
|
||||||
'sys:role:list',
|
|
||||||
'sys:resource:list'
|
|
||||||
)
|
|
||||||
AND existing.id IS NULL;
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS `user_freight_sub_seller_relation` (
|
|
||||||
`id` bigint NOT NULL COMMENT '主键标识',
|
|
||||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
|
||||||
`parent_freight_id` bigint NOT NULL COMMENT '超级经销商 freight balance id',
|
|
||||||
`parent_user_id` bigint NOT NULL COMMENT '超级经销商用户 id',
|
|
||||||
`child_user_id` bigint NOT NULL COMMENT '子 coin seller 用户 id',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`create_user` bigint DEFAULT NULL COMMENT '创建人',
|
|
||||||
`update_user` bigint DEFAULT NULL COMMENT '更新人',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_sys_origin_child_user_id` (`sys_origin`, `child_user_id`),
|
|
||||||
KEY `idx_sys_origin_parent_user_id` (`sys_origin`, `parent_user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='超级经销商子 coin seller 关系';
|
|
||||||
@ -1,257 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_DIR="${BASE_DIR:-/opt/aslan-test}"
|
|
||||||
SRC_DIR="${SRC_DIR:-$BASE_DIR/source/aslan-server}"
|
|
||||||
COMPOSE_FILE="${COMPOSE_FILE:-$BASE_DIR/docker-compose.yml}"
|
|
||||||
ENV_FILE="${ENV_FILE:-$BASE_DIR/config/.env}"
|
|
||||||
LOCK_FILE="${LOCK_FILE:-$BASE_DIR/deploy.lock}"
|
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-git@gitea.haiyihy.com:hy/aslan-server.git}"
|
|
||||||
GIT_REF="${GIT_REF:-aslan_test}"
|
|
||||||
ALLOWED_GIT_REFS="${ALLOWED_GIT_REFS:-aslan_test}"
|
|
||||||
MAVEN_GOALS="${MAVEN_GOALS:-clean package}"
|
|
||||||
MAVEN_PROFILE="${MAVEN_PROFILE:-prod}"
|
|
||||||
MAVEN_EXTRA_ARGS="${MAVEN_EXTRA_ARGS:-}"
|
|
||||||
SKIP_BUILD="${SKIP_BUILD:-0}"
|
|
||||||
MODE="${MODE:-compose}"
|
|
||||||
BUILD_TS="${BUILD_TS:-$(date +%Y%m%d%H%M%S)}"
|
|
||||||
|
|
||||||
ALL_SERVICES=(auth gateway external wallet order live other console)
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage:
|
|
||||||
deploy-likei-services.sh [--mode compose|build-only|status] [--skip-build] [--fast] [auth|gateway|external|wallet|order|live|other|console ...]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
/opt/aslan-test/deploy-likei-services.sh status
|
|
||||||
/opt/aslan-test/deploy-likei-services.sh other
|
|
||||||
/opt/aslan-test/deploy-likei-services.sh order
|
|
||||||
/opt/aslan-test/deploy-likei-services.sh --fast other external console
|
|
||||||
GIT_REF=aslan_test /opt/aslan-test/deploy-likei-services.sh auth gateway external wallet order live other console
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- This is the single-machine test deployment flow.
|
|
||||||
- It pulls aslan_test, builds local aslan-test/* images, then replaces docker compose app containers.
|
|
||||||
- MySQL/Mongo/Redis/Nacos/RocketMQ are not rebuilt or restarted by this script.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
log() {
|
|
||||||
printf '[%s] %s\n' "$(date '+%F %T')" "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
need_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || {
|
|
||||||
echo "missing command: $1" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
acquire_deploy_lock() {
|
|
||||||
need_cmd flock
|
|
||||||
mkdir -p "$(dirname "$LOCK_FILE")"
|
|
||||||
exec 9>"$LOCK_FILE"
|
|
||||||
# The visual ops panel and manual SSH deploys share this lock so builds cannot interleave.
|
|
||||||
flock -n 9 || {
|
|
||||||
echo "another deploy is already running: $LOCK_FILE" >&2
|
|
||||||
exit 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_git_ref() {
|
|
||||||
if [[ ! "$GIT_REF" =~ ^[A-Za-z0-9._/-]{1,80}$ || "$GIT_REF" == -* || "$GIT_REF" == *..* ]]; then
|
|
||||||
echo "invalid GIT_REF: $GIT_REF" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
local allowed
|
|
||||||
for allowed in $ALLOWED_GIT_REFS; do
|
|
||||||
[[ "$GIT_REF" == "$allowed" ]] && return 0
|
|
||||||
done
|
|
||||||
echo "GIT_REF is not allowed for test deploy: $GIT_REF (allowed: $ALLOWED_GIT_REFS)" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
service_module() {
|
|
||||||
case "$1" in
|
|
||||||
auth) echo "rc-auth" ;;
|
|
||||||
gateway) echo "rc-gateway" ;;
|
|
||||||
external) echo "rc-service/rc-service-external/external-start" ;;
|
|
||||||
wallet) echo "rc-service/rc-service-wallet/wallet-start" ;;
|
|
||||||
order) echo "rc-service/rc-service-order/order-start" ;;
|
|
||||||
live) echo "rc-service/rc-service-live/live-start" ;;
|
|
||||||
other) echo "rc-service/rc-service-other/other-start" ;;
|
|
||||||
console) echo "rc-service/rc-service-console/console-start" ;;
|
|
||||||
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
service_dir() {
|
|
||||||
case "$1" in
|
|
||||||
auth) echo "rc-auth" ;;
|
|
||||||
gateway) echo "rc-gateway" ;;
|
|
||||||
external) echo "rc-service/rc-service-external" ;;
|
|
||||||
wallet) echo "rc-service/rc-service-wallet" ;;
|
|
||||||
order) echo "rc-service/rc-service-order" ;;
|
|
||||||
live) echo "rc-service/rc-service-live" ;;
|
|
||||||
other) echo "rc-service/rc-service-other" ;;
|
|
||||||
console) echo "rc-service/rc-service-console" ;;
|
|
||||||
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
service_image() {
|
|
||||||
case "$1" in
|
|
||||||
auth|gateway|external|wallet|order|live|other|console) echo "aslan-test/$1:latest" ;;
|
|
||||||
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
contains_service() {
|
|
||||||
local needle="$1"
|
|
||||||
local item
|
|
||||||
for item in "${ALL_SERVICES[@]}"; do
|
|
||||||
[[ "$item" == "$needle" ]] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
update_source_from_git() {
|
|
||||||
need_cmd git
|
|
||||||
validate_git_ref
|
|
||||||
[[ -d "$SRC_DIR/.git" ]] || { echo "missing git source: $SRC_DIR" >&2; exit 2; }
|
|
||||||
|
|
||||||
log "reset source to origin/$GIT_REF from $GIT_REMOTE_URL"
|
|
||||||
git -C "$SRC_DIR" remote set-url origin "$GIT_REMOTE_URL"
|
|
||||||
git -C "$SRC_DIR" fetch origin "$GIT_REF"
|
|
||||||
git -C "$SRC_DIR" checkout -B "$GIT_REF" "origin/$GIT_REF"
|
|
||||||
git -C "$SRC_DIR" reset --hard "origin/$GIT_REF"
|
|
||||||
}
|
|
||||||
|
|
||||||
package_services() {
|
|
||||||
local services=("$@")
|
|
||||||
local modules=()
|
|
||||||
local svc
|
|
||||||
for svc in "${services[@]}"; do
|
|
||||||
modules+=("$(service_module "$svc")")
|
|
||||||
done
|
|
||||||
local module_csv
|
|
||||||
module_csv="$(IFS=,; echo "${modules[*]}")"
|
|
||||||
|
|
||||||
if [[ "$SKIP_BUILD" == "1" ]]; then
|
|
||||||
log "skip maven build"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$SRC_DIR"
|
|
||||||
log "maven $MAVEN_GOALS -pl $module_csv -P $MAVEN_PROFILE"
|
|
||||||
# The test compose stack still reads the prod-profile Nacos configs; use -P prod unless the caller overrides it.
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
mvn $MAVEN_GOALS -pl "$module_csv" -am -P "$MAVEN_PROFILE" -Dmaven.test.skip=true $MAVEN_EXTRA_ARGS
|
|
||||||
}
|
|
||||||
|
|
||||||
build_image() {
|
|
||||||
local svc="$1"
|
|
||||||
local dir image
|
|
||||||
dir="$(service_dir "$svc")"
|
|
||||||
image="$(service_image "$svc")"
|
|
||||||
cd "$SRC_DIR"
|
|
||||||
log "docker build $image"
|
|
||||||
docker build \
|
|
||||||
--build-arg "SERVICE_VERSION=aslan-test:${svc}-${BUILD_TS}" \
|
|
||||||
-f "$dir/Dockerfile" \
|
|
||||||
-t "$image" \
|
|
||||||
"$dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
compose_up() {
|
|
||||||
local services=("$@")
|
|
||||||
cd "$BASE_DIR"
|
|
||||||
log "docker compose recreate: ${services[*]}"
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --no-deps --force-recreate "${services[@]}"
|
|
||||||
}
|
|
||||||
|
|
||||||
status() {
|
|
||||||
log "source"
|
|
||||||
git -C "$SRC_DIR" status -sb || true
|
|
||||||
git -C "$SRC_DIR" log -1 --oneline --decorate || true
|
|
||||||
log "compose"
|
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps --format table
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
local services=()
|
|
||||||
while [[ "$#" -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--mode)
|
|
||||||
[[ -n "${2:-}" ]] || { echo "--mode requires an argument" >&2; exit 2; }
|
|
||||||
MODE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--skip-build)
|
|
||||||
SKIP_BUILD=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--fast)
|
|
||||||
MAVEN_GOALS=package
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
MODE=status
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
services+=("$1")
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
need_cmd git
|
|
||||||
need_cmd docker
|
|
||||||
[[ -f "$COMPOSE_FILE" ]] || { echo "missing compose file: $COMPOSE_FILE" >&2; exit 2; }
|
|
||||||
[[ -f "$ENV_FILE" ]] || { echo "missing env file: $ENV_FILE" >&2; exit 2; }
|
|
||||||
|
|
||||||
if [[ "$MODE" == "status" ]]; then
|
|
||||||
status
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
need_cmd java
|
|
||||||
need_cmd mvn
|
|
||||||
acquire_deploy_lock
|
|
||||||
update_source_from_git
|
|
||||||
|
|
||||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
|
||||||
services=("${ALL_SERVICES[@]}")
|
|
||||||
fi
|
|
||||||
local svc
|
|
||||||
for svc in "${services[@]}"; do
|
|
||||||
contains_service "$svc" || { echo "unsupported service: $svc" >&2; exit 2; }
|
|
||||||
done
|
|
||||||
|
|
||||||
package_services "${services[@]}"
|
|
||||||
for svc in "${services[@]}"; do
|
|
||||||
build_image "$svc"
|
|
||||||
done
|
|
||||||
|
|
||||||
case "$MODE" in
|
|
||||||
compose)
|
|
||||||
compose_up "${services[@]}"
|
|
||||||
;;
|
|
||||||
build-only)
|
|
||||||
log "build-only completed: ${services[*]}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "unsupported MODE: $MODE" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
status
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Aslan test docker compose deployment panel
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
WorkingDirectory=/opt/aslan-test
|
|
||||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
||||||
Environment=HOME=/root
|
|
||||||
Environment=ASLAN_DEPLOY_BASE=/opt/aslan-test
|
|
||||||
Environment=ASLAN_SOURCE_DIR=/opt/aslan-test/source/aslan-server
|
|
||||||
Environment=ASLAN_DEPLOY_SCRIPT=/opt/aslan-test/deploy-likei-services.sh
|
|
||||||
Environment=ASLAN_COMPOSE_FILE=/opt/aslan-test/docker-compose.yml
|
|
||||||
Environment=ASLAN_ENV_FILE=/opt/aslan-test/config/.env
|
|
||||||
Environment=ASLAN_ALLOWED_BRANCHES=aslan_test
|
|
||||||
Environment=ASLAN_OPS_HOST=127.0.0.1
|
|
||||||
Environment=ASLAN_OPS_PORT=18081
|
|
||||||
Environment=ASLAN_OPS_COOKIE_PATH=/deploy
|
|
||||||
EnvironmentFile=/etc/aslan-test-ops.env
|
|
||||||
ExecStart=/usr/bin/python3 /opt/aslan-test/ops/aslan_ops.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@ -1,591 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(os.environ.get("ASLAN_DEPLOY_BASE", "/opt/aslan-test"))
|
|
||||||
RUN_DIR = Path(os.environ.get("ASLAN_OPS_RUN_DIR", str(BASE_DIR / "ops" / "runs")))
|
|
||||||
DEPLOY_SCRIPT = Path(os.environ.get("ASLAN_DEPLOY_SCRIPT", str(BASE_DIR / "deploy-likei-services.sh")))
|
|
||||||
SOURCE_DIR = Path(os.environ.get("ASLAN_SOURCE_DIR", str(BASE_DIR / "source" / "aslan-server")))
|
|
||||||
COMPOSE_FILE = Path(os.environ.get("ASLAN_COMPOSE_FILE", str(BASE_DIR / "docker-compose.yml")))
|
|
||||||
ENV_FILE = Path(os.environ.get("ASLAN_ENV_FILE", str(BASE_DIR / "config" / ".env")))
|
|
||||||
GIT_REF = os.environ.get("ASLAN_GIT_REF", "aslan_test")
|
|
||||||
REMOTE_URL = os.environ.get("ASLAN_GIT_REMOTE_URL", "git@gitea.haiyihy.com:hy/aslan-server.git")
|
|
||||||
HOST = os.environ.get("ASLAN_OPS_HOST", "127.0.0.1")
|
|
||||||
PORT = int(os.environ.get("ASLAN_OPS_PORT", "18081"))
|
|
||||||
USERNAME = os.environ.get("ASLAN_OPS_USER", "admin")
|
|
||||||
PASSWORD = os.environ.get("ASLAN_OPS_PASSWORD", "")
|
|
||||||
SESSION_SECRET = os.environ.get("ASLAN_OPS_SESSION_SECRET", PASSWORD or secrets.token_hex(32))
|
|
||||||
COOKIE_PATH = os.environ.get("ASLAN_OPS_COOKIE_PATH", "/ops")
|
|
||||||
ALLOWED_BRANCHES = {
|
|
||||||
branch.strip()
|
|
||||||
for branch in os.environ.get("ASLAN_ALLOWED_BRANCHES", GIT_REF).split(",")
|
|
||||||
if branch.strip()
|
|
||||||
}
|
|
||||||
|
|
||||||
SERVICES = {
|
|
||||||
"auth": {"label": "Auth", "port": "", "health": ""},
|
|
||||||
"gateway": {"label": "Gateway", "port": "9000", "health": "/"},
|
|
||||||
"external": {"label": "External", "port": "", "health": ""},
|
|
||||||
"wallet": {"label": "Wallet", "port": "", "health": ""},
|
|
||||||
"order": {"label": "Order", "port": "", "health": ""},
|
|
||||||
"live": {"label": "Live", "port": "", "health": ""},
|
|
||||||
"other": {"label": "Other", "port": "", "health": ""},
|
|
||||||
"console": {"label": "Console", "port": "2700", "health": "/console/"},
|
|
||||||
}
|
|
||||||
|
|
||||||
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,80}$")
|
|
||||||
JOB_RE = re.compile(r"^[A-Za-z0-9-]+$")
|
|
||||||
|
|
||||||
job_lock = threading.Lock()
|
|
||||||
active_job = None
|
|
||||||
login_failures = {}
|
|
||||||
|
|
||||||
|
|
||||||
def now_ts():
|
|
||||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
|
|
||||||
def json_response(handler, status, payload):
|
|
||||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
handler.send_response(status)
|
|
||||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
handler.send_header("Cache-Control", "no-store")
|
|
||||||
handler.send_header("X-Frame-Options", "DENY")
|
|
||||||
handler.send_header("Content-Length", str(len(body)))
|
|
||||||
handler.end_headers()
|
|
||||||
handler.wfile.write(body)
|
|
||||||
|
|
||||||
|
|
||||||
def text_response(handler, status, body, content_type="text/plain; charset=utf-8"):
|
|
||||||
data = body.encode("utf-8")
|
|
||||||
handler.send_response(status)
|
|
||||||
handler.send_header("Content-Type", content_type)
|
|
||||||
handler.send_header("Cache-Control", "no-store")
|
|
||||||
handler.send_header("X-Frame-Options", "DENY")
|
|
||||||
handler.send_header("Content-Length", str(len(data)))
|
|
||||||
handler.end_headers()
|
|
||||||
handler.wfile.write(data)
|
|
||||||
|
|
||||||
|
|
||||||
def redirect_response(handler, location, cookies=None):
|
|
||||||
handler.send_response(302)
|
|
||||||
for cookie in cookies or []:
|
|
||||||
handler.send_header("Set-Cookie", cookie)
|
|
||||||
handler.send_header("Location", location)
|
|
||||||
handler.send_header("Content-Length", "0")
|
|
||||||
handler.end_headers()
|
|
||||||
|
|
||||||
|
|
||||||
def sign_session(value):
|
|
||||||
mac = hmac.new(SESSION_SECRET.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
return f"{value}.{mac}"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_session(signed):
|
|
||||||
if not signed or "." not in signed:
|
|
||||||
return False
|
|
||||||
value, mac = signed.rsplit(".", 1)
|
|
||||||
expected = hmac.new(SESSION_SECRET.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
if not hmac.compare_digest(mac, expected):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
username, issued = value.split(":", 1)
|
|
||||||
return username == USERNAME and time.time() - int(issued) < 86400
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cookies(header):
|
|
||||||
cookies = {}
|
|
||||||
for part in (header or "").split(";"):
|
|
||||||
if "=" not in part:
|
|
||||||
continue
|
|
||||||
key, value = part.strip().split("=", 1)
|
|
||||||
cookies[key] = value
|
|
||||||
return cookies
|
|
||||||
|
|
||||||
|
|
||||||
def too_many_login_failures(ip):
|
|
||||||
now = time.time()
|
|
||||||
window = [ts for ts in login_failures.get(ip, []) if now - ts < 300]
|
|
||||||
login_failures[ip] = window
|
|
||||||
return len(window) >= 5
|
|
||||||
|
|
||||||
|
|
||||||
def record_login_failure(ip):
|
|
||||||
window = login_failures.get(ip, [])
|
|
||||||
window.append(time.time())
|
|
||||||
login_failures[ip] = window[-10:]
|
|
||||||
|
|
||||||
|
|
||||||
def run_text(cmd, timeout=20):
|
|
||||||
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return (result.stderr.strip() or result.stdout.strip())
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def run_checked(cmd, timeout=30):
|
|
||||||
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"command failed: {cmd[0]}")
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def compose_cmd(*args):
|
|
||||||
return ["docker", "compose", "-f", str(COMPOSE_FILE), "--env-file", str(ENV_FILE), *args]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_compose_json_lines(text):
|
|
||||||
rows = []
|
|
||||||
for line in text.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
rows.append(json.loads(line))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def compose_status():
|
|
||||||
output = run_checked(compose_cmd("ps", "--all", "--format", "json"), timeout=20)
|
|
||||||
rows = parse_compose_json_lines(output)
|
|
||||||
by_service = {row.get("Service"): row for row in rows}
|
|
||||||
items = []
|
|
||||||
for name, meta in SERVICES.items():
|
|
||||||
row = by_service.get(name, {})
|
|
||||||
state = row.get("State") or "missing"
|
|
||||||
health = row.get("Health") or ""
|
|
||||||
status = row.get("Status") or ""
|
|
||||||
image = row.get("Image") or f"aslan-test/{name}:latest"
|
|
||||||
items.append({
|
|
||||||
"name": name,
|
|
||||||
"label": meta["label"],
|
|
||||||
"state": state,
|
|
||||||
"status": status,
|
|
||||||
"image": image,
|
|
||||||
"ports": row.get("Ports", ""),
|
|
||||||
"healthy": state == "running" and health not in {"unhealthy", "starting"},
|
|
||||||
"port": meta["port"],
|
|
||||||
"health": meta["health"],
|
|
||||||
})
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def git_status():
|
|
||||||
head = run_text(["git", "-C", str(SOURCE_DIR), "rev-parse", "HEAD"])
|
|
||||||
branch = run_text(["git", "-C", str(SOURCE_DIR), "branch", "--show-current"])
|
|
||||||
short = run_text(["git", "-C", str(SOURCE_DIR), "status", "--short", "--branch"])
|
|
||||||
last = run_text(["git", "-C", str(SOURCE_DIR), "log", "-1", "--oneline", "--decorate"])
|
|
||||||
return {"head": head, "branch": branch, "status": short, "last": last}
|
|
||||||
|
|
||||||
|
|
||||||
def list_jobs():
|
|
||||||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
jobs = []
|
|
||||||
for meta_path in sorted(RUN_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:30]:
|
|
||||||
try:
|
|
||||||
jobs.append(json.loads(meta_path.read_text()))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
return jobs
|
|
||||||
|
|
||||||
|
|
||||||
def save_job(job):
|
|
||||||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
(RUN_DIR / f"{job['id']}.json").write_text(json.dumps(job, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def stream_process(job, cmd, env):
|
|
||||||
log_path = RUN_DIR / f"{job['id']}.log"
|
|
||||||
try:
|
|
||||||
with log_path.open("a", buffering=1) as log:
|
|
||||||
log.write(f"[{now_ts()}] start {' '.join(cmd)}\n")
|
|
||||||
process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
cwd=str(BASE_DIR),
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
)
|
|
||||||
job["pid"] = process.pid
|
|
||||||
job["status"] = "running"
|
|
||||||
save_job(job)
|
|
||||||
for line in process.stdout:
|
|
||||||
log.write(line)
|
|
||||||
rc = process.wait()
|
|
||||||
job["status"] = "success" if rc == 0 else "failed"
|
|
||||||
job["returnCode"] = rc
|
|
||||||
job["finishedAt"] = now_ts()
|
|
||||||
save_job(job)
|
|
||||||
log.write(f"[{now_ts()}] finished rc={rc}\n")
|
|
||||||
except Exception as exc:
|
|
||||||
job["status"] = "failed"
|
|
||||||
job["returnCode"] = -1
|
|
||||||
job["finishedAt"] = now_ts()
|
|
||||||
job["error"] = str(exc)
|
|
||||||
save_job(job)
|
|
||||||
with log_path.open("a", buffering=1) as log:
|
|
||||||
log.write(f"[{now_ts()}] runner error: {exc}\n")
|
|
||||||
finally:
|
|
||||||
global active_job
|
|
||||||
with job_lock:
|
|
||||||
active_job = None
|
|
||||||
|
|
||||||
|
|
||||||
def start_deploy(services, branch, fast):
|
|
||||||
global active_job
|
|
||||||
invalid = [svc for svc in services if svc not in SERVICES]
|
|
||||||
if invalid:
|
|
||||||
raise ValueError(f"unsupported services: {', '.join(invalid)}")
|
|
||||||
if not services:
|
|
||||||
raise ValueError("select at least one service")
|
|
||||||
if not BRANCH_RE.match(branch):
|
|
||||||
raise ValueError("invalid branch name")
|
|
||||||
if branch not in ALLOWED_BRANCHES:
|
|
||||||
raise ValueError(f"branch is not allowed: {branch}")
|
|
||||||
if not DEPLOY_SCRIPT.exists():
|
|
||||||
raise ValueError(f"missing deploy script: {DEPLOY_SCRIPT}")
|
|
||||||
|
|
||||||
with job_lock:
|
|
||||||
if active_job:
|
|
||||||
raise RuntimeError(f"deploy already running: {active_job['id']}")
|
|
||||||
job_id = time.strftime("%Y%m%d%H%M%S") + "-" + secrets.token_hex(3)
|
|
||||||
job = {
|
|
||||||
"id": job_id,
|
|
||||||
"services": services,
|
|
||||||
"branch": branch,
|
|
||||||
"fast": fast,
|
|
||||||
"status": "queued",
|
|
||||||
"createdAt": now_ts(),
|
|
||||||
"finishedAt": "",
|
|
||||||
"returnCode": None,
|
|
||||||
}
|
|
||||||
active_job = job
|
|
||||||
save_job(job)
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.update({
|
|
||||||
"BASE_DIR": str(BASE_DIR),
|
|
||||||
"SRC_DIR": str(SOURCE_DIR),
|
|
||||||
"COMPOSE_FILE": str(COMPOSE_FILE),
|
|
||||||
"ENV_FILE": str(ENV_FILE),
|
|
||||||
"GIT_REF": branch,
|
|
||||||
"ALLOWED_GIT_REFS": " ".join(sorted(ALLOWED_BRANCHES)),
|
|
||||||
"GIT_REMOTE_URL": REMOTE_URL,
|
|
||||||
"MODE": "compose",
|
|
||||||
"HOME": str(Path.home()),
|
|
||||||
})
|
|
||||||
if fast:
|
|
||||||
# Fast mode keeps Maven target caches and is enough for small Java-only updates.
|
|
||||||
env["MAVEN_GOALS"] = "package"
|
|
||||||
cmd = [str(DEPLOY_SCRIPT), *services]
|
|
||||||
thread = threading.Thread(target=stream_process, args=(job, cmd, env), daemon=True)
|
|
||||||
thread.start()
|
|
||||||
return job
|
|
||||||
|
|
||||||
|
|
||||||
def service_log(svc):
|
|
||||||
if svc not in SERVICES:
|
|
||||||
raise ValueError(f"unsupported service: {svc}")
|
|
||||||
return run_checked(compose_cmd("logs", "--tail=300", svc), timeout=20)
|
|
||||||
|
|
||||||
|
|
||||||
def login_page(error=""):
|
|
||||||
message = f"<div class='error'>{html.escape(error)}</div>" if error else ""
|
|
||||||
return f"""<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Aslan Test Deploy</title>
|
|
||||||
<style>
|
|
||||||
:root {{ --ink:#172026; --muted:#65727f; --line:#d9e1e7; --brand:#0f766e; --bg:#f6f8fa; --danger:#b42318; }}
|
|
||||||
* {{ box-sizing:border-box; }}
|
|
||||||
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; background:var(--bg); color:var(--ink); }}
|
|
||||||
main {{ min-height:100vh; display:grid; place-items:center; padding:24px; }}
|
|
||||||
form {{ width:min(360px,100%); background:#fff; border:1px solid var(--line); border-radius:8px; padding:24px; box-shadow:0 10px 28px rgba(16,24,40,.08); }}
|
|
||||||
h1 {{ margin:0 0 20px; font-size:22px; letter-spacing:0; }}
|
|
||||||
label {{ display:block; margin:12px 0 6px; color:var(--muted); font-size:13px; }}
|
|
||||||
input {{ width:100%; height:40px; border:1px solid var(--line); border-radius:6px; padding:0 10px; font-size:15px; }}
|
|
||||||
button {{ width:100%; height:40px; border:0; border-radius:6px; background:var(--brand); color:white; font-weight:600; margin-top:18px; cursor:pointer; }}
|
|
||||||
.error {{ color:var(--danger); font-size:13px; margin-bottom:8px; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body><main><form method="post" action="login"><h1>Aslan 测试部署</h1>{message}<label>账号</label><input name="username" autocomplete="username" autofocus><label>密码</label><input name="password" type="password" autocomplete="current-password"><button type="submit">登录</button></form></main></body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
INDEX_HTML = """<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Aslan Test Deploy</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light; --bg:#f4f7f8; --panel:#fff; --ink:#14212b; --muted:#647382; --line:#d8e2e8; --brand:#0f766e; --brand-strong:#0b5f59; --warn:#b54708; --ok:#067647; --bad:#b42318; --chip:#e6f4f1; }
|
|
||||||
* { box-sizing:border-box; }
|
|
||||||
body { margin:0; background:var(--bg); color:var(--ink); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; letter-spacing:0; }
|
|
||||||
header { height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:1; }
|
|
||||||
h1 { margin:0; font-size:18px; }
|
|
||||||
header nav { display:flex; gap:12px; align-items:center; }
|
|
||||||
button, .link { height:34px; border-radius:6px; border:1px solid var(--line); background:#fff; color:var(--ink); padding:0 12px; cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
|
|
||||||
button.primary { background:var(--brand); border-color:var(--brand); color:white; }
|
|
||||||
button.primary:hover { background:var(--brand-strong); }
|
|
||||||
button:disabled { opacity:.55; cursor:not-allowed; }
|
|
||||||
main { max-width:1220px; margin:0 auto; padding:24px; display:grid; gap:16px; }
|
|
||||||
section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; }
|
|
||||||
.bar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; }
|
|
||||||
.bar h2 { margin:0; font-size:16px; }
|
|
||||||
.muted { color:var(--muted); font-size:13px; }
|
|
||||||
.grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
|
|
||||||
.svc { border:1px solid var(--line); border-radius:8px; padding:14px; display:grid; gap:10px; min-height:154px; }
|
|
||||||
.svc-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
|
||||||
.svc h3 { margin:0; font-size:16px; }
|
|
||||||
.badge { display:inline-flex; align-items:center; height:24px; border-radius:999px; padding:0 9px; font-size:12px; background:var(--chip); color:var(--brand-strong); white-space:nowrap; }
|
|
||||||
.badge.warn { background:#fff1e7; color:var(--warn); }
|
|
||||||
.badge.bad { background:#fee4e2; color:var(--bad); }
|
|
||||||
.meta { display:grid; grid-template-columns:64px 1fr; gap:6px; font-size:13px; }
|
|
||||||
.meta span:nth-child(odd) { color:var(--muted); }
|
|
||||||
.image { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
|
||||||
.deploy-row { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
|
|
||||||
.checks { display:flex; flex-wrap:wrap; gap:12px; }
|
|
||||||
label.check { display:inline-flex; align-items:center; gap:6px; color:var(--ink); font-size:14px; }
|
|
||||||
input[type="checkbox"] { width:16px; height:16px; accent-color:var(--brand); }
|
|
||||||
input.branch { width:180px; height:34px; border:1px solid var(--line); border-radius:6px; padding:0 10px; }
|
|
||||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
||||||
th, td { text-align:left; padding:10px; border-top:1px solid var(--line); vertical-align:top; }
|
|
||||||
th { color:var(--muted); font-weight:600; }
|
|
||||||
pre { margin:0; height:420px; overflow:auto; background:#101820; color:#e6edf3; padding:14px; border-radius:8px; font-size:12px; line-height:1.45; white-space:pre-wrap; }
|
|
||||||
@media (max-width: 1100px) { .grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
|
||||||
@media (max-width: 720px) { header { padding:0 14px; } main { padding:14px; } .grid { grid-template-columns:1fr; } .bar { align-items:flex-start; flex-direction:column; } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>Aslan 测试部署</h1>
|
|
||||||
<nav><span class="muted" id="git"></span><button id="refresh">刷新</button><a class="link" href="logout">退出</a></nav>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>服务状态</h2><span class="muted" id="updated"></span></div>
|
|
||||||
<div id="services" class="grid"></div>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>更新部署</h2><span class="muted">拉取 aslan_test,构建本地镜像,docker compose 替换容器</span></div>
|
|
||||||
<div class="deploy-row">
|
|
||||||
<div class="checks" id="serviceChecks"></div>
|
|
||||||
<label class="check">快速构建 <input id="fast" type="checkbox" checked></label>
|
|
||||||
<input id="branch" class="branch" value="aslan_test">
|
|
||||||
<button class="primary" id="deploy">部署选中服务</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>部署日志</h2><span class="muted" id="jobState">无运行任务</span></div>
|
|
||||||
<pre id="log"></pre>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="bar"><h2>最近任务</h2></div>
|
|
||||||
<table><thead><tr><th>ID</th><th>服务</th><th>分支</th><th>状态</th><th>时间</th></tr></thead><tbody id="jobs"></tbody></table>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
const serviceNames = ["auth", "gateway", "external", "wallet", "order", "live", "other", "console"];
|
|
||||||
let currentJob = "";
|
|
||||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
||||||
function badge(ok, text) { return `<span class="badge ${ok ? "" : "bad"}">${esc(text)}</span>`; }
|
|
||||||
async function api(path, opts) {
|
|
||||||
const res = await fetch(path, Object.assign({headers:{'Content-Type':'application/json','X-Aslan-Ops':'1'}}, opts || {}));
|
|
||||||
if (!res.ok) throw new Error((await res.text()) || res.statusText);
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
function renderChecks() {
|
|
||||||
document.getElementById("serviceChecks").innerHTML = serviceNames.map(s => `<label class="check"><input type="checkbox" value="${s}" checked> ${s}</label>`).join("");
|
|
||||||
}
|
|
||||||
async function loadStatus() {
|
|
||||||
const data = await api("api/status");
|
|
||||||
document.getElementById("updated").textContent = new Date().toLocaleString();
|
|
||||||
document.getElementById("git").textContent = `${data.git.branch || "unknown"} ${String(data.git.head || "").slice(0,8)}`;
|
|
||||||
document.getElementById("services").innerHTML = data.services.map(s => `
|
|
||||||
<article class="svc">
|
|
||||||
<div class="svc-head"><h3>${esc(s.label)}</h3>${badge(s.healthy, s.state || "missing")}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span>状态</span><span>${esc(s.status)}</span>
|
|
||||||
<span>端口</span><span>${esc(s.ports || s.port || "-")}</span>
|
|
||||||
<span>镜像</span><span class="image" title="${esc(s.image)}">${esc(s.image)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="deploy-row"><button data-service="${esc(s.name)}">部署</button><button data-log="${esc(s.name)}">日志</button></div>
|
|
||||||
</article>`).join("");
|
|
||||||
document.querySelectorAll("[data-service]").forEach(btn => btn.onclick = () => deploy([btn.dataset.service]));
|
|
||||||
document.querySelectorAll("[data-log]").forEach(btn => btn.onclick = () => loadServiceLog(btn.dataset.log));
|
|
||||||
document.getElementById("jobs").innerHTML = data.jobs.map(j => `<tr><td><button data-job="${esc(j.id)}">${esc(j.id)}</button></td><td>${esc((j.services || []).join(","))}</td><td>${esc(j.branch)}</td><td>${esc(j.status)}</td><td>${esc(j.createdAt)}</td></tr>`).join("");
|
|
||||||
document.querySelectorAll("[data-job]").forEach(btn => btn.onclick = () => { currentJob = btn.dataset.job; loadLog(); });
|
|
||||||
const running = data.jobs.find(j => j.status === "running" || j.status === "queued");
|
|
||||||
if (running && !currentJob) currentJob = running.id;
|
|
||||||
if (currentJob) loadLog();
|
|
||||||
}
|
|
||||||
async function deploy(services) {
|
|
||||||
const selected = services || Array.from(document.querySelectorAll("#serviceChecks input:checked")).map(i => i.value);
|
|
||||||
document.getElementById("deploy").disabled = true;
|
|
||||||
try {
|
|
||||||
const data = await api("api/deploy", {method:"POST", body:JSON.stringify({services:selected, branch:document.getElementById("branch").value, fast:document.getElementById("fast").checked})});
|
|
||||||
currentJob = data.job.id;
|
|
||||||
await loadStatus();
|
|
||||||
await loadLog();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
document.getElementById("deploy").disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function loadLog() {
|
|
||||||
if (!currentJob) return;
|
|
||||||
const data = await api(`api/jobs/${currentJob}/log`);
|
|
||||||
document.getElementById("jobState").textContent = `${currentJob} ${data.job.status}`;
|
|
||||||
const log = document.getElementById("log");
|
|
||||||
log.textContent = data.log || "";
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
async function loadServiceLog(service) {
|
|
||||||
const data = await api(`api/services/${service}/log`);
|
|
||||||
currentJob = "";
|
|
||||||
document.getElementById("jobState").textContent = `${service} 容器日志`;
|
|
||||||
const log = document.getElementById("log");
|
|
||||||
log.textContent = data.log || "";
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
renderChecks();
|
|
||||||
document.getElementById("refresh").onclick = loadStatus;
|
|
||||||
document.getElementById("deploy").onclick = () => deploy();
|
|
||||||
loadStatus().catch(err => document.getElementById("log").textContent = err.message);
|
|
||||||
setInterval(() => loadStatus().catch(() => {}), 5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
server_version = "AslanTestOps/1.0"
|
|
||||||
|
|
||||||
def authenticated(self):
|
|
||||||
cookies = parse_cookies(self.headers.get("Cookie"))
|
|
||||||
return verify_session(cookies.get("aslan_ops"))
|
|
||||||
|
|
||||||
def require_auth(self):
|
|
||||||
if self.authenticated():
|
|
||||||
return True
|
|
||||||
redirect_response(self, "login")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read_body(self, max_bytes=65536):
|
|
||||||
size = int(self.headers.get("Content-Length", "0"))
|
|
||||||
if size > max_bytes:
|
|
||||||
raise ValueError("request body too large")
|
|
||||||
return self.rfile.read(size).decode("utf-8")
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
if parsed.path == "/login":
|
|
||||||
return text_response(self, 200, login_page(), "text/html; charset=utf-8")
|
|
||||||
if parsed.path == "/logout":
|
|
||||||
redirect_response(self, "login", [f"aslan_ops=; Max-Age=0; HttpOnly; SameSite=Lax; Path={COOKIE_PATH}"])
|
|
||||||
return
|
|
||||||
if not self.require_auth():
|
|
||||||
return
|
|
||||||
if parsed.path in ("", "/"):
|
|
||||||
return text_response(self, 200, INDEX_HTML, "text/html; charset=utf-8")
|
|
||||||
if parsed.path == "/api/status":
|
|
||||||
try:
|
|
||||||
return json_response(self, 200, {
|
|
||||||
"services": compose_status(),
|
|
||||||
"git": git_status(),
|
|
||||||
"jobs": list_jobs(),
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
return json_response(self, 500, {"error": str(exc)})
|
|
||||||
match = re.match(r"^/api/jobs/([A-Za-z0-9-]+)/log$", parsed.path)
|
|
||||||
if match:
|
|
||||||
job_id = match.group(1)
|
|
||||||
if not JOB_RE.match(job_id):
|
|
||||||
return json_response(self, 400, {"error": "invalid job id"})
|
|
||||||
meta_path = RUN_DIR / f"{job_id}.json"
|
|
||||||
log_path = RUN_DIR / f"{job_id}.log"
|
|
||||||
if not meta_path.exists():
|
|
||||||
return json_response(self, 404, {"error": "job not found"})
|
|
||||||
job = json.loads(meta_path.read_text())
|
|
||||||
log = log_path.read_text(errors="replace")[-160000:] if log_path.exists() else ""
|
|
||||||
return json_response(self, 200, {"job": job, "log": log})
|
|
||||||
match = re.match(r"^/api/services/([A-Za-z0-9_-]+)/log$", parsed.path)
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
return json_response(self, 200, {"service": match.group(1), "log": service_log(match.group(1))})
|
|
||||||
except Exception as exc:
|
|
||||||
return json_response(self, 400, {"error": str(exc)})
|
|
||||||
return text_response(self, 404, "not found")
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
if parsed.path == "/login":
|
|
||||||
if too_many_login_failures(self.client_address[0]):
|
|
||||||
return text_response(self, 429, login_page("登录失败次数过多,稍后再试"), "text/html; charset=utf-8")
|
|
||||||
try:
|
|
||||||
form = parse_qs(self.read_body(max_bytes=4096))
|
|
||||||
except ValueError as exc:
|
|
||||||
return text_response(self, 413, str(exc))
|
|
||||||
username = form.get("username", [""])[0]
|
|
||||||
password = form.get("password", [""])[0]
|
|
||||||
if hmac.compare_digest(username, USERNAME) and hmac.compare_digest(password, PASSWORD):
|
|
||||||
login_failures.pop(self.client_address[0], None)
|
|
||||||
session = sign_session(f"{USERNAME}:{int(time.time())}")
|
|
||||||
redirect_response(self, ".", [f"aslan_ops={session}; HttpOnly; SameSite=Lax; Path={COOKIE_PATH}"])
|
|
||||||
return
|
|
||||||
record_login_failure(self.client_address[0])
|
|
||||||
return text_response(self, 401, login_page("账号或密码错误"), "text/html; charset=utf-8")
|
|
||||||
if not self.require_auth():
|
|
||||||
return
|
|
||||||
if parsed.path == "/api/deploy":
|
|
||||||
try:
|
|
||||||
if self.headers.get("X-Aslan-Ops") != "1":
|
|
||||||
return json_response(self, 403, {"error": "missing deploy header"})
|
|
||||||
payload = json.loads(self.read_body(max_bytes=4096) or "{}")
|
|
||||||
services = payload.get("services") or []
|
|
||||||
branch = payload.get("branch") or GIT_REF
|
|
||||||
fast = bool(payload.get("fast", True))
|
|
||||||
job = start_deploy(services, branch, fast)
|
|
||||||
return json_response(self, 202, {"job": job})
|
|
||||||
except Exception as exc:
|
|
||||||
return json_response(self, 400, {"error": str(exc)})
|
|
||||||
return text_response(self, 404, "not found")
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
|
||||||
print(f"[{now_ts()}] {self.address_string()} {fmt % args}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not PASSWORD:
|
|
||||||
raise SystemExit("ASLAN_OPS_PASSWORD is required")
|
|
||||||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
||||||
print(f"aslan test ops listening on {HOST}:{PORT}", flush=True)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
-- Webconsole salary settlement records menu.
|
|
||||||
-- Adds the second-level menu under 主播中心 and binds the list API resource.
|
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
|
|
||||||
INSERT INTO sys_resource (id, resource_name, mapping, method, auth_type, perm, update_time)
|
|
||||||
VALUES (
|
|
||||||
'team:salary:settlement:list',
|
|
||||||
'工资记录列表',
|
|
||||||
'/team/salary/settlement/list',
|
|
||||||
'GET',
|
|
||||||
3,
|
|
||||||
'team:salary:record:list',
|
|
||||||
NOW()
|
|
||||||
)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
resource_name = VALUES(resource_name),
|
|
||||||
mapping = VALUES(mapping),
|
|
||||||
method = VALUES(method),
|
|
||||||
auth_type = VALUES(auth_type),
|
|
||||||
perm = VALUES(perm),
|
|
||||||
update_time = NOW();
|
|
||||||
|
|
||||||
INSERT INTO sys_menu (
|
|
||||||
parent_id,
|
|
||||||
menu_name,
|
|
||||||
path,
|
|
||||||
router,
|
|
||||||
menu_type,
|
|
||||||
icon,
|
|
||||||
alias,
|
|
||||||
sort,
|
|
||||||
status,
|
|
||||||
create_user,
|
|
||||||
update_user,
|
|
||||||
create_time,
|
|
||||||
update_time
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
parent.id,
|
|
||||||
'工资记录',
|
|
||||||
'team/salary-record/index',
|
|
||||||
'team/salary-record',
|
|
||||||
2,
|
|
||||||
'form',
|
|
||||||
'team:salary:record',
|
|
||||||
880,
|
|
||||||
0,
|
|
||||||
'0',
|
|
||||||
'0',
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
FROM sys_menu parent
|
|
||||||
LEFT JOIN sys_menu existing ON existing.alias = 'team:salary:record'
|
|
||||||
WHERE (parent.alias = 'team:manaber' OR parent.router = '/team' OR parent.menu_name = '主播中心')
|
|
||||||
AND parent.menu_type = 1
|
|
||||||
AND existing.id IS NULL
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
UPDATE sys_menu salary_menu
|
|
||||||
JOIN (
|
|
||||||
SELECT id
|
|
||||||
FROM sys_menu
|
|
||||||
WHERE (alias = 'team:manaber' OR router = '/team' OR menu_name = '主播中心')
|
|
||||||
AND menu_type = 1
|
|
||||||
LIMIT 1
|
|
||||||
) parent ON 1 = 1
|
|
||||||
SET
|
|
||||||
salary_menu.parent_id = parent.id,
|
|
||||||
salary_menu.menu_name = '工资记录',
|
|
||||||
salary_menu.path = 'team/salary-record/index',
|
|
||||||
salary_menu.router = 'team/salary-record',
|
|
||||||
salary_menu.menu_type = 2,
|
|
||||||
salary_menu.icon = 'form',
|
|
||||||
salary_menu.sort = 880,
|
|
||||||
salary_menu.status = 0,
|
|
||||||
salary_menu.update_user = '0',
|
|
||||||
salary_menu.update_time = NOW()
|
|
||||||
WHERE salary_menu.alias = 'team:salary:record';
|
|
||||||
|
|
||||||
INSERT INTO sys_menu_resource (menu_id, resource_id)
|
|
||||||
SELECT menu.id, resource.id
|
|
||||||
FROM sys_menu menu
|
|
||||||
JOIN sys_resource resource ON resource.id = 'team:salary:settlement:list'
|
|
||||||
LEFT JOIN sys_menu_resource existing
|
|
||||||
ON existing.menu_id = menu.id
|
|
||||||
AND existing.resource_id = resource.id
|
|
||||||
WHERE menu.alias = 'team:salary:record'
|
|
||||||
AND existing.id IS NULL;
|
|
||||||
|
|
||||||
-- Salary records include admin salary, so auto-grant only to roles that can manage system permissions.
|
|
||||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
|
||||||
SELECT DISTINCT source_role.role_id, salary_menu.id
|
|
||||||
FROM sys_role_menu source_role
|
|
||||||
JOIN sys_menu source_menu ON source_menu.id = source_role.menu_id
|
|
||||||
JOIN sys_menu salary_menu ON salary_menu.alias = 'team:salary:record'
|
|
||||||
LEFT JOIN sys_role_menu existing
|
|
||||||
ON existing.role_id = source_role.role_id
|
|
||||||
AND existing.menu_id = salary_menu.id
|
|
||||||
WHERE source_menu.alias IN (
|
|
||||||
'sys:menu:list',
|
|
||||||
'sys:role:list',
|
|
||||||
'sys:resource:list'
|
|
||||||
)
|
|
||||||
AND existing.id IS NULL;
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS `user_freight_sub_seller_relation` (
|
|
||||||
`id` bigint NOT NULL COMMENT '主键标识',
|
|
||||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
|
||||||
`parent_freight_id` bigint NOT NULL COMMENT '超级经销商 freight balance id',
|
|
||||||
`parent_user_id` bigint NOT NULL COMMENT '超级经销商用户 id',
|
|
||||||
`child_user_id` bigint NOT NULL COMMENT '子 coin seller 用户 id',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`create_user` bigint DEFAULT NULL COMMENT '创建人',
|
|
||||||
`update_user` bigint DEFAULT NULL COMMENT '更新人',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_sys_origin_child_user_id` (`sys_origin`, `child_user_id`),
|
|
||||||
KEY `idx_sys_origin_parent_user_id` (`sys_origin`, `parent_user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='超级经销商子 coin seller 关系';
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
DEPLOY_HOST="${DEPLOY_HOST:-43.160.220.141}"
|
|
||||||
DEPLOY_USER="${DEPLOY_USER:-root}"
|
|
||||||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/aslan-test-singapore-ed25519}"
|
|
||||||
REMOTE_BASE="${REMOTE_BASE:-/opt/aslan-test}"
|
|
||||||
REMOTE_SRC="${REMOTE_SRC:-$REMOTE_BASE/source/aslan-server}"
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage:
|
|
||||||
.deploy/test-deploy/sync-and-deploy.sh [remote deploy args...]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
.deploy/test-deploy/sync-and-deploy.sh
|
|
||||||
.deploy/test-deploy/sync-and-deploy.sh status
|
|
||||||
.deploy/test-deploy/sync-and-deploy.sh --fast other
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
DEPLOY_HOST default 43.160.220.141
|
|
||||||
DEPLOY_USER default root
|
|
||||||
SSH_KEY default ~/.ssh/aslan-test-singapore-ed25519
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh_base=(ssh -i "$SSH_KEY" -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST")
|
|
||||||
rsync_ssh="ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new"
|
|
||||||
|
|
||||||
"${ssh_base[@]}" "mkdir -p '$REMOTE_BASE/ops' '$REMOTE_SRC'"
|
|
||||||
|
|
||||||
rsync -az \
|
|
||||||
-e "$rsync_ssh" \
|
|
||||||
"$SCRIPT_DIR/deploy-likei-services.sh" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/deploy-likei-services.sh"
|
|
||||||
|
|
||||||
rsync -az --delete \
|
|
||||||
--exclude 'runs/' \
|
|
||||||
-e "$rsync_ssh" \
|
|
||||||
"$SCRIPT_DIR/ops/" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/ops/"
|
|
||||||
|
|
||||||
"${ssh_base[@]}" "flock -n '$REMOTE_BASE/deploy.lock' sh -c 'chmod +x \"\$0/deploy-likei-services.sh\" \"\$0/ops/aslan_ops.py\" && cp \"\$0/ops/aslan-test-ops.service\" /etc/systemd/system/aslan-test-ops.service && systemctl daemon-reload && systemctl enable aslan-test-ops.service >/dev/null && systemctl restart aslan-test-ops.service' '$REMOTE_BASE'"
|
|
||||||
|
|
||||||
if [[ "$#" -gt 0 ]]; then
|
|
||||||
printf -v remote_script_q '%q' "$REMOTE_BASE/deploy-likei-services.sh"
|
|
||||||
remote_cmd="$remote_script_q"
|
|
||||||
printf -v remote_args_q ' %q' "$@"
|
|
||||||
remote_cmd+="$remote_args_q"
|
|
||||||
"${ssh_base[@]}" "$remote_cmd"
|
|
||||||
fi
|
|
||||||
@ -1,7 +1,5 @@
|
|||||||
package com.red.circle.auth.endpoint;
|
package com.red.circle.auth.endpoint;
|
||||||
|
|
||||||
import com.alibaba.fastjson.JSON;
|
|
||||||
import com.alibaba.fastjson.JSONObject;
|
|
||||||
import com.red.circle.auth.common.ProcessToken;
|
import com.red.circle.auth.common.ProcessToken;
|
||||||
import com.red.circle.auth.dto.TokenCredentialCO;
|
import com.red.circle.auth.dto.TokenCredentialCO;
|
||||||
import com.red.circle.auth.response.AuthErrorCode;
|
import com.red.circle.auth.response.AuthErrorCode;
|
||||||
@ -32,12 +30,6 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.http.client.config.RequestConfig;
|
|
||||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
|
||||||
import org.apache.http.client.methods.HttpGet;
|
|
||||||
import org.apache.http.impl.client.CloseableHttpClient;
|
|
||||||
import org.apache.http.impl.client.HttpClients;
|
|
||||||
import org.apache.http.util.EntityUtils;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@ -68,9 +60,6 @@ public class EndpointRestController {
|
|||||||
|
|
||||||
private final String LOGIN_WHITE_CONFIG = "LOGIN_WHITE_CONFIG";
|
private final String LOGIN_WHITE_CONFIG = "LOGIN_WHITE_CONFIG";
|
||||||
|
|
||||||
private final String APPCODE_CONFIG = "APPCODE 35493154e8304e47943ca596706da44f";
|
|
||||||
private final String REQUEST_URL = "https://c2ba.api.huachen.cn/ip";
|
|
||||||
|
|
||||||
private final RedisService redisService;
|
private final RedisService redisService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -283,44 +272,8 @@ public class EndpointRestController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String checkIpAddress(String ip) {
|
public String checkIpAddress(String ip) {
|
||||||
String redisKey = "IP_KEY:" + ip;
|
log.info("IP:{} 临时跳过 IP 国家校验", ip);
|
||||||
String data = redisService.getString(redisKey);
|
return null;
|
||||||
JSONObject dataObject = null;
|
|
||||||
if (StringUtils.isNotBlank(data)) {
|
|
||||||
dataObject = JSONObject.parseObject(data);
|
|
||||||
} else {
|
|
||||||
//每一个ip地址只能有一次校验
|
|
||||||
HttpGet httpGet = new HttpGet(REQUEST_URL + "?ip=" + ip);
|
|
||||||
httpGet.addHeader("Authorization", APPCODE_CONFIG);
|
|
||||||
RequestConfig requestConfig = RequestConfig.custom()
|
|
||||||
.setConnectTimeout(10000).setSocketTimeout(10000).setConnectionRequestTimeout(10000).build();
|
|
||||||
|
|
||||||
try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build();
|
|
||||||
CloseableHttpResponse response = httpClient.execute(httpGet)) {
|
|
||||||
String result = EntityUtils.toString(response.getEntity());
|
|
||||||
|
|
||||||
log.info("请求地址:{},返回信息:{}", REQUEST_URL + "?ip=" + ip, result);
|
|
||||||
JSONObject jsonObject = JSON.parseObject(result);
|
|
||||||
if (jsonObject.getInteger("ret").equals(200)) {
|
|
||||||
dataObject = jsonObject.getJSONObject("data");
|
|
||||||
redisService.setString(redisKey, dataObject.toJSONString());
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("请求地址:{}, 异常信息:{}", REQUEST_URL + "?ip=" + ip, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("IP:{}, 国家信息:{}", ip, dataObject);
|
|
||||||
// IP 归属接口超时或返回异常时不能让登录链路 NPE,中港台拦截只在拿到明确国家信息时执行。
|
|
||||||
if (dataObject == null) {
|
|
||||||
log.warn("IP:{} 国家信息获取失败,跳过 IP 国家校验", ip);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (dataObject != null && "中国".equals(dataObject.getString("country"))) {
|
|
||||||
if (!Arrays.asList("香港", "澳门", "台湾").contains(dataObject.getString("region"))) {
|
|
||||||
ResponseAssert.isTrue(UserErrorCode.REGISTRATION_FAILED, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dataObject.getString("country_id");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void checkZone(String reqZone) {
|
public void checkZone(String reqZone) {
|
||||||
|
|||||||
@ -70,13 +70,6 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered {
|
|||||||
return chain.filter(exchange);
|
return chain.filter(exchange);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isV5PayCallbackPath(request)) {
|
|
||||||
// V5Pay 的签名覆盖请求原始字段文本:读取为 Object 再序列化会把 14400.00 改成 14400.0,
|
|
||||||
// 同时常规网关日志会泄露 sign/appKey。该单一路径只补 trace header,body Flux 原样转发且完全不上传 body 日志。
|
|
||||||
ServerHttpRequest forwarded = request.mutate().headers(this::setRequestTraceId).build();
|
|
||||||
return chain.filter(exchange.mutate().request(forwarded).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange);
|
GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange);
|
||||||
|
|
||||||
return checkBodyType(exchange.getRequest())
|
return checkBodyType(exchange.getRequest())
|
||||||
@ -90,13 +83,6 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered {
|
|||||||
|| MediaType.APPLICATION_JSON.isCompatibleWith(mediaType);
|
|| MediaType.APPLICATION_JSON.isCompatibleWith(mediaType);
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean isV5PayCallbackPath(ServerHttpRequest request) {
|
|
||||||
String path = request.getURI().getRawPath();
|
|
||||||
// ApiLoggingFilter 可能看到 ForwardRoute 处理前的 /order 前缀,也可能在测试/后续过滤器顺序中看到已剥离路径。
|
|
||||||
return "/order/play-server-notice/v5pay/receive_payment".equals(path)
|
|
||||||
|| "/play-server-notice/v5pay/receive_payment".equals(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private Mono<Void> writeBasicLog(ServerWebExchange exchange, GatewayFilterChain chain,
|
private Mono<Void> writeBasicLog(ServerWebExchange exchange, GatewayFilterChain chain,
|
||||||
GatewayLogging logging) {
|
GatewayLogging logging) {
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
package com.red.circle.gateway.filter;
|
|
||||||
|
|
||||||
import com.red.circle.gateway.logging.LoggingUploadService;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import org.junit.jupiter.api.Assertions;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.Mockito;
|
|
||||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
|
||||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
|
||||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
|
||||||
|
|
||||||
public class ApiLoggingFilterTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void v5PayCallbackForwardsOriginalJsonBytesWithoutLogging() {
|
|
||||||
LoggingUploadService loggingUploadService = Mockito.mock(LoggingUploadService.class);
|
|
||||||
ApiLoggingFilter filter = new ApiLoggingFilter(loggingUploadService);
|
|
||||||
String rawBody = "{\"amount\":14400.00,\"appKey\":\"secret-app\",\"sign\":\"abc\"}";
|
|
||||||
MockServerWebExchange exchange = MockServerWebExchange.from(
|
|
||||||
MockServerHttpRequest.post("/order/play-server-notice/v5pay/receive_payment")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(rawBody));
|
|
||||||
AtomicReference<byte[]> forwarded = new AtomicReference<>();
|
|
||||||
GatewayFilterChain chain = current -> DataBufferUtils.join(current.getRequest().getBody())
|
|
||||||
.doOnNext(buffer -> {
|
|
||||||
byte[] bytes = new byte[buffer.readableByteCount()];
|
|
||||||
buffer.read(bytes);
|
|
||||||
DataBufferUtils.release(buffer);
|
|
||||||
forwarded.set(bytes);
|
|
||||||
})
|
|
||||||
.then();
|
|
||||||
|
|
||||||
filter.filter(exchange, chain).block();
|
|
||||||
|
|
||||||
Assertions.assertArrayEquals(rawBody.getBytes(StandardCharsets.UTF_8), forwarded.get());
|
|
||||||
Mockito.verifyNoInteractions(loggingUploadService);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void v5PayCallbackMatchesBothExternalAndStrippedGatewayPaths() {
|
|
||||||
Assertions.assertTrue(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest
|
|
||||||
.post("/order/play-server-notice/v5pay/receive_payment").build()));
|
|
||||||
Assertions.assertTrue(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest
|
|
||||||
.post("/play-server-notice/v5pay/receive_payment").build()));
|
|
||||||
Assertions.assertFalse(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest
|
|
||||||
.post("/order/play-server-notice/mifa_pay/receive_payment").build()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -24,9 +24,6 @@ public enum MonthlyRechargeType {
|
|||||||
*/
|
*/
|
||||||
MIFA_PAY,
|
MIFA_PAY,
|
||||||
|
|
||||||
/** V5Pay web 充值。 */
|
|
||||||
V5_PAY,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Airwallex.
|
* Airwallex.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
package com.red.circle.other.inner.endpoint.team.bd;
|
|
||||||
|
|
||||||
import com.red.circle.other.inner.endpoint.team.bd.api.SalarySettlementRecordClientApi;
|
|
||||||
import org.springframework.cloud.openfeign.FeignClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
@FeignClient(name = "salarySettlementRecordClient", url = "${feign.other.url}" +
|
|
||||||
SalarySettlementRecordClientApi.API_PREFIX)
|
|
||||||
public interface SalarySettlementRecordClient extends SalarySettlementRecordClientApi {
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package com.red.circle.other.inner.endpoint.team.bd.api;
|
|
||||||
|
|
||||||
import com.red.circle.framework.dto.PageResult;
|
|
||||||
import com.red.circle.framework.dto.ResultResponse;
|
|
||||||
import com.red.circle.other.inner.model.cmd.team.bd.SalarySettlementRecordPageQryCmd;
|
|
||||||
import com.red.circle.other.inner.model.dto.agency.bd.SalarySettlementRecordDTO;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
public interface SalarySettlementRecordClientApi {
|
|
||||||
|
|
||||||
String API_PREFIX = "/bd/salary-settlement-record/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页查询工资结算记录.
|
|
||||||
*/
|
|
||||||
@PostMapping("/page")
|
|
||||||
ResultResponse<PageResult<SalarySettlementRecordDTO>> page(
|
|
||||||
@RequestBody SalarySettlementRecordPageQryCmd qryCmd);
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package com.red.circle.other.inner.model.cmd.team.bd;
|
|
||||||
|
|
||||||
import com.red.circle.framework.core.dto.PageCommand;
|
|
||||||
import java.io.Serial;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录分页查询.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Accessors(chain = true)
|
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
public class SalarySettlementRecordPageQryCmd extends PageCommand {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 来源系统.
|
|
||||||
*/
|
|
||||||
private String sysOrigin;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 身份:BD | BD_LEADER | ADMIN,空表示全部.
|
|
||||||
*/
|
|
||||||
private String identity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户 ID.
|
|
||||||
*/
|
|
||||||
private Long userId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 账期,yyyyMMdd 半月起始日,如 20260701 / 20260716.
|
|
||||||
*/
|
|
||||||
private Integer billBelong;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 状态:PENDING | SUCCESS | FAILED.
|
|
||||||
*/
|
|
||||||
private String status;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 幂等业务号.
|
|
||||||
*/
|
|
||||||
private String bizNo;
|
|
||||||
}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
package com.red.circle.other.inner.model.dto.agency.bd;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
|
||||||
import com.red.circle.framework.dto.DTO;
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Accessors(chain = true)
|
|
||||||
@EqualsAndHashCode(callSuper = false)
|
|
||||||
public class SalarySettlementRecordDTO extends DTO {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private String id;
|
|
||||||
|
|
||||||
@JsonSerialize(using = ToStringSerializer.class)
|
|
||||||
private Long timeId;
|
|
||||||
|
|
||||||
private String sysOrigin;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一用户 ID,按身份分别对应 bdUserId/bdLeaderUserId/adminUserId.
|
|
||||||
*/
|
|
||||||
@JsonSerialize(using = ToStringSerializer.class)
|
|
||||||
private Long userId;
|
|
||||||
|
|
||||||
private String identity;
|
|
||||||
|
|
||||||
@JsonSerialize(using = ToStringSerializer.class)
|
|
||||||
private Long bdUserId;
|
|
||||||
|
|
||||||
@JsonSerialize(using = ToStringSerializer.class)
|
|
||||||
private Long bdLeaderUserId;
|
|
||||||
|
|
||||||
@JsonSerialize(using = ToStringSerializer.class)
|
|
||||||
private Long adminUserId;
|
|
||||||
|
|
||||||
private Boolean isAdmin;
|
|
||||||
|
|
||||||
private Integer billBelong;
|
|
||||||
|
|
||||||
private String billTitle;
|
|
||||||
|
|
||||||
private String bizNo;
|
|
||||||
|
|
||||||
private String status;
|
|
||||||
|
|
||||||
private String failureReason;
|
|
||||||
|
|
||||||
private Integer agencyNumber;
|
|
||||||
|
|
||||||
private Integer bdNumber;
|
|
||||||
|
|
||||||
private BigDecimal teamSalaryAmount;
|
|
||||||
|
|
||||||
private BigDecimal teamRechargeAmount;
|
|
||||||
|
|
||||||
private String hitPolicyType;
|
|
||||||
|
|
||||||
private Integer hitLevel;
|
|
||||||
|
|
||||||
private BigDecimal commissionRate;
|
|
||||||
|
|
||||||
private BigDecimal basicSalary;
|
|
||||||
|
|
||||||
private BigDecimal rewardAmount;
|
|
||||||
|
|
||||||
private Boolean rewardEligible;
|
|
||||||
|
|
||||||
private Integer newRechargeAgentCount;
|
|
||||||
|
|
||||||
private BigDecimal settlementSalary;
|
|
||||||
|
|
||||||
private BigDecimal rechargeAgentAmount;
|
|
||||||
|
|
||||||
private BigDecimal rechargeAgentSalary;
|
|
||||||
|
|
||||||
private BigDecimal rechargeAgentPercentage;
|
|
||||||
|
|
||||||
private BigDecimal bdLeaderTeamSalary;
|
|
||||||
|
|
||||||
private BigDecimal bdLeaderSalary;
|
|
||||||
|
|
||||||
private Integer bdLeaderHitLevel;
|
|
||||||
|
|
||||||
private BigDecimal bdLeaderBasicSalary;
|
|
||||||
|
|
||||||
private BigDecimal bdLeaderRewardAmount;
|
|
||||||
|
|
||||||
private BigDecimal bdTeamSalaryAmount;
|
|
||||||
|
|
||||||
private BigDecimal bdTeamSalarySalary;
|
|
||||||
|
|
||||||
private Integer bdTeamSalaryHitLevel;
|
|
||||||
|
|
||||||
private BigDecimal bdTeamSalaryCommissionRate;
|
|
||||||
|
|
||||||
private BigDecimal bdTeamRechargeAmount;
|
|
||||||
|
|
||||||
private BigDecimal bdTeamRechargeSalary;
|
|
||||||
|
|
||||||
private Integer bdTeamRechargeHitLevel;
|
|
||||||
|
|
||||||
private BigDecimal bdTeamRechargeCommissionRate;
|
|
||||||
|
|
||||||
private BigDecimal bdPolicyFinalSalary;
|
|
||||||
|
|
||||||
private String bdPolicyHitType;
|
|
||||||
|
|
||||||
private BigDecimal totalSalary;
|
|
||||||
|
|
||||||
private Timestamp createTime;
|
|
||||||
|
|
||||||
private Timestamp updateTime;
|
|
||||||
}
|
|
||||||
@ -79,21 +79,6 @@ public enum FreightErrorCode implements IResponseErrorCode {
|
|||||||
* 不是超级货运代理
|
* 不是超级货运代理
|
||||||
*/
|
*/
|
||||||
NOT_SUPER_FREIGHT(5512, "Not a Super Freight forwarder"),
|
NOT_SUPER_FREIGHT(5512, "Not a Super Freight forwarder"),
|
||||||
|
|
||||||
/**
|
|
||||||
* 子 coin seller 已绑定.
|
|
||||||
*/
|
|
||||||
SUB_FREIGHT_SELLER_EXISTS(5513, "Sub coin seller already exists"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 不是当前超级经销商的子 coin seller.
|
|
||||||
*/
|
|
||||||
NOT_SUB_FREIGHT_SELLER(5514, "Not a sub coin seller"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 无效的子 coin seller.
|
|
||||||
*/
|
|
||||||
INVALID_SUB_FREIGHT_SELLER(5515, "Invalid sub coin seller"),
|
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
package com.red.circle.console.adapter.app.team;
|
package com.red.circle.console.adapter.app.team;
|
||||||
|
|
||||||
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryCO;
|
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryCO;
|
||||||
import com.red.circle.console.app.dto.clienobject.team.SalarySettlementRecordCO;
|
|
||||||
import com.red.circle.console.app.service.admin.UserAccountService;
|
|
||||||
import com.red.circle.console.app.service.app.team.TeamSalaryBackService;
|
import com.red.circle.console.app.service.app.team.TeamSalaryBackService;
|
||||||
import com.red.circle.framework.dto.PageResult;
|
import com.red.circle.framework.dto.PageResult;
|
||||||
import com.red.circle.framework.web.controller.BaseController;
|
import com.red.circle.framework.web.controller.BaseController;
|
||||||
import com.red.circle.other.inner.model.cmd.team.TeamSalaryBackQryCmd;
|
import com.red.circle.other.inner.model.cmd.team.TeamSalaryBackQryCmd;
|
||||||
import com.red.circle.other.inner.model.cmd.team.bd.SalarySettlementRecordPageQryCmd;
|
|
||||||
import java.util.Objects;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@ -24,9 +20,6 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RequestMapping("/team/salary")
|
@RequestMapping("/team/salary")
|
||||||
public class TeamSalaryRestController extends BaseController {
|
public class TeamSalaryRestController extends BaseController {
|
||||||
|
|
||||||
private static final String SALARY_RECORD_MENU_ALIAS = "team:salary:record";
|
|
||||||
|
|
||||||
private final UserAccountService userAccountService;
|
|
||||||
private final TeamSalaryBackService teamSalaryBackService;
|
private final TeamSalaryBackService teamSalaryBackService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,24 +30,4 @@ public class TeamSalaryRestController extends BaseController {
|
|||||||
return teamSalaryBackService.listByCondition(query);
|
return teamSalaryBackService.listByCondition(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*/
|
|
||||||
@GetMapping("/settlement/list")
|
|
||||||
public PageResult<SalarySettlementRecordCO> listSettlementRecords(
|
|
||||||
SalarySettlementRecordPageQryCmd query) {
|
|
||||||
checkSalaryRecordPermission();
|
|
||||||
query.setPageQuery(getPage());
|
|
||||||
return teamSalaryBackService.listSettlementRecords(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkSalaryRecordPermission() {
|
|
||||||
Long reqUserId = getReqUserId();
|
|
||||||
// 工资记录包含 Admin 薪资,不能只依赖菜单隐藏,接口层也必须校验显式菜单权限。
|
|
||||||
if (Objects.isNull(reqUserId)
|
|
||||||
|| !userAccountService.hasButtonsAlias(reqUserId.intValue(), SALARY_RECORD_MENU_ALIAS)) {
|
|
||||||
throw new RuntimeException("无权限访问工资记录");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,18 +3,14 @@ package com.red.circle.console.app.service.app.team;
|
|||||||
import com.google.common.collect.Maps;
|
import com.google.common.collect.Maps;
|
||||||
import com.google.common.collect.Sets;
|
import com.google.common.collect.Sets;
|
||||||
import com.red.circle.console.app.convertor.team.TeamAppConvertor;
|
import com.red.circle.console.app.convertor.team.TeamAppConvertor;
|
||||||
import com.red.circle.console.app.dto.clienobject.team.SalarySettlementRecordCO;
|
|
||||||
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryCO;
|
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryCO;
|
||||||
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryDetailsCO;
|
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryDetailsCO;
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
import com.red.circle.framework.core.asserts.ResponseAssert;
|
||||||
import com.red.circle.framework.dto.PageResult;
|
import com.red.circle.framework.dto.PageResult;
|
||||||
import com.red.circle.other.inner.endpoint.team.bd.SalarySettlementRecordClient;
|
|
||||||
import com.red.circle.other.inner.endpoint.team.target.TeamProfileClient;
|
import com.red.circle.other.inner.endpoint.team.target.TeamProfileClient;
|
||||||
import com.red.circle.other.inner.model.cmd.team.TeamSalaryBackQryCmd;
|
import com.red.circle.other.inner.model.cmd.team.TeamSalaryBackQryCmd;
|
||||||
import com.red.circle.other.inner.model.cmd.team.bd.SalarySettlementRecordPageQryCmd;
|
|
||||||
import com.red.circle.other.inner.model.dto.agency.agency.TeamSalaryPaymentDTO;
|
import com.red.circle.other.inner.model.dto.agency.agency.TeamSalaryPaymentDTO;
|
||||||
import com.red.circle.other.inner.model.dto.agency.agency.TeamSalaryPaymentDetailsDTO;
|
import com.red.circle.other.inner.model.dto.agency.agency.TeamSalaryPaymentDetailsDTO;
|
||||||
import com.red.circle.other.inner.model.dto.agency.bd.SalarySettlementRecordDTO;
|
|
||||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
import com.red.circle.tool.core.collection.CollectionUtils;
|
||||||
import com.red.circle.tool.core.text.StringUtils;
|
import com.red.circle.tool.core.text.StringUtils;
|
||||||
import com.red.circle.other.inner.model.dto.user.UserProfileDTO;
|
import com.red.circle.other.inner.model.dto.user.UserProfileDTO;
|
||||||
@ -26,7 +22,6 @@ import java.util.Objects;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.BeanUtils;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +37,6 @@ public class TeamSalaryBackServiceImpl implements TeamSalaryBackService {
|
|||||||
private final TeamProfileClient teamProfileClient;
|
private final TeamProfileClient teamProfileClient;
|
||||||
private final UserProfileClient userProfileClient;
|
private final UserProfileClient userProfileClient;
|
||||||
private final RegionConfigClient regionConfigClient;
|
private final RegionConfigClient regionConfigClient;
|
||||||
private final SalarySettlementRecordClient salarySettlementRecordClient;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query) {
|
public PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query) {
|
||||||
@ -119,25 +113,6 @@ public class TeamSalaryBackServiceImpl implements TeamSalaryBackService {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public PageResult<SalarySettlementRecordCO> listSettlementRecords(
|
|
||||||
SalarySettlementRecordPageQryCmd query) {
|
|
||||||
PageResult<SalarySettlementRecordDTO> pageResult = ResponseAssert.requiredSuccess(
|
|
||||||
salarySettlementRecordClient.page(query));
|
|
||||||
|
|
||||||
PageResult<SalarySettlementRecordCO> page = pageResult.convert(this::toSettlementRecordCO);
|
|
||||||
if (pageResult.getTotal() <= 0) {
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<Long, UserProfileDTO> userProfileMap = getSettlementUserProfileMap(pageResult);
|
|
||||||
page.setRecords(page.getRecords().stream().peek(record -> {
|
|
||||||
// 后台工资记录按身份统一展示,用户资料统一按结算用户 userId 补齐。
|
|
||||||
record.setUserProfile(userProfileMap.get(record.getUserId()));
|
|
||||||
}).collect(Collectors.toList()));
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> getMapRegionName(TeamSalaryBackQryCmd query) {
|
private Map<String, String> getMapRegionName(TeamSalaryBackQryCmd query) {
|
||||||
return ResponseAssert.requiredSuccess(
|
return ResponseAssert.requiredSuccess(
|
||||||
regionConfigClient.mapRegionNameBySysOrigin(query.getSysOrigin()));
|
regionConfigClient.mapRegionNameBySysOrigin(query.getSysOrigin()));
|
||||||
@ -172,24 +147,4 @@ public class TeamSalaryBackServiceImpl implements TeamSalaryBackService {
|
|||||||
return ResponseAssert.requiredSuccess(userProfileClient.mapByUserIds(userIds));
|
return ResponseAssert.requiredSuccess(userProfileClient.mapByUserIds(userIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
private SalarySettlementRecordCO toSettlementRecordCO(SalarySettlementRecordDTO record) {
|
|
||||||
SalarySettlementRecordCO co = new SalarySettlementRecordCO();
|
|
||||||
BeanUtils.copyProperties(record, co);
|
|
||||||
return co;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<Long, UserProfileDTO> getSettlementUserProfileMap(
|
|
||||||
PageResult<SalarySettlementRecordDTO> pageResult) {
|
|
||||||
Set<Long> userIds = pageResult.getRecords().stream()
|
|
||||||
.map(SalarySettlementRecordDTO::getUserId)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
if (CollectionUtils.isEmpty(userIds)) {
|
|
||||||
return Maps.newHashMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseAssert.requiredSuccess(userProfileClient.mapByUserIds(userIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,7 +206,7 @@ public class UserBaseInfoServiceImpl implements
|
|||||||
|
|
||||||
boolean countryChanged = countryChanged(param, currentProfile);
|
boolean countryChanged = countryChanged(param, currentProfile);
|
||||||
if (countryChanged) {
|
if (countryChanged) {
|
||||||
fillConsoleCountry(param, currentProfile);
|
fillCountryAndAssertAllowed(param, currentProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
userProfileClient.updateSelectiveById(userAppConvertor.toUserBaseInfoCmd(param));
|
userProfileClient.updateSelectiveById(userAppConvertor.toUserBaseInfoCmd(param));
|
||||||
@ -228,20 +228,52 @@ public class UserBaseInfoServiceImpl implements
|
|||||||
&& !Objects.equals(param.getCountryId(), currentProfile.getCountryId());
|
&& !Objects.equals(param.getCountryId(), currentProfile.getCountryId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void fillConsoleCountry(UserProfileUpdateCmd param,
|
private void fillCountryAndAssertAllowed(UserProfileUpdateCmd param,
|
||||||
UserProfileDTO currentProfile) {
|
UserProfileDTO currentProfile) {
|
||||||
SysCountryCodeDTO country = ResponseAssert.requiredSuccess(
|
SysCountryCodeDTO country = ResponseAssert.requiredSuccess(
|
||||||
countryCodeClient.getById(param.getCountryId()));
|
countryCodeClient.getById(param.getCountryId()));
|
||||||
ResponseAssert.notNull(UserErrorCode.SYS_REGION_NOT_EXIST_ERROR, country);
|
ResponseAssert.notNull(UserErrorCode.SYS_REGION_NOT_EXIST_ERROR, country);
|
||||||
|
|
||||||
// 后台用户资料编辑是最高权限路径,只校验目标国家有效,不受主播/代理/BD 等用户身份限制。
|
boolean locked = hasCountryLockedIdentity(param.getId());
|
||||||
log.warn("console country-change userId={}, currentCountryId={}, targetCountryId={}",
|
log.warn("console country-change identity check userId={}, locked={}, currentCountryId={}, targetCountryId={}",
|
||||||
param.getId(), currentProfile.getCountryId(), param.getCountryId());
|
param.getId(), locked, currentProfile.getCountryId(), param.getCountryId());
|
||||||
|
// 国家变更只限制平台身份用户;普通用户允许后台多次调整并同步房间国家。
|
||||||
|
ResponseAssert.isFalse(UserErrorCode.USER_COUNTRY_UPDATE_NOT_ALLOWED, locked);
|
||||||
|
|
||||||
param.setCountryCode(country.getAlphaTwo());
|
param.setCountryCode(country.getAlphaTwo());
|
||||||
param.setCountryName(country.getCountryName());
|
param.setCountryName(country.getCountryName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasCountryLockedIdentity(Long userId) {
|
||||||
|
TeamMemberDTO teamMember = getTeamMember(userId);
|
||||||
|
boolean anchor = Objects.nonNull(teamMember);
|
||||||
|
boolean agent = anchor && TeamMemberRole.OWN.eq(teamMember.getRole());
|
||||||
|
boolean bd = checkBd(userId);
|
||||||
|
boolean bdLeader = checkBdLeader(userId);
|
||||||
|
log.warn("console country-change identity detail userId={}, anchor={}, agent={}, bd={}, bdLeader={}, teamMemberRole={}",
|
||||||
|
userId, anchor, agent, bd, bdLeader,
|
||||||
|
Objects.isNull(teamMember) ? null : teamMember.getRole());
|
||||||
|
return anchor || agent || bd || bdLeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TeamMemberDTO getTeamMember(Long userId) {
|
||||||
|
try {
|
||||||
|
return ResponseAssert.requiredSuccess(teamProfileClient.getByMemberId(userId));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("console country-change team member check failed, treat as no team member, userId={}",
|
||||||
|
userId, ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean checkBd(Long userId) {
|
||||||
|
return Boolean.TRUE.equals(ResponseAssert.requiredSuccess(bdTeamInfoClient.check(userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean checkBdLeader(Long userId) {
|
||||||
|
return Boolean.TRUE.equals(ResponseAssert.requiredSuccess(bdTeamLeaderClient.check(userId)));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateVestUserInfo(UserBaseInfoCmd cmd) {
|
public void updateVestUserInfo(UserBaseInfoCmd cmd) {
|
||||||
cmd.setUpdateTime(TimestampUtils.now());
|
cmd.setUpdateTime(TimestampUtils.now());
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
package com.red.circle.console.app.dto.clienobject.team;
|
|
||||||
|
|
||||||
import com.red.circle.other.inner.model.dto.agency.bd.SalarySettlementRecordDTO;
|
|
||||||
import com.red.circle.other.inner.model.dto.user.UserProfileDTO;
|
|
||||||
import java.io.Serial;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Accessors(chain = true)
|
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
public class SalarySettlementRecordCO extends SalarySettlementRecordDTO {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 结算用户资料.
|
|
||||||
*/
|
|
||||||
private UserProfileDTO userProfile;
|
|
||||||
}
|
|
||||||
@ -2,10 +2,8 @@ package com.red.circle.console.app.service.app.team;
|
|||||||
|
|
||||||
|
|
||||||
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryCO;
|
import com.red.circle.console.app.dto.clienobject.team.TeamSalaryCO;
|
||||||
import com.red.circle.console.app.dto.clienobject.team.SalarySettlementRecordCO;
|
|
||||||
import com.red.circle.framework.dto.PageResult;
|
import com.red.circle.framework.dto.PageResult;
|
||||||
import com.red.circle.other.inner.model.cmd.team.TeamSalaryBackQryCmd;
|
import com.red.circle.other.inner.model.cmd.team.TeamSalaryBackQryCmd;
|
||||||
import com.red.circle.other.inner.model.cmd.team.bd.SalarySettlementRecordPageQryCmd;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 团队自动发送工资凭证.
|
* 团队自动发送工资凭证.
|
||||||
@ -19,10 +17,4 @@ public interface TeamSalaryBackService {
|
|||||||
*/
|
*/
|
||||||
PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query);
|
PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query);
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询 BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*/
|
|
||||||
PageResult<SalarySettlementRecordCO> listSettlementRecords(
|
|
||||||
SalarySettlementRecordPageQryCmd query);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
package com.red.circle.order.adapter.app;
|
package com.red.circle.order.adapter.app;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonFactory;
|
|
||||||
import com.fasterxml.jackson.core.JsonParser;
|
|
||||||
import com.fasterxml.jackson.core.JsonToken;
|
|
||||||
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
|
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
|
||||||
import com.red.circle.component.pay.paymax.PayMaxNoticeResponse;
|
import com.red.circle.component.pay.paymax.PayMaxNoticeResponse;
|
||||||
import com.red.circle.component.pay.paymax.service.PayMaxResponse;
|
import com.red.circle.component.pay.paymax.service.PayMaxResponse;
|
||||||
@ -15,9 +12,7 @@ import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
|
|||||||
import com.red.circle.order.app.scheduler.GoogleReimburseCheckTask;
|
import com.red.circle.order.app.scheduler.GoogleReimburseCheckTask;
|
||||||
import com.red.circle.order.app.service.InAppPurchaseProductService;
|
import com.red.circle.order.app.service.InAppPurchaseProductService;
|
||||||
import com.red.circle.tool.core.json.JacksonUtils;
|
import com.red.circle.tool.core.json.JacksonUtils;
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -95,53 +90,6 @@ public class PayServerNoticeController extends BaseController {
|
|||||||
return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body);
|
return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** V5Pay JSON 回调;必须返回小写 text/plain,不能套统一 JSON 响应结构。 */
|
|
||||||
@IgnoreResultResponse
|
|
||||||
@PostMapping(value = "/v5pay/receive_payment",
|
|
||||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
|
||||||
produces = MediaType.TEXT_PLAIN_VALUE)
|
|
||||||
public String v5PayReceivePaymentJson(@RequestBody(required = false) byte[] body) {
|
|
||||||
try {
|
|
||||||
return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(
|
|
||||||
parseV5PayJsonFields(body));
|
|
||||||
} catch (IOException exception) {
|
|
||||||
// V5Pay 需要纯文本失败响应;JSON 解析错误不能再经过全局异常处理包装成业务 JSON。
|
|
||||||
return "fail";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** V5Pay form 回调与 JSON 走同一验签/订单核验链路。 */
|
|
||||||
@IgnoreResultResponse
|
|
||||||
@PostMapping(value = "/v5pay/receive_payment",
|
|
||||||
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
|
|
||||||
produces = MediaType.TEXT_PLAIN_VALUE)
|
|
||||||
public String v5PayReceivePaymentForm(@RequestParam Map<String, String> fields) {
|
|
||||||
return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(fields);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, String> parseV5PayJsonFields(byte[] body) throws IOException {
|
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
|
||||||
if (Objects.isNull(body) || body.length == 0) {
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
try (JsonParser parser = new JsonFactory().createParser(body)) {
|
|
||||||
if (parser.nextToken() != JsonToken.START_OBJECT) {
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
while (parser.nextToken() != JsonToken.END_OBJECT) {
|
|
||||||
String key = parser.currentName();
|
|
||||||
JsonToken valueToken = parser.nextToken();
|
|
||||||
if (valueToken != null && valueToken.isScalarValue()) {
|
|
||||||
// getText 保留 14400.00 这类数字的原始小数位,避免反序列化成 Double 后改变签名原文。
|
|
||||||
fields.put(key, valueToken == JsonToken.VALUE_NULL ? "" : parser.getText());
|
|
||||||
} else {
|
|
||||||
parser.skipChildren();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* payerMax 退款回调地址-自定义.
|
* payerMax 退款回调地址-自定义.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -123,9 +123,9 @@ public class WebPayRestController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* H5 三方支付下单(MiFaPay/V5Pay).
|
* H5 MiFaPay 下单.
|
||||||
*
|
*
|
||||||
* @eo.name H5 三方支付下单.
|
* @eo.name H5 MiFaPay 下单.
|
||||||
* @eo.url /h5/orders
|
* @eo.url /h5/orders
|
||||||
* @eo.method post
|
* @eo.method post
|
||||||
* @eo.request-type json
|
* @eo.request-type json
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
package com.red.circle.order.adapter.app;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Map;
|
|
||||||
import org.junit.Assert;
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
public class PayServerNoticeControllerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void v5PayJsonParserPreservesSignedNumberText() throws Exception {
|
|
||||||
Map<String, String> fields = PayServerNoticeController.parseV5PayJsonFields(
|
|
||||||
"{\"amount\":14400.00,\"status\":2,\"orderNo\":\"ASLAN123\"}"
|
|
||||||
.getBytes(StandardCharsets.UTF_8));
|
|
||||||
|
|
||||||
Assert.assertEquals("14400.00", fields.get("amount"));
|
|
||||||
Assert.assertEquals("2", fields.get("status"));
|
|
||||||
Assert.assertEquals("ASLAN123", fields.get("orderNo"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -18,12 +18,6 @@
|
|||||||
<artifactId>order-infrastructure</artifactId>
|
<artifactId>order-infrastructure</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.mockito</groupId>
|
|
||||||
<artifactId>mockito-core</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
|
|||||||
@ -31,13 +31,12 @@ import com.red.circle.tool.core.text.StringUtils;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aslan H5 MiFaPay/V5Pay 下单和查单。
|
* Aslan H5 MiFaPay 下单和查单。
|
||||||
*
|
*
|
||||||
* <p>前端只提交商品 id 和支付方式标识;金额、金币数、商品类型、币种和汇率全部从后台表重新生成。</p>
|
* <p>前端只提交商品 id 和支付方式标识;金额、金币数、商品类型、币种和汇率全部从后台表重新生成。</p>
|
||||||
*
|
*
|
||||||
@ -48,9 +47,7 @@ import org.springframework.stereotype.Component;
|
|||||||
public class PayWebH5PlaceOrderCmdExe {
|
public class PayWebH5PlaceOrderCmdExe {
|
||||||
|
|
||||||
private static final String PROVIDER_MIFAPAY = "mifapay";
|
private static final String PROVIDER_MIFAPAY = "mifapay";
|
||||||
private static final String PROVIDER_V5PAY = "v5pay";
|
|
||||||
private static final String FACTORY_MIFAPAY = "MIFA_PAY";
|
private static final String FACTORY_MIFAPAY = "MIFA_PAY";
|
||||||
private static final String FACTORY_V5PAY = "V5_PAY";
|
|
||||||
|
|
||||||
private final PayCountryService payCountryService;
|
private final PayCountryService payCountryService;
|
||||||
private final PayApplicationService payApplicationService;
|
private final PayApplicationService payApplicationService;
|
||||||
@ -58,30 +55,23 @@ public class PayWebH5PlaceOrderCmdExe {
|
|||||||
private final PayPlaceAnOrderService payPlaceAnOrderService;
|
private final PayPlaceAnOrderService payPlaceAnOrderService;
|
||||||
private final InAppPurchaseDetailsService inAppPurchaseDetailsService;
|
private final InAppPurchaseDetailsService inAppPurchaseDetailsService;
|
||||||
private final PayApplicationCommodityService payApplicationCommodityService;
|
private final PayApplicationCommodityService payApplicationCommodityService;
|
||||||
private final V5PayOrderStateService v5PayOrderStateService;
|
|
||||||
|
|
||||||
public PayWebH5OrderCO execute(PayWebH5PlaceOrderCmd cmd) {
|
public PayWebH5OrderCO execute(PayWebH5PlaceOrderCmd cmd) {
|
||||||
String providerCode = Objects.toString(cmd.getProviderCode(), "").trim()
|
|
||||||
.toLowerCase(Locale.ROOT);
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
||||||
PROVIDER_MIFAPAY.equals(providerCode) || PROVIDER_V5PAY.equals(providerCode));
|
PROVIDER_MIFAPAY.equalsIgnoreCase(Objects.toString(cmd.getProviderCode(), "")));
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
||||||
Objects.nonNull(cmd.getApplicationId())
|
Objects.nonNull(cmd.getApplicationId())
|
||||||
&& Objects.nonNull(cmd.getGoodsId())
|
&& Objects.nonNull(cmd.getGoodsId())
|
||||||
&& Objects.nonNull(cmd.getPayCountryId())
|
&& Objects.nonNull(cmd.getPayCountryId())
|
||||||
&& Objects.nonNull(cmd.getUserId())
|
&& Objects.nonNull(cmd.getUserId())
|
||||||
|
&& StringUtils.isNotBlank(cmd.getPayWay())
|
||||||
&& StringUtils.isNotBlank(cmd.getPayType()));
|
&& StringUtils.isNotBlank(cmd.getPayType()));
|
||||||
// MiFaPay 的 payWay 是独立必填字段;V5Pay 只把 payType 作为 productType,仍保留 payWay/channelCode 供 H5 配置透传。
|
|
||||||
if (PROVIDER_MIFAPAY.equals(providerCode)) {
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
|
||||||
StringUtils.isNotBlank(cmd.getPayWay()));
|
|
||||||
}
|
|
||||||
|
|
||||||
PayPlaceAnOrderDetailsV2 details = buildDetails(cmd, providerCode);
|
PayPlaceAnOrderDetailsV2 details = buildDetails(cmd);
|
||||||
PlaceAnOrderResponseCO response = payPlaceAnOrderService.doRequest(details);
|
PlaceAnOrderResponseCO response = payPlaceAnOrderService.doRequest(details);
|
||||||
return new PayWebH5OrderCO()
|
return new PayWebH5OrderCO()
|
||||||
.setOrderId(details.getOrderId())
|
.setOrderId(details.getOrderId())
|
||||||
.setProviderCode(providerCode)
|
.setProviderCode(PROVIDER_MIFAPAY)
|
||||||
.setPayUrl(response.getRequestUrl())
|
.setPayUrl(response.getRequestUrl())
|
||||||
.setStatus("redirected")
|
.setStatus("redirected")
|
||||||
.setProviderAmountMinor(minor(details.getAmount(), 100L))
|
.setProviderAmountMinor(minor(details.getAmount(), 100L))
|
||||||
@ -93,14 +83,9 @@ public class PayWebH5PlaceOrderCmdExe {
|
|||||||
public PayWebH5OrderCO getOrder(String orderId) {
|
public PayWebH5OrderCO getOrder(String orderId) {
|
||||||
InAppPurchaseDetails order = inAppPurchaseDetailsService.getById(orderId);
|
InAppPurchaseDetails order = inAppPurchaseDetailsService.getById(orderId);
|
||||||
ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, order);
|
ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, order);
|
||||||
// 只有 V5Pay 待处理订单主动查三方;MiFaPay 保持原有本地状态查询,避免改变存量渠道行为。
|
|
||||||
if (Objects.nonNull(order.getFactory())
|
|
||||||
&& FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode())) {
|
|
||||||
order = v5PayOrderStateService.refresh(order);
|
|
||||||
}
|
|
||||||
return new PayWebH5OrderCO()
|
return new PayWebH5OrderCO()
|
||||||
.setOrderId(order.getId())
|
.setOrderId(order.getId())
|
||||||
.setProviderCode(providerCode(order))
|
.setProviderCode(PROVIDER_MIFAPAY)
|
||||||
.setStatus(toH5Status(order.getStatus()))
|
.setStatus(toH5Status(order.getStatus()))
|
||||||
.setProviderAmountMinor(minor(order.getAmount(), 100L))
|
.setProviderAmountMinor(minor(order.getAmount(), 100L))
|
||||||
.setUsdMinorAmount(minor(order.getAmountUsd(), 100L))
|
.setUsdMinorAmount(minor(order.getAmountUsd(), 100L))
|
||||||
@ -108,8 +93,7 @@ public class PayWebH5PlaceOrderCmdExe {
|
|||||||
.setFailureReason(order.getReason());
|
.setFailureReason(order.getReason());
|
||||||
}
|
}
|
||||||
|
|
||||||
private PayPlaceAnOrderDetailsV2 buildDetails(PayWebH5PlaceOrderCmd cmd,
|
private PayPlaceAnOrderDetailsV2 buildDetails(PayWebH5PlaceOrderCmd cmd) {
|
||||||
String providerCode) {
|
|
||||||
PayApplication application = payApplicationService.getById(cmd.getApplicationId());
|
PayApplication application = payApplicationService.getById(cmd.getApplicationId());
|
||||||
ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, application);
|
ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, application);
|
||||||
|
|
||||||
@ -159,7 +143,7 @@ public class PayWebH5PlaceOrderCmdExe {
|
|||||||
.setAmountUsd(commodity.getAmountUsd())
|
.setAmountUsd(commodity.getAmountUsd())
|
||||||
.setQuantity(1)));
|
.setQuantity(1)));
|
||||||
details.setProductDescriptor(productDescriptor(commodity));
|
details.setProductDescriptor(productDescriptor(commodity));
|
||||||
details.setChannelDetails(paymentChannel(cmd, payCountry, providerCode));
|
details.setChannelDetails(mifaPayChannel(cmd, payCountry));
|
||||||
details.setCountry(sysCountryCode);
|
details.setCountry(sysCountryCode);
|
||||||
details.setPayCountry(payCountry);
|
details.setPayCountry(payCountry);
|
||||||
details.setRechargeUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), ""));
|
details.setRechargeUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), ""));
|
||||||
@ -167,25 +151,19 @@ public class PayWebH5PlaceOrderCmdExe {
|
|||||||
details.setCancelRedirectUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), ""));
|
details.setCancelRedirectUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), ""));
|
||||||
details.setNewVersion(Boolean.TRUE);
|
details.setNewVersion(Boolean.TRUE);
|
||||||
details.setRequestIp(IpUtils.getIpAddr());
|
details.setRequestIp(IpUtils.getIpAddr());
|
||||||
details.setLanguage(cmd.getLanguage());
|
|
||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PayCountryChannelDetails paymentChannel(PayWebH5PlaceOrderCmd cmd,
|
private PayCountryChannelDetails mifaPayChannel(PayWebH5PlaceOrderCmd cmd, PayCountry payCountry) {
|
||||||
PayCountry payCountry, String providerCode) {
|
|
||||||
boolean v5Pay = PROVIDER_V5PAY.equals(providerCode);
|
|
||||||
String fallbackChannel = v5Pay
|
|
||||||
? cmd.getPayType()
|
|
||||||
: cmd.getPayWay() + "_" + cmd.getPayType();
|
|
||||||
String channelCode = StringUtils.isNotBlank(cmd.getChannelCode())
|
String channelCode = StringUtils.isNotBlank(cmd.getChannelCode())
|
||||||
? cmd.getChannelCode()
|
? cmd.getChannelCode()
|
||||||
: fallbackChannel;
|
: cmd.getPayWay() + "_" + cmd.getPayType();
|
||||||
// H5 支付方式来自 app JSON,本对象用于复用统一策略工厂并把 productType 固化到订单快照。
|
// 这里刻意不查后台渠道明细表:H5 支付国家和方式来自 app JSON,本对象只为复用现有 MiFaPay 策略和订单快照。
|
||||||
return new PayCountryChannelDetails()
|
return new PayCountryChannelDetails()
|
||||||
.setId(0L)
|
.setId(0L)
|
||||||
.setPayCountryId(payCountry.getId())
|
.setPayCountryId(payCountry.getId())
|
||||||
.setChannelCode(channelCode)
|
.setChannelCode(channelCode)
|
||||||
.setFactoryCode(v5Pay ? FACTORY_V5PAY : FACTORY_MIFAPAY)
|
.setFactoryCode(FACTORY_MIFAPAY)
|
||||||
.setFactoryChannel(cmd.getPayType())
|
.setFactoryChannel(cmd.getPayType())
|
||||||
.setFactoryCurrencyPoint(2)
|
.setFactoryCurrencyPoint(2)
|
||||||
.setShelf(Boolean.TRUE);
|
.setShelf(Boolean.TRUE);
|
||||||
@ -212,13 +190,6 @@ public class PayWebH5PlaceOrderCmdExe {
|
|||||||
return "pending";
|
return "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String providerCode(InAppPurchaseDetails order) {
|
|
||||||
return Objects.nonNull(order.getFactory())
|
|
||||||
&& FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode())
|
|
||||||
? PROVIDER_V5PAY
|
|
||||||
: PROVIDER_MIFAPAY;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long minor(BigDecimal amount, Long scale) {
|
private Long minor(BigDecimal amount, Long scale) {
|
||||||
if (Objects.isNull(amount)) {
|
if (Objects.isNull(amount)) {
|
||||||
return 0L;
|
return 0L;
|
||||||
|
|||||||
@ -1,216 +0,0 @@
|
|||||||
package com.red.circle.order.app.command.pay.web;
|
|
||||||
|
|
||||||
import com.red.circle.order.app.common.InAppPurchaseCommon;
|
|
||||||
import com.red.circle.order.app.common.V5PayTransactionData;
|
|
||||||
import com.red.circle.order.app.service.V5PayService;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseCollectionReceiptService;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseDetailsService;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.entity.assist.InAppPurchaseEventNotice;
|
|
||||||
import com.red.circle.order.infra.database.rds.service.order.OrderCacheService;
|
|
||||||
import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus;
|
|
||||||
import com.red.circle.tool.core.date.TimestampUtils;
|
|
||||||
import com.red.circle.tool.core.text.StringUtils;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V5Pay 查询/回调共用的订单状态处理器。
|
|
||||||
*
|
|
||||||
* <p>成功状态只在签名已验证且订单号、金额、币种、productType 与本地快照全部一致时入账。</p>
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class V5PayOrderStateService {
|
|
||||||
|
|
||||||
static final String FACTORY_V5PAY = "V5_PAY";
|
|
||||||
|
|
||||||
private final V5PayService v5PayService;
|
|
||||||
private final InAppPurchaseCommon inAppPurchaseCommon;
|
|
||||||
private final InAppPurchaseDetailsService inAppPurchaseDetailsService;
|
|
||||||
private final InAppPurchaseCollectionReceiptService inAppPurchaseCollectionReceiptService;
|
|
||||||
private final OrderCacheService orderCacheService;
|
|
||||||
|
|
||||||
public String handleNotification(Map<String, String> fields) {
|
|
||||||
try {
|
|
||||||
V5PayTransactionData data = v5PayService.parseNotification(fields);
|
|
||||||
InAppPurchaseDetails order = inAppPurchaseDetailsService.getById(data.getOrderNo());
|
|
||||||
if (Objects.isNull(order)) {
|
|
||||||
log.error("V5Pay callback order not found: orderNo={}", data.getOrderNo());
|
|
||||||
return "fail";
|
|
||||||
}
|
|
||||||
return apply(order, data, "callback") ? "success" : "fail";
|
|
||||||
} catch (Exception exception) {
|
|
||||||
// 回调日志不打印 fields、sign 或异常对象的序列化内容,防止签名材料进入日志系统。
|
|
||||||
log.error("V5Pay callback rejected: reason={}", exception.getMessage());
|
|
||||||
return "fail";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** H5 查单时主动向 V5Pay 对账,补偿异步通知延迟或丢失。 */
|
|
||||||
public InAppPurchaseDetails refresh(InAppPurchaseDetails order) {
|
|
||||||
if (!isV5PayOrder(order) || isTerminal(order.getStatus())) {
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
V5PayTransactionData data = v5PayService.queryOrder(
|
|
||||||
order.getId(), order.getCountryCode(), order.getCurrency());
|
|
||||||
apply(order, data, "query");
|
|
||||||
InAppPurchaseDetails refreshed = inAppPurchaseDetailsService.getById(order.getId());
|
|
||||||
return Objects.requireNonNullElse(refreshed, order);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
// 三方暂时不可用时保留本地待支付状态;不能把查单网络异常误写成用户支付失败。
|
|
||||||
log.warn("V5Pay query failed: orderNo={}, reason={}", order.getId(),
|
|
||||||
exception.getMessage());
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean apply(InAppPurchaseDetails order, V5PayTransactionData data, String source) {
|
|
||||||
if (!isV5PayOrder(order)) {
|
|
||||||
log.error("V5Pay {} factory mismatch: orderNo={}", source, order.getId());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
recordNotice(order, data, source);
|
|
||||||
String status = Objects.toString(data.getStatus(), "").trim();
|
|
||||||
if ("2".equals(status)) {
|
|
||||||
if (Objects.equals(order.getStatus(), InAppPurchaseStatus.SUCCESS)) {
|
|
||||||
// V5Pay 会重试通知;本地成功订单直接确认,不能再次触发余额和统计副作用。
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!Objects.equals(order.getStatus(), InAppPurchaseStatus.CREATE)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!matchesOrderSnapshot(order, data)) {
|
|
||||||
inAppPurchaseDetailsService.updateStatusReason(order.getId(), InAppPurchaseStatus.FAIL,
|
|
||||||
"V5Pay " + source + " order snapshot mismatch");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String claimKey = "V5PAY:CREDIT:" + order.getId();
|
|
||||||
if (!orderCacheService.lock(claimKey)) {
|
|
||||||
// Redis SET NX 是跨实例原子 claim;竞争失败不能直接 ACK callback,否则持锁者稍后失败时三方会停止重试并造成丢单。
|
|
||||||
InAppPurchaseDetails latest = inAppPurchaseDetailsService.getById(order.getId());
|
|
||||||
if (Objects.nonNull(latest) && isTerminal(latest.getStatus())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// 主动 query 只需保留 pending;callback 必须返回 fail,让 V5Pay 在首个处理者失败时继续重试。
|
|
||||||
return !"callback".equals(source);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
InAppPurchaseDetails latest = inAppPurchaseDetailsService.getById(order.getId());
|
|
||||||
if (Objects.isNull(latest)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (Objects.equals(latest.getStatus(), InAppPurchaseStatus.SUCCESS)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!Objects.equals(latest.getStatus(), InAppPurchaseStatus.CREATE)
|
|
||||||
|| !matchesOrderSnapshot(latest, data)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (latest.receiptTypeEqReceipt()) {
|
|
||||||
inAppPurchaseCommon.inAppPurchaseReceipt(latest);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (latest.receiptTypeEqPayment()) {
|
|
||||||
inAppPurchaseCommon.inAppPurchasePayment(latest);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
log.error("V5Pay order receipt type invalid: orderNo={}", latest.getId());
|
|
||||||
return false;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
// 失败后 finally 释放 claim 且订单仍为 CREATE,后续三方重试或主动查单可恢复;成功状态会在重试前被拦截。
|
|
||||||
log.error("V5Pay credit failed: orderNo={}, reason={}", order.getId(),
|
|
||||||
exception.getMessage());
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
orderCacheService.unLock(claimKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ("3".equals(status) || "5".equals(status) || "6".equals(status)) {
|
|
||||||
if (Objects.equals(order.getStatus(), InAppPurchaseStatus.CREATE)) {
|
|
||||||
if (order.receiptTypeEqReceipt()) {
|
|
||||||
inAppPurchaseCollectionReceiptService.updateStatusSuccessFail(order.getId());
|
|
||||||
}
|
|
||||||
inAppPurchaseDetailsService.updateStatusReason(order.getId(), InAppPurchaseStatus.FAIL,
|
|
||||||
"V5Pay payment failed: status=" + status);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (status.isEmpty() || "0".equals(status) || "1".equals(status)) {
|
|
||||||
// 协议明确的待处理状态可以 ACK,但不改变本地订单。
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// 未知状态不能在 callback 上直接 ACK,否则未来新增成功码会被永久吞掉;主动 query 仍按 pending 展示。
|
|
||||||
return !"callback".equals(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean matchesOrderSnapshot(InAppPurchaseDetails order, V5PayTransactionData data) {
|
|
||||||
if (Objects.isNull(order) || Objects.isNull(data)
|
|
||||||
|| StringUtils.isBlank(data.getOrderNo())
|
|
||||||
|| StringUtils.isBlank(data.getAmount())
|
|
||||||
|| StringUtils.isBlank(data.getCurrency())
|
|
||||||
|| StringUtils.isBlank(data.getProductType())
|
|
||||||
|| !Objects.equals(order.getId(), data.getOrderNo())
|
|
||||||
|| !Objects.toString(order.getCurrency(), "").equalsIgnoreCase(data.getCurrency())
|
|
||||||
|| Objects.isNull(order.getFactory())
|
|
||||||
|| !Objects.toString(order.getFactory().getFactoryChannelCode(), "")
|
|
||||||
.equalsIgnoreCase(data.getProductType())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return toMinor(order.getAmount()) == toMinor(data.getAmount());
|
|
||||||
} catch (ArithmeticException | NumberFormatException exception) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static long toMinor(String amount) {
|
|
||||||
return toMinor(new BigDecimal(amount.trim()));
|
|
||||||
}
|
|
||||||
|
|
||||||
static long toMinor(BigDecimal amount) {
|
|
||||||
// V5Pay 金额协议固定两位小数;超过两位时拒绝而不是截断,避免不同金额被误判为同一订单。
|
|
||||||
return amount.setScale(2, RoundingMode.UNNECESSARY)
|
|
||||||
.movePointRight(2)
|
|
||||||
.longValueExact();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void recordNotice(InAppPurchaseDetails order, V5PayTransactionData data, String source) {
|
|
||||||
Map<String, String> sanitized = new LinkedHashMap<>(data.getFields());
|
|
||||||
sanitized.remove("sign");
|
|
||||||
sanitized.remove("appKey");
|
|
||||||
sanitized.remove("merchantNo");
|
|
||||||
String eventId = "v5pay-" + source + "-"
|
|
||||||
+ StringUtils.isBlankOrElse(data.getTransactionId(), data.getOrderNo()) + "-"
|
|
||||||
+ Objects.toString(data.getStatus(), "pending");
|
|
||||||
// 重复通知不重复扩张 payNotices;订单状态门禁负责业务幂等,这里负责审计记录幂等。
|
|
||||||
if (!inAppPurchaseDetailsService.existsPayNoticeByEventId(order.getId(), eventId)) {
|
|
||||||
inAppPurchaseDetailsService.addPayNotices(order.getId(), new InAppPurchaseEventNotice()
|
|
||||||
.setEventId(eventId)
|
|
||||||
.setNoticeType("v5pay-" + source)
|
|
||||||
.setNoticeData(sanitized)
|
|
||||||
.setCreateTime(TimestampUtils.now()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isV5PayOrder(InAppPurchaseDetails order) {
|
|
||||||
return Objects.nonNull(order)
|
|
||||||
&& Objects.nonNull(order.getFactory())
|
|
||||||
&& FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isTerminal(InAppPurchaseStatus status) {
|
|
||||||
return Objects.equals(status, InAppPurchaseStatus.SUCCESS)
|
|
||||||
|| Objects.equals(status, InAppPurchaseStatus.FAIL)
|
|
||||||
|| Objects.equals(status, InAppPurchaseStatus.CANCEL)
|
|
||||||
|| Objects.equals(status, InAppPurchaseStatus.REFUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
package com.red.circle.order.app.command.pay.web;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/** V5Pay 回调命令入口。 */
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class V5PayServerNoticeReceivePaymentCmdExe {
|
|
||||||
|
|
||||||
private final V5PayOrderStateService orderStateService;
|
|
||||||
|
|
||||||
public String execute(Map<String, String> fields) {
|
|
||||||
return orderStateService.handleNotification(fields);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -120,9 +120,6 @@ public class PayPlaceAnOrderDetailsV2 implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String requestIp;
|
private String requestIp;
|
||||||
|
|
||||||
/** H5 语言,用于让 V5Pay 收银台返回对应语言;非法或空值由客户端降级为 en-US。 */
|
|
||||||
private String language;
|
|
||||||
|
|
||||||
public String getFactoryChannel() {
|
public String getFactoryChannel() {
|
||||||
return channelDetails.getFactoryChannel();
|
return channelDetails.getFactoryChannel();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,7 @@ public class PayPlaceAnOrderService {
|
|||||||
new InAppPurchaseFactory()
|
new InAppPurchaseFactory()
|
||||||
.setPlatform(RequestClientEnum.H5.getClientName())
|
.setPlatform(RequestClientEnum.H5.getClientName())
|
||||||
.setFactoryCode(param.getFactoryCode())
|
.setFactoryCode(param.getFactoryCode())
|
||||||
.setFactoryChannelCode(factoryChannelCode(param))
|
.setFactoryChannelCode(param.getChannelCode())
|
||||||
)
|
)
|
||||||
.setProducts(param.getProducts())
|
.setProducts(param.getProducts())
|
||||||
.setProductDescriptor(param.getProductDescriptor())
|
.setProductDescriptor(param.getProductDescriptor())
|
||||||
@ -93,13 +93,6 @@ public class PayPlaceAnOrderService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String factoryChannelCode(PayPlaceAnOrderDetailsV2 param) {
|
|
||||||
// 只有 V5Pay 回调必须用 productType 做严格快照校验;存量 MiFa/PayMax 继续保存 channelCode,避免改变历史查询语义。
|
|
||||||
return "V5_PAY".equalsIgnoreCase(param.getFactoryCode())
|
|
||||||
? param.getFactoryChannel()
|
|
||||||
: param.getChannelCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String env() {
|
private String env() {
|
||||||
return envProperties.isProd() ? PayEnv.PROD.name() : PayEnv.TEST.name();
|
return envProperties.isProd() ? PayEnv.PROD.name() : PayEnv.TEST.name();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
package com.red.circle.order.app.command.pay.web.strategy;
|
|
||||||
|
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
|
||||||
import com.red.circle.order.app.common.V5PayGatewayResponse;
|
|
||||||
import com.red.circle.order.app.common.V5PayPlaceAnOrderParam;
|
|
||||||
import com.red.circle.order.app.dto.clientobject.pay.PlaceAnOrderResponseCO;
|
|
||||||
import com.red.circle.order.app.service.V5PayService;
|
|
||||||
import com.red.circle.order.infra.util.MemberInfoGenerator;
|
|
||||||
import com.red.circle.order.inner.asserts.OrderErrorCode;
|
|
||||||
import com.red.circle.tool.core.text.StringUtils;
|
|
||||||
import java.util.Objects;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/** V5Pay web 支付策略,复用统一的预支付订单持久化流程。 */
|
|
||||||
@Component("V5_PAY_PLACE_AN_ORDER")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class V5PayWebPayPlaceAnOrderStrategy implements PayWebPlaceAnOrderStrategyV2 {
|
|
||||||
|
|
||||||
private final V5PayService v5PayService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public PlaceAnOrderResponseCO doOperation(PayPlaceAnOrderDetailsV2 details) {
|
|
||||||
MemberInfoGenerator.MemberInfo member = MemberInfoGenerator.generate(
|
|
||||||
Objects.toString(details.getAcceptUserId()));
|
|
||||||
V5PayPlaceAnOrderParam param = V5PayPlaceAnOrderParam.builder()
|
|
||||||
.orderNo(details.getOrderId())
|
|
||||||
.countryCode(details.getCountryCode())
|
|
||||||
.currency(details.getCurrency())
|
|
||||||
.amount(details.getAmount())
|
|
||||||
.productType(details.getFactoryChannel())
|
|
||||||
.returnUrl(details.getSuccessRedirectUrl())
|
|
||||||
.language(details.getLanguage())
|
|
||||||
.tradeSummary(details.getProductDescriptor())
|
|
||||||
.userId(details.getAcceptUserId())
|
|
||||||
.email(member.getEmail())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
V5PayGatewayResponse response = v5PayService.createOrder(param);
|
|
||||||
ResponseAssert.notNull(OrderErrorCode.CREATE_ORDER_ERROR, response);
|
|
||||||
return new PlaceAnOrderResponseCO()
|
|
||||||
.setTradeNo(StringUtils.isBlankOrElse(response.text("transactionId"),
|
|
||||||
response.text("orderNo")))
|
|
||||||
.setOrderId(details.getOrderId())
|
|
||||||
.setCurrency(details.getCurrency())
|
|
||||||
.setCountryCode(details.getCountryCode())
|
|
||||||
.setFactoryCode(details.getFactoryCode())
|
|
||||||
.setFactoryChannelCode(details.getFactoryChannel())
|
|
||||||
.setRequestUrl(response.text("checkoutUrl"))
|
|
||||||
// 订单快照只保存非敏感业务参数;商户号、appKey、secretKey 不进入订单扩展数据。
|
|
||||||
.putRequestParam(param)
|
|
||||||
.putResponseParam(response.sanitizedFields());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package com.red.circle.order.app.common;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保留 V5Pay 响应的全部顶层字段。
|
|
||||||
*
|
|
||||||
* <p>响应签名覆盖全部非空字段,不能只反序列化当前业务使用的几个属性,否则新增字段会导致误判验签失败。</p>
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
public class V5PayGatewayResponse {
|
|
||||||
|
|
||||||
private final Map<String, Object> fields = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
@JsonAnySetter
|
|
||||||
public void put(String key, Object value) {
|
|
||||||
fields.put(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String text(String key) {
|
|
||||||
return V5PaySignUtils.value(fields.get(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> sanitizedFields() {
|
|
||||||
Map<String, Object> sanitized = new LinkedHashMap<>(fields);
|
|
||||||
sanitized.remove("sign");
|
|
||||||
sanitized.remove("appKey");
|
|
||||||
sanitized.remove("merchantNo");
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package com.red.circle.order.app.common;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** 共享 V5Pay 商户时隔离 Aslan 与其他系统订单号。 */
|
|
||||||
public final class V5PayOrderNoUtils {
|
|
||||||
|
|
||||||
private static final String DEFAULT_PREFIX = "ASLAN";
|
|
||||||
private static final Pattern PREFIX_PATTERN = Pattern.compile("[A-Za-z]+");
|
|
||||||
private static final Pattern PROVIDER_ORDER_NO_PATTERN = Pattern.compile("[A-Za-z0-9]+");
|
|
||||||
private static final Pattern LOCAL_ORDER_NO_PATTERN = Pattern.compile("[1-9][0-9]{0,18}");
|
|
||||||
|
|
||||||
private V5PayOrderNoUtils() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String providerOrderNo(String prefix, String localOrderNo) {
|
|
||||||
String normalizedPrefix = normalizedPrefix(prefix);
|
|
||||||
String normalizedLocalOrderNo = Objects.toString(localOrderNo, "").trim();
|
|
||||||
if (!isValidLocalOrderNo(normalizedLocalOrderNo)) {
|
|
||||||
// Aslan 订单由 IdWorkerUtils 生成正数 long;提前拒绝非当前格式,避免把无效单号送到支付方。
|
|
||||||
throw new IllegalArgumentException("V5Pay local orderNo must be a positive numeric ID");
|
|
||||||
}
|
|
||||||
// V5Pay orderNo 只允许字母数字,不能使用常见的连字符分隔符。
|
|
||||||
return normalizedPrefix + normalizedLocalOrderNo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String localOrderNo(String prefix, String providerOrderNo) {
|
|
||||||
String expected = normalizedPrefix(prefix);
|
|
||||||
String value = Objects.toString(providerOrderNo, "").trim();
|
|
||||||
if (!PROVIDER_ORDER_NO_PATTERN.matcher(value).matches()
|
|
||||||
|| !value.startsWith(expected)
|
|
||||||
|| value.length() == expected.length()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
String localOrderNo = value.substring(expected.length());
|
|
||||||
// 纯字母命名空间后只能紧跟正数雪花 ID;禁止数字前缀才能避免 ASLAN+123 与 ASLAN1+23 产生碰撞。
|
|
||||||
return isValidLocalOrderNo(localOrderNo) ? localOrderNo : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizedPrefix(String prefix) {
|
|
||||||
String value = Objects.toString(prefix, DEFAULT_PREFIX).trim();
|
|
||||||
String rawPrefix = value.isBlank() ? DEFAULT_PREFIX : value;
|
|
||||||
if (!PREFIX_PATTERN.matcher(rawPrefix).matches()) {
|
|
||||||
// 必须在大小写转换前校验原文;否则 ı/ß 等 Unicode 可能折叠成 ASCII 并与合法命名空间碰撞。
|
|
||||||
throw new IllegalArgumentException("V5Pay order prefix must contain only ASCII letters");
|
|
||||||
}
|
|
||||||
return rawPrefix.toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isValidLocalOrderNo(String localOrderNo) {
|
|
||||||
if (!LOCAL_ORDER_NO_PATTERN.matcher(localOrderNo).matches()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Long.parseLong(localOrderNo) > 0;
|
|
||||||
} catch (NumberFormatException exception) {
|
|
||||||
// 最多 19 位仍可能超过 Long.MAX_VALUE;当前 IdWorker 订单不可能出现该值。
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package com.red.circle.order.app.common;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/** V5Pay 收银台下单所需的非敏感业务参数。 */
|
|
||||||
@Data
|
|
||||||
@Builder
|
|
||||||
public class V5PayPlaceAnOrderParam {
|
|
||||||
|
|
||||||
private String orderNo;
|
|
||||||
private String countryCode;
|
|
||||||
private String currency;
|
|
||||||
private BigDecimal amount;
|
|
||||||
private String productType;
|
|
||||||
private String returnUrl;
|
|
||||||
private String language;
|
|
||||||
private String tradeSummary;
|
|
||||||
private Long userId;
|
|
||||||
private String email;
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package com.red.circle.order.app.common;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V5Pay MD5 签名工具。
|
|
||||||
*
|
|
||||||
* <p>协议要求过滤空值和 sign 字段,按 key 排序后拼成 key=value&...,最后直接追加 secretKey。</p>
|
|
||||||
*/
|
|
||||||
public final class V5PaySignUtils {
|
|
||||||
|
|
||||||
private V5PaySignUtils() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String sign(Map<String, ?> fields, String secretKey) {
|
|
||||||
List<String> keys = new ArrayList<>();
|
|
||||||
for (Map.Entry<String, ?> entry : fields.entrySet()) {
|
|
||||||
String value = value(entry.getValue());
|
|
||||||
if (!"sign".equals(entry.getKey()) && !value.isBlank()) {
|
|
||||||
keys.add(entry.getKey());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys.sort(Comparator.naturalOrder());
|
|
||||||
|
|
||||||
StringBuilder source = new StringBuilder();
|
|
||||||
for (String key : keys) {
|
|
||||||
if (!source.isEmpty()) {
|
|
||||||
source.append('&');
|
|
||||||
}
|
|
||||||
source.append(key).append('=').append(value(fields.get(key)));
|
|
||||||
}
|
|
||||||
// V5Pay 不是 key=secret 的形式,排序字段串后必须直接追加密钥,否则网关会报签名错误。
|
|
||||||
source.append(Objects.toString(secretKey, ""));
|
|
||||||
try {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
|
||||||
return HexFormat.of().formatHex(digest.digest(source.toString()
|
|
||||||
.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (NoSuchAlgorithmException exception) {
|
|
||||||
throw new IllegalStateException("MD5 algorithm is unavailable", exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean verify(Map<String, ?> fields, String secretKey) {
|
|
||||||
String actual = value(fields.get("sign")).trim();
|
|
||||||
return !actual.isBlank() && MessageDigest.isEqual(
|
|
||||||
actual.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII),
|
|
||||||
sign(fields, secretKey).getBytes(StandardCharsets.US_ASCII)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String value(Object value) {
|
|
||||||
return Objects.toString(value, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package com.red.circle.order.app.common;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/** V5Pay 查询或回调中用于本地订单核验的统一字段。 */
|
|
||||||
@Data
|
|
||||||
@Accessors(chain = true)
|
|
||||||
public class V5PayTransactionData {
|
|
||||||
|
|
||||||
private String orderNo;
|
|
||||||
private String providerOrderNo;
|
|
||||||
private String transactionId;
|
|
||||||
private String status;
|
|
||||||
private String amount;
|
|
||||||
private String currency;
|
|
||||||
private String productType;
|
|
||||||
private Map<String, String> fields;
|
|
||||||
}
|
|
||||||
@ -29,7 +29,6 @@ import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeReceivePayme
|
|||||||
import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeRefundNoticeCmdExe;
|
import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeRefundNoticeCmdExe;
|
||||||
import com.red.circle.order.app.command.pay.web.PaynicornServerNoticeCmdExe;
|
import com.red.circle.order.app.command.pay.web.PaynicornServerNoticeCmdExe;
|
||||||
import com.red.circle.order.app.command.pay.web.MiFaPayServerNoticeReceivePaymentCmdExe;
|
import com.red.circle.order.app.command.pay.web.MiFaPayServerNoticeReceivePaymentCmdExe;
|
||||||
import com.red.circle.order.app.command.pay.web.V5PayServerNoticeReceivePaymentCmdExe;
|
|
||||||
import com.red.circle.order.app.command.pay.web.ReceiptPayWebPlaceAnOrderReceiptCmdExe;
|
import com.red.circle.order.app.command.pay.web.ReceiptPayWebPlaceAnOrderReceiptCmdExe;
|
||||||
import com.red.circle.order.app.dto.clientobject.PayMaxResponseCO;
|
import com.red.circle.order.app.dto.clientobject.PayMaxResponseCO;
|
||||||
import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
|
import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
|
||||||
@ -72,7 +71,6 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
|
|||||||
private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe;
|
private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe;
|
||||||
private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe;
|
private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe;
|
||||||
private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe;
|
private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe;
|
||||||
private final V5PayServerNoticeReceivePaymentCmdExe v5PayServerNoticeReceivePaymentCmdExe;
|
|
||||||
private final TaskMqMessage taskMqMessage;
|
private final TaskMqMessage taskMqMessage;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -186,9 +184,4 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
|
|||||||
return miFaPayServerNoticeReceivePaymentCmdExe.execute(body);
|
return miFaPayServerNoticeReceivePaymentCmdExe.execute(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public String v5PayServerNoticeReceivePayment(Map<String, String> fields) {
|
|
||||||
return v5PayServerNoticeReceivePaymentCmdExe.execute(fields);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
package com.red.circle.order.app.service;
|
|
||||||
|
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
|
||||||
import com.red.circle.framework.core.response.ResponseErrorCode;
|
|
||||||
import com.red.circle.order.app.common.V5PayGatewayResponse;
|
|
||||||
import com.red.circle.order.app.common.V5PayOrderNoUtils;
|
|
||||||
import com.red.circle.order.app.common.V5PayPlaceAnOrderParam;
|
|
||||||
import com.red.circle.order.app.common.V5PaySignUtils;
|
|
||||||
import com.red.circle.order.app.common.V5PayTransactionData;
|
|
||||||
import com.red.circle.order.infra.config.V5PayProperties;
|
|
||||||
import com.red.circle.tool.core.http.RcHttpClient;
|
|
||||||
import com.red.circle.tool.core.text.StringUtils;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/** V5Pay 下单、查单和通知验签客户端。 */
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class V5PayService {
|
|
||||||
|
|
||||||
private static final String CREATE_ORDER_PATH = "/cgi/cashier/v2/payin";
|
|
||||||
private static final String QUERY_ORDER_PATH = "/cgi/payment/v1/payin/query";
|
|
||||||
private static final String SUCCESS_CODE = "1000";
|
|
||||||
|
|
||||||
private final V5PayProperties properties;
|
|
||||||
private final RcHttpClient httpClient = RcHttpClient.builder().build();
|
|
||||||
|
|
||||||
public V5PayGatewayResponse createOrder(V5PayPlaceAnOrderParam param) {
|
|
||||||
requireCreateConfigured();
|
|
||||||
Map<String, Object> request = new LinkedHashMap<>();
|
|
||||||
request.put("merchantNo", properties.getMerchantNo());
|
|
||||||
request.put("appKey", properties.getAppKey());
|
|
||||||
request.put("sysCountryCode", upper(param.getCountryCode()));
|
|
||||||
request.put("currency", upper(param.getCurrency()));
|
|
||||||
request.put("orderNo", V5PayOrderNoUtils.providerOrderNo(
|
|
||||||
properties.getOrderPrefix(), param.getOrderNo()));
|
|
||||||
request.put("amount", param.getAmount().setScale(2, RoundingMode.UNNECESSARY).toPlainString());
|
|
||||||
request.put("productType", param.getProductType());
|
|
||||||
request.put("callbackUrl", properties.getNotifyUrl());
|
|
||||||
request.put("redirectUrl", param.getReturnUrl());
|
|
||||||
request.put("merchantParam", param.getOrderNo());
|
|
||||||
request.put("language", normalizeLanguage(param.getLanguage()));
|
|
||||||
request.put("tradeSummary", param.getTradeSummary());
|
|
||||||
request.put("email", StringUtils.isBlankOrElse(param.getEmail(),
|
|
||||||
"v5pay-payer-" + param.getUserId() + "@haiyihy.com"));
|
|
||||||
request.put("merchantCustomerId", Objects.toString(param.getUserId(), ""));
|
|
||||||
request.put("sign", V5PaySignUtils.sign(request, properties.getSecretKey()));
|
|
||||||
|
|
||||||
V5PayGatewayResponse response = post(CREATE_ORDER_PATH, request);
|
|
||||||
validateResponse(response, "create", param.getOrderNo());
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR,
|
|
||||||
StringUtils.isNotBlank(response.text("checkoutUrl")));
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
public V5PayTransactionData queryOrder(String orderNo, String countryCode, String currency) {
|
|
||||||
requireGatewayConfigured();
|
|
||||||
Map<String, Object> request = new LinkedHashMap<>();
|
|
||||||
request.put("merchantNo", properties.getMerchantNo());
|
|
||||||
request.put("appKey", properties.getAppKey());
|
|
||||||
request.put("sysCountryCode", upper(countryCode));
|
|
||||||
request.put("currency", upper(currency));
|
|
||||||
request.put("orderNo", V5PayOrderNoUtils.providerOrderNo(properties.getOrderPrefix(), orderNo));
|
|
||||||
request.put("sign", V5PaySignUtils.sign(request, properties.getSecretKey()));
|
|
||||||
|
|
||||||
V5PayGatewayResponse response = post(QUERY_ORDER_PATH, request);
|
|
||||||
validateResponse(response, "query", orderNo);
|
|
||||||
return transactionData(toStringFields(response.getFields()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 回调验签必须先于订单查询和入账;调用方只能拿到通过签名验证后的业务字段。
|
|
||||||
*/
|
|
||||||
public V5PayTransactionData parseNotification(Map<String, String> fields) {
|
|
||||||
requireSigningConfigured();
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
|
||||||
Objects.nonNull(fields) && !fields.isEmpty()
|
|
||||||
&& V5PaySignUtils.verify(fields, properties.getSecretKey()));
|
|
||||||
V5PayTransactionData data = transactionData(fields);
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
|
||||||
StringUtils.isNotBlank(data.getOrderNo()));
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isConfigured() {
|
|
||||||
return isGatewayConfigured()
|
|
||||||
&& StringUtils.isNotBlank(properties.getNotifyUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isGatewayConfigured() {
|
|
||||||
return isSigningConfigured()
|
|
||||||
&& StringUtils.isNotBlank(properties.getMerchantNo())
|
|
||||||
&& StringUtils.isNotBlank(properties.getAppKey())
|
|
||||||
&& StringUtils.isNotBlank(properties.getGatewayBaseUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isSigningConfigured() {
|
|
||||||
return properties.isEnabled()
|
|
||||||
&& StringUtils.isNotBlank(properties.getSecretKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
private V5PayGatewayResponse post(String path, Map<String, Object> request) {
|
|
||||||
String baseUrl = properties.getGatewayBaseUrl().replaceAll("/+$", "");
|
|
||||||
return httpClient.post()
|
|
||||||
.uri(baseUrl + path)
|
|
||||||
.contentType("application/json")
|
|
||||||
.bodyValue(request)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToEntity(V5PayGatewayResponse.class)
|
|
||||||
.block();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateResponse(V5PayGatewayResponse response, String operation, String orderNo) {
|
|
||||||
ResponseAssert.notNull(ResponseErrorCode.INTERNAL_SERVER_ERROR, response);
|
|
||||||
String code = response.text("code");
|
|
||||||
if (StringUtils.isNotBlank(code) && !SUCCESS_CODE.equals(code)) {
|
|
||||||
// 只记录订单号、操作和错误码,不输出响应签名或任何商户密钥。
|
|
||||||
log.error("V5Pay {} rejected: orderNo={}, code={}", operation, orderNo, code);
|
|
||||||
ResponseAssert.failure(ResponseErrorCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"V5Pay request rejected: " + code);
|
|
||||||
}
|
|
||||||
if (!V5PaySignUtils.verify(response.getFields(), properties.getSecretKey())) {
|
|
||||||
log.error("V5Pay {} response signature invalid: orderNo={}", operation, orderNo);
|
|
||||||
ResponseAssert.failure(ResponseErrorCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"V5Pay response signature invalid");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireCreateConfigured() {
|
|
||||||
// enabled 默认开启;缺少真实环境配置时明确拒绝调用,避免使用伪造默认凭证发起请求。
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isConfigured());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireGatewayConfigured() {
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isGatewayConfigured());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireSigningConfigured() {
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isSigningConfigured());
|
|
||||||
}
|
|
||||||
|
|
||||||
private V5PayTransactionData transactionData(Map<String, String> fields) {
|
|
||||||
String providerOrderNo = first(fields, "orderNo", "order_no");
|
|
||||||
String localOrderNo = V5PayOrderNoUtils.localOrderNo(
|
|
||||||
properties.getOrderPrefix(), providerOrderNo);
|
|
||||||
// 共享商户收到其他系统前缀的回调时必须拒绝,不能用 provider orderNo 直接查询 Aslan 本地订单。
|
|
||||||
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
|
|
||||||
StringUtils.isNotBlank(localOrderNo));
|
|
||||||
return new V5PayTransactionData()
|
|
||||||
.setOrderNo(localOrderNo)
|
|
||||||
.setProviderOrderNo(providerOrderNo)
|
|
||||||
.setTransactionId(first(fields, "transactionId", "transaction_id"))
|
|
||||||
.setStatus(first(fields, "status", "orderStatus", "order_status"))
|
|
||||||
.setAmount(first(fields, "amount"))
|
|
||||||
.setCurrency(first(fields, "currency"))
|
|
||||||
.setProductType(first(fields, "productType", "product_type"))
|
|
||||||
.setFields(fields);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> toStringFields(Map<String, Object> fields) {
|
|
||||||
Map<String, String> values = new LinkedHashMap<>();
|
|
||||||
fields.forEach((key, value) -> values.put(key, V5PaySignUtils.value(value)));
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String first(Map<String, String> fields, String... keys) {
|
|
||||||
for (String key : keys) {
|
|
||||||
if (StringUtils.isNotBlank(fields.get(key))) {
|
|
||||||
return fields.get(key).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String upper(String value) {
|
|
||||||
return Objects.toString(value, "").trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeLanguage(String value) {
|
|
||||||
return switch (Objects.toString(value, "").trim().toLowerCase(Locale.ROOT)) {
|
|
||||||
case "zh", "zh-cn" -> "zh-CN";
|
|
||||||
case "zh-tw", "zh-hk", "zh-tc" -> "zh-TC";
|
|
||||||
case "es", "es-es" -> "es-ES";
|
|
||||||
case "pt", "pt-pt" -> "pt-PT";
|
|
||||||
case "ru", "ru-ru" -> "ru-RU";
|
|
||||||
case "ja", "jp", "ja-jp", "jp-jp" -> "jp-JP";
|
|
||||||
default -> "en-US";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
package com.red.circle.order.app.command.pay.web;
|
|
||||||
|
|
||||||
import com.red.circle.order.app.common.V5PayTransactionData;
|
|
||||||
import com.red.circle.order.app.common.InAppPurchaseCommon;
|
|
||||||
import com.red.circle.order.app.service.V5PayService;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseCollectionReceiptService;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseDetailsService;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails;
|
|
||||||
import com.red.circle.order.infra.database.mongo.order.entity.assist.InAppPurchaseFactory;
|
|
||||||
import com.red.circle.order.infra.database.rds.service.order.OrderCacheService;
|
|
||||||
import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseReceiptType;
|
|
||||||
import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.Future;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import org.junit.Assert;
|
|
||||||
import org.junit.Test;
|
|
||||||
import org.mockito.Mockito;
|
|
||||||
|
|
||||||
public class V5PayOrderStateServiceTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void successfulProviderDataMustMatchEveryLocalSnapshotField() {
|
|
||||||
InAppPurchaseDetails order = order();
|
|
||||||
V5PayTransactionData data = data();
|
|
||||||
Assert.assertTrue(V5PayOrderStateService.matchesOrderSnapshot(order, data));
|
|
||||||
|
|
||||||
data.setOrderNo("other");
|
|
||||||
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
|
|
||||||
data = data().setAmount("14400.01");
|
|
||||||
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
|
|
||||||
data = data().setCurrency("USD");
|
|
||||||
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
|
|
||||||
data = data().setProductType("other-product");
|
|
||||||
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void amountConversionRejectsPrecisionLoss() {
|
|
||||||
Assert.assertEquals(1_440_000L, V5PayOrderStateService.toMinor("14400.00"));
|
|
||||||
Assert.assertEquals(100L, V5PayOrderStateService.toMinor("1"));
|
|
||||||
try {
|
|
||||||
V5PayOrderStateService.toMinor("1.001");
|
|
||||||
Assert.fail("more than two decimals must be rejected");
|
|
||||||
} catch (ArithmeticException expected) {
|
|
||||||
// 精度超出协议时抛错,调用方会拒绝入账。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void missingProviderSnapshotFieldNeverCredits() {
|
|
||||||
V5PayTransactionData data = data().setProductType("");
|
|
||||||
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order(), data));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void concurrentCallbackDoesNotAckBeforeClaimOwnerFinishes() throws Exception {
|
|
||||||
AtomicBoolean claimed = new AtomicBoolean();
|
|
||||||
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
|
|
||||||
CountDownLatch creditStarted = new CountDownLatch(1);
|
|
||||||
CountDownLatch allowCreditFinish = new CountDownLatch(1);
|
|
||||||
TestContext context = context(claimed, status);
|
|
||||||
Mockito.doAnswer(invocation -> {
|
|
||||||
creditStarted.countDown();
|
|
||||||
Assert.assertTrue(allowCreditFinish.await(5, TimeUnit.SECONDS));
|
|
||||||
status.set(InAppPurchaseStatus.SUCCESS);
|
|
||||||
return null;
|
|
||||||
}).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any());
|
|
||||||
|
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
|
||||||
try {
|
|
||||||
Future<Boolean> first = executor.submit(() -> context.service.apply(order(), data(), "callback"));
|
|
||||||
Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS));
|
|
||||||
Future<Boolean> duplicate = executor.submit(
|
|
||||||
() -> context.service.apply(order(), data(), "callback"));
|
|
||||||
Assert.assertFalse(duplicate.get(5, TimeUnit.SECONDS));
|
|
||||||
allowCreditFinish.countDown();
|
|
||||||
Assert.assertTrue(first.get(5, TimeUnit.SECONDS));
|
|
||||||
} finally {
|
|
||||||
executor.shutdownNow();
|
|
||||||
}
|
|
||||||
Mockito.verify(context.purchaseCommon, Mockito.times(1))
|
|
||||||
.inAppPurchasePayment(Mockito.any());
|
|
||||||
Assert.assertEquals(InAppPurchaseStatus.SUCCESS, status.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void concurrentCallbackStaysUnacknowledgedWhenClaimOwnerLaterFails() throws Exception {
|
|
||||||
AtomicBoolean claimed = new AtomicBoolean();
|
|
||||||
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
|
|
||||||
CountDownLatch creditStarted = new CountDownLatch(1);
|
|
||||||
CountDownLatch allowFailure = new CountDownLatch(1);
|
|
||||||
TestContext context = context(claimed, status);
|
|
||||||
Mockito.doAnswer(invocation -> {
|
|
||||||
creditStarted.countDown();
|
|
||||||
Assert.assertTrue(allowFailure.await(5, TimeUnit.SECONDS));
|
|
||||||
throw new IllegalStateException("owner failed");
|
|
||||||
}).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any());
|
|
||||||
|
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
|
||||||
try {
|
|
||||||
Future<Boolean> owner = executor.submit(
|
|
||||||
() -> context.service.apply(order(), data(), "callback"));
|
|
||||||
Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS));
|
|
||||||
Future<Boolean> duplicate = executor.submit(
|
|
||||||
() -> context.service.apply(order(), data(), "callback"));
|
|
||||||
Assert.assertFalse(duplicate.get(5, TimeUnit.SECONDS));
|
|
||||||
allowFailure.countDown();
|
|
||||||
Assert.assertFalse(owner.get(5, TimeUnit.SECONDS));
|
|
||||||
} finally {
|
|
||||||
executor.shutdownNow();
|
|
||||||
}
|
|
||||||
Assert.assertEquals(InAppPurchaseStatus.CREATE, status.get());
|
|
||||||
Assert.assertFalse(claimed.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void failedCreditReleasesClaimAndNextNotificationCanRetry() {
|
|
||||||
AtomicBoolean claimed = new AtomicBoolean();
|
|
||||||
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
|
|
||||||
AtomicInteger attempts = new AtomicInteger();
|
|
||||||
TestContext context = context(claimed, status);
|
|
||||||
Mockito.doAnswer(invocation -> {
|
|
||||||
if (attempts.incrementAndGet() == 1) {
|
|
||||||
throw new IllegalStateException("temporary downstream failure");
|
|
||||||
}
|
|
||||||
status.set(InAppPurchaseStatus.SUCCESS);
|
|
||||||
return null;
|
|
||||||
}).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any());
|
|
||||||
|
|
||||||
Assert.assertFalse(context.service.apply(order(), data(), "callback"));
|
|
||||||
Assert.assertFalse(claimed.get());
|
|
||||||
Assert.assertTrue(context.service.apply(order(), data(), "callback"));
|
|
||||||
Assert.assertEquals(2, attempts.get());
|
|
||||||
Assert.assertEquals(InAppPurchaseStatus.SUCCESS, status.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void unknownProviderStatusIsNotAcknowledgedByCallback() {
|
|
||||||
AtomicBoolean claimed = new AtomicBoolean();
|
|
||||||
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
|
|
||||||
TestContext context = context(claimed, status);
|
|
||||||
V5PayTransactionData unknown = data().setStatus("7");
|
|
||||||
|
|
||||||
Assert.assertFalse(context.service.apply(order(), unknown, "callback"));
|
|
||||||
Assert.assertTrue(context.service.apply(order(), unknown, "query"));
|
|
||||||
Mockito.verifyNoInteractions(context.purchaseCommon);
|
|
||||||
}
|
|
||||||
|
|
||||||
private InAppPurchaseDetails order() {
|
|
||||||
return new InAppPurchaseDetails()
|
|
||||||
.setId("123")
|
|
||||||
.setCurrency("TRY")
|
|
||||||
.setAmount(new BigDecimal("14400"))
|
|
||||||
.setReceiptType(InAppPurchaseReceiptType.PAYMENT)
|
|
||||||
.setStatus(InAppPurchaseStatus.CREATE)
|
|
||||||
.setFactory(new InAppPurchaseFactory()
|
|
||||||
.setFactoryCode("V5_PAY")
|
|
||||||
.setFactoryChannelCode("1263"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private V5PayTransactionData data() {
|
|
||||||
return new V5PayTransactionData()
|
|
||||||
.setOrderNo("123")
|
|
||||||
.setAmount("14400.00")
|
|
||||||
.setCurrency("try")
|
|
||||||
.setProductType("1263")
|
|
||||||
.setStatus("2")
|
|
||||||
.setFields(Map.of(
|
|
||||||
"orderNo", "ASLAN123",
|
|
||||||
"amount", "14400.00",
|
|
||||||
"currency", "TRY",
|
|
||||||
"productType", "1263",
|
|
||||||
"status", "2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private TestContext context(AtomicBoolean claimed,
|
|
||||||
AtomicReference<InAppPurchaseStatus> status) {
|
|
||||||
V5PayService v5PayService = Mockito.mock(V5PayService.class);
|
|
||||||
InAppPurchaseCommon purchaseCommon = Mockito.mock(InAppPurchaseCommon.class);
|
|
||||||
InAppPurchaseDetailsService detailsService = Mockito.mock(InAppPurchaseDetailsService.class);
|
|
||||||
InAppPurchaseCollectionReceiptService receiptService = Mockito.mock(
|
|
||||||
InAppPurchaseCollectionReceiptService.class);
|
|
||||||
OrderCacheService cacheService = Mockito.mock(OrderCacheService.class);
|
|
||||||
Mockito.when(detailsService.getById("123")).thenAnswer(invocation -> {
|
|
||||||
InAppPurchaseDetails latest = order();
|
|
||||||
latest.setStatus(status.get());
|
|
||||||
return latest;
|
|
||||||
});
|
|
||||||
Mockito.when(detailsService.existsPayNoticeByEventId(Mockito.anyString(), Mockito.anyString()))
|
|
||||||
.thenReturn(true);
|
|
||||||
Mockito.when(cacheService.lock(Mockito.anyString()))
|
|
||||||
.thenAnswer(invocation -> claimed.compareAndSet(false, true));
|
|
||||||
Mockito.doAnswer(invocation -> {
|
|
||||||
claimed.set(false);
|
|
||||||
return null;
|
|
||||||
}).when(cacheService).unLock(Mockito.anyString());
|
|
||||||
return new TestContext(
|
|
||||||
new V5PayOrderStateService(v5PayService, purchaseCommon, detailsService,
|
|
||||||
receiptService, cacheService),
|
|
||||||
purchaseCommon
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class TestContext {
|
|
||||||
|
|
||||||
private final V5PayOrderStateService service;
|
|
||||||
private final InAppPurchaseCommon purchaseCommon;
|
|
||||||
|
|
||||||
private TestContext(V5PayOrderStateService service,
|
|
||||||
InAppPurchaseCommon purchaseCommon) {
|
|
||||||
this.service = service;
|
|
||||||
this.purchaseCommon = purchaseCommon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
package com.red.circle.order.app.command.pay.web.strategy;
|
|
||||||
|
|
||||||
import com.red.circle.order.infra.database.rds.entity.pay.PayCountryChannelDetails;
|
|
||||||
import org.junit.Assert;
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
public class PayPlaceAnOrderServiceTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void onlyV5PayPersistsFactoryProductTypeAsSnapshot() {
|
|
||||||
PayPlaceAnOrderDetailsV2 details = details("V5_PAY");
|
|
||||||
Assert.assertEquals("1263", PayPlaceAnOrderService.factoryChannelCode(details));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void existingFactoriesKeepHistoricalChannelCodeSnapshot() {
|
|
||||||
PayPlaceAnOrderDetailsV2 details = details("MIFA_PAY");
|
|
||||||
Assert.assertEquals("card_visa", PayPlaceAnOrderService.factoryChannelCode(details));
|
|
||||||
}
|
|
||||||
|
|
||||||
private PayPlaceAnOrderDetailsV2 details(String factoryCode) {
|
|
||||||
PayPlaceAnOrderDetailsV2 details = new PayPlaceAnOrderDetailsV2();
|
|
||||||
details.setChannelDetails(new PayCountryChannelDetails()
|
|
||||||
.setFactoryCode(factoryCode)
|
|
||||||
.setChannelCode("card_visa")
|
|
||||||
.setFactoryChannel("1263"));
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
package com.red.circle.order.app.common;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import org.junit.Assert;
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
public class V5PaySignUtilsTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void signSortsFieldsSkipsEmptyAndAppendsSecretDirectly() {
|
|
||||||
Map<String, Object> fields = new LinkedHashMap<>();
|
|
||||||
fields.put("orderNo", "ASLAN123");
|
|
||||||
fields.put("sign", "must-be-ignored");
|
|
||||||
fields.put("merchantNo", "M");
|
|
||||||
fields.put("empty", "");
|
|
||||||
fields.put("appKey", "A");
|
|
||||||
fields.put("amount", "10.00");
|
|
||||||
|
|
||||||
Assert.assertEquals("dd8e6f2b76cf5107c53aff3ba1d6da5d",
|
|
||||||
V5PaySignUtils.sign(fields, "secret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void verifyAcceptsUppercaseMd5ButRejectsChangedBusinessField() {
|
|
||||||
Map<String, Object> fields = new LinkedHashMap<>();
|
|
||||||
fields.put("orderNo", "ASLAN123");
|
|
||||||
fields.put("amount", "10.00");
|
|
||||||
fields.put("sign", V5PaySignUtils.sign(fields, "secret").toUpperCase());
|
|
||||||
Assert.assertTrue(V5PaySignUtils.verify(fields, "secret"));
|
|
||||||
|
|
||||||
fields.put("amount", "10.01");
|
|
||||||
Assert.assertFalse(V5PaySignUtils.verify(fields, "secret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void orderPrefixNormalizesToUppercaseAsciiLetterNamespace() {
|
|
||||||
Assert.assertEquals("ASLAN123", V5PayOrderNoUtils.providerOrderNo("aslan", "123"));
|
|
||||||
Assert.assertEquals("ASLAN123", V5PayOrderNoUtils.providerOrderNo("", "123"));
|
|
||||||
Assert.assertEquals("123", V5PayOrderNoUtils.localOrderNo("aslan", "ASLAN123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void numericPrefixIsRejectedToPreventNamespaceCollision() {
|
|
||||||
Assert.assertEquals("ASLAN123", V5PayOrderNoUtils.providerOrderNo("ASLAN", "123"));
|
|
||||||
// ASLAN1 + 23 会与 ASLAN + 123 形成相同字符串,因此共享商户命名空间不能允许数字前缀。
|
|
||||||
assertInvalidOrderNo("ASLAN1", "23");
|
|
||||||
assertInvalidOrderNo("1ASLAN", "23");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void unicodeCaseFoldingCannotAliasAsciiNamespace() {
|
|
||||||
Assert.assertEquals("ASLANI23", V5PayOrderNoUtils.providerOrderNo("aslani", "23"));
|
|
||||||
Assert.assertEquals("ASSLAN23", V5PayOrderNoUtils.providerOrderNo("asslan", "23"));
|
|
||||||
// Java 大写会将 dotless-i 与 sharp-s 折叠成 ASCII;原文校验必须在转换之前完成。
|
|
||||||
assertInvalidOrderNo("aslanı", "23");
|
|
||||||
assertInvalidOrderNo("aßlan", "23");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void orderParserRejectsOtherNamespaceEmptyAndIllegalCharacters() {
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "LALU123"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", ""));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", null));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN-123"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN_123"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "aslan123"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN123A"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void orderValidationRejectsValuesOutsideCurrentSnowflakeIdFormat() {
|
|
||||||
Assert.assertEquals("9223372036854775807", V5PayOrderNoUtils.localOrderNo(
|
|
||||||
"ASLAN", "ASLAN9223372036854775807"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN0"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN0123"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo(
|
|
||||||
"ASLAN", "ASLAN9223372036854775808"));
|
|
||||||
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo(
|
|
||||||
"ASLAN", "ASLAN10000000000000000000"));
|
|
||||||
|
|
||||||
assertInvalidOrderNo("ASLAN-", "123");
|
|
||||||
assertInvalidOrderNo("AS LAN", "123");
|
|
||||||
assertInvalidOrderNo("A5SLAN", "123");
|
|
||||||
assertInvalidOrderNo("阿斯兰", "123");
|
|
||||||
assertInvalidOrderNo("ASLAN", "");
|
|
||||||
assertInvalidOrderNo("ASLAN", "1-23");
|
|
||||||
assertInvalidOrderNo("ASLAN", "9223372036854775808");
|
|
||||||
assertInvalidOrderNo("ASLAN", "10000000000000000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void assertInvalidOrderNo(String prefix, String localOrderNo) {
|
|
||||||
try {
|
|
||||||
V5PayOrderNoUtils.providerOrderNo(prefix, localOrderNo);
|
|
||||||
Assert.fail("expected invalid V5Pay order number");
|
|
||||||
} catch (IllegalArgumentException expected) {
|
|
||||||
// Invalid local/config values must fail before a signed request reaches V5Pay.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -51,7 +51,4 @@ public interface InAppPurchaseProductService {
|
|||||||
*/
|
*/
|
||||||
String miFaPayServerNoticeReceivePayment(byte[] body);
|
String miFaPayServerNoticeReceivePayment(byte[] body);
|
||||||
|
|
||||||
/** V5Pay 支付回调通知,返回小写纯文本 success/fail。 */
|
|
||||||
String v5PayServerNoticeReceivePayment(Map<String, String> fields);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
package com.red.circle.order.infra.config;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V5Pay 商户和网关配置。
|
|
||||||
*
|
|
||||||
* <p>功能默认开启,但商户号、应用 Key、签名密钥和地址必须由环境或 Nacos 注入;代码仓库不提供可用凭证。</p>
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Component
|
|
||||||
@RefreshScope
|
|
||||||
@ConfigurationProperties(prefix = "red-circle.pay.v5-pay")
|
|
||||||
public class V5PayProperties {
|
|
||||||
|
|
||||||
/** 是否允许创建和查询 V5Pay 订单。 */
|
|
||||||
private boolean enabled = true;
|
|
||||||
|
|
||||||
/** V5Pay 分配的商户号。 */
|
|
||||||
private String merchantNo;
|
|
||||||
|
|
||||||
/** V5Pay 分配的应用 Key。 */
|
|
||||||
private String appKey;
|
|
||||||
|
|
||||||
/** MD5 签名密钥,只能通过部署环境注入。 */
|
|
||||||
private String secretKey;
|
|
||||||
|
|
||||||
/** V5Pay API 根地址。 */
|
|
||||||
private String gatewayBaseUrl;
|
|
||||||
|
|
||||||
/** V5Pay 可从公网访问的异步回调地址。 */
|
|
||||||
private String notifyUrl;
|
|
||||||
|
|
||||||
/** 共享商户下的订单隔离前缀;默认 ASLAN,可由部署环境覆盖。 */
|
|
||||||
private String orderPrefix = "ASLAN";
|
|
||||||
}
|
|
||||||
@ -30,15 +30,4 @@ management:
|
|||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: always
|
||||||
|
|
||||||
# V5Pay 功能默认打开;商户凭证和地址全部由环境/Nacos 注入,仓库内不放置任何可用密钥。
|
|
||||||
red-circle:
|
|
||||||
pay:
|
|
||||||
v5-pay:
|
|
||||||
enabled: ${V5PAY_ENABLED:true}
|
|
||||||
merchant-no: ${V5PAY_MERCHANT_NO:}
|
|
||||||
app-key: ${V5PAY_APP_KEY:}
|
|
||||||
secret-key: ${V5PAY_SECRET_KEY:}
|
|
||||||
gateway-base-url: ${V5PAY_GATEWAY_BASE_URL:}
|
|
||||||
notify-url: ${V5PAY_NOTIFY_URL:}
|
|
||||||
order-prefix: ${V5PAY_ORDER_PREFIX:ASLAN}
|
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
package com.red.circle.other.app.inner.endpoint.team.bd;
|
|
||||||
|
|
||||||
import com.red.circle.framework.dto.PageResult;
|
|
||||||
import com.red.circle.framework.dto.ResultResponse;
|
|
||||||
import com.red.circle.other.app.inner.service.team.SalarySettlementRecordClientService;
|
|
||||||
import com.red.circle.other.inner.endpoint.team.bd.api.SalarySettlementRecordClientApi;
|
|
||||||
import com.red.circle.other.inner.model.cmd.team.bd.SalarySettlementRecordPageQryCmd;
|
|
||||||
import com.red.circle.other.inner.model.dto.agency.bd.SalarySettlementRecordDTO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping(value = SalarySettlementRecordClientApi.API_PREFIX,
|
|
||||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SalarySettlementRecordClientEndpoint implements SalarySettlementRecordClientApi {
|
|
||||||
|
|
||||||
private final SalarySettlementRecordClientService salarySettlementRecordClientService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResultResponse<PageResult<SalarySettlementRecordDTO>> page(
|
|
||||||
SalarySettlementRecordPageQryCmd qryCmd) {
|
|
||||||
return ResultResponse.success(salarySettlementRecordClientService.page(qryCmd));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
package com.red.circle.other.app.inner.service.team;
|
|
||||||
|
|
||||||
import com.red.circle.framework.dto.PageResult;
|
|
||||||
import com.red.circle.other.inner.model.cmd.team.bd.SalarySettlementRecordPageQryCmd;
|
|
||||||
import com.red.circle.other.inner.model.dto.agency.bd.SalarySettlementRecordDTO;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
public interface SalarySettlementRecordClientService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页查询工资结算记录.
|
|
||||||
*/
|
|
||||||
PageResult<SalarySettlementRecordDTO> page(SalarySettlementRecordPageQryCmd qryCmd);
|
|
||||||
}
|
|
||||||
@ -1,310 +0,0 @@
|
|||||||
package com.red.circle.other.app.inner.service.team.impl;
|
|
||||||
|
|
||||||
import com.red.circle.framework.dto.PageQuery;
|
|
||||||
import com.red.circle.framework.dto.PageResult;
|
|
||||||
import com.red.circle.other.app.inner.service.team.SalarySettlementRecordClientService;
|
|
||||||
import com.red.circle.other.infra.database.mongo.entity.bd.AdminSalarySettlementRecord;
|
|
||||||
import com.red.circle.other.infra.database.mongo.entity.bd.BdLeaderSalarySettlementRecord;
|
|
||||||
import com.red.circle.other.infra.database.mongo.entity.bd.BdSalarySettlementRecord;
|
|
||||||
import com.red.circle.other.infra.enums.BdSettlementStatus;
|
|
||||||
import com.red.circle.other.inner.model.cmd.team.bd.SalarySettlementRecordPageQryCmd;
|
|
||||||
import com.red.circle.other.inner.model.dto.agency.bd.SalarySettlementRecordDTO;
|
|
||||||
import com.red.circle.tool.core.text.StringUtils;
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.function.Function;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
|
||||||
import org.springframework.data.mongodb.core.query.Criteria;
|
|
||||||
import org.springframework.data.mongodb.core.query.Query;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BD/BD Leader/Admin 工资结算记录.
|
|
||||||
*
|
|
||||||
* @author AI Assistant
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SalarySettlementRecordClientServiceImpl implements SalarySettlementRecordClientService {
|
|
||||||
|
|
||||||
private static final String IDENTITY_BD = "BD";
|
|
||||||
private static final String IDENTITY_BD_LEADER = "BD_LEADER";
|
|
||||||
private static final String IDENTITY_ADMIN = "ADMIN";
|
|
||||||
private static final int DEFAULT_CURSOR = 1;
|
|
||||||
private static final int DEFAULT_LIMIT = 20;
|
|
||||||
|
|
||||||
private final MongoTemplate mongoTemplate;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public PageResult<SalarySettlementRecordDTO> page(SalarySettlementRecordPageQryCmd qryCmd) {
|
|
||||||
SalarySettlementRecordPageQryCmd query = Objects.requireNonNullElseGet(qryCmd,
|
|
||||||
SalarySettlementRecordPageQryCmd::new);
|
|
||||||
PageBounds pageBounds = getPageBounds(query);
|
|
||||||
String identity = normalizeIdentity(query.getIdentity());
|
|
||||||
if (StringUtils.isNotBlank(query.getIdentity()) && !isSupportedIdentity(identity)) {
|
|
||||||
return buildPage(Collections.emptyList(), 0, pageBounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(identity)) {
|
|
||||||
return pageSingleIdentity(query, identity, pageBounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SalarySettlementRecordDTO> records = new ArrayList<>();
|
|
||||||
long total = 0;
|
|
||||||
|
|
||||||
total += count(query, "bdUserId", BdSalarySettlementRecord.class);
|
|
||||||
total += count(query, "bdLeaderUserId", BdLeaderSalarySettlementRecord.class);
|
|
||||||
total += count(query, "adminUserId", AdminSalarySettlementRecord.class);
|
|
||||||
records.addAll(listRecords(query, "bdUserId", BdSalarySettlementRecord.class,
|
|
||||||
this::toBdRecordDTO, 0, pageBounds.fetchSize()));
|
|
||||||
records.addAll(listRecords(query, "bdLeaderUserId", BdLeaderSalarySettlementRecord.class,
|
|
||||||
this::toBdLeaderRecordDTO, 0, pageBounds.fetchSize()));
|
|
||||||
records.addAll(listRecords(query, "adminUserId", AdminSalarySettlementRecord.class,
|
|
||||||
this::toAdminRecordDTO, 0, pageBounds.fetchSize()));
|
|
||||||
|
|
||||||
// 三类记录分散在不同 collection,全部身份查询只各取当前页所需前 N 条再合并排序。
|
|
||||||
records.sort((left, right) -> compareTimestampDesc(left.getCreateTime(), right.getCreateTime()));
|
|
||||||
Collection<SalarySettlementRecordDTO> pageRecords = slice(records, pageBounds);
|
|
||||||
return buildPage(pageRecords, total, pageBounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PageResult<SalarySettlementRecordDTO> pageSingleIdentity(
|
|
||||||
SalarySettlementRecordPageQryCmd query, String identity, PageBounds pageBounds) {
|
|
||||||
if (Objects.equals(identity, IDENTITY_BD)) {
|
|
||||||
return pageTypedRecords(query, "bdUserId", BdSalarySettlementRecord.class,
|
|
||||||
this::toBdRecordDTO, pageBounds);
|
|
||||||
}
|
|
||||||
if (Objects.equals(identity, IDENTITY_BD_LEADER)) {
|
|
||||||
return pageTypedRecords(query, "bdLeaderUserId", BdLeaderSalarySettlementRecord.class,
|
|
||||||
this::toBdLeaderRecordDTO, pageBounds);
|
|
||||||
}
|
|
||||||
return pageTypedRecords(query, "adminUserId", AdminSalarySettlementRecord.class,
|
|
||||||
this::toAdminRecordDTO, pageBounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T> PageResult<SalarySettlementRecordDTO> pageTypedRecords(
|
|
||||||
SalarySettlementRecordPageQryCmd query, String userIdField, Class<T> entityClass,
|
|
||||||
Function<T, SalarySettlementRecordDTO> mapper, PageBounds pageBounds) {
|
|
||||||
long total = count(query, userIdField, entityClass);
|
|
||||||
List<SalarySettlementRecordDTO> records = listRecords(query, userIdField, entityClass, mapper,
|
|
||||||
pageBounds.skip(), pageBounds.limit());
|
|
||||||
return buildPage(records, total, pageBounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PageBounds getPageBounds(SalarySettlementRecordPageQryCmd query) {
|
|
||||||
PageQuery pageQuery = query.getPageQuery();
|
|
||||||
int cursor = pageQuery == null || pageQuery.getCursor() == null ? DEFAULT_CURSOR
|
|
||||||
: pageQuery.getCursor();
|
|
||||||
int limit = pageQuery == null || pageQuery.getLimit() == null ? DEFAULT_LIMIT
|
|
||||||
: pageQuery.getLimit();
|
|
||||||
int safeCursor = cursor <= 0 ? DEFAULT_CURSOR : cursor;
|
|
||||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : limit;
|
|
||||||
return new PageBounds(safeCursor, safeLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Collection<SalarySettlementRecordDTO> slice(List<SalarySettlementRecordDTO> records,
|
|
||||||
PageBounds pageBounds) {
|
|
||||||
int fromIndex = Math.max(pageBounds.skip(), 0);
|
|
||||||
int toIndex = Math.min(fromIndex + pageBounds.limit(), records.size());
|
|
||||||
return fromIndex >= records.size()
|
|
||||||
? Collections.emptyList()
|
|
||||||
: records.subList(fromIndex, toIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PageResult<SalarySettlementRecordDTO> buildPage(
|
|
||||||
Collection<SalarySettlementRecordDTO> records, long total, PageBounds pageBounds) {
|
|
||||||
PageResult<SalarySettlementRecordDTO> pageResult = new PageResult<>();
|
|
||||||
pageResult.setCurrent(pageBounds.cursor());
|
|
||||||
pageResult.setSize(pageBounds.limit());
|
|
||||||
pageResult.setTotal(total);
|
|
||||||
pageResult.setRecords(records);
|
|
||||||
return pageResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T> long count(SalarySettlementRecordPageQryCmd query, String userIdField,
|
|
||||||
Class<T> entityClass) {
|
|
||||||
return mongoTemplate.count(buildQuery(query, userIdField), entityClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T> List<SalarySettlementRecordDTO> listRecords(SalarySettlementRecordPageQryCmd query,
|
|
||||||
String userIdField, Class<T> entityClass, Function<T, SalarySettlementRecordDTO> mapper,
|
|
||||||
int skip, int limit) {
|
|
||||||
Query mongoQuery = buildQuery(query, userIdField);
|
|
||||||
mongoQuery.skip(skip);
|
|
||||||
mongoQuery.limit(limit);
|
|
||||||
return mongoTemplate.find(mongoQuery, entityClass)
|
|
||||||
.stream()
|
|
||||||
.map(mapper)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Query buildQuery(SalarySettlementRecordPageQryCmd query, String userIdField) {
|
|
||||||
List<Criteria> criteriaList = new ArrayList<>();
|
|
||||||
if (StringUtils.isNotBlank(query.getSysOrigin())) {
|
|
||||||
criteriaList.add(Criteria.where("sysOrigin").is(query.getSysOrigin()));
|
|
||||||
}
|
|
||||||
if (Objects.nonNull(query.getUserId())) {
|
|
||||||
criteriaList.add(Criteria.where(userIdField).is(query.getUserId()));
|
|
||||||
}
|
|
||||||
if (Objects.nonNull(query.getBillBelong())) {
|
|
||||||
criteriaList.add(Criteria.where("billBelong").is(query.getBillBelong()));
|
|
||||||
}
|
|
||||||
if (StringUtils.isNotBlank(query.getStatus())) {
|
|
||||||
BdSettlementStatus status = parseStatus(query.getStatus());
|
|
||||||
criteriaList.add(Objects.isNull(status)
|
|
||||||
? Criteria.where("_id").is("__invalid_status__")
|
|
||||||
: Criteria.where("status").is(status));
|
|
||||||
}
|
|
||||||
if (StringUtils.isNotBlank(query.getBizNo())) {
|
|
||||||
criteriaList.add(Criteria.where("bizNo").is(query.getBizNo()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Query mongoQuery = criteriaList.isEmpty()
|
|
||||||
? new Query()
|
|
||||||
: new Query(new Criteria().andOperator(criteriaList.toArray(new Criteria[0])));
|
|
||||||
mongoQuery.with(Sort.by(Sort.Order.desc("createTime")));
|
|
||||||
return mongoQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BdSettlementStatus parseStatus(String status) {
|
|
||||||
try {
|
|
||||||
return BdSettlementStatus.valueOf(status.trim().toUpperCase(Locale.ROOT));
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeIdentity(String identity) {
|
|
||||||
return StringUtils.isBlank(identity) ? null : identity.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isSupportedIdentity(String identity) {
|
|
||||||
return Objects.equals(identity, IDENTITY_BD)
|
|
||||||
|| Objects.equals(identity, IDENTITY_BD_LEADER)
|
|
||||||
|| Objects.equals(identity, IDENTITY_ADMIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int compareTimestampDesc(Timestamp left, Timestamp right) {
|
|
||||||
if (Objects.isNull(left) && Objects.isNull(right)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (Objects.isNull(left)) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (Objects.isNull(right)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return right.compareTo(left);
|
|
||||||
}
|
|
||||||
|
|
||||||
private SalarySettlementRecordDTO toBdRecordDTO(BdSalarySettlementRecord record) {
|
|
||||||
String status = record.getStatus() == null ? null : record.getStatus().name();
|
|
||||||
String hitPolicyType = record.getHitPolicyType() == null ? null : record.getHitPolicyType().name();
|
|
||||||
return new SalarySettlementRecordDTO()
|
|
||||||
.setId(record.getId())
|
|
||||||
.setTimeId(record.getTimeId())
|
|
||||||
.setSysOrigin(record.getSysOrigin())
|
|
||||||
.setIdentity(IDENTITY_BD)
|
|
||||||
.setUserId(record.getBdUserId())
|
|
||||||
.setBdUserId(record.getBdUserId())
|
|
||||||
.setIsAdmin(record.getIsAdmin())
|
|
||||||
.setBillBelong(record.getBillBelong())
|
|
||||||
.setBillTitle(record.getBillTitle())
|
|
||||||
.setAgencyNumber(record.getAgencyNumber())
|
|
||||||
.setTeamSalaryAmount(record.getTeamSalaryAmount())
|
|
||||||
.setTeamRechargeAmount(record.getTeamRechargeAmount())
|
|
||||||
.setHitPolicyType(hitPolicyType)
|
|
||||||
.setHitLevel(record.getHitLevel())
|
|
||||||
.setCommissionRate(record.getCommissionRate())
|
|
||||||
.setSettlementSalary(record.getSettlementSalary())
|
|
||||||
.setStatus(status)
|
|
||||||
.setFailureReason(record.getFailureReason())
|
|
||||||
.setBizNo(record.getBizNo())
|
|
||||||
.setCreateTime(record.getCreateTime())
|
|
||||||
.setUpdateTime(record.getUpdateTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
private SalarySettlementRecordDTO toBdLeaderRecordDTO(BdLeaderSalarySettlementRecord record) {
|
|
||||||
String status = record.getStatus() == null ? null : record.getStatus().name();
|
|
||||||
return new SalarySettlementRecordDTO()
|
|
||||||
.setId(record.getId())
|
|
||||||
.setTimeId(record.getTimeId())
|
|
||||||
.setSysOrigin(record.getSysOrigin())
|
|
||||||
.setIdentity(IDENTITY_BD_LEADER)
|
|
||||||
.setUserId(record.getBdLeaderUserId())
|
|
||||||
.setBdLeaderUserId(record.getBdLeaderUserId())
|
|
||||||
.setIsAdmin(record.getIsAdmin())
|
|
||||||
.setBillBelong(record.getBillBelong())
|
|
||||||
.setBillTitle(record.getBillTitle())
|
|
||||||
.setBdNumber(record.getBdNumber())
|
|
||||||
.setTeamSalaryAmount(record.getTeamSalaryAmount())
|
|
||||||
.setHitLevel(record.getHitLevel())
|
|
||||||
.setBasicSalary(record.getBasicSalary())
|
|
||||||
.setRewardAmount(record.getRewardAmount())
|
|
||||||
.setRewardEligible(record.getRewardEligible())
|
|
||||||
.setNewRechargeAgentCount(record.getNewRechargeAgentCount())
|
|
||||||
.setSettlementSalary(record.getSettlementSalary())
|
|
||||||
.setStatus(status)
|
|
||||||
.setFailureReason(record.getFailureReason())
|
|
||||||
.setBizNo(record.getBizNo())
|
|
||||||
.setCreateTime(record.getCreateTime())
|
|
||||||
.setUpdateTime(record.getUpdateTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
private SalarySettlementRecordDTO toAdminRecordDTO(AdminSalarySettlementRecord record) {
|
|
||||||
String status = record.getStatus() == null ? null : record.getStatus().name();
|
|
||||||
return new SalarySettlementRecordDTO()
|
|
||||||
.setId(record.getId())
|
|
||||||
.setTimeId(record.getTimeId())
|
|
||||||
.setSysOrigin(record.getSysOrigin())
|
|
||||||
.setIdentity(IDENTITY_ADMIN)
|
|
||||||
.setUserId(record.getAdminUserId())
|
|
||||||
.setAdminUserId(record.getAdminUserId())
|
|
||||||
.setBillBelong(record.getBillBelong())
|
|
||||||
.setBillTitle(record.getBillTitle())
|
|
||||||
.setBizNo(record.getBizNo())
|
|
||||||
.setRechargeAgentAmount(record.getRechargeAgentAmount())
|
|
||||||
.setRechargeAgentSalary(record.getRechargeAgentSalary())
|
|
||||||
.setRechargeAgentPercentage(record.getRechargeAgentPercentage())
|
|
||||||
.setBdLeaderTeamSalary(record.getBdLeaderTeamSalary())
|
|
||||||
.setBdLeaderSalary(record.getBdLeaderSalary())
|
|
||||||
.setBdLeaderHitLevel(record.getBdLeaderHitLevel())
|
|
||||||
.setBdLeaderBasicSalary(record.getBdLeaderBasicSalary())
|
|
||||||
.setBdLeaderRewardAmount(record.getBdLeaderRewardAmount())
|
|
||||||
.setBdTeamSalaryAmount(record.getBdTeamSalaryAmount())
|
|
||||||
.setBdTeamSalarySalary(record.getBdTeamSalarySalary())
|
|
||||||
.setBdTeamSalaryHitLevel(record.getBdTeamSalaryHitLevel())
|
|
||||||
.setBdTeamSalaryCommissionRate(record.getBdTeamSalaryCommissionRate())
|
|
||||||
.setBdTeamRechargeAmount(record.getBdTeamRechargeAmount())
|
|
||||||
.setBdTeamRechargeSalary(record.getBdTeamRechargeSalary())
|
|
||||||
.setBdTeamRechargeHitLevel(record.getBdTeamRechargeHitLevel())
|
|
||||||
.setBdTeamRechargeCommissionRate(record.getBdTeamRechargeCommissionRate())
|
|
||||||
.setBdPolicyFinalSalary(record.getBdPolicyFinalSalary())
|
|
||||||
.setBdPolicyHitType(record.getBdPolicyHitType())
|
|
||||||
.setTotalSalary(record.getTotalSalary())
|
|
||||||
.setSettlementSalary(record.getTotalSalary())
|
|
||||||
.setStatus(status)
|
|
||||||
.setFailureReason(record.getFailureReason())
|
|
||||||
.setCreateTime(record.getCreateTime())
|
|
||||||
.setUpdateTime(record.getUpdateTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
private record PageBounds(int cursor, int limit) {
|
|
||||||
|
|
||||||
private int skip() {
|
|
||||||
return Math.max((cursor - 1) * limit, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int fetchSize() {
|
|
||||||
long size = (long) cursor * limit;
|
|
||||||
return size > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,7 +13,6 @@ import com.red.circle.wallet.app.dto.clientobject.FreightDealerUserSearchResultC
|
|||||||
import com.red.circle.wallet.app.dto.clientobject.FreightRunningWaterCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightRunningWaterCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSearchUserResultCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightSearchUserResultCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSellerCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightSellerCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSubSellerCO;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.*;
|
import com.red.circle.wallet.app.dto.cmd.*;
|
||||||
import com.red.circle.wallet.app.service.UserFreightService;
|
import com.red.circle.wallet.app.service.UserFreightService;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@ -270,46 +269,6 @@ public class FreightRestController extends BaseController {
|
|||||||
return userFreightService.listFreightSeller(cmd);
|
return userFreightService.listFreightSeller(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商子 coin seller 列表.
|
|
||||||
*
|
|
||||||
* @eo.name 超级经销商子 coin seller 列表
|
|
||||||
* @eo.url /super/sub-sellers
|
|
||||||
* @eo.method get
|
|
||||||
* @eo.request-type formdata
|
|
||||||
*/
|
|
||||||
@GetMapping("/super/sub-sellers")
|
|
||||||
public List<FreightSubSellerCO> listFreightSubSeller(AppExtCommand cmd) {
|
|
||||||
return userFreightService.listFreightSubSeller(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商绑定已有 coin seller.
|
|
||||||
*
|
|
||||||
* @eo.name 超级经销商绑定已有 coin seller
|
|
||||||
* @eo.url /super/sub-sellers
|
|
||||||
* @eo.method post
|
|
||||||
* @eo.request-type json
|
|
||||||
*/
|
|
||||||
@PostMapping("/super/sub-sellers")
|
|
||||||
public void addFreightSubSeller(@RequestBody @Validated FreightSubSellerCmd cmd) {
|
|
||||||
userFreightService.addFreightSubSeller(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商给子 coin seller 转账.
|
|
||||||
*
|
|
||||||
* @eo.name 超级经销商给子 coin seller 转账
|
|
||||||
* @eo.url /super/sub-sellers/transfer
|
|
||||||
* @eo.method post
|
|
||||||
* @eo.request-type json
|
|
||||||
*/
|
|
||||||
@PostMapping("/super/sub-sellers/transfer")
|
|
||||||
public BigDecimal superDealerTransferToSubSeller(
|
|
||||||
@RequestBody @Validated FreightSubSellerTransferCmd cmd) {
|
|
||||||
return userFreightService.superDealerTransferToSubSeller(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否是经销商卖家.
|
* 是否是经销商卖家.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -31,7 +31,6 @@ import java.util.Objects;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 经销商发送货运给代理.
|
* 经销商发送货运给代理.
|
||||||
@ -54,7 +53,6 @@ public class DealerToFreightShipCmdExe {
|
|||||||
private final UserPayAuthCacheService userPayAuthCacheService;
|
private final UserPayAuthCacheService userPayAuthCacheService;
|
||||||
private final FreightBalanceRunningWaterService freightBalanceRunningWaterService;
|
private final FreightBalanceRunningWaterService freightBalanceRunningWaterService;
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
|
||||||
public BigDecimal execute(FreightDealerShipCmd cmd) {
|
public BigDecimal execute(FreightDealerShipCmd cmd) {
|
||||||
controlSwitch.execute(cmd.requireReqSysOriginChildEnum());
|
controlSwitch.execute(cmd.requireReqSysOriginChildEnum());
|
||||||
log.warn("{}", JacksonUtils.toJson(cmd));
|
log.warn("{}", JacksonUtils.toJson(cmd));
|
||||||
@ -81,8 +79,7 @@ public class DealerToFreightShipCmdExe {
|
|||||||
|
|
||||||
// 接收用户是否为货运代理
|
// 接收用户是否为货运代理
|
||||||
ResponseAssert.isTrue(FreightErrorCode.NOT_FREIGHT,
|
ResponseAssert.isTrue(FreightErrorCode.NOT_FREIGHT,
|
||||||
freightBalanceService.existsBalance(cmd.requireReqSysOriginChildEnum(),
|
freightBalanceService.existsBalance(cmd.getAcceptUserId()));
|
||||||
cmd.getAcceptUserId()));
|
|
||||||
|
|
||||||
// 发送人与接受人必须要在相同区域下才能交易
|
// 发送人与接受人必须要在相同区域下才能交易
|
||||||
ResponseAssert.isTrue(CommonErrorCode.REGION_NOT_SUPPORTED_ERROR,
|
ResponseAssert.isTrue(CommonErrorCode.REGION_NOT_SUPPORTED_ERROR,
|
||||||
@ -115,16 +112,14 @@ public class DealerToFreightShipCmdExe {
|
|||||||
.decrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.requiredReqUserId(),
|
.decrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.requiredReqUserId(),
|
||||||
cmd.getQuantity()));
|
cmd.getQuantity()));
|
||||||
|
|
||||||
BigDecimal balance = freightBalanceService.getAvailableBalance(
|
BigDecimal balance = freightBalanceService.getAvailableBalance(cmd.requiredReqUserId());
|
||||||
cmd.requireReqSysOriginChildEnum(), cmd.requiredReqUserId());
|
|
||||||
|
|
||||||
// 插入流水
|
// 插入流水
|
||||||
saveRunningWater(cmd, balance);
|
saveRunningWater(cmd, balance);
|
||||||
|
|
||||||
// 发货
|
// 发货
|
||||||
ResponseAssert.isTrue(WalletErrorCode.DELIVERY_FAILED,
|
freightBalanceService.incrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.getAcceptUserId(),
|
||||||
freightBalanceService.incrCandyBalance(cmd.requireReqSysOriginEnum(),
|
cmd.getQuantity());
|
||||||
cmd.getAcceptUserId(), cmd.getQuantity()));
|
|
||||||
|
|
||||||
// 余额如果等于0则回收货运代理徽章
|
// 余额如果等于0则回收货运代理徽章
|
||||||
freightShipGateway.removeFreightShipBadge(cmd.requiredReqUserId(), balance);
|
freightShipGateway.removeFreightShipBadge(cmd.requiredReqUserId(), balance);
|
||||||
@ -186,8 +181,7 @@ public class DealerToFreightShipCmdExe {
|
|||||||
if (Objects.nonNull(userProfile)) {
|
if (Objects.nonNull(userProfile)) {
|
||||||
freightBalanceRunningWater.setRemark("从[" + userProfile.getAccount() + "]经销商进货");
|
freightBalanceRunningWater.setRemark("从[" + userProfile.getAccount() + "]经销商进货");
|
||||||
}
|
}
|
||||||
BigDecimal acceptBalance = freightBalanceService.getAvailableBalance(
|
BigDecimal acceptBalance = freightBalanceService.getAvailableBalance(cmd.getAcceptUserId());
|
||||||
cmd.requireReqSysOriginChildEnum(), cmd.getAcceptUserId());
|
|
||||||
freightBalanceRunningWater.setBalance(acceptBalance.add(cmd.getQuantity()));
|
freightBalanceRunningWater.setBalance(acceptBalance.add(cmd.getQuantity()));
|
||||||
freightBalanceRunningWater.setId(IdWorkerUtils.getId());
|
freightBalanceRunningWater.setId(IdWorkerUtils.getId());
|
||||||
freightBalanceRunningWater.setUserId(cmd.getAcceptUserId());
|
freightBalanceRunningWater.setUserId(cmd.getAcceptUserId());
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
package com.red.circle.wallet.app.command.freight;
|
|
||||||
|
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
|
||||||
import com.red.circle.framework.core.dto.ReqSysOrigin;
|
|
||||||
import com.red.circle.tool.core.num.ArithmeticUtils;
|
|
||||||
import com.red.circle.wallet.app.command.freight.support.SuperFreightDealerChecker;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightDealerShipCmd;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSubSellerTransferCmd;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightBalance;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightSubSellerRelation;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.service.freight.FreightSubSellerRelationService;
|
|
||||||
import com.red.circle.wallet.inner.error.FreightErrorCode;
|
|
||||||
import com.red.circle.wallet.inner.error.WalletErrorCode;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商向已绑定子 coin seller 转账.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SuperDealerSubSellerTransferCmdExe {
|
|
||||||
|
|
||||||
private final DealerToFreightShipCmdExe dealerToFreightShipCmdExe;
|
|
||||||
private final SuperFreightDealerChecker superFreightDealerChecker;
|
|
||||||
private final FreightSubSellerRelationService freightSubSellerRelationService;
|
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
|
||||||
public BigDecimal execute(FreightSubSellerTransferCmd cmd) {
|
|
||||||
FreightBalance parentFreightBalance = superFreightDealerChecker.requireSuperDealer(cmd);
|
|
||||||
|
|
||||||
ResponseAssert.isTrue(WalletErrorCode.REQUIRED_GT_ZERO,
|
|
||||||
ArithmeticUtils.gte(cmd.getQuantity(), BigDecimal.ONE));
|
|
||||||
|
|
||||||
FreightSubSellerRelation relation =
|
|
||||||
freightSubSellerRelationService.getByParentAndChild(cmd.requireReqSysOrigin(),
|
|
||||||
cmd.requiredReqUserId(), parentFreightBalance.getId(), cmd.getAcceptUserId());
|
|
||||||
|
|
||||||
// 子级转账必须绑定关系命中,避免绕过 H5 入口给任意 coin seller 出货.
|
|
||||||
ResponseAssert.notNull(FreightErrorCode.NOT_SUB_FREIGHT_SELLER, relation);
|
|
||||||
|
|
||||||
FreightDealerShipCmd dealerShipCmd = new FreightDealerShipCmd()
|
|
||||||
.setAcceptUserId(cmd.getAcceptUserId())
|
|
||||||
.setQuantity(cmd.getQuantity())
|
|
||||||
.setPassword(cmd.getPassword());
|
|
||||||
dealerShipCmd.setReqSysOrigin(ReqSysOrigin.of(cmd.requireReqSysOrigin()));
|
|
||||||
dealerShipCmd.setReqUserId(cmd.requiredReqUserId());
|
|
||||||
dealerShipCmd.setReqTraceId(cmd.getReqTraceId());
|
|
||||||
dealerShipCmd.setReqAppIntel(cmd.getReqAppIntel());
|
|
||||||
dealerShipCmd.setReqTime(cmd.getReqTime());
|
|
||||||
|
|
||||||
return dealerToFreightShipCmdExe.execute(dealerShipCmd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
package com.red.circle.wallet.app.command.freight;
|
|
||||||
|
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
|
||||||
import com.red.circle.framework.core.response.CommonErrorCode;
|
|
||||||
import com.red.circle.other.inner.endpoint.user.region.UserRegionClient;
|
|
||||||
import com.red.circle.tool.core.sequence.IdWorkerUtils;
|
|
||||||
import com.red.circle.wallet.app.command.freight.support.SuperFreightDealerChecker;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSubSellerCmd;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightBalance;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightSubSellerRelation;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.service.freight.FreightBalanceService;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.service.freight.FreightSubSellerRelationService;
|
|
||||||
import com.red.circle.wallet.infra.sync.ControlSwitch;
|
|
||||||
import com.red.circle.wallet.inner.error.FreightErrorCode;
|
|
||||||
import java.util.Objects;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商绑定子 coin seller.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UserFreightSubSellerAddCmdExe {
|
|
||||||
|
|
||||||
private final ControlSwitch controlSwitch;
|
|
||||||
private final UserRegionClient userRegionClient;
|
|
||||||
private final FreightBalanceService freightBalanceService;
|
|
||||||
private final SuperFreightDealerChecker superFreightDealerChecker;
|
|
||||||
private final FreightSubSellerRelationService freightSubSellerRelationService;
|
|
||||||
|
|
||||||
public void execute(FreightSubSellerCmd cmd) {
|
|
||||||
controlSwitch.execute(cmd.requireReqSysOriginChildEnum());
|
|
||||||
|
|
||||||
FreightBalance parentFreight = superFreightDealerChecker.requireSuperDealer(cmd);
|
|
||||||
|
|
||||||
ResponseAssert.isFalse(FreightErrorCode.INVALID_SUB_FREIGHT_SELLER,
|
|
||||||
Objects.equals(cmd.requiredReqUserId(), cmd.getUserId()));
|
|
||||||
|
|
||||||
FreightBalance childFreight = freightBalanceService.getFreightBalance(
|
|
||||||
cmd.requireReqSysOriginChildEnum(), cmd.getUserId());
|
|
||||||
ResponseAssert.notNull(FreightErrorCode.NOT_FREIGHT, childFreight);
|
|
||||||
|
|
||||||
// 子 coin seller 必须是已有且未关闭的普通币商,不能绑定经销商或超级经销商.
|
|
||||||
boolean validChild = Objects.equals(childFreight.getSysOrigin(), cmd.requireReqSysOrigin())
|
|
||||||
&& Objects.equals(childFreight.getClose(), Boolean.FALSE)
|
|
||||||
&& !Objects.equals(childFreight.getDealer(), Boolean.TRUE)
|
|
||||||
&& !Objects.equals(childFreight.getSuperDealer(), Boolean.TRUE);
|
|
||||||
ResponseAssert.isTrue(FreightErrorCode.INVALID_SUB_FREIGHT_SELLER, validChild);
|
|
||||||
|
|
||||||
ResponseAssert.isTrue(CommonErrorCode.REGION_NOT_SUPPORTED_ERROR,
|
|
||||||
ResponseAssert.requiredSuccess(
|
|
||||||
userRegionClient.checkEqRegion(cmd.requiredReqUserId(), cmd.getUserId())));
|
|
||||||
|
|
||||||
ResponseAssert.isFalse(FreightErrorCode.SUB_FREIGHT_SELLER_EXISTS,
|
|
||||||
freightSubSellerRelationService.existsByChildUserId(cmd.requireReqSysOrigin(),
|
|
||||||
cmd.getUserId()));
|
|
||||||
|
|
||||||
freightSubSellerRelationService.save(new FreightSubSellerRelation()
|
|
||||||
.setId(IdWorkerUtils.getId())
|
|
||||||
.setSysOrigin(cmd.requireReqSysOrigin())
|
|
||||||
.setParentFreightId(parentFreight.getId())
|
|
||||||
.setParentUserId(cmd.requiredReqUserId())
|
|
||||||
.setChildUserId(cmd.getUserId()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
package com.red.circle.wallet.app.command.freight.query;
|
|
||||||
|
|
||||||
import com.red.circle.common.business.dto.cmd.AppExtCommand;
|
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
|
||||||
import com.red.circle.other.inner.endpoint.user.user.UserProfileClient;
|
|
||||||
import com.red.circle.other.inner.model.dto.user.UserProfileDTO;
|
|
||||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
|
||||||
import com.red.circle.wallet.app.command.freight.support.SuperFreightDealerChecker;
|
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSubSellerCO;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightBalance;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightSubSellerRelation;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.service.freight.FreightBalanceService;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.service.freight.FreightSubSellerRelationService;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询当前超级经销商的子 coin seller 列表.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UserFreightSubSellerQryExe {
|
|
||||||
|
|
||||||
private final UserProfileClient userProfileClient;
|
|
||||||
private final FreightBalanceService freightBalanceService;
|
|
||||||
private final SuperFreightDealerChecker superFreightDealerChecker;
|
|
||||||
private final FreightSubSellerRelationService freightSubSellerRelationService;
|
|
||||||
|
|
||||||
public List<FreightSubSellerCO> execute(AppExtCommand cmd) {
|
|
||||||
FreightBalance parentFreightBalance = superFreightDealerChecker.requireSuperDealer(cmd);
|
|
||||||
|
|
||||||
List<FreightSubSellerRelation> relations =
|
|
||||||
freightSubSellerRelationService.listByParentUserId(cmd.requireReqSysOrigin(),
|
|
||||||
cmd.requiredReqUserId(), parentFreightBalance.getId());
|
|
||||||
if (CollectionUtils.isEmpty(relations)) {
|
|
||||||
return CollectionUtils.newArrayList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<Long, UserProfileDTO> userProfileMap = ResponseAssert.requiredSuccess(
|
|
||||||
userProfileClient.mapByUserIds(relations.stream()
|
|
||||||
.map(FreightSubSellerRelation::getChildUserId)
|
|
||||||
.collect(Collectors.toSet())));
|
|
||||||
|
|
||||||
return relations.stream()
|
|
||||||
.map(relation -> toCO(relation, userProfileMap.get(relation.getChildUserId()), cmd))
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
private FreightSubSellerCO toCO(FreightSubSellerRelation relation, UserProfileDTO userProfile,
|
|
||||||
AppExtCommand cmd) {
|
|
||||||
if (Objects.isNull(userProfile)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Objects.nonNull(userProfile.getOwnSpecialId())) {
|
|
||||||
userProfile.setAccount(userProfile.getOwnSpecialId().getAccount());
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FreightSubSellerCO()
|
|
||||||
.setRelationId(relation.getId())
|
|
||||||
.setChildUserId(relation.getChildUserId())
|
|
||||||
.setBalance(freightBalanceService.getAvailableBalance(cmd.requireReqSysOriginChildEnum(),
|
|
||||||
relation.getChildUserId()))
|
|
||||||
.setCreateTime(relation.getCreateTime())
|
|
||||||
.setUserProfile(userProfile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
package com.red.circle.wallet.app.command.freight.support;
|
|
||||||
|
|
||||||
import com.red.circle.common.business.dto.cmd.AppExtCommand;
|
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightBalance;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.service.freight.FreightBalanceService;
|
|
||||||
import com.red.circle.wallet.inner.error.FreightErrorCode;
|
|
||||||
import java.util.Objects;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商服务端授权检查.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SuperFreightDealerChecker {
|
|
||||||
|
|
||||||
private final FreightBalanceService freightBalanceService;
|
|
||||||
|
|
||||||
public FreightBalance requireSuperDealer(AppExtCommand cmd) {
|
|
||||||
FreightBalance freightBalance = freightBalanceService.getFreightBalance(
|
|
||||||
cmd.requireReqSysOriginChildEnum(), cmd.requiredReqUserId());
|
|
||||||
|
|
||||||
ResponseAssert.notNull(FreightErrorCode.NOT_FREIGHT, freightBalance);
|
|
||||||
|
|
||||||
// 超级经销商权限必须在服务端校验,H5 入口展示不能作为授权依据.
|
|
||||||
boolean isSuperDealer = Objects.equals(freightBalance.getSysOrigin(),
|
|
||||||
cmd.requireReqSysOrigin())
|
|
||||||
&& Objects.equals(freightBalance.getClose(), Boolean.FALSE)
|
|
||||||
&& Objects.equals(freightBalance.getDealer(), Boolean.TRUE)
|
|
||||||
&& Objects.equals(freightBalance.getSuperDealer(), Boolean.TRUE);
|
|
||||||
ResponseAssert.isTrue(FreightErrorCode.NOT_SUPER_FREIGHT, isSuperDealer);
|
|
||||||
return freightBalance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,10 +7,8 @@ import com.red.circle.common.business.dto.cmd.app.AppUserIdCmd;
|
|||||||
import com.red.circle.wallet.app.command.freight.DealerToFreightShipCmdExe;
|
import com.red.circle.wallet.app.command.freight.DealerToFreightShipCmdExe;
|
||||||
import com.red.circle.wallet.app.command.freight.DealerToUserShipCmdExe;
|
import com.red.circle.wallet.app.command.freight.DealerToUserShipCmdExe;
|
||||||
import com.red.circle.wallet.app.command.freight.SendFreightShipCmdExe;
|
import com.red.circle.wallet.app.command.freight.SendFreightShipCmdExe;
|
||||||
import com.red.circle.wallet.app.command.freight.SuperDealerSubSellerTransferCmdExe;
|
|
||||||
import com.red.circle.wallet.app.command.freight.UserFreightSellerAddCmdExe;
|
import com.red.circle.wallet.app.command.freight.UserFreightSellerAddCmdExe;
|
||||||
import com.red.circle.wallet.app.command.freight.UserFreightSellerUsableGoldCmdExe;
|
import com.red.circle.wallet.app.command.freight.UserFreightSellerUsableGoldCmdExe;
|
||||||
import com.red.circle.wallet.app.command.freight.UserFreightSubSellerAddCmdExe;
|
|
||||||
import com.red.circle.wallet.app.command.freight.query.CheckDealerQryExe;
|
import com.red.circle.wallet.app.command.freight.query.CheckDealerQryExe;
|
||||||
import com.red.circle.wallet.app.command.freight.query.CheckFreightQryExe;
|
import com.red.circle.wallet.app.command.freight.query.CheckFreightQryExe;
|
||||||
import com.red.circle.wallet.app.command.freight.query.CheckFreightSellerQryExe;
|
import com.red.circle.wallet.app.command.freight.query.CheckFreightSellerQryExe;
|
||||||
@ -23,13 +21,11 @@ import com.red.circle.wallet.app.command.freight.query.UserFreightAccountQryExe;
|
|||||||
import com.red.circle.wallet.app.command.freight.query.UserFreightAvailableBalanceQryExe;
|
import com.red.circle.wallet.app.command.freight.query.UserFreightAvailableBalanceQryExe;
|
||||||
import com.red.circle.wallet.app.command.freight.query.UserFreightSellerInfoQryExe;
|
import com.red.circle.wallet.app.command.freight.query.UserFreightSellerInfoQryExe;
|
||||||
import com.red.circle.wallet.app.command.freight.query.UserFreightSellerQryExe;
|
import com.red.circle.wallet.app.command.freight.query.UserFreightSellerQryExe;
|
||||||
import com.red.circle.wallet.app.command.freight.query.UserFreightSubSellerQryExe;
|
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightAccountCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightAccountCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightDealerUserSearchResultCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightDealerUserSearchResultCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightRunningWaterCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightRunningWaterCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSearchUserResultCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightSearchUserResultCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSellerCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightSellerCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSubSellerCO;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightDealerShipCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightDealerShipCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightDealerToUserShipCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightDealerToUserShipCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSellerCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightSellerCmd;
|
||||||
@ -37,8 +33,6 @@ import com.red.circle.wallet.app.dto.cmd.FreightSellerQryCmd;
|
|||||||
import com.red.circle.wallet.app.dto.cmd.FreightSellerRunningWaterQryCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightSellerRunningWaterQryCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSellerUsableGoldCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightSellerUsableGoldCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightShipSendCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightShipSendCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSubSellerCmd;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSubSellerTransferCmd;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -58,12 +52,10 @@ public class UserFreightServiceImpl implements UserFreightService {
|
|||||||
private final SendFreightShipCmdExe sendFreightShipCmdExe;
|
private final SendFreightShipCmdExe sendFreightShipCmdExe;
|
||||||
private final DealerToUserShipCmdExe dealerToUserShipCmdExe;
|
private final DealerToUserShipCmdExe dealerToUserShipCmdExe;
|
||||||
private final UserFreightSellerQryExe userFreightSellerQryExe;
|
private final UserFreightSellerQryExe userFreightSellerQryExe;
|
||||||
private final UserFreightSubSellerQryExe userFreightSubSellerQryExe;
|
|
||||||
private final CheckFreightSellerQryExe checkFreightSellerQryExe;
|
private final CheckFreightSellerQryExe checkFreightSellerQryExe;
|
||||||
private final UserFreightAccountQryExe userFreightAccountQryExe;
|
private final UserFreightAccountQryExe userFreightAccountQryExe;
|
||||||
private final DealerToFreightShipCmdExe dealerToFreightShipCmdExe;
|
private final DealerToFreightShipCmdExe dealerToFreightShipCmdExe;
|
||||||
private final UserFreightSellerAddCmdExe userFreightSellerAddCmdExe;
|
private final UserFreightSellerAddCmdExe userFreightSellerAddCmdExe;
|
||||||
private final UserFreightSubSellerAddCmdExe userFreightSubSellerAddCmdExe;
|
|
||||||
private final UserFreightSellerInfoQryExe userFreightSellerInfoQryExe;
|
private final UserFreightSellerInfoQryExe userFreightSellerInfoQryExe;
|
||||||
private final DealerFreightUserSearchQryExe dealerFreightUserSearchQryExe;
|
private final DealerFreightUserSearchQryExe dealerFreightUserSearchQryExe;
|
||||||
private final FreightSearchUserResultQryExe freightSearchUserResultQryExe;
|
private final FreightSearchUserResultQryExe freightSearchUserResultQryExe;
|
||||||
@ -72,7 +64,6 @@ public class UserFreightServiceImpl implements UserFreightService {
|
|||||||
private final UserFreightAvailableBalanceQryExe userFreightAvailableBalanceQryExe;
|
private final UserFreightAvailableBalanceQryExe userFreightAvailableBalanceQryExe;
|
||||||
private final UserFreightSellerUsableGoldCmdExe userFreightSellerUsableGoldCmdExe;
|
private final UserFreightSellerUsableGoldCmdExe userFreightSellerUsableGoldCmdExe;
|
||||||
private final FreightSellerRunningWaterFlowQryExe freightSellerRunningWaterFlowQryExe;
|
private final FreightSellerRunningWaterFlowQryExe freightSellerRunningWaterFlowQryExe;
|
||||||
private final SuperDealerSubSellerTransferCmdExe superDealerSubSellerTransferCmdExe;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addFreightSeller(FreightSellerCmd cmd) {
|
public void addFreightSeller(FreightSellerCmd cmd) {
|
||||||
@ -160,19 +151,4 @@ public class UserFreightServiceImpl implements UserFreightService {
|
|||||||
return freightFirstRechargeCheckCmdExe.execute(cmd);
|
return freightFirstRechargeCheckCmdExe.execute(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<FreightSubSellerCO> listFreightSubSeller(AppExtCommand cmd) {
|
|
||||||
return userFreightSubSellerQryExe.execute(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addFreightSubSeller(FreightSubSellerCmd cmd) {
|
|
||||||
userFreightSubSellerAddCmdExe.execute(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public BigDecimal superDealerTransferToSubSeller(FreightSubSellerTransferCmd cmd) {
|
|
||||||
return superDealerSubSellerTransferCmdExe.execute(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
package com.red.circle.wallet.app.dto.clientobject;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
|
||||||
import com.red.circle.framework.dto.ClientObject;
|
|
||||||
import com.red.circle.other.inner.model.dto.user.UserProfileDTO;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.Date;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商子 coin seller 信息.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Accessors(chain = true)
|
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
public class FreightSubSellerCO extends ClientObject {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定关系 id.
|
|
||||||
*/
|
|
||||||
@JsonSerialize(using = ToStringSerializer.class)
|
|
||||||
private Long relationId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 子 coin seller 用户 id.
|
|
||||||
*/
|
|
||||||
@JsonSerialize(using = ToStringSerializer.class)
|
|
||||||
private Long childUserId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 子 coin seller 币商钱包余额.
|
|
||||||
*/
|
|
||||||
private BigDecimal balance;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定时间.
|
|
||||||
*/
|
|
||||||
private Date createTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户资料.
|
|
||||||
*/
|
|
||||||
private UserProfileDTO userProfile;
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
package com.red.circle.wallet.app.dto.cmd;
|
|
||||||
|
|
||||||
import com.red.circle.common.business.dto.cmd.AppExtCommand;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import java.io.Serial;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商绑定子 coin seller.
|
|
||||||
*/
|
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
@Data
|
|
||||||
@Accessors(chain = true)
|
|
||||||
public class FreightSubSellerCmd extends AppExtCommand {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 子 coin seller 用户 id.
|
|
||||||
*
|
|
||||||
* @eo.required
|
|
||||||
*/
|
|
||||||
@NotNull(message = "userId required.")
|
|
||||||
private Long userId;
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package com.red.circle.wallet.app.dto.cmd;
|
|
||||||
|
|
||||||
import com.red.circle.common.business.dto.cmd.AppExtCommand;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商给子 coin seller 转币商钱包金币.
|
|
||||||
*/
|
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
@Data
|
|
||||||
@Accessors(chain = true)
|
|
||||||
public class FreightSubSellerTransferCmd extends AppExtCommand {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 接收用户.
|
|
||||||
*
|
|
||||||
* @eo.required
|
|
||||||
*/
|
|
||||||
@NotNull(message = "acceptUserId required.")
|
|
||||||
private Long acceptUserId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 数量.
|
|
||||||
*
|
|
||||||
* @eo.required
|
|
||||||
*/
|
|
||||||
@NotNull(message = "quantity required.")
|
|
||||||
private BigDecimal quantity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 支付密码.
|
|
||||||
*/
|
|
||||||
private String password;
|
|
||||||
}
|
|
||||||
@ -9,7 +9,6 @@ import com.red.circle.wallet.app.dto.clientobject.FreightDealerUserSearchResultC
|
|||||||
import com.red.circle.wallet.app.dto.clientobject.FreightRunningWaterCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightRunningWaterCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSearchUserResultCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightSearchUserResultCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSellerCO;
|
import com.red.circle.wallet.app.dto.clientobject.FreightSellerCO;
|
||||||
import com.red.circle.wallet.app.dto.clientobject.FreightSubSellerCO;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightDealerShipCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightDealerShipCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightDealerToUserShipCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightDealerToUserShipCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSellerCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightSellerCmd;
|
||||||
@ -17,8 +16,6 @@ import com.red.circle.wallet.app.dto.cmd.FreightSellerQryCmd;
|
|||||||
import com.red.circle.wallet.app.dto.cmd.FreightSellerRunningWaterQryCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightSellerRunningWaterQryCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSellerUsableGoldCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightSellerUsableGoldCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightShipSendCmd;
|
import com.red.circle.wallet.app.dto.cmd.FreightShipSendCmd;
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSubSellerCmd;
|
|
||||||
import com.red.circle.wallet.app.dto.cmd.FreightSubSellerTransferCmd;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -63,10 +60,4 @@ public interface UserFreightService {
|
|||||||
|
|
||||||
boolean checkFreightFirstRecharge(AppUserIdCmd cmd);
|
boolean checkFreightFirstRecharge(AppUserIdCmd cmd);
|
||||||
|
|
||||||
List<FreightSubSellerCO> listFreightSubSeller(AppExtCommand cmd);
|
|
||||||
|
|
||||||
void addFreightSubSeller(FreightSubSellerCmd cmd);
|
|
||||||
|
|
||||||
BigDecimal superDealerTransferToSubSeller(FreightSubSellerTransferCmd cmd);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
package com.red.circle.wallet.infra.database.rds.dao;
|
|
||||||
|
|
||||||
import com.red.circle.framework.mybatis.dao.BaseDAO;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightSubSellerRelation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商子 coin seller 关系 DAO.
|
|
||||||
*/
|
|
||||||
public interface FreightSubSellerRelationDAO extends BaseDAO<FreightSubSellerRelation> {
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
package com.red.circle.wallet.infra.database.rds.entity.freight;
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
|
||||||
import com.red.circle.framework.mybatis.entity.TimestampBaseEntity;
|
|
||||||
import java.io.Serial;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.experimental.Accessors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商与子 coin seller 的绑定关系.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@EqualsAndHashCode(callSuper = false)
|
|
||||||
@Accessors(chain = true)
|
|
||||||
@TableName("user_freight_sub_seller_relation")
|
|
||||||
public class FreightSubSellerRelation extends TimestampBaseEntity {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主键标识.
|
|
||||||
*/
|
|
||||||
@TableId(value = "id", type = IdType.ASSIGN_ID)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 来源系统.
|
|
||||||
*/
|
|
||||||
@TableField("sys_origin")
|
|
||||||
private String sysOrigin;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商 freight balance id.
|
|
||||||
*/
|
|
||||||
@TableField("parent_freight_id")
|
|
||||||
private Long parentFreightId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商用户 id.
|
|
||||||
*/
|
|
||||||
@TableField("parent_user_id")
|
|
||||||
private Long parentUserId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 子 coin seller 用户 id.
|
|
||||||
*/
|
|
||||||
@TableField("child_user_id")
|
|
||||||
private Long childUserId;
|
|
||||||
}
|
|
||||||
@ -37,8 +37,6 @@ public interface FreightBalanceService extends BaseService<FreightBalance> {
|
|||||||
*/
|
*/
|
||||||
boolean existsBalance(Long userId);
|
boolean existsBalance(Long userId);
|
||||||
|
|
||||||
boolean existsBalance(SysOriginPlatformEnum sysOrigin, Long userId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 累计余额.
|
* 累计余额.
|
||||||
*
|
*
|
||||||
@ -67,8 +65,6 @@ public interface FreightBalanceService extends BaseService<FreightBalance> {
|
|||||||
*/
|
*/
|
||||||
BigDecimal getAvailableBalance(Long userId);
|
BigDecimal getAvailableBalance(Long userId);
|
||||||
|
|
||||||
BigDecimal getAvailableBalance(SysOriginPlatformEnum sysOrigin, Long userId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取平台货运代理人列表.
|
* 获取平台货运代理人列表.
|
||||||
*
|
*
|
||||||
@ -123,8 +119,6 @@ public interface FreightBalanceService extends BaseService<FreightBalance> {
|
|||||||
*/
|
*/
|
||||||
FreightBalance getFreightBalance(Long userId);
|
FreightBalance getFreightBalance(Long userId);
|
||||||
|
|
||||||
FreightBalance getFreightBalance(SysOriginPlatformEnum sysOrigin, Long userId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据id获取货运代理
|
* 根据id获取货运代理
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
package com.red.circle.wallet.infra.database.rds.service.freight;
|
|
||||||
|
|
||||||
import com.red.circle.framework.mybatis.service.BaseService;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightSubSellerRelation;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商子 coin seller 关系服务.
|
|
||||||
*/
|
|
||||||
public interface FreightSubSellerRelationService extends BaseService<FreightSubSellerRelation> {
|
|
||||||
|
|
||||||
boolean existsByChildUserId(String sysOrigin, Long childUserId);
|
|
||||||
|
|
||||||
FreightSubSellerRelation getByParentAndChild(String sysOrigin, Long parentUserId,
|
|
||||||
Long parentFreightId, Long childUserId);
|
|
||||||
|
|
||||||
List<FreightSubSellerRelation> listByParentUserId(String sysOrigin, Long parentUserId,
|
|
||||||
Long parentFreightId);
|
|
||||||
}
|
|
||||||
@ -59,21 +59,10 @@ public class FreightBalanceServiceImpl extends
|
|||||||
.orElse(Boolean.FALSE);
|
.orElse(Boolean.FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean existsBalance(SysOriginPlatformEnum sysOrigin, Long userId) {
|
|
||||||
return Optional.ofNullable(
|
|
||||||
query().select(FreightBalance::getClose)
|
|
||||||
.eq(FreightBalance::getSysOrigin, sysOrigin)
|
|
||||||
.eq(FreightBalance::getUserId, userId)
|
|
||||||
.last(PageConstant.LIMIT_ONE).getOne())
|
|
||||||
.map(freightBalance -> Objects.equals(freightBalance.getClose(), Boolean.FALSE))
|
|
||||||
.orElse(Boolean.FALSE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean incrCandyBalance(SysOriginPlatformEnum sysOrigin, Long userId,
|
public boolean incrCandyBalance(SysOriginPlatformEnum sysOrigin, Long userId,
|
||||||
BigDecimal quantity) {
|
BigDecimal quantity) {
|
||||||
FreightBalance freightBalance = getFreightBalance(sysOrigin, userId);
|
FreightBalance freightBalance = getFreightBalance(userId);
|
||||||
|
|
||||||
if (Objects.isNull(freightBalance)) {
|
if (Objects.isNull(freightBalance)) {
|
||||||
return save(new FreightBalance()
|
return save(new FreightBalance()
|
||||||
@ -115,15 +104,6 @@ public class FreightBalanceServiceImpl extends
|
|||||||
.orElse(BigDecimal.ZERO);
|
.orElse(BigDecimal.ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public BigDecimal getAvailableBalance(SysOriginPlatformEnum sysOrigin, Long userId) {
|
|
||||||
return Optional.ofNullable(getFreightBalance(sysOrigin, userId))
|
|
||||||
.map(balance -> balance.getEarnPoints()
|
|
||||||
.subtract(balance.getConsumptionPoints())
|
|
||||||
.setScale(2, RoundingMode.DOWN))
|
|
||||||
.orElse(BigDecimal.ZERO);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Set<Long> listUserIdBySysOrigin(SysOriginPlatformEnum sysOrigin) {
|
public Set<Long> listUserIdBySysOrigin(SysOriginPlatformEnum sysOrigin) {
|
||||||
return Optional.ofNullable(
|
return Optional.ofNullable(
|
||||||
@ -169,15 +149,6 @@ public class FreightBalanceServiceImpl extends
|
|||||||
return query().eq(FreightBalance::getUserId, userId).last(PageConstant.LIMIT_ONE).getOne();
|
return query().eq(FreightBalance::getUserId, userId).last(PageConstant.LIMIT_ONE).getOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public FreightBalance getFreightBalance(SysOriginPlatformEnum sysOrigin, Long userId) {
|
|
||||||
return query()
|
|
||||||
.eq(FreightBalance::getSysOrigin, sysOrigin)
|
|
||||||
.eq(FreightBalance::getUserId, userId)
|
|
||||||
.last(PageConstant.LIMIT_ONE)
|
|
||||||
.getOne();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public FreightBalance getFreightBalanceById(Long id) {
|
public FreightBalance getFreightBalanceById(Long id) {
|
||||||
return query()
|
return query()
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
package com.red.circle.wallet.infra.database.rds.service.freight.impl;
|
|
||||||
|
|
||||||
import com.red.circle.framework.mybatis.constant.PageConstant;
|
|
||||||
import com.red.circle.framework.mybatis.service.impl.BaseServiceImpl;
|
|
||||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.dao.FreightSubSellerRelationDAO;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.freight.FreightSubSellerRelation;
|
|
||||||
import com.red.circle.wallet.infra.database.rds.service.freight.FreightSubSellerRelationService;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超级经销商子 coin seller 关系服务实现.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FreightSubSellerRelationServiceImpl extends
|
|
||||||
BaseServiceImpl<FreightSubSellerRelationDAO, FreightSubSellerRelation> implements
|
|
||||||
FreightSubSellerRelationService {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean existsByChildUserId(String sysOrigin, Long childUserId) {
|
|
||||||
return Optional.ofNullable(query()
|
|
||||||
.select(FreightSubSellerRelation::getId)
|
|
||||||
.eq(FreightSubSellerRelation::getSysOrigin, sysOrigin)
|
|
||||||
.eq(FreightSubSellerRelation::getChildUserId, childUserId)
|
|
||||||
.last(PageConstant.LIMIT_ONE)
|
|
||||||
.getOne()).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public FreightSubSellerRelation getByParentAndChild(String sysOrigin, Long parentUserId,
|
|
||||||
Long parentFreightId, Long childUserId) {
|
|
||||||
return query()
|
|
||||||
.eq(FreightSubSellerRelation::getSysOrigin, sysOrigin)
|
|
||||||
.eq(FreightSubSellerRelation::getParentUserId, parentUserId)
|
|
||||||
.eq(FreightSubSellerRelation::getParentFreightId, parentFreightId)
|
|
||||||
.eq(FreightSubSellerRelation::getChildUserId, childUserId)
|
|
||||||
.last(PageConstant.LIMIT_ONE)
|
|
||||||
.getOne();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<FreightSubSellerRelation> listByParentUserId(String sysOrigin, Long parentUserId,
|
|
||||||
Long parentFreightId) {
|
|
||||||
return Optional.ofNullable(query()
|
|
||||||
.eq(FreightSubSellerRelation::getSysOrigin, sysOrigin)
|
|
||||||
.eq(FreightSubSellerRelation::getParentUserId, parentUserId)
|
|
||||||
.eq(FreightSubSellerRelation::getParentFreightId, parentFreightId)
|
|
||||||
.orderByDesc(FreightSubSellerRelation::getId)
|
|
||||||
.last(PageConstant.MAX_LIMIT)
|
|
||||||
.list())
|
|
||||||
.orElseGet(CollectionUtils::newArrayList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS `user_freight_sub_seller_relation` (
|
|
||||||
`id` bigint NOT NULL COMMENT '主键标识',
|
|
||||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
|
||||||
`parent_freight_id` bigint NOT NULL COMMENT '超级经销商 freight balance id',
|
|
||||||
`parent_user_id` bigint NOT NULL COMMENT '超级经销商用户 id',
|
|
||||||
`child_user_id` bigint NOT NULL COMMENT '子 coin seller 用户 id',
|
|
||||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
||||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
||||||
`create_user` bigint DEFAULT NULL COMMENT '创建人',
|
|
||||||
`update_user` bigint DEFAULT NULL COMMENT '更新人',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `uk_sys_origin_child_user_id` (`sys_origin`, `child_user_id`),
|
|
||||||
KEY `idx_sys_origin_parent_user_id` (`sys_origin`, `parent_user_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='超级经销商子 coin seller 关系';
|
|
||||||
Loading…
x
Reference in New Issue
Block a user