Compare commits

..

9 Commits

Author SHA1 Message Date
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
44 changed files with 3605 additions and 60 deletions

View File

@ -4,26 +4,33 @@ set -euo pipefail
BASE_DIR="${BASE_DIR:-/opt/aslan-deploy}"
SRC_DIR="${SRC_DIR:-$BASE_DIR/source/likei-services}"
KUBECONFIG="${KUBECONFIG:-$BASE_DIR/kubeconfig}"
LOCK_FILE="${LOCK_FILE:-$BASE_DIR/deploy.lock}"
NAMESPACE="${NAMESPACE:-prod}"
MODE="${MODE:-preload}"
SKIP_BUILD="${SKIP_BUILD:-0}"
USE_GIT_SOURCE="${USE_GIT_SOURCE:-0}"
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:-}"
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"
FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}"
usage() {
cat <<'EOF'
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] [wallet|other|external|console ...]
Examples:
/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 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
Notes:
@ -44,6 +51,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() {
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
return
@ -62,6 +86,7 @@ update_source_from_git() {
fi
need_cmd git
validate_git_ref
mkdir -p "$(dirname "$SRC_DIR")"
if [[ ! -d "$SRC_DIR/.git" ]]; then
rm -rf "$SRC_DIR"
@ -73,12 +98,13 @@ update_source_from_git() {
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" 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"
}
service_module() {
case "$1" in
wallet) echo "rc-service/rc-service-wallet/wallet-start" ;;
other) echo "rc-service/rc-service-other/other-start" ;;
external) echo "rc-service/rc-service-external/external-start" ;;
console) echo "rc-service/rc-service-console/console-start" ;;
@ -88,6 +114,7 @@ service_module() {
service_dir() {
case "$1" in
wallet) echo "rc-service/rc-service-wallet" ;;
other) echo "rc-service/rc-service-other" ;;
external) echo "rc-service/rc-service-external" ;;
console) echo "rc-service/rc-service-console" ;;
@ -97,9 +124,10 @@ service_dir() {
image_repo() {
case "$1" in
other) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/other" ;;
external) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/external" ;;
console) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/console" ;;
wallet) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/wallet" ;;
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 ;;
esac
}
@ -121,13 +149,14 @@ build_service() {
if [[ "$SKIP_BUILD" != "1" ]]; then
log "maven package $svc ($module)"
# 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
ensure_base_image
log "docker build $image"
docker build \
--build-arg "SERVICE_VERSION=atyou-prod:${tag}" \
--build-arg "SERVICE_VERSION=${IMAGE_PROJECT}:${tag}" \
-f "$dir/Dockerfile" \
-t "$image" \
"$dir"
@ -266,7 +295,7 @@ rollout() {
}
status() {
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy other external console -o wide
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy wallet other external console -o wide
kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide
}
@ -274,6 +303,7 @@ main() {
while [[ "$#" -gt 0 ]]; do
case "$1" in
--mode)
[[ -n "${2:-}" ]] || { echo "--mode requires an argument" >&2; exit 2; }
MODE="$2"
shift 2
;;
@ -281,6 +311,11 @@ main() {
SKIP_BUILD=1
shift
;;
--fast)
# Code-only wallet changes can keep Maven's target cache instead of forcing clean package.
MAVEN_GOALS=package
shift
;;
-h|--help)
usage
exit 0
@ -304,6 +339,7 @@ main() {
need_cmd kubectl
[[ -f "$KUBECONFIG" ]] || { echo "missing kubeconfig: $KUBECONFIG" >&2; exit 2; }
acquire_deploy_lock
update_source_from_git
local services=("$@")

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() {
cat <<'EOF'
Usage:
.deploy/aslan-deploy/sync-and-deploy.sh [remote deploy args...]
.deploy/prod-deploy/sync-and-deploy.sh [remote deploy args...]
Examples:
.deploy/aslan-deploy/sync-and-deploy.sh status
.deploy/aslan-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 status
.deploy/prod-deploy/sync-and-deploy.sh --mode preload other
.deploy/prod-deploy/sync-and-deploy.sh --mode preload other external console
Environment:
DEPLOY_HOST default 43.160.219.15
@ -29,7 +29,7 @@ Environment:
Git source on deploy host:
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
}
@ -58,6 +58,10 @@ rsync -az \
-e "$rsync_ssh" \
"$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
local_cache_path="$LOCAL_M2_ROOT/$cache_group"
remote_cache_parent="$REMOTE_M2_ROOT/$(dirname "$cache_group")"

View File

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

@ -1,5 +1,7 @@
package com.red.circle.auth.endpoint;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.red.circle.auth.common.ProcessToken;
import com.red.circle.auth.dto.TokenCredentialCO;
import com.red.circle.auth.response.AuthErrorCode;
@ -30,6 +32,12 @@ import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@ -60,6 +68,9 @@ public class EndpointRestController {
private final String LOGIN_WHITE_CONFIG = "LOGIN_WHITE_CONFIG";
private final String APPCODE_CONFIG = "APPCODE 35493154e8304e47943ca596706da44f";
private final String REQUEST_URL = "https://c2ba.api.huachen.cn/ip";
private final RedisService redisService;
/**
@ -272,8 +283,44 @@ public class EndpointRestController {
}
public String checkIpAddress(String ip) {
log.info("IP:{} 临时跳过 IP 国家校验", ip);
return null;
String redisKey = "IP_KEY:" + ip;
String data = redisService.getString(redisKey);
JSONObject dataObject = null;
if (StringUtils.isNotBlank(data)) {
dataObject = JSONObject.parseObject(data);
} else {
//每一个ip地址只能有一次校验
HttpGet httpGet = new HttpGet(REQUEST_URL + "?ip=" + ip);
httpGet.addHeader("Authorization", APPCODE_CONFIG);
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(10000).setSocketTimeout(10000).setConnectionRequestTimeout(10000).build();
try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build();
CloseableHttpResponse response = httpClient.execute(httpGet)) {
String result = EntityUtils.toString(response.getEntity());
log.info("请求地址:{},返回信息:{}", REQUEST_URL + "?ip=" + ip, result);
JSONObject jsonObject = JSON.parseObject(result);
if (jsonObject.getInteger("ret").equals(200)) {
dataObject = jsonObject.getJSONObject("data");
redisService.setString(redisKey, dataObject.toJSONString());
}
} catch (Exception e) {
log.error("请求地址:{}, 异常信息:{}", REQUEST_URL + "?ip=" + ip, e.getMessage());
}
}
log.info("IP:{}, 国家信息:{}", ip, dataObject);
// IP 归属接口超时或返回异常时不能让登录链路 NPE中港台拦截只在拿到明确国家信息时执行
if (dataObject == null) {
log.warn("IP:{} 国家信息获取失败,跳过 IP 国家校验", ip);
return null;
}
if (dataObject != null && "中国".equals(dataObject.getString("country"))) {
if (!Arrays.asList("香港", "澳门", "台湾").contains(dataObject.getString("region"))) {
ResponseAssert.isTrue(UserErrorCode.REGISTRATION_FAILED, false);
}
}
return dataObject.getString("country_id");
}
public void checkZone(String reqZone) {

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"),
/**
* 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;
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.framework.dto.PageResult;
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.bd.SalarySettlementRecordPageQryCmd;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -20,6 +24,9 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/team/salary")
public class TeamSalaryRestController extends BaseController {
private static final String SALARY_RECORD_MENU_ALIAS = "team:salary:record";
private final UserAccountService userAccountService;
private final TeamSalaryBackService teamSalaryBackService;
/**
@ -30,4 +37,24 @@ public class TeamSalaryRestController extends BaseController {
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.Sets;
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.TeamSalaryDetailsCO;
import com.red.circle.framework.core.asserts.ResponseAssert;
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.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.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.text.StringUtils;
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.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
/**
@ -37,6 +42,7 @@ public class TeamSalaryBackServiceImpl implements TeamSalaryBackService {
private final TeamProfileClient teamProfileClient;
private final UserProfileClient userProfileClient;
private final RegionConfigClient regionConfigClient;
private final SalarySettlementRecordClient salarySettlementRecordClient;
@Override
public PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query) {
@ -113,6 +119,25 @@ public class TeamSalaryBackServiceImpl implements TeamSalaryBackService {
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) {
return ResponseAssert.requiredSuccess(
regionConfigClient.mapRegionNameBySysOrigin(query.getSysOrigin()));
@ -147,4 +172,24 @@ public class TeamSalaryBackServiceImpl implements TeamSalaryBackService {
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);
if (countryChanged) {
fillCountryAndAssertAllowed(param, currentProfile);
fillConsoleCountry(param, currentProfile);
}
userProfileClient.updateSelectiveById(userAppConvertor.toUserBaseInfoCmd(param));
@ -228,52 +228,20 @@ public class UserBaseInfoServiceImpl implements
&& !Objects.equals(param.getCountryId(), currentProfile.getCountryId());
}
private void fillCountryAndAssertAllowed(UserProfileUpdateCmd param,
private void fillConsoleCountry(UserProfileUpdateCmd param,
UserProfileDTO currentProfile) {
SysCountryCodeDTO country = ResponseAssert.requiredSuccess(
countryCodeClient.getById(param.getCountryId()));
ResponseAssert.notNull(UserErrorCode.SYS_REGION_NOT_EXIST_ERROR, country);
boolean locked = hasCountryLockedIdentity(param.getId());
log.warn("console country-change identity check userId={}, locked={}, currentCountryId={}, targetCountryId={}",
param.getId(), locked, currentProfile.getCountryId(), param.getCountryId());
// 国家变更只限制平台身份用户普通用户允许后台多次调整并同步房间国家
ResponseAssert.isFalse(UserErrorCode.USER_COUNTRY_UPDATE_NOT_ALLOWED, locked);
// 后台用户资料编辑是最高权限路径只校验目标国家有效不受主播/代理/BD 等用户身份限制
log.warn("console country-change userId={}, currentCountryId={}, targetCountryId={}",
param.getId(), currentProfile.getCountryId(), param.getCountryId());
param.setCountryCode(country.getAlphaTwo());
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
public void updateVestUserInfo(UserBaseInfoCmd cmd) {
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.SalarySettlementRecordCO;
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.bd.SalarySettlementRecordPageQryCmd;
/**
* 团队自动发送工资凭证.
@ -17,4 +19,10 @@ public interface TeamSalaryBackService {
*/
PageResult<TeamSalaryCO> listByCondition(TeamSalaryBackQryCmd query);
/**
* 查询 BD/BD Leader/Admin 工资结算记录.
*/
PageResult<SalarySettlementRecordCO> listSettlementRecords(
SalarySettlementRecordPageQryCmd query);
}

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.FreightSearchUserResultCO;
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.service.UserFreightService;
import java.math.BigDecimal;
@ -269,6 +270,46 @@ public class FreightRestController extends BaseController {
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.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 经销商发送货运给代理.
@ -53,6 +54,7 @@ public class DealerToFreightShipCmdExe {
private final UserPayAuthCacheService userPayAuthCacheService;
private final FreightBalanceRunningWaterService freightBalanceRunningWaterService;
@Transactional(rollbackFor = Exception.class)
public BigDecimal execute(FreightDealerShipCmd cmd) {
controlSwitch.execute(cmd.requireReqSysOriginChildEnum());
log.warn("{}", JacksonUtils.toJson(cmd));
@ -79,7 +81,8 @@ public class DealerToFreightShipCmdExe {
// 接收用户是否为货运代理
ResponseAssert.isTrue(FreightErrorCode.NOT_FREIGHT,
freightBalanceService.existsBalance(cmd.getAcceptUserId()));
freightBalanceService.existsBalance(cmd.requireReqSysOriginChildEnum(),
cmd.getAcceptUserId()));
// 发送人与接受人必须要在相同区域下才能交易
ResponseAssert.isTrue(CommonErrorCode.REGION_NOT_SUPPORTED_ERROR,
@ -112,14 +115,16 @@ public class DealerToFreightShipCmdExe {
.decrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.requiredReqUserId(),
cmd.getQuantity()));
BigDecimal balance = freightBalanceService.getAvailableBalance(cmd.requiredReqUserId());
BigDecimal balance = freightBalanceService.getAvailableBalance(
cmd.requireReqSysOriginChildEnum(), cmd.requiredReqUserId());
// 插入流水
saveRunningWater(cmd, balance);
// 发货
freightBalanceService.incrCandyBalance(cmd.requireReqSysOriginEnum(), cmd.getAcceptUserId(),
cmd.getQuantity());
ResponseAssert.isTrue(WalletErrorCode.DELIVERY_FAILED,
freightBalanceService.incrCandyBalance(cmd.requireReqSysOriginEnum(),
cmd.getAcceptUserId(), cmd.getQuantity()));
// 余额如果等于0则回收货运代理徽章
freightShipGateway.removeFreightShipBadge(cmd.requiredReqUserId(), balance);
@ -181,7 +186,8 @@ public class DealerToFreightShipCmdExe {
if (Objects.nonNull(userProfile)) {
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.setId(IdWorkerUtils.getId());
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.DealerToUserShipCmdExe;
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.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.CheckFreightQryExe;
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.UserFreightSellerInfoQryExe;
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.FreightDealerUserSearchResultCO;
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.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.FreightDealerToUserShipCmd;
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.FreightSellerUsableGoldCmd;
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.util.List;
import lombok.RequiredArgsConstructor;
@ -52,10 +58,12 @@ public class UserFreightServiceImpl implements UserFreightService {
private final SendFreightShipCmdExe sendFreightShipCmdExe;
private final DealerToUserShipCmdExe dealerToUserShipCmdExe;
private final UserFreightSellerQryExe userFreightSellerQryExe;
private final UserFreightSubSellerQryExe userFreightSubSellerQryExe;
private final CheckFreightSellerQryExe checkFreightSellerQryExe;
private final UserFreightAccountQryExe userFreightAccountQryExe;
private final DealerToFreightShipCmdExe dealerToFreightShipCmdExe;
private final UserFreightSellerAddCmdExe userFreightSellerAddCmdExe;
private final UserFreightSubSellerAddCmdExe userFreightSubSellerAddCmdExe;
private final UserFreightSellerInfoQryExe userFreightSellerInfoQryExe;
private final DealerFreightUserSearchQryExe dealerFreightUserSearchQryExe;
private final FreightSearchUserResultQryExe freightSearchUserResultQryExe;
@ -64,6 +72,7 @@ public class UserFreightServiceImpl implements UserFreightService {
private final UserFreightAvailableBalanceQryExe userFreightAvailableBalanceQryExe;
private final UserFreightSellerUsableGoldCmdExe userFreightSellerUsableGoldCmdExe;
private final FreightSellerRunningWaterFlowQryExe freightSellerRunningWaterFlowQryExe;
private final SuperDealerSubSellerTransferCmdExe superDealerSubSellerTransferCmdExe;
@Override
public void addFreightSeller(FreightSellerCmd cmd) {
@ -151,4 +160,19 @@ public class UserFreightServiceImpl implements UserFreightService {
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.FreightSearchUserResultCO;
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.FreightDealerToUserShipCmd;
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.FreightSellerUsableGoldCmd;
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.util.List;
@ -60,4 +63,10 @@ public interface UserFreightService {
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(SysOriginPlatformEnum sysOrigin, Long userId);
/**
* 累计余额.
*
@ -65,6 +67,8 @@ public interface FreightBalanceService extends BaseService<FreightBalance> {
*/
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(SysOriginPlatformEnum sysOrigin, Long userId);
/**
* 根据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);
}
@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
public boolean incrCandyBalance(SysOriginPlatformEnum sysOrigin, Long userId,
BigDecimal quantity) {
FreightBalance freightBalance = getFreightBalance(userId);
FreightBalance freightBalance = getFreightBalance(sysOrigin, userId);
if (Objects.isNull(freightBalance)) {
return save(new FreightBalance()
@ -104,6 +115,15 @@ public class FreightBalanceServiceImpl extends
.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
public Set<Long> listUserIdBySysOrigin(SysOriginPlatformEnum sysOrigin) {
return Optional.ofNullable(
@ -149,6 +169,15 @@ public class FreightBalanceServiceImpl extends
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
public FreightBalance getFreightBalanceById(Long id) {
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 关系';