Compare commits

...

13 Commits

Author SHA1 Message Date
zhx
58dd20c70a fix(order): validate V5Pay prefix before case folding 2026-07-10 20:43:51 +08:00
zhx
534574eb71 fix(order): prevent V5Pay prefix collisions 2026-07-10 20:41:44 +08:00
zhx
0a4ca3df58 fix(order): use alphanumeric V5Pay order numbers 2026-07-10 20:39:26 +08:00
zhx
c70cb20de5 feat(order): add Aslan V5Pay recharge 2026-07-10 19:59:12 +08:00
zhx
c2e12ffb06 fix: preserve salary menu sql charset 2026-07-09 16:14:07 +08:00
zhx
23ec104e4d fix: set salary menu migration users 2026-07-09 15:54:24 +08:00
zhx
2dfbc32786 feat: add salary settlement records 2026-07-09 15:50:08 +08:00
zhx
b46c2ac951 feat: add super dealer sub coin sellers 2026-07-09 13:36:36 +08:00
zhx
305d511c19 fix(console): allow admin country changes 2026-07-08 19:23:53 +08:00
zhx
26d1c909f0 部署 2026-07-08 19:05:41 +08:00
zhx
1f370c8542 chore: adapt test deploy to compose ops panel 2026-07-08 19:02:55 +08:00
zhx
0edf38041e 部署 2026-07-08 17:47:58 +08:00
zhx
1eba136810 chore: switch test deploy branch to aslan_test 2026-07-08 17:18:10 +08:00
69 changed files with 4865 additions and 73 deletions

View File

@ -4,26 +4,35 @@ 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:-atyou_prod}" GIT_REF="${GIT_REF:-aslan_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] [other|external|console ...] deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [--fast] [gateway|wallet|order|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
USE_GIT_SOURCE=1 GIT_REF=atyou_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other /opt/aslan-deploy/deploy-likei-services.sh --fast wallet
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:
@ -44,6 +53,23 @@ 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
@ -62,6 +88,7 @@ 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"
@ -73,12 +100,15 @@ 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 "$GIT_REF" git -C "$SRC_DIR" checkout -B "$GIT_REF" "origin/$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" ;;
@ -88,6 +118,9 @@ 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" ;;
@ -97,9 +130,12 @@ service_dir() {
image_repo() { image_repo() {
case "$1" in case "$1" in
other) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/other" ;; gateway) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/gateway" ;;
external) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/external" ;; wallet) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/wallet" ;;
console) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/console" ;; order) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/order" ;;
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
} }
@ -121,13 +157,14 @@ 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
mvn clean package -pl "$module" -am -P prod -Dmaven.test.skip=true $MAVEN_EXTRA_ARGS # MAVEN_GOALS=package keeps the existing target cache for code-only fast builds; use the default clean package for safer full builds.
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=atyou-prod:${tag}" \ --build-arg "SERVICE_VERSION=${IMAGE_PROJECT}:${tag}" \
-f "$dir/Dockerfile" \ -f "$dir/Dockerfile" \
-t "$image" \ -t "$image" \
"$dir" "$dir"
@ -266,7 +303,7 @@ rollout() {
} }
status() { status() {
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy other external console -o wide kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy gateway wallet order other external console -o wide
kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide
} }
@ -274,6 +311,7 @@ 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
;; ;;
@ -281,6 +319,11 @@ 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
@ -304,10 +347,12 @@ 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

View File

@ -0,0 +1,21 @@
[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

View File

@ -0,0 +1,984 @@
#!/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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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()

View File

@ -0,0 +1,105 @@
-- 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;

View File

@ -0,0 +1,14 @@
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 关系';

View File

@ -15,12 +15,12 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: Usage:
.deploy/aslan-deploy/sync-and-deploy.sh [remote deploy args...] .deploy/prod-deploy/sync-and-deploy.sh [remote deploy args...]
Examples: Examples:
.deploy/aslan-deploy/sync-and-deploy.sh status .deploy/prod-deploy/sync-and-deploy.sh status
.deploy/aslan-deploy/sync-and-deploy.sh --mode preload other .deploy/prod-deploy/sync-and-deploy.sh --mode preload other
.deploy/aslan-deploy/sync-and-deploy.sh --mode preload other external console .deploy/prod-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=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'
EOF EOF
} }
@ -58,6 +58,10 @@ 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")"

View File

@ -0,0 +1,257 @@
#!/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 "$@"

View File

@ -0,0 +1,28 @@
[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

View File

@ -0,0 +1,591 @@
#!/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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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()

View File

@ -0,0 +1,105 @@
-- 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;

View File

@ -0,0 +1,14 @@
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 关系';

View File

@ -0,0 +1,55 @@
#!/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

View File

@ -70,6 +70,13 @@ 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 headerbody 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())
@ -83,6 +90,13 @@ 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) {

View File

@ -0,0 +1,51 @@
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()));
}
}

View File

@ -24,6 +24,9 @@ public enum MonthlyRechargeType {
*/ */
MIFA_PAY, MIFA_PAY,
/** V5Pay web 充值。 */
V5_PAY,
/** /**
* Airwallex. * Airwallex.
*/ */

View File

@ -0,0 +1,15 @@
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 {
}

View File

@ -0,0 +1,25 @@
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);
}

View File

@ -0,0 +1,51 @@
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;
}

View File

@ -0,0 +1,127 @@
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;
}

View File

@ -79,6 +79,21 @@ 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"),
; ;

View File

@ -1,10 +1,14 @@
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;
@ -20,6 +24,9 @@ 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;
/** /**
@ -30,4 +37,24 @@ 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("无权限访问工资记录");
}
}
} }

View File

@ -3,14 +3,18 @@ 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;
@ -22,6 +26,7 @@ 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;
/** /**
@ -37,6 +42,7 @@ 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) {
@ -113,6 +119,25 @@ 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()));
@ -147,4 +172,24 @@ 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));
}
} }

View File

@ -206,7 +206,7 @@ public class UserBaseInfoServiceImpl implements
boolean countryChanged = countryChanged(param, currentProfile); boolean countryChanged = countryChanged(param, currentProfile);
if (countryChanged) { if (countryChanged) {
fillCountryAndAssertAllowed(param, currentProfile); fillConsoleCountry(param, currentProfile);
} }
userProfileClient.updateSelectiveById(userAppConvertor.toUserBaseInfoCmd(param)); userProfileClient.updateSelectiveById(userAppConvertor.toUserBaseInfoCmd(param));
@ -228,52 +228,20 @@ public class UserBaseInfoServiceImpl implements
&& !Objects.equals(param.getCountryId(), currentProfile.getCountryId()); && !Objects.equals(param.getCountryId(), currentProfile.getCountryId());
} }
private void fillCountryAndAssertAllowed(UserProfileUpdateCmd param, private void fillConsoleCountry(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);
boolean locked = hasCountryLockedIdentity(param.getId()); // 后台用户资料编辑是最高权限路径只校验目标国家有效不受主播/代理/BD 等用户身份限制
log.warn("console country-change identity check userId={}, locked={}, currentCountryId={}, targetCountryId={}", log.warn("console country-change userId={}, currentCountryId={}, targetCountryId={}",
param.getId(), locked, currentProfile.getCountryId(), param.getCountryId()); param.getId(), 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());

View File

@ -0,0 +1,27 @@
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;
}

View File

@ -2,8 +2,10 @@ 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;
/** /**
* 团队自动发送工资凭证. * 团队自动发送工资凭证.
@ -17,4 +19,10 @@ public interface TeamSalaryBackService {
*/ */
PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query); PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query);
/**
* 查询 BD/BD Leader/Admin 工资结算记录.
*/
PageResult<SalarySettlementRecordCO> listSettlementRecords(
SalarySettlementRecordPageQryCmd query);
} }

View File

@ -1,5 +1,8 @@
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;
@ -12,7 +15,9 @@ 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;
@ -90,6 +95,53 @@ 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 退款回调地址-自定义.
*/ */

View File

@ -123,9 +123,9 @@ public class WebPayRestController {
} }
/** /**
* H5 MiFaPay 下单. * H5 三方支付下单MiFaPay/V5Pay.
* *
* @eo.name H5 MiFaPay 下单. * @eo.name H5 三方支付下单.
* @eo.url /h5/orders * @eo.url /h5/orders
* @eo.method post * @eo.method post
* @eo.request-type json * @eo.request-type json

View File

@ -0,0 +1,20 @@
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"));
}
}

View File

@ -18,6 +18,12 @@
<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>

View File

@ -31,12 +31,13 @@ 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 下单和查单 * Aslan H5 MiFaPay/V5Pay 下单和查单
* *
* <p>前端只提交商品 id 和支付方式标识金额金币数商品类型币种和汇率全部从后台表重新生成</p> * <p>前端只提交商品 id 和支付方式标识金额金币数商品类型币种和汇率全部从后台表重新生成</p>
* *
@ -47,7 +48,9 @@ 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;
@ -55,23 +58,30 @@ 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.equalsIgnoreCase(Objects.toString(cmd.getProviderCode(), ""))); PROVIDER_MIFAPAY.equals(providerCode) || PROVIDER_V5PAY.equals(providerCode));
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); PayPlaceAnOrderDetailsV2 details = buildDetails(cmd, providerCode);
PlaceAnOrderResponseCO response = payPlaceAnOrderService.doRequest(details); PlaceAnOrderResponseCO response = payPlaceAnOrderService.doRequest(details);
return new PayWebH5OrderCO() return new PayWebH5OrderCO()
.setOrderId(details.getOrderId()) .setOrderId(details.getOrderId())
.setProviderCode(PROVIDER_MIFAPAY) .setProviderCode(providerCode)
.setPayUrl(response.getRequestUrl()) .setPayUrl(response.getRequestUrl())
.setStatus("redirected") .setStatus("redirected")
.setProviderAmountMinor(minor(details.getAmount(), 100L)) .setProviderAmountMinor(minor(details.getAmount(), 100L))
@ -83,9 +93,14 @@ 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(PROVIDER_MIFAPAY) .setProviderCode(providerCode(order))
.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))
@ -93,7 +108,8 @@ 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);
@ -143,7 +159,7 @@ public class PayWebH5PlaceOrderCmdExe {
.setAmountUsd(commodity.getAmountUsd()) .setAmountUsd(commodity.getAmountUsd())
.setQuantity(1))); .setQuantity(1)));
details.setProductDescriptor(productDescriptor(commodity)); details.setProductDescriptor(productDescriptor(commodity));
details.setChannelDetails(mifaPayChannel(cmd, payCountry)); details.setChannelDetails(paymentChannel(cmd, payCountry, providerCode));
details.setCountry(sysCountryCode); details.setCountry(sysCountryCode);
details.setPayCountry(payCountry); details.setPayCountry(payCountry);
details.setRechargeUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), "")); details.setRechargeUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), ""));
@ -151,19 +167,25 @@ 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 mifaPayChannel(PayWebH5PlaceOrderCmd cmd, PayCountry payCountry) { private PayCountryChannelDetails paymentChannel(PayWebH5PlaceOrderCmd cmd,
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()
: cmd.getPayWay() + "_" + cmd.getPayType(); : fallbackChannel;
// 这里刻意不查后台渠道明细表H5 支付国家和方式来自 app JSON本对象只为复用现有 MiFaPay 策略和订单快照 // H5 支付方式来自 app JSON本对象用于复用统一策略工厂并把 productType 固化到订单快照
return new PayCountryChannelDetails() return new PayCountryChannelDetails()
.setId(0L) .setId(0L)
.setPayCountryId(payCountry.getId()) .setPayCountryId(payCountry.getId())
.setChannelCode(channelCode) .setChannelCode(channelCode)
.setFactoryCode(FACTORY_MIFAPAY) .setFactoryCode(v5Pay ? FACTORY_V5PAY : FACTORY_MIFAPAY)
.setFactoryChannel(cmd.getPayType()) .setFactoryChannel(cmd.getPayType())
.setFactoryCurrencyPoint(2) .setFactoryCurrencyPoint(2)
.setShelf(Boolean.TRUE); .setShelf(Boolean.TRUE);
@ -190,6 +212,13 @@ 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;

View File

@ -0,0 +1,216 @@
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) {
// 回调日志不打印 fieldssign 或异常对象的序列化内容防止签名材料进入日志系统
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 只需保留 pendingcallback 必须返回 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);
}
}

View File

@ -0,0 +1,17 @@
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);
}
}

View File

@ -120,6 +120,9 @@ 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();
} }

View File

@ -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(param.getChannelCode()) .setFactoryChannelCode(factoryChannelCode(param))
) )
.setProducts(param.getProducts()) .setProducts(param.getProducts())
.setProductDescriptor(param.getProductDescriptor()) .setProductDescriptor(param.getProductDescriptor())
@ -93,6 +93,13 @@ 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();
} }

View File

@ -0,0 +1,54 @@
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"))
// 订单快照只保存非敏感业务参数商户号appKeysecretKey 不进入订单扩展数据
.putRequestParam(param)
.putResponseParam(response.sanitizedFields());
}
}

View File

@ -0,0 +1,34 @@
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;
}
}

View File

@ -0,0 +1,63 @@
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;
}
}
}

View File

@ -0,0 +1,22 @@
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;
}

View File

@ -0,0 +1,63 @@
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&amp;...最后直接追加 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, "");
}
}

View File

@ -0,0 +1,20 @@
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;
}

View File

@ -29,6 +29,7 @@ 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;
@ -71,6 +72,7 @@ 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
@ -184,4 +186,9 @@ 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);
}
} }

View File

@ -0,0 +1,197 @@
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";
};
}
}

View File

@ -0,0 +1,223 @@
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;
}
}
}

View File

@ -0,0 +1,29 @@
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;
}
}

View File

@ -0,0 +1,101 @@
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.
}
}
}

View File

@ -51,4 +51,7 @@ public interface InAppPurchaseProductService {
*/ */
String miFaPayServerNoticeReceivePayment(byte[] body); String miFaPayServerNoticeReceivePayment(byte[] body);
/** V5Pay 支付回调通知,返回小写纯文本 success/fail。 */
String v5PayServerNoticeReceivePayment(Map<String, String> fields);
} }

View File

@ -0,0 +1,39 @@
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";
}

View File

@ -30,4 +30,15 @@ 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}

View File

@ -0,0 +1,32 @@
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));
}
}

View File

@ -0,0 +1,18 @@
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);
}

View File

@ -0,0 +1,310 @@
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;
}
}
}

View File

@ -13,6 +13,7 @@ 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;
@ -269,6 +270,46 @@ 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);
}
/** /**
* 是否是经销商卖家. * 是否是经销商卖家.
* *

View File

@ -31,6 +31,7 @@ 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;
/** /**
* 经销商发送货运给代理. * 经销商发送货运给代理.
@ -53,6 +54,7 @@ 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));
@ -79,7 +81,8 @@ public class DealerToFreightShipCmdExe {
// 接收用户是否为货运代理 // 接收用户是否为货运代理
ResponseAssert.isTrue(FreightErrorCode.NOT_FREIGHT, ResponseAssert.isTrue(FreightErrorCode.NOT_FREIGHT,
freightBalanceService.existsBalance(cmd.getAcceptUserId())); freightBalanceService.existsBalance(cmd.requireReqSysOriginChildEnum(),
cmd.getAcceptUserId()));
// 发送人与接受人必须要在相同区域下才能交易 // 发送人与接受人必须要在相同区域下才能交易
ResponseAssert.isTrue(CommonErrorCode.REGION_NOT_SUPPORTED_ERROR, ResponseAssert.isTrue(CommonErrorCode.REGION_NOT_SUPPORTED_ERROR,
@ -112,14 +115,16 @@ public class DealerToFreightShipCmdExe {
.decrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.requiredReqUserId(), .decrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.requiredReqUserId(),
cmd.getQuantity())); cmd.getQuantity()));
BigDecimal balance = freightBalanceService.getAvailableBalance(cmd.requiredReqUserId()); BigDecimal balance = freightBalanceService.getAvailableBalance(
cmd.requireReqSysOriginChildEnum(), cmd.requiredReqUserId());
// 插入流水 // 插入流水
saveRunningWater(cmd, balance); saveRunningWater(cmd, balance);
// 发货 // 发货
freightBalanceService.incrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.getAcceptUserId(), ResponseAssert.isTrue(WalletErrorCode.DELIVERY_FAILED,
cmd.getQuantity()); freightBalanceService.incrCandyBalance(cmd.requireReqSysOriginEnum(),
cmd.getAcceptUserId(), cmd.getQuantity()));
// 余额如果等于0则回收货运代理徽章 // 余额如果等于0则回收货运代理徽章
freightShipGateway.removeFreightShipBadge(cmd.requiredReqUserId(), balance); freightShipGateway.removeFreightShipBadge(cmd.requiredReqUserId(), balance);
@ -181,7 +186,8 @@ public class DealerToFreightShipCmdExe {
if (Objects.nonNull(userProfile)) { if (Objects.nonNull(userProfile)) {
freightBalanceRunningWater.setRemark("从[" + userProfile.getAccount() + "]经销商进货"); freightBalanceRunningWater.setRemark("从[" + userProfile.getAccount() + "]经销商进货");
} }
BigDecimal acceptBalance = freightBalanceService.getAvailableBalance(cmd.getAcceptUserId()); BigDecimal acceptBalance = freightBalanceService.getAvailableBalance(
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());

View File

@ -0,0 +1,56 @@
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);
}
}

View File

@ -0,0 +1,66 @@
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()));
}
}

View File

@ -0,0 +1,72 @@
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);
}
}

View File

@ -0,0 +1,36 @@
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;
}
}

View File

@ -7,8 +7,10 @@ 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;
@ -21,11 +23,13 @@ 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;
@ -33,6 +37,8 @@ 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;
@ -52,10 +58,12 @@ 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;
@ -64,6 +72,7 @@ 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) {
@ -151,4 +160,19 @@ 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);
}
} }

View File

@ -0,0 +1,47 @@
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;
}

View File

@ -0,0 +1,28 @@
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;
}

View File

@ -0,0 +1,42 @@
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;
}

View File

@ -9,6 +9,7 @@ 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;
@ -16,6 +17,8 @@ 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;
@ -60,4 +63,10 @@ public interface UserFreightService {
boolean checkFreightFirstRecharge(AppUserIdCmd cmd); boolean checkFreightFirstRecharge(AppUserIdCmd cmd);
List<FreightSubSellerCO> listFreightSubSeller(AppExtCommand cmd);
void addFreightSubSeller(FreightSubSellerCmd cmd);
BigDecimal superDealerTransferToSubSeller(FreightSubSellerTransferCmd cmd);
} }

View File

@ -0,0 +1,11 @@
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> {
}

View File

@ -0,0 +1,54 @@
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;
}

View File

@ -37,6 +37,8 @@ public interface FreightBalanceService extends BaseService<FreightBalance> {
*/ */
boolean existsBalance(Long userId); boolean existsBalance(Long userId);
boolean existsBalance(SysOriginPlatformEnum sysOrigin, Long userId);
/** /**
* 累计余额. * 累计余额.
* *
@ -65,6 +67,8 @@ public interface FreightBalanceService extends BaseService<FreightBalance> {
*/ */
BigDecimal getAvailableBalance(Long userId); BigDecimal getAvailableBalance(Long userId);
BigDecimal getAvailableBalance(SysOriginPlatformEnum sysOrigin, Long userId);
/** /**
* 获取平台货运代理人列表. * 获取平台货运代理人列表.
* *
@ -119,6 +123,8 @@ public interface FreightBalanceService extends BaseService<FreightBalance> {
*/ */
FreightBalance getFreightBalance(Long userId); FreightBalance getFreightBalance(Long userId);
FreightBalance getFreightBalance(SysOriginPlatformEnum sysOrigin, Long userId);
/** /**
* 根据id获取货运代理 * 根据id获取货运代理
*/ */

View File

@ -0,0 +1,19 @@
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);
}

View File

@ -59,10 +59,21 @@ 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(userId); FreightBalance freightBalance = getFreightBalance(sysOrigin, userId);
if (Objects.isNull(freightBalance)) { if (Objects.isNull(freightBalance)) {
return save(new FreightBalance() return save(new FreightBalance()
@ -104,6 +115,15 @@ 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(
@ -149,6 +169,15 @@ 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()

View File

@ -0,0 +1,57 @@
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);
}
}

View File

@ -0,0 +1,14 @@
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 关系';