+
门店名称
{storeName}
{String(s.address || s.district || '')}
-
- {pill.label}
- {audit && {audit.label}}
-
+
+ {storeStatusLabel(currentStatus)}
+
-
-
-
+
+
+ {currentStatus === 'PAUSED' && (
+
+ )}
+
edit
diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css
index 95de32b..aadbbc5 100644
--- a/apps/h5-partner/src/styles.css
+++ b/apps/h5-partner/src/styles.css
@@ -926,6 +926,230 @@ html, body, #root {
font-size: 10px;
}
+.partner-upload-dashed--compact {
+ padding: 12px;
+ font-size: 10px;
+ aspect-ratio: 1;
+}
+
+.partner-oss-upload-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+}
+
+.partner-upload-preview {
+ position: relative;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 0;
+ background: transparent;
+ border-radius: 12px;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.partner-upload-preview--wide {
+ aspect-ratio: 16 / 9;
+}
+
+.partner-upload-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.partner-upload-preview-mask {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ background: rgba(0, 0, 0, 0.35);
+ color: #fff;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.partner-upload-preview:hover .partner-upload-preview-mask,
+.partner-upload-preview:focus-within .partner-upload-preview-mask {
+ opacity: 1;
+}
+
+.partner-upload-file {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ min-height: 96px;
+ padding: 16px;
+ border-radius: 12px;
+ background-color: var(--color-surface-container-low);
+ border: 1px solid rgba(166, 29, 36, 0.2);
+ cursor: pointer;
+ text-align: left;
+}
+
+.partner-region-trigger {
+ width: 100%;
+ justify-content: flex-start;
+ text-align: left;
+}
+
+.partner-region-chevron {
+ margin-left: auto;
+}
+
+.partner-region-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+.partner-region-sheet {
+ width: 100%;
+ max-width: 480px;
+ max-height: min(78vh, 640px);
+ background: var(--color-surface);
+ border-radius: 16px 16px 0 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.partner-region-sheet-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--color-outline-variant);
+ flex-shrink: 0;
+}
+
+.partner-region-sheet-btn {
+ border: none;
+ background: transparent;
+ color: var(--color-on-surface-variant);
+ font-size: 14px;
+ padding: 8px;
+ min-width: 48px;
+}
+
+.partner-region-sheet-btn--primary {
+ color: var(--color-primary);
+ font-weight: 600;
+}
+
+.partner-region-sheet-btn--primary:disabled {
+ opacity: 0.4;
+}
+
+.partner-region-steps {
+ display: flex;
+ gap: 8px;
+ padding: 12px 16px 0;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ flex-shrink: 0;
+}
+
+.partner-region-step {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid var(--color-outline-variant);
+ border-radius: 999px;
+ padding: 6px 12px;
+ background: var(--color-surface-container-low);
+ color: var(--color-on-surface-variant);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.partner-region-step--active {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+ background: rgba(166, 29, 36, 0.08);
+}
+
+.partner-region-step--done {
+ color: var(--color-on-surface);
+}
+
+.partner-region-step-index {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-surface-container-highest);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.partner-region-step--active .partner-region-step-index {
+ background: var(--color-primary);
+ color: #fff;
+}
+
+.partner-region-breadcrumb {
+ padding: 8px 16px 0;
+ flex-shrink: 0;
+}
+
+.partner-region-list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+ touch-action: pan-y;
+ padding: 8px 0 calc(12px + env(safe-area-inset-bottom, 0px));
+}
+
+.partner-region-columns {
+ display: none;
+}
+
+.partner-region-column {
+ overflow-y: auto;
+ border-right: 1px solid var(--color-outline-variant);
+}
+
+.partner-region-column:last-child {
+ border-right: none;
+}
+
+.partner-region-item {
+ display: block;
+ width: 100%;
+ border: none;
+ background: transparent;
+ text-align: left;
+ padding: 14px 16px;
+ font-size: 15px;
+ color: var(--color-on-surface);
+}
+
+.partner-region-item--active {
+ color: var(--color-primary);
+ background: rgba(166, 29, 36, 0.08);
+ font-weight: 600;
+}
+
.partner-sticky-footer {
position: fixed;
bottom: 0;
@@ -1053,7 +1277,39 @@ html, body, #root {
display: flex;
justify-content: space-between;
align-items: flex-start;
- margin-bottom: 16px;
+ gap: 12px;
+}
+
+.partner-store-card-hit {
+ display: block;
+ width: 100%;
+ color: inherit;
+ text-decoration: none;
+}
+
+.partner-audit-result {
+ margin: 48px var(--space-page) 0;
+ padding: 32px 24px;
+ text-align: center;
+ background: var(--color-card);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-card);
+}
+
+.partner-audit-result-icon {
+ width: 72px;
+ height: 72px;
+ margin: 0 auto 16px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(42, 110, 187, 0.12);
+ color: var(--color-status-blue);
+}
+
+.partner-audit-result-icon .material-symbols-outlined {
+ font-size: 36px;
}
.partner-status-pill {
@@ -1074,6 +1330,11 @@ html, body, #root {
color: var(--color-secondary);
}
+.partner-status-pill--auditing {
+ background: rgba(42, 110, 187, 0.12);
+ color: var(--color-status-blue);
+}
+
.partner-status-pill--closed {
background: var(--color-surface-container);
color: var(--color-subtle-gray);
@@ -1217,7 +1478,7 @@ html, body, #root {
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
- z-index: 200;
+ z-index: 10001;
}
/* ── Orders ── */
diff --git a/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt b/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt
new file mode 100644
index 0000000..af78290
--- /dev/null
+++ b/apps/h5-user/public/MP_verify_ayPJ4CQqbUcec3jX.txt
@@ -0,0 +1 @@
+ayPJ4CQqbUcec3jX
diff --git a/deploy/deploy.env.example b/deploy/deploy.env.example
new file mode 100644
index 0000000..cc0c8fe
--- /dev/null
+++ b/deploy/deploy.env.example
@@ -0,0 +1,9 @@
+# 复制为 deploy.env 后填写(deploy.env 勿提交)
+DEPLOY_HOST=your.server.ip.or.domain
+DEPLOY_USER=root
+DEPLOY_PORT=22
+# DEPLOY_SSH_KEY=~/.ssh/id_rsa
+
+APP_ROOT=/opt/dukang-haoke
+GIT_REMOTE=origin
+GIT_BRANCH=main
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100644
index 0000000..f65eab4
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+# 本地执行:SSH 到服务器拉代码并发布
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ENV_FILE="$SCRIPT_DIR/deploy.env"
+
+DEPLOY_HOST=""
+DEPLOY_USER="root"
+DEPLOY_PORT="22"
+DEPLOY_SSH_KEY=""
+APP_ROOT="/opt/dukang-haoke"
+GIT_REMOTE="origin"
+GIT_BRANCH="main"
+
+CHECK_ONLY=false
+RELEASE_ARGS=()
+
+usage() {
+ cat <<'EOF'
+用法: deploy/deploy.sh [选项] [-- 远程 release 参数]
+
+环境配置: deploy/deploy.env(可复制 deploy.env.example)
+
+选项:
+ --host HOST 服务器地址(覆盖 deploy.env)
+ --user USER SSH 用户,默认 root
+ --port PORT SSH 端口,默认 22
+ --key PATH SSH 私钥路径
+ --branch BRANCH 发布分支,默认 main
+ --check 仅 SSH 连接并查看 PM2 / 端口状态
+ -h, --help 显示帮助
+
+示例:
+ ./deploy/deploy.sh
+ ./deploy/deploy.sh --branch main
+ ./deploy/deploy.sh -- --skip-db
+ ./deploy/deploy.sh -- --seed --accept-data-loss
+EOF
+}
+
+load_env() {
+ if [[ -f "$ENV_FILE" ]]; then
+ # shellcheck disable=SC1090
+ source "$ENV_FILE"
+ fi
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --host) DEPLOY_HOST="$2"; shift 2 ;;
+ --user) DEPLOY_USER="$2"; shift 2 ;;
+ --port) DEPLOY_PORT="$2"; shift 2 ;;
+ --key) DEPLOY_SSH_KEY="$2"; shift 2 ;;
+ --branch) GIT_BRANCH="$2"; shift 2 ;;
+ --check) CHECK_ONLY=true; shift ;;
+ -h|--help) usage; exit 0 ;;
+ --)
+ shift
+ RELEASE_ARGS=("$@")
+ break
+ ;;
+ *) RELEASE_ARGS+=("$1"); shift ;;
+ esac
+done
+
+load_env
+
+if [[ -z "$DEPLOY_HOST" ]]; then
+ echo "错误: 未配置 DEPLOY_HOST。请创建 deploy/deploy.env 或使用 --host" >&2
+ exit 1
+fi
+
+SSH_OPTS=(-o "StrictHostKeyChecking=accept-new" -p "$DEPLOY_PORT")
+if [[ -n "$DEPLOY_SSH_KEY" ]]; then
+ SSH_OPTS+=(-i "$DEPLOY_SSH_KEY")
+fi
+TARGET="${DEPLOY_USER}@${DEPLOY_HOST}"
+
+run_remote() {
+ ssh "${SSH_OPTS[@]}" "$TARGET" "$@"
+}
+
+if [[ "$CHECK_ONLY" == true ]]; then
+ echo "==> 检查 $TARGET"
+ run_remote "pm2 list; ss -tlnp | grep -E '809[0-3]' || true"
+ exit 0
+fi
+
+REMOTE_RELEASE_ARGS=""
+if [[ ${#RELEASE_ARGS[@]} -gt 0 ]]; then
+ REMOTE_RELEASE_ARGS="${RELEASE_ARGS[*]}"
+fi
+
+echo "==> 发布到 $TARGET ($GIT_BRANCH)"
+
+run_remote bash -s <
&2
+ exit 1
+fi
+
+echo "==> 拉取代码"
+git fetch "\$GIT_REMOTE"
+git checkout "\$GIT_BRANCH"
+git pull --ff-only "\$GIT_REMOTE" "\$GIT_BRANCH"
+
+if [[ ! -f deploy/remote-release.sh ]]; then
+ echo "错误: deploy/remote-release.sh 不存在,请先推送部署脚本到仓库" >&2
+ exit 1
+fi
+
+bash deploy/remote-release.sh \$RELEASE_ARGS
+EOF
+
+echo "==> 本地发布命令已完成"
diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs
index 8f8dce5..22f5798 100644
--- a/deploy/ecosystem.config.cjs
+++ b/deploy/ecosystem.config.cjs
@@ -41,5 +41,14 @@ module.exports = {
instances: 1,
exec_mode: 'fork',
},
+ {
+ name: 'dukang-admin-web',
+ cwd: `${APP_ROOT}/apps/admin-web`,
+ script: 'npx',
+ args: 'serve -s dist -l 8094',
+ interpreter: 'none',
+ instances: 1,
+ exec_mode: 'fork',
+ },
],
};
diff --git a/deploy/enable-api-lingshi-ssl.sh b/deploy/enable-api-lingshi-ssl.sh
new file mode 100644
index 0000000..e028a91
--- /dev/null
+++ b/deploy/enable-api-lingshi-ssl.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# 为 api.lingshivip.cn 申请 DNS 证书并启用 HTTPS
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SSL_DIR="/etc/nginx/ssl/api.lingshivip.cn"
+NGINX_CONF="/etc/nginx/conf.d/dukang-lingshivip.conf"
+
+mkdir -p "$SSL_DIR" /var/www/certbot/.well-known/acme-challenge
+
+echo "==> 1. 发起 DNS 验证(需先在 DNSPod 添加 TXT 记录)"
+~/.acme.sh/acme.sh --issue -d api.lingshivip.cn --dns \
+ --yes-I-know-dns-manual-mode-enough-go-ahead-please \
+ --server letsencrypt || true
+
+echo ""
+echo "请在 DNSPod 添加上述 TXT 记录后,执行:"
+echo " $0 --renew"
+echo ""
+
+if [[ "${1:-}" != "--renew" ]]; then
+ exit 0
+fi
+
+echo "==> 2. 继续 DNS 验证并签发证书"
+~/.acme.sh/acme.sh --renew -d api.lingshivip.cn --yes-I-know-dns-manual-mode-enough-go-ahead-please
+
+echo "==> 3. 安装证书到 nginx 目录"
+install -m 644 ~/.acme.sh/api.lingshivip.cn_ecc/fullchain.cer "$SSL_DIR/fullchain.cer"
+install -m 600 ~/.acme.sh/api.lingshivip.cn_ecc/api.lingshivip.cn.key "$SSL_DIR/api.lingshivip.cn.key"
+
+echo "==> 4. 更新 nginx 并重载"
+cp "$SCRIPT_DIR/nginx-lingshivip.conf" "$NGINX_CONF"
+python3 - <<'PY'
+from pathlib import Path
+p = Path("/etc/nginx/conf.d/dukang-lingshivip.conf")
+text = p.read_text()
+old = """ location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+}
+
+# API 独立域名 HTTPS"""
+new = """ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+# API 独立域名 HTTPS"""
+if old not in text:
+ raise SystemExit("nginx api http redirect patch failed")
+p.write_text(text.replace(old, new, 1))
+PY
+
+nginx -t
+systemctl reload nginx
+
+echo "==> 5. 健康检查"
+curl -sf -o /dev/null -w "api-https:%{http_code}\n" https://api.lingshivip.cn/api/v1/health
+echo "==> api.lingshivip.cn HTTPS 已启用"
diff --git a/deploy/enable-runxian-dukang-ssl.sh b/deploy/enable-runxian-dukang-ssl.sh
new file mode 100644
index 0000000..4240266
--- /dev/null
+++ b/deploy/enable-runxian-dukang-ssl.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# 为杜康 runxian.top 五域名申请 Let's Encrypt 并切换 HTTPS 配置
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+DOMAINS=(
+ user.runxian.top
+ shop.runxian.top
+ partner.runxian.top
+ webadmin.runxian.top
+ dkapi.runxian.top
+)
+
+echo "==> 检查 DNS 解析到本机..."
+for d in "${DOMAINS[@]}"; do
+ ip="$(dig +short "$d" @223.5.5.5 | tail -1)"
+ if [[ -z "$ip" ]]; then
+ echo "ERROR: $d 无 A 记录,请先在 DNS 添加指向本机公网 IP"
+ exit 1
+ fi
+ echo " $d -> $ip"
+done
+
+mkdir -p /var/www/certbot /var/log/nginx/dukang
+
+echo "==> 申请证书..."
+certbot certonly --webroot -w /var/www/certbot \
+ --cert-name user.runxian.top \
+ -d user.runxian.top \
+ -d shop.runxian.top \
+ -d partner.runxian.top \
+ -d webadmin.runxian.top \
+ -d dkapi.runxian.top \
+ --non-interactive --agree-tos -m admin@runxian.top || {
+ echo "certbot 失败,请确认 DNS 已生效且 80 端口可从公网访问"
+ exit 1
+}
+
+echo "==> 切换 nginx HTTPS 配置..."
+install -m 644 "$SCRIPT_DIR/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf
+nginx -t
+systemctl reload nginx
+
+echo "==> 验证..."
+for url in \
+ https://user.runxian.top/ \
+ https://shop.runxian.top/ \
+ https://partner.runxian.top/ \
+ https://webadmin.runxian.top/ \
+ https://dkapi.runxian.top/api/v1/health; do
+ code="$(curl -sf -o /dev/null -w '%{http_code}' "$url" || echo fail)"
+ echo " $url -> $code"
+done
+
+echo "==> runxian.top 杜康域名 HTTPS 已启用"
diff --git a/deploy/nginx-lingshivip-http.conf b/deploy/nginx-lingshivip-http.conf
new file mode 100644
index 0000000..4da8d6a
--- /dev/null
+++ b/deploy/nginx-lingshivip-http.conf
@@ -0,0 +1,105 @@
+# 证书申请期间 — lingshivip.cn HTTP(api HTTPS 证书就绪后换 nginx-lingshivip.conf)
+
+map $host $dukang_lingshi_port {
+ user.lingshivip.cn 8091;
+ shop.lingshivip.cn 8092;
+ partner.lingshivip.cn 8093;
+ admin.lingshivip.cn 8094;
+}
+
+server {
+ listen 80;
+ server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 80;
+ server_name api.lingshivip.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+}
+
+server {
+ listen 80;
+ server_name admin.lingshivip.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8094;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 443 ssl;
+ server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
+
+ access_log /var/log/nginx/dukang/lingshi.access.log main;
+ error_log /var/log/nginx/dukang/lingshi.error.log warn;
+
+ ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_prefer_server_ciphers on;
+
+ client_max_body_size 20m;
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:$dukang_lingshi_port;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/deploy/nginx-lingshivip.conf b/deploy/nginx-lingshivip.conf
new file mode 100644
index 0000000..1efb64b
--- /dev/null
+++ b/deploy/nginx-lingshivip.conf
@@ -0,0 +1,135 @@
+# 杜康好客 — lingshivip.cn 五端 + API
+# API 8090;H5 8091/8092/8093;admin-web 8094
+
+map $host $dukang_lingshi_port {
+ user.lingshivip.cn 8091;
+ shop.lingshivip.cn 8092;
+ partner.lingshivip.cn 8093;
+ admin.lingshivip.cn 8094;
+}
+
+# HTTP → HTTPS(H5 三端)
+server {
+ listen 80;
+ server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+# API:证书就绪前 HTTP 直连;就绪后改走 HTTPS server
+server {
+ listen 80;
+ server_name api.lingshivip.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+}
+
+# admin 待 DNS + 证书就绪前先走 HTTP
+server {
+ listen 80;
+ server_name admin.lingshivip.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8094;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# API 独立域名 HTTPS(证书:/etc/nginx/ssl/api.lingshivip.cn/)
+server {
+ listen 443 ssl;
+ server_name api.lingshivip.cn;
+
+ access_log /var/log/nginx/dukang/lingshi-api.access.log main;
+ error_log /var/log/nginx/dukang/lingshi-api.error.log warn;
+
+ ssl_certificate /etc/nginx/ssl/api.lingshivip.cn/fullchain.cer;
+ ssl_certificate_key /etc/nginx/ssl/api.lingshivip.cn/api.lingshivip.cn.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_prefer_server_ciphers on;
+
+ client_max_body_size 20m;
+
+ location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+}
+
+server {
+ listen 443 ssl;
+ server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
+
+ access_log /var/log/nginx/dukang/lingshi.access.log main;
+ error_log /var/log/nginx/dukang/lingshi.error.log warn;
+
+ ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_prefer_server_ciphers on;
+
+ client_max_body_size 20m;
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:$dukang_lingshi_port;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/deploy/nginx-runxian-dukang-ssl.conf b/deploy/nginx-runxian-dukang-ssl.conf
new file mode 100644
index 0000000..b240a5c
--- /dev/null
+++ b/deploy/nginx-runxian-dukang-ssl.conf
@@ -0,0 +1,150 @@
+# 杜康好客 — runxian.top HTTPS(证书申请后启用)
+# 执行: deploy/enable-runxian-dukang-ssl.sh
+
+map $host $dukang_runxian_port {
+ user.runxian.top 8091;
+ shop.runxian.top 8092;
+ partner.runxian.top 8093;
+ webadmin.runxian.top 8094;
+}
+
+server {
+ listen 80;
+ server_name user.runxian.top shop.runxian.top partner.runxian.top;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location ~ ^/MP_verify_.*\.txt$ {
+ root /opt/dukang-haoke/apps/h5-user/dist;
+ default_type text/plain;
+ access_log off;
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ server_name user.runxian.top shop.runxian.top partner.runxian.top;
+
+ access_log /var/log/nginx/dukang/runxian-h5.access.log main;
+ error_log /var/log/nginx/dukang/runxian-h5.error.log warn;
+
+ ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_prefer_server_ciphers on;
+
+ client_max_body_size 20m;
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:$dukang_runxian_port;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 80;
+ server_name dkapi.runxian.top;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ server_name dkapi.runxian.top;
+
+ access_log /var/log/nginx/dukang/runxian-api.access.log main;
+ error_log /var/log/nginx/dukang/runxian-api.error.log warn;
+
+ ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_prefer_server_ciphers on;
+
+ client_max_body_size 20m;
+
+ location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+}
+
+server {
+ listen 80;
+ server_name webadmin.runxian.top;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ server_name webadmin.runxian.top;
+
+ access_log /var/log/nginx/dukang/runxian-admin.access.log main;
+ error_log /var/log/nginx/dukang/runxian-admin.error.log warn;
+
+ ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_prefer_server_ciphers on;
+
+ client_max_body_size 20m;
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8094;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/deploy/nginx-runxian-dukang.conf b/deploy/nginx-runxian-dukang.conf
new file mode 100644
index 0000000..7247625
--- /dev/null
+++ b/deploy/nginx-runxian-dukang.conf
@@ -0,0 +1,110 @@
+# 杜康好客 — runxian.top 别名域名(与 lingshivip.cn 同后端)
+# user/shop/partner → 8091/8092/8093;webadmin → 8094;dkapi → 8090
+# 证书:certbot --cert-name user.runxian.top -d user.runxian.top -d shop.runxian.top ...
+
+map $host $dukang_runxian_port {
+ user.runxian.top 8091;
+ shop.runxian.top 8092;
+ partner.runxian.top 8093;
+ webadmin.runxian.top 8094;
+}
+
+# --- HTTP: H5 三端(校验文件 + API;证书就绪后见 dukang-runxian-ssl.conf)---
+server {
+ listen 80;
+ server_name user.runxian.top shop.runxian.top partner.runxian.top;
+
+ access_log /var/log/nginx/dukang/runxian-h5.access.log main;
+ error_log /var/log/nginx/dukang/runxian-h5.error.log warn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location ~ ^/MP_verify_.*\.txt$ {
+ root /opt/dukang-haoke/apps/h5-user/dist;
+ default_type text/plain;
+ access_log off;
+ }
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:$dukang_runxian_port;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# --- HTTP: API ---
+server {
+ listen 80;
+ server_name dkapi.runxian.top;
+
+ access_log /var/log/nginx/dukang/runxian-api.access.log main;
+ error_log /var/log/nginx/dukang/runxian-api.error.log warn;
+
+ client_max_body_size 20m;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+}
+
+# --- HTTP: admin-web ---
+server {
+ listen 80;
+ server_name webadmin.runxian.top;
+
+ access_log /var/log/nginx/dukang/runxian-admin.access.log main;
+ error_log /var/log/nginx/dukang/runxian-admin.error.log warn;
+
+ client_max_body_size 20m;
+
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/certbot;
+ default_type "text/plain";
+ }
+
+ location /api/ {
+ proxy_pass http://127.0.0.1:8090;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ }
+
+ location / {
+ proxy_pass http://127.0.0.1:8094;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/deploy/remote-release.sh b/deploy/remote-release.sh
new file mode 100644
index 0000000..b0f24ff
--- /dev/null
+++ b/deploy/remote-release.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# 在服务器上执行:拉取后的常规发版(不覆盖 .env、默认不 seed)
+set -euo pipefail
+
+APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
+DEPLOY_DIR="$APP_ROOT/deploy"
+
+SKIP_BUILD=false
+SKIP_DB=false
+RUN_SEED=false
+DB_PUSH_ACCEPT_DATA_LOSS=false
+
+usage() {
+ cat <<'EOF'
+用法: remote-release.sh [选项]
+
+ --skip-build 跳过 pnpm build(仅重启 PM2)
+ --skip-db 跳过 Prisma generate / db push
+ --seed 执行 prisma:seed(默认不执行)
+ --accept-data-loss prisma db push 时允许数据丢失(慎用)
+ -h, --help 显示帮助
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --skip-build) SKIP_BUILD=true ;;
+ --skip-db) SKIP_DB=true ;;
+ --seed) RUN_SEED=true ;;
+ --accept-data-loss) DB_PUSH_ACCEPT_DATA_LOSS=true ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "未知参数: $1" >&2; usage; exit 1 ;;
+ esac
+ shift
+done
+
+cd "$APP_ROOT"
+
+echo "==> 1. 启用 pnpm"
+corepack enable 2>/dev/null || true
+corepack prepare pnpm@11.2.2 --activate 2>/dev/null || true
+
+echo "==> 2. 安装依赖"
+export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}"
+pnpm install --frozen-lockfile 2>/dev/null || pnpm install
+
+if [[ "$SKIP_BUILD" == false ]]; then
+ echo "==> 3. 构建"
+ pnpm build
+else
+ echo "==> 3. 跳过构建"
+fi
+
+if [[ "$SKIP_DB" == false ]]; then
+ echo "==> 4. 数据库 schema 同步"
+ cd "$APP_ROOT/server/dukang-api"
+ pnpm prisma:generate
+ if [[ "$DB_PUSH_ACCEPT_DATA_LOSS" == true ]]; then
+ npx prisma db push --accept-data-loss
+ else
+ npx prisma db push
+ fi
+ if [[ "$RUN_SEED" == true ]]; then
+ echo "==> 执行 seed"
+ pnpm prisma:seed
+ fi
+else
+ echo "==> 4. 跳过数据库"
+fi
+
+echo "==> 5. 重启 PM2"
+if pm2 describe dukang-api &>/dev/null; then
+ pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner dukang-admin-web 2>/dev/null \
+ || pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner
+else
+ pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
+fi
+pm2 save
+
+echo "==> 6. 健康检查"
+sleep 2
+pm2 list
+ss -tlnp | grep -E '809[0-3]' || true
+curl -sf -o /dev/null -w "h5-user: %{http_code}\n" http://127.0.0.1:8091/ || echo "h5-user: FAIL"
+curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/api/v1/health 2>/dev/null \
+ || curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/ \
+ || echo "api: FAIL"
+
+echo "==> 发版完成"
diff --git a/deploy/sync-api-env.sh b/deploy/sync-api-env.sh
new file mode 100644
index 0000000..6f1ee62
--- /dev/null
+++ b/deploy/sync-api-env.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# 同步 API 环境变量到服务器
+# 用法: deploy/sync-api-env.sh [development|production]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+ENV_FILE="$REPO_ROOT/deploy/deploy.env"
+
+DEPLOY_HOST=""
+DEPLOY_USER="root"
+DEPLOY_PORT="22"
+DEPLOY_SSH_KEY=""
+APP_ROOT="/opt/dukang-haoke"
+
+TARGET="${1:-development}"
+case "$TARGET" in
+ development|dev) SRC_ENV="$REPO_ROOT/server/dukang-api/.env.development" ;;
+ production|prod) SRC_ENV="$REPO_ROOT/server/dukang-api/.env.production" ;;
+ *)
+ echo "用法: $0 [development|production]" >&2
+ exit 1
+ ;;
+esac
+
+if [[ -f "$ENV_FILE" ]]; then
+ # shellcheck disable=SC1090
+ source "$ENV_FILE"
+fi
+
+if [[ ! -f "$SRC_ENV" ]]; then
+ echo "错误: 未找到 $SRC_ENV" >&2
+ exit 1
+fi
+
+if [[ -z "$DEPLOY_HOST" ]]; then
+ echo "错误: 未配置 DEPLOY_HOST(deploy/deploy.env)" >&2
+ exit 1
+fi
+
+SSH_OPTS=(-o "StrictHostKeyChecking=accept-new" -p "$DEPLOY_PORT")
+[[ -n "$DEPLOY_SSH_KEY" ]] && SSH_OPTS+=(-i "$DEPLOY_SSH_KEY")
+REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
+
+echo "==> 同步 $TARGET 环境到 $REMOTE:$APP_ROOT/server/dukang-api/.env"
+scp "${SSH_OPTS[@]}" "$SRC_ENV" "$REMOTE:$APP_ROOT/server/dukang-api/.env"
+ssh "${SSH_OPTS[@]}" "$REMOTE" "pm2 restart dukang-api && sleep 2 && curl -sf -o /dev/null -w 'api-health:%{http_code}\n' http://127.0.0.1:8090/api/v1/health"
+echo "==> 完成"
diff --git a/packages/weixin-sdk/src/chooseImage.ts b/packages/weixin-sdk/src/chooseImage.ts
new file mode 100644
index 0000000..e467939
--- /dev/null
+++ b/packages/weixin-sdk/src/chooseImage.ts
@@ -0,0 +1,83 @@
+import { getRuntimePlatform } from './env';
+import { ensureJssdkReady } from './jssdk';
+import type { WeixinSdkConfig } from './types';
+
+export type ChooseWechatImageOptions = {
+ count?: number;
+ sourceType?: Array<'album' | 'camera'>;
+};
+
+function base64ToFile(base64: string, fileName: string): File {
+ const normalized = base64.startsWith('data:')
+ ? base64
+ : `data:image/jpeg;base64,${base64.replace(/\s/g, '')}`;
+ const [header, body] = normalized.split(',');
+ const mime = header.match(/:(.*?);/)?.[1] ?? 'image/jpeg';
+ const binary = atob(body);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return new File([bytes], fileName, { type: mime });
+}
+
+function localIdToFile(localId: string): Promise {
+ return new Promise((resolve, reject) => {
+ if (!window.wx?.getLocalImgData) {
+ reject(new Error('微信 getLocalImgData 不可用'));
+ return;
+ }
+ window.wx.getLocalImgData({
+ localId,
+ success: (res) => {
+ try {
+ resolve(base64ToFile(res.localData, `wx-${Date.now()}.jpg`));
+ } catch (e) {
+ reject(e instanceof Error ? e : new Error('图片解析失败'));
+ }
+ },
+ fail: (err) => reject(new Error(err.errMsg || '读取图片失败')),
+ });
+ });
+}
+
+/** 微信内选图(相册/拍照),返回 File 列表;非微信环境返回 null */
+export async function chooseWechatImages(
+ config: WeixinSdkConfig,
+ options: ChooseWechatImageOptions = {},
+): Promise {
+ const platform = getRuntimePlatform();
+ if (platform === 'browser') return null;
+
+ const count = options.count ?? 1;
+ const sourceType = options.sourceType ?? ['album', 'camera'];
+
+ if (platform === 'wechat-h5') {
+ await ensureJssdkReady({
+ apiBase: config.apiBase ?? '/api/v1',
+ clientApp: config.clientApp,
+ getAccessToken: config.getAccessToken,
+ });
+ if (!window.wx?.chooseImage) return null;
+
+ const localIds = await new Promise((resolve, reject) => {
+ window.wx!.chooseImage!({
+ count,
+ sizeType: ['compressed'],
+ sourceType,
+ success: (res) => resolve(res.localIds ?? []),
+ fail: (err) => reject(new Error(err.errMsg || '无法打开相册')),
+ });
+ });
+
+ const files: File[] = [];
+ for (const localId of localIds) {
+ files.push(await localIdToFile(localId));
+ }
+ return files;
+ }
+
+ return null;
+}
+
+export function canUseWechatChooseImage(): boolean {
+ return getRuntimePlatform() !== 'browser';
+}
diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts
index c7c0b5b..574f4c6 100644
--- a/packages/weixin-sdk/src/index.ts
+++ b/packages/weixin-sdk/src/index.ts
@@ -7,6 +7,8 @@ export {
} from './location';
export { scanQrCode } from './scan';
export { invokeWechatPay } from './pay';
+export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
+export type { ChooseWechatImageOptions } from './chooseImage';
export {
getWechatOAuthUrl,
startWechatOAuthLogin,
@@ -25,6 +27,7 @@ import { initWechatJssdk } from './jssdk';
import { getWechatLocation } from './location';
import { scanQrCode } from './scan';
import { invokeWechatPay } from './pay';
+import { chooseWechatImages } from './chooseImage';
import {
wechatLogin,
handleWechatOAuthCallback,
@@ -43,6 +46,8 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
getPhoneNumber: () => getWechatPhoneNumber(config),
getLocation: () => getWechatLocation(config),
scanQrCode: () => scanQrCode(config),
+ chooseImages: (options?: Parameters[1]) =>
+ chooseWechatImages(config, options),
pay: (prepay: Parameters[0]) =>
invokeWechatPay(prepay, { apiBase: config.apiBase, clientApp: config.clientApp }),
};
diff --git a/packages/weixin-sdk/src/types.ts b/packages/weixin-sdk/src/types.ts
index 2041494..00450d0 100644
--- a/packages/weixin-sdk/src/types.ts
+++ b/packages/weixin-sdk/src/types.ts
@@ -34,6 +34,18 @@ export type WxApi = {
fail?: (res: { errMsg: string }) => void;
cancel?: () => void;
}) => void;
+ chooseImage: (options: {
+ count?: number;
+ sizeType?: Array<'original' | 'compressed'>;
+ sourceType?: Array<'album' | 'camera'>;
+ success?: (res: { localIds: string[] }) => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
+ getLocalImgData: (options: {
+ localId: string;
+ success?: (res: { localData: string }) => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
};
export type MiniProgramWx = {
@@ -80,6 +92,8 @@ export const DEFAULT_JS_API_LIST = [
'getLocation',
'scanQRCode',
'chooseWXPay',
+ 'chooseImage',
+ 'getLocalImgData',
'updateAppMessageShareData',
'updateTimelineShareData',
] as const;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ac4c679..ac2c434 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,6 +59,12 @@ importers:
'@dukang/shared-ui':
specifier: workspace:*
version: link:../../packages/shared-ui
+ '@dukang/weixin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/weixin-sdk
+ element-china-area-data:
+ specifier: ^6.1.0
+ version: 6.1.0
react:
specifier: ^18.3.1
version: 18.3.1
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index cb14b88..acbc058 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -1,3 +1,8 @@
+# 杜康 API 环境变量模板
+# 开发:cp .env.development.example .env.development → 本地再 cp 为 .env(改 PORT/DATABASE_URL)
+# 生产:cp .env.production.example .env.production
+# 服务器同步:bash deploy/sync-api-env.sh development|production
+
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
@@ -23,7 +28,7 @@ WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
WX_PLATFORM_CERT=
-WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
+WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
@@ -46,4 +51,4 @@ OSS_UPLOAD_EXPIRE_SECONDS=900
# 单文件大小上限(字节,默认 10MB)
OSS_MAX_UPLOAD_BYTES=10485760
# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
-# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
\ No newline at end of file
+# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example
new file mode 100644
index 0000000..1bfcc93
--- /dev/null
+++ b/server/dukang-api/.env.production.example
@@ -0,0 +1,40 @@
+# 生产环境(真实第三方,关闭 Mock)
+# 用法:cp .env.production.example .env.production 后填写密钥
+# 服务器:bash deploy/sync-api-env.sh production
+
+NODE_ENV=production
+
+DATABASE_URL="mysql://dukang:CHANGE_ME@localhost:3306/dukang_haoke"
+REDIS_URL="redis://localhost:6379"
+JWT_SECRET="CHANGE_ME-strong-random-secret"
+JWT_EXPIRES_IN="7d"
+PORT=8090
+
+MOCK_SMS=false
+MOCK_SMS_CODE=
+MOCK_PAY=false
+MOCK_DELIVERY_AUTO=false
+AUTO_APPROVE_STORE=false
+
+TRUST_PROXY=true
+
+WECHAT_AUTH_ENABLED=true
+WECHAT_PAY_ENABLED=true
+WX_APP_ID=
+WX_APP_SECRET=
+WX_MCH_ID=
+WX_MCH_SERIAL_NO=
+WX_MCH_PRIVATE_KEY=
+WX_API_V3_KEY=
+WX_PLATFORM_CERT=
+WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
+
+OSS_ENABLED=true
+OSS_ACCESS_KEY_ID=
+OSS_ACCESS_KEY_SECRET=
+OSS_BUCKET=dukang-prod
+OSS_REGION=oss-cn-beijing
+OSS_CDN_BASE=https://dukang-prod.oss-cn-beijing.aliyuncs.com
+OSS_UPLOAD_PREFIX=uploads
+OSS_UPLOAD_EXPIRE_SECONDS=900
+OSS_MAX_UPLOAD_BYTES=10485760
diff --git a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
index 29f43e0..7f31f26 100644
--- a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
+++ b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
@@ -6,7 +6,7 @@ import type {
OssUploadTokenInput,
OssUploadTokenResult,
} from './oss.interface';
-import { buildOssObjectKey } from './oss.key.util';
+import { buildOssObjectKey, resolveOssUploadDir } from './oss.key.util';
import { createAliyunOssClient, resolveOssUploadHost } from './oss.aliyun.client';
const DEFAULT_EXPIRE_SECONDS = 15 * 60;
@@ -64,13 +64,14 @@ export class OssAliyunProvider implements IOssProvider {
const ossKey = buildOssObjectKey(this.uploadPrefix, dto.bizType, dto.fileName);
const expireAt = new Date(Date.now() + this.expireSeconds * 1000);
const host = resolveOssUploadHost(this.bucket, this.region);
+ const keyPrefix = resolveOssUploadDir(this.uploadPrefix, dto.bizType);
const policy = {
expiration: expireAt.toISOString(),
conditions: [
['content-length-range', 0, this.maxUploadBytes],
['eq', '$bucket', this.bucket],
- ['starts-with', '$key', `${this.uploadPrefix}/${dto.bizType.toLowerCase()}/`],
+ ['starts-with', '$key', keyPrefix],
],
};
diff --git a/server/dukang-api/src/integrations/oss/oss.key.util.ts b/server/dukang-api/src/integrations/oss/oss.key.util.ts
index c098278..ca234b4 100644
--- a/server/dukang-api/src/integrations/oss/oss.key.util.ts
+++ b/server/dukang-api/src/integrations/oss/oss.key.util.ts
@@ -1,7 +1,20 @@
import { randomUUID } from 'crypto';
+/** 门店资源上传目录(OSS object key 前缀) */
+const STORE_UPLOAD_DIRS: Record = {
+ STORE_TITLE: 'store/title',
+ STORE_ENV: 'store/env',
+ STORE_CONTRACT: 'store/contract',
+};
+
+export function resolveOssUploadDir(uploadPrefix: string, bizType: string): string {
+ const storeDir = STORE_UPLOAD_DIRS[bizType];
+ if (storeDir) return `${storeDir}/`;
+ return `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
+}
+
export function buildOssObjectKey(uploadPrefix: string, bizType: string, fileName: string): string {
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
- const dir = `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
+ const dir = resolveOssUploadDir(uploadPrefix, bizType);
return `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
}
diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
index a7619ba..168615d 100644
--- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
+++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
@@ -119,7 +119,7 @@ export class WechatApiProvider implements IWechatProvider {
timestamp,
nonceStr,
signature,
- jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay'],
+ jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
};
}
diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts
index c906dc7..be076eb 100644
--- a/server/dukang-api/src/modules/iam/auth.service.ts
+++ b/server/dukang-api/src/modules/iam/auth.service.ts
@@ -17,7 +17,6 @@ import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.c
import { ISmsProvider } from '../../integrations/sms/sms.interface';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
-import type { Prisma } from '@prisma/client';
import type { User } from '@prisma/client';
@@ -584,12 +583,12 @@ export class AuthService {
}
}
- const primaryUpdate: Prisma.UserUpdateInput = {};
- if (guest.deviceKey && !primary.deviceKey) {
- primaryUpdate.deviceKey = guest.deviceKey;
- }
- if (Object.keys(primaryUpdate).length > 0) {
- await tx.user.update({ where: { id: primaryId }, data: primaryUpdate });
+ const deviceKeyToTransfer =
+ guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null;
+ if (deviceKeyToTransfer) {
+ // 先清空访客 deviceKey,避免 uk_user_user_device_key 冲突
+ await tx.user.update({ where: { id: guestId }, data: { deviceKey: null } });
+ await tx.user.update({ where: { id: primaryId }, data: { deviceKey: deviceKeyToTransfer } });
}
await tx.user.update({
diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts
index cdaee0d..d0d092b 100644
--- a/server/dukang-api/src/modules/store/store.controller.ts
+++ b/server/dukang-api/src/modules/store/store.controller.ts
@@ -29,10 +29,38 @@ export class PartnerStoreController {
return this.storeService.partnerListStores(user.actorId);
}
+ @Get('cities')
+ cities(@CurrentUser() user: AuthUser) {
+ return this.storeService.partnerListCities(user.actorId);
+ }
+
+ @Get(':id')
+ detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
+ return this.storeService.partnerGetStore(user.actorId, BigInt(id));
+ }
+
@Post()
create(@CurrentUser() user: AuthUser, @Body() body: Record) {
return this.storeService.createStore(user.actorId, body);
}
+
+ @Put(':id/status')
+ updateStatus(
+ @CurrentUser() user: AuthUser,
+ @Param('id') id: string,
+ @Body() body: { status: 'OPEN' | 'PAUSED' | 'CLOSED' },
+ ) {
+ return this.storeService.partnerUpdateStoreStatus(user.actorId, BigInt(id), body.status);
+ }
+
+ @Put(':id/basic')
+ updateBasic(
+ @CurrentUser() user: AuthUser,
+ @Param('id') id: string,
+ @Body() body: Record,
+ ) {
+ return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
+ }
}
@Controller('partner/dashboard')
diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts
index 9811930..6b3c517 100644
--- a/server/dukang-api/src/modules/store/store.service.ts
+++ b/server/dukang-api/src/modules/store/store.service.ts
@@ -51,10 +51,44 @@ export class StoreService {
return serializeBigInt(stores.map(mapStoreCompat));
}
+ async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
+ const account = await this.getPartnerAccount(partnerAccountId);
+ const store = await this.prisma.store.findFirst({
+ where: { id: storeId, partnerId: account.partnerId },
+ include: { category: true, coverResource: true },
+ });
+ if (!store) throw new NotFoundException('门店不存在');
+
+ const media = await this.prisma.commonResource.findMany({
+ where: {
+ ownerType: 'STORE',
+ ownerId: storeId,
+ status: 'ACTIVE',
+ bizType: { in: ['ENV', 'CONTRACT'] },
+ },
+ orderBy: { sortOrder: 'asc' },
+ });
+ return serializeBigInt(mapStoreCompat({ ...store, media }));
+ }
+
+ async partnerListCities(partnerAccountId: bigint) {
+ const account = await this.getPartnerAccount(partnerAccountId);
+ const cities = await this.prisma.commonCity.findMany({
+ where: { partnerId: account.partnerId },
+ select: { id: true, name: true, code: true, province: true, partnerId: true },
+ orderBy: { createdAt: 'desc' },
+ });
+ return serializeBigInt(cities);
+ }
+
async createStore(partnerAccountId: bigint, body: Record) {
const account = await this.getPartnerAccount(partnerAccountId);
- const city = await this.prisma.commonCity.findFirst({ where: { partnerId: account.partnerId } });
- if (!city) throw new BadRequestException('合伙人未绑定开城');
+ const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
+ const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
+ const envPhotoUrls = Array.isArray(body.envPhotoUrls)
+ ? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
+ : [];
+ const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
const store = await this.prisma.store.create({
data: {
@@ -63,12 +97,11 @@ export class StoreService {
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
name: String(body.name),
phone: String(body.phone),
- province: String(body.province ?? '河南省'),
- cityName: String(body.city ?? '郑州市'),
+ province: String(body.province ?? city.province ?? '河南省'),
+ cityName: String(body.city ?? city.name ?? '郑州市'),
district: String(body.district ?? ''),
address: String(body.address),
intro: body.intro ? String(body.intro) : null,
- coverResourceId: body.coverResourceId ? BigInt(String(body.coverResourceId)) : null,
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
@@ -78,6 +111,52 @@ export class StoreService {
},
});
+ const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
+
+ if (coverUrl) {
+ const cover = await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'STORE',
+ ownerId: store.id,
+ bizType: 'COVER',
+ mediaType: 'IMAGE',
+ ossBucket,
+ ossKey: coverUrl,
+ url: coverUrl,
+ },
+ });
+ await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
+ }
+
+ for (let i = 0; i < envPhotoUrls.length; i++) {
+ await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'STORE',
+ ownerId: store.id,
+ bizType: 'ENV',
+ mediaType: 'IMAGE',
+ ossBucket,
+ ossKey: envPhotoUrls[i],
+ url: envPhotoUrls[i],
+ sortOrder: i,
+ },
+ });
+ }
+
+ if (contractUrl) {
+ await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'STORE',
+ ownerId: store.id,
+ bizType: 'CONTRACT',
+ mediaType: 'FILE',
+ ossBucket,
+ ossKey: contractUrl,
+ url: contractUrl,
+ },
+ });
+ }
+
const audit = await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
@@ -95,7 +174,10 @@ export class StoreService {
await this.prisma.storeAccount.create({
data: {
storeId: store.id,
- phone: String(body.accountPhone ?? body.phone),
+ phone: await this.resolveStoreAccountPhone(
+ String(body.accountPhone ?? body.phone),
+ store.id,
+ ),
name: String(body.accountName ?? body.name),
},
});
@@ -103,6 +185,71 @@ export class StoreService {
return serializeBigInt({ store, audit });
}
+ async partnerUpdateStoreStatus(
+ partnerAccountId: bigint,
+ storeId: bigint,
+ status: 'OPEN' | 'PAUSED' | 'CLOSED',
+ ) {
+ const account = await this.getPartnerAccount(partnerAccountId);
+ const store = await this.prisma.store.findFirst({
+ where: { id: storeId, partnerId: account.partnerId },
+ });
+ if (!store) throw new NotFoundException('门店不存在');
+ if (store.status === 'CLOSED') {
+ throw new BadRequestException('门店已关闭,不可变更状态');
+ }
+ if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) {
+ throw new BadRequestException('无效的门店状态');
+ }
+
+ const updated = await this.prisma.store.update({
+ where: { id: storeId },
+ data: { status },
+ include: { coverResource: true },
+ });
+ return serializeBigInt(mapStoreCompat(updated));
+ }
+
+ async partnerUpdateStoreBasic(
+ partnerAccountId: bigint,
+ storeId: bigint,
+ body: Record,
+ ) {
+ const account = await this.getPartnerAccount(partnerAccountId);
+ const store = await this.prisma.store.findFirst({
+ where: { id: storeId, partnerId: account.partnerId },
+ });
+ if (!store) throw new NotFoundException('门店不存在');
+ if (store.status === 'CLOSED') {
+ throw new BadRequestException('门店已关闭,不可编辑');
+ }
+
+ const name = body.name !== undefined ? String(body.name).trim() : undefined;
+ const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
+ const address = body.address !== undefined ? String(body.address).trim() : undefined;
+ const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
+
+ if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
+ if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
+ throw new BadRequestException('联系电话须为11位手机号');
+ }
+ if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
+ if (introRaw && (introRaw.length < 10 || introRaw.length > 500)) {
+ throw new BadRequestException('门店简介须为 10~500 字');
+ }
+
+ await this.prisma.store.update({
+ where: { id: storeId },
+ data: {
+ ...(name !== undefined ? { name } : {}),
+ ...(phone !== undefined ? { phone } : {}),
+ ...(address !== undefined ? { address } : {}),
+ ...(introRaw !== undefined ? { intro: introRaw || null } : {}),
+ },
+ });
+ return this.partnerGetStore(partnerAccountId, storeId);
+ }
+
async getShopStore(storeAccountId: bigint) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
@@ -150,4 +297,26 @@ export class StoreService {
include: { partner: true },
});
}
+
+ private async resolvePartnerCity(partnerId: bigint, cityId: unknown) {
+ if (cityId) {
+ const city = await this.prisma.commonCity.findFirst({
+ where: { id: BigInt(String(cityId)), partnerId },
+ });
+ if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
+ return city;
+ }
+ const city = await this.prisma.commonCity.findFirst({ where: { partnerId } });
+ if (!city) throw new BadRequestException('合伙人未绑定开城');
+ return city;
+ }
+
+ private async resolveStoreAccountPhone(phone: string, storeId: bigint): Promise {
+ const normalized = phone.trim();
+ const existing = await this.prisma.storeAccount.findUnique({ where: { phone: normalized } });
+ if (!existing) return normalized;
+ const suffix = String(storeId).slice(-4);
+ const candidate = `${normalized.slice(0, 15)}${suffix}`.slice(0, 20);
+ return candidate;
+ }
}