Compare commits

...
6 Commits
Author SHA1 Message Date
duanshuwen e78287a944 fix(WonderQ-Admin): 移除硬编码的生产OSS密钥并忽略.env配置文件
注释掉已追踪的 .env 文件中的所有生产OSS配置项,避免敏感生产凭证泄露;新增.gitignore文件防止本地.env配置文件被误提交到版本库
2026-08-28 10:01:31 +08:00
andy cd007e2f6a 解决登录和oss问题 2026-08-28 09:52:11 +08:00
duanshuwen 30c2e44dc0 chore(WonderQ-Admin): 清理目录下旧gitignore并新增环境变量配置文件 2026-08-27 22:16:45 +08:00
duanshuwen feec6ab80f feat(wanfa): 新增路线详情参考价格配置及展示功能
- 新增数据库迁移脚本,为详情表添加四个价格相关字段
- 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则
- 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位
- 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明
- 更新小程序端详情页面,支持展示配置的参考价格信息
- 补充相关测试用例,更新API文档与类型定义
2026-08-27 22:02:51 +08:00
duanshuwen f18971a430 build(miniapp): 为WonderQ小程序添加环境配置并调整gitignore规则
1.  新增WonderQ-MiniAPP/.env环境配置文件,配置生产环境API基础地址
2.  移除根目录.gitignore中对.env文件的忽略规则
2026-08-27 21:02:28 +08:00
andy 680e0b9799 打包部署bug修复 2026-08-27 16:21:31 +08:00
39 changed files with 538 additions and 43 deletions

No files matched your search

-1
View File
@@ -3,7 +3,6 @@ dist/
**/public/
apps/*/dist/
.pnpm-store/
.env
.env.local
.env.*.local
**/.venv/
Vendored
+1
View File
@@ -112,6 +112,7 @@ pipeline {
"$API_IMAGE:$IMAGE_TAG" \
-m pytest)"
docker cp WonderQ-Admin/tests/. "$test_container:/app/tests"
docker cp WonderQ-Admin/docker-compose.yml "$test_container:/app/docker-compose.yml"
docker start --attach "$test_container"
'''
}
@@ -35,6 +35,29 @@ function updateLines(field: "highlights" | "included" | "excluded" | "notes" | "
<el-input :model-value="modelValue[field].join('\n')" type="textarea" :rows="4" :disabled="disabled" :placeholder="placeholder" @update:model-value="updateLines(field, $event)" />
</el-form-item>
<ImageGalleryEditor :model-value="modelValue.gallery" :disabled="disabled" @update:model-value="update('gallery', $event)" />
<div class="detail-price-heading">
<strong>路线价格</strong>
<span>配置后将在前台路线详情中展示参考起价。</span>
</div>
<div class="editor-options">
<el-form-item label="价格起始值">
<el-input-number :model-value="modelValue.priceStartingValue ?? undefined" :disabled="disabled" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="例如:1.68" @update:model-value="update('priceStartingValue', $event)" />
</el-form-item>
<el-form-item label="价格单位">
<el-select :model-value="modelValue.priceUnit ?? '__none__'" :disabled="disabled" placeholder="请选择单位" @update:model-value="update('priceUnit', $event === '__none__' ? null : $event)">
<el-option value="__none__" label="未配置" />
<el-option value="person" label="人" />
<el-option value="day" label="天" />
<el-option value="group" label="团" />
</el-select>
</el-form-item>
</div>
<el-form-item label="适用人数范围">
<el-input :model-value="modelValue.pricePeopleRange ?? ''" maxlength="80" :disabled="disabled" placeholder="例如:2-4人" @update:model-value="update('pricePeopleRange', $event)" />
</el-form-item>
<el-form-item label="价格说明">
<el-input :model-value="modelValue.priceDescription ?? ''" type="textarea" :rows="3" maxlength="500" :disabled="disabled" placeholder="例如:价格以最终确认方案为准。" @update:model-value="update('priceDescription', $event)" />
</el-form-item>
<el-form-item label="联系管家">
<el-select :model-value="modelValue.conciergeAdvisorId ?? '__none__'" :disabled="disabled" placeholder="不配置管家" @update:model-value="update('conciergeAdvisorId', $event === '__none__' ? null : $event)">
<el-option value="__none__" label="不配置管家" />
@@ -40,6 +40,10 @@ watch(() => [props.route?.id, props.detail?.id], () => {
excluded: [...props.detail.excluded],
notes: [...props.detail.notes],
gallery: [...props.detail.gallery],
priceStartingValue: props.detail.priceStartingValue,
priceUnit: props.detail.priceUnit,
pricePeopleRange: props.detail.pricePeopleRange,
priceDescription: props.detail.priceDescription,
conciergeAdvisorId: props.detail.conciergeAdvisorId,
isActive: props.detail.isActive,
sortOrder: props.detail.sortOrder,
@@ -38,6 +38,10 @@ describe("wanfa editor helpers", () => {
excluded: [],
notes: [" 雨具 ", ""],
gallery: [" https://example.com/detail.jpg "],
priceStartingValue: 1.68,
priceUnit: "person",
pricePeopleRange: " 2-4人 ",
priceDescription: " 价格以最终确认方案为准。 ",
conciergeAdvisorId: " advisor-1 ",
isActive: true,
})).toEqual({
@@ -52,6 +56,10 @@ describe("wanfa editor helpers", () => {
excluded: [],
notes: ["雨具"],
gallery: ["https://example.com/detail.jpg"],
priceStartingValue: 1.68,
priceUnit: "person",
pricePeopleRange: "2-4人",
priceDescription: "价格以最终确认方案为准。",
conciergeAdvisorId: "advisor-1",
isActive: true,
});
+22 -2
View File
@@ -1,4 +1,4 @@
import type { DetailCreate, WanfaRouteCreate } from "@/types";
import type { DetailCreate, DetailPriceUnit, WanfaRouteCreate } from "@/types";
export function createEmptyWanfaRoute(): WanfaRouteCreate {
return { title: "", subtitle: "", image: "", routeCount: 0, demandKeyword: "" };
@@ -27,6 +27,10 @@ export function createEmptyDetail(): DetailCreate {
excluded: [],
notes: [],
gallery: [],
priceStartingValue: null,
priceUnit: null,
pricePeopleRange: "",
priceDescription: "",
conciergeAdvisorId: null,
isActive: true,
};
@@ -45,9 +49,13 @@ export function compactDetail(draft: DetailCreate): DetailCreate {
excluded: compactList(draft.excluded),
notes: compactList(draft.notes),
gallery: compactList(draft.gallery),
priceStartingValue: normalizePriceStartingValue(draft.priceStartingValue),
priceUnit: normalizePriceUnit(draft.priceUnit),
pricePeopleRange: draft.pricePeopleRange?.trim() || null,
priceDescription: draft.priceDescription?.trim() || null,
conciergeAdvisorId: draft.conciergeAdvisorId?.trim() || null,
isActive: draft.isActive ?? true,
sortOrder: draft.sortOrder,
...(draft.sortOrder === undefined ? {} : { sortOrder: draft.sortOrder }),
};
}
@@ -63,9 +71,21 @@ export function validateWanfaWrite(route: WanfaRouteCreate, detail: DetailCreate
return "请完整填写详情眉标、时长、标题、副标题和详情介绍。";
}
if (detail.gallery.some((image) => !isHttpUrl(image))) return "详情图片必须是有效的 HTTP(S) URL。";
if (detail.priceStartingValue !== null && detail.priceStartingValue !== undefined && !detail.priceUnit) {
return "配置价格起始值时请选择价格单位。";
}
return null;
}
function normalizePriceStartingValue(value: number | null | undefined) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return null;
return Math.max(0, Number(Number(value).toFixed(2)));
}
function normalizePriceUnit(value: DetailPriceUnit | null | undefined): DetailPriceUnit | null {
return value === "person" || value === "day" || value === "group" ? value : null;
}
function compactList(items: string[]) {
return items.map((item) => item.trim()).filter(Boolean);
}
+12
View File
@@ -604,6 +604,18 @@ input {
font-size: 12px;
line-height: 1.5;
}
.detail-price-heading {
display: grid;
gap: 4px;
margin: 22px 0 18px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.detail-price-heading span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.route-editor .el-alert {
margin-top: 18px;
}
+12 -1
View File
@@ -204,6 +204,10 @@ export type DetailRecord = {
excluded: string[];
notes: string[];
gallery: string[];
priceStartingValue: number | null;
priceUnit: "person" | "day" | "group" | null;
pricePeopleRange: string | null;
priceDescription: string | null;
conciergeAdvisorId: string | null;
isActive: boolean;
sortOrder: number;
@@ -211,7 +215,14 @@ export type DetailRecord = {
updatedAt?: string;
};
export type DetailCreate = Omit<DetailRecord, "id" | "createdAt" | "updatedAt" | "sortOrder"> & { sortOrder?: number };
export type DetailPriceUnit = "person" | "day" | "group";
export type DetailPriceConfig = {
priceStartingValue: number | null;
priceUnit: DetailPriceUnit | null;
pricePeopleRange: string | null;
priceDescription: string | null;
};
export type DetailCreate = Omit<DetailRecord, "id" | "createdAt" | "updatedAt" | "sortOrder" | keyof DetailPriceConfig> & Partial<DetailPriceConfig> & { sortOrder?: number };
export type DetailPatch = Partial<DetailCreate>;
export type MediaAsset = {
+21
View File
@@ -0,0 +1,21 @@
DATABASE_URL="postgresql://miniapp:miniapp_dev_password@localhost:5433/miniapp"
JWT_SECRET="rveg3AlgYyK4rNA70JnMML9xIoO01Nt8HJbzRja2MfblBxBMy9pzQEIpAgI5v3fT"
PORT=4000
LOG_LEVEL="info"
CORS_ORIGINS=http://localhost:5602,http://localhost:5173,http://127.0.0.1:5602,http://127.0.0.1:5173
# 测试环境OSS
OSS_ACCESS_KEY_ID="LTAI5t84mz3Pt7N3xKGxPxWz"
OSS_ACCESS_KEY_SECRET="uMxYAca1IPLHjXc3ZHvuwvaqUwmt7E"
OSS_ENDPOINT="oss-cn-guangzhou.aliyuncs.com"
OSS_BUCKET_NAME="one-feel-ota-data"
# 生产环境OSS
# OSS_ACCESS_KEY_ID="LTAI5t84mz3Pt7N3xKGxPxWz"
# OSS_ACCESS_KEY_SECRET="uMxYAca1IPLHjXc3ZHvuwvaqUwmt7E"
# OSS_ENDPOINT="oss-cn-chengdu.aliyuncs.com"
# OSS_BUCKET_NAME="one-feel-ota-data"
WECHAT_MINIAPP_APPID="wx72c9792d304525b7"
WECHAT_MINIAPP_SECRET="70d7b3e98d228fe4970aac7d63d0f564"
CUSTOMER_JWT_EXPIRES_HOURS=720
+1
View File
@@ -14,6 +14,7 @@ CORS_ORIGINS="*"
OSS_ACCESS_KEY_ID="your-oss-access-key-id"
OSS_ACCESS_KEY_SECRET="your-oss-access-key-secret"
OSS_ENDPOINT="your-oss-endpoint"
OSS_PUBLIC_BASE_URL="https://your-oss-bucket.your-oss-public-endpoint"
OSS_BUCKET_NAME="your-oss-bucket-name"
WECHAT_MINIAPP_APPID="your-miniapp-appid"
WECHAT_MINIAPP_SECRET="your-miniapp-secret"
+1 -13
View File
@@ -1,13 +1 @@
node_modules/
dist/
.env
.env.local
*.log
coverage/
.prisma/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
*.pyc
.env
+4 -1
View File
@@ -22,7 +22,10 @@ Copy-Item .env.example .env
- `ADMIN_PERMISSION_CACHE_SECONDS`:管理员权限菜单缓存秒数,默认 300。
- `ADMIN_LOGIN_RATE_LIMIT` / `ADMIN_LOGIN_RATE_WINDOW_SECONDS`:登录限流窗口,默认每个 IP+账号 60 秒最多 5 次。
- `ADMIN_ACCESS_EXPIRES_MINUTES`、`ADMIN_REFRESH_EXPIRES_DAYS`:管理员访问令牌和刷新令牌有效期。
- `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET_NAME`:填写实际 OSS 配置;真实密钥只放在 `.env` 或部署平台密钥中,不提交到 Git。
- `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_BUCKET_NAME`:填写实际 OSS 认证与 Bucket 配置。
- `OSS_ENDPOINT`:后端上传连接地址;ACK 与 OSS 同地域时使用内网 Endpoint。
- `OSS_PUBLIC_BASE_URL`:浏览器可访问的完整 Bucket 公网根地址或已绑定 OSS 的自定义域名,例如 `https://<bucket>.oss-cn-<region>.aliyuncs.com`;不要包含对象路径。
- 真实密钥只放在本地 `.env` 或部署平台 Secret 中,不提交到 Git。
菜单管理接口 `GET/POST/PATCH/DELETE /api/admin/system/menus` 维护目录、页面和按钮的父子树。服务端校验父级存在、禁止按钮作为父级、禁止循环归属和重复权限标识;具体字段规则以 [`docs/admin-api-requirements.md`](../docs/admin-api-requirements.md) 为准。
@@ -0,0 +1,64 @@
"""Add configurable price presentation fields to detail records."""
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from app.migration_compat import baseline_column_names
revision = "0037_detail_price_config"
down_revision = "0036_customer_browse_history"
branch_labels = None
depends_on = None
PRICE_COLUMNS = {
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}
def missing_price_columns(existing_columns: set[str]) -> set[str]:
return PRICE_COLUMNS.difference(existing_columns)
def is_offline_mode() -> bool:
try:
return context.is_offline_mode()
except (NameError, AttributeError):
return False
def upgrade() -> None:
if is_offline_mode():
columns_to_add = missing_price_columns(baseline_column_names("DetailRecord"))
else:
existing_columns = {
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
}
columns_to_add = missing_price_columns(existing_columns)
definitions = {
"priceStartingValue": sa.Column("priceStartingValue", sa.Float(), nullable=True),
"priceUnit": sa.Column("priceUnit", sa.String(), nullable=True),
"pricePeopleRange": sa.Column("pricePeopleRange", sa.String(), nullable=True),
"priceDescription": sa.Column("priceDescription", sa.Text(), nullable=True),
}
for name, column in definitions.items():
if name in columns_to_add:
op.add_column("DetailRecord", column)
def downgrade() -> None:
if is_offline_mode():
columns_to_drop = PRICE_COLUMNS.intersection(baseline_column_names("DetailRecord"))
else:
columns_to_drop = {
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
}.intersection(PRICE_COLUMNS)
for name in ("priceDescription", "pricePeopleRange", "priceUnit", "priceStartingValue"):
if name in columns_to_drop:
op.drop_column("DetailRecord", name)
+1
View File
@@ -24,6 +24,7 @@ class Settings(BaseSettings):
oss_access_key_id: str | None = Field(default=None)
oss_access_key_secret: str | None = Field(default=None)
oss_endpoint: str | None = Field(default=None)
oss_public_base_url: str | None = Field(default=None)
oss_bucket_name: str | None = Field(default=None)
wechat_miniapp_appid: str | None = Field(default=None)
wechat_miniapp_secret: str | None = Field(default=None)
+29 -3
View File
@@ -24,6 +24,24 @@ def normalized_oss_host(endpoint: str, bucket: str) -> tuple[str, str]:
return parsed.scheme, host
def normalized_public_base_url(base_url: str) -> tuple[str, str]:
raw = base_url.strip().rstrip("/")
if "://" not in raw:
raw = f"https://{raw}"
parsed = urlsplit(raw)
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or parsed.username
or parsed.password
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
raise ValueError("invalid OSS public base URL")
return parsed.scheme, parsed.netloc
def sign_oss_get_url(
url: str,
*,
@@ -32,10 +50,17 @@ def sign_oss_get_url(
endpoint: str,
bucket: str,
expires_at: int,
public_base_url: str | None = None,
) -> str:
scheme, expected_host = normalized_oss_host(endpoint, bucket)
source_scheme, source_host = normalized_oss_host(endpoint, bucket)
public_scheme, public_host = (
normalized_public_base_url(public_base_url)
if public_base_url and public_base_url.strip()
else (source_scheme, source_host)
)
parsed = urlsplit(url)
if parsed.scheme != scheme or parsed.netloc.lower() != expected_host.lower():
configured_hosts = {source_host.lower(), public_host.lower()}
if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() not in configured_hosts:
return url
path = parsed.path or "/"
@@ -60,7 +85,7 @@ def sign_oss_get_url(
("Signature", signature),
]
)
return urlunsplit((scheme, expected_host, path, urlencode(query), ""))
return urlunsplit((public_scheme, public_host, path, urlencode(query), ""))
def resolve_media_url(url: str | None) -> str | None:
@@ -85,6 +110,7 @@ def resolve_media_url(url: str | None) -> str | None:
endpoint=settings.oss_endpoint,
bucket=settings.oss_bucket_name,
expires_at=int(time()) + MEDIA_URL_EXPIRES_SECONDS,
public_base_url=getattr(settings, "oss_public_base_url", None),
)
except (TypeError, ValueError):
return url
+5 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
@@ -273,6 +273,10 @@ class DetailRecord(AdminOwnedContent, Base):
excluded: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
notes: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
gallery: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
priceStartingValue: Mapped[float | None] = mapped_column(Float, nullable=True)
priceUnit: Mapped[str | None] = mapped_column(String, nullable=True)
pricePeopleRange: Mapped[str | None] = mapped_column(String, nullable=True)
priceDescription: Mapped[str | None] = mapped_column(Text, nullable=True)
conciergeAdvisorId: Mapped[str | None] = mapped_column(String, nullable=True)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False, index=True)
+4 -2
View File
@@ -182,6 +182,8 @@ class InMemoryAdminSessionStore:
class RedisAdminSessionStore:
prefix = "wonderq:admin"
# WATCH/MULTI/EXEC requires every transaction key to share one Redis Cluster slot.
session_slot_tag = "{wonderq-admin-session}"
def __init__(self) -> None:
try:
@@ -194,10 +196,10 @@ class RedisAdminSessionStore:
raise RedisUnavailableError("Redis 会话存储不可用") from exc
def _session_key(self, session_id: str) -> str:
return f"{self.prefix}:session:{session_id}"
return f"{self.prefix}:{self.session_slot_tag}:session:{session_id}"
def _refresh_key(self, refresh_hash: str) -> str:
return f"{self.prefix}:refresh:{refresh_hash}"
return f"{self.prefix}:{self.session_slot_tag}:refresh:{refresh_hash}"
def _permission_key(self, user_id: str) -> str:
return f"{self.prefix}:permission:{user_id}"
+7 -3
View File
@@ -58,7 +58,7 @@ from ..schemas import (
DetailPatch,
)
from ..seed import create_media
from ..media_urls import resolve_media_url
from ..media_urls import normalized_public_base_url, resolve_media_url
from ..serializers import concierge_advisor_dict, detail_record_dict, encode_value, lead_dict, media_model_dict, model_dict
from .shared import site_config
@@ -168,6 +168,7 @@ def oss_settings():
"OSS_ACCESS_KEY_ID": settings.oss_access_key_id,
"OSS_ACCESS_KEY_SECRET": settings.oss_access_key_secret,
"OSS_ENDPOINT": settings.oss_endpoint,
"OSS_PUBLIC_BASE_URL": settings.oss_public_base_url,
"OSS_BUCKET_NAME": settings.oss_bucket_name,
}
missing = [key for key, value in values.items() if not (value and value.strip())]
@@ -193,6 +194,10 @@ def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) ->
settings = oss_settings()
bucket = settings.oss_bucket_name.strip()
scheme, host = normalized_oss_host(settings.oss_endpoint, bucket)
try:
public_scheme, public_host = normalized_public_base_url(settings.oss_public_base_url)
except (TypeError, ValueError):
media_error(503, "OSS 公网访问地址配置不正确", "MEDIA_STORAGE_INVALID_PUBLIC_URL")
date = formatdate(usegmt=True)
canonical_resource = f"/{bucket}/{key}"
string_to_sign = f"PUT\n\n{mime_type}\n{date}\n{canonical_resource}"
@@ -219,7 +224,7 @@ def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) ->
media_error(502, "OSS 上传失败", "MEDIA_STORAGE_UPLOAD_FAILED")
finally:
connection.close()
return urlunsplit((scheme, host, f"/{quoted_key}", "", ""))
return urlunsplit((public_scheme, public_host, f"/{quoted_key}", "", ""))
def audit(db: Session, actor_id: str | None, action: str, entity: str, entity_id: str | None = None, after=None, before=None) -> None:
@@ -1551,4 +1556,3 @@ def upload_media_asset(
db.commit()
return success_response(media_model_dict(asset), status_code=status.HTTP_201_CREATED)
+26
View File
@@ -11,6 +11,7 @@ LeadType = Literal["general", "vehicle"]
VehicleServiceType = Literal["charter", "transfer"]
CharterDuration = Literal["halfDay", "fullDay"]
BrowseHistoryType = Literal["wanfa-route", "team-building", "wild-archive"]
PriceUnit = Literal["person", "day", "group"]
class LoginIn(BaseModel):
@@ -281,6 +282,13 @@ def normalize_detail_gallery(value: list[str]) -> list[str]:
return normalized
def normalize_optional_price_text(value: str | None) -> str | None:
if value is None:
return None
normalized = normalize_optional_text(value)
return normalized or None
class DetailCreate(BaseModel):
key: str = Field(min_length=1, max_length=120)
eyebrow: str = Field(min_length=1, max_length=80)
@@ -293,6 +301,10 @@ class DetailCreate(BaseModel):
excluded: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
gallery: list[str] = Field(default_factory=list)
priceStartingValue: float | None = Field(default=None, ge=0)
priceUnit: PriceUnit | None = None
pricePeopleRange: str | None = Field(default=None, max_length=80)
priceDescription: str | None = Field(default=None, max_length=500)
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
isActive: bool = True
sortOrder: int | None = Field(default=None, ge=0)
@@ -312,6 +324,11 @@ class DetailCreate(BaseModel):
def validate_detail_gallery(cls, value: list[str]) -> list[str]:
return normalize_detail_gallery(value)
@field_validator("pricePeopleRange", "priceDescription", mode="before")
@classmethod
def normalize_price_text(cls, value: str | None) -> str | None:
return normalize_optional_price_text(value)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_concierge_advisor_id(cls, value: str | None) -> str | None:
@@ -333,6 +350,10 @@ class DetailPatch(BaseModel):
excluded: list[str] | None = None
notes: list[str] | None = None
gallery: list[str] | None = None
priceStartingValue: float | None = Field(default=None, ge=0)
priceUnit: PriceUnit | None = None
pricePeopleRange: str | None = Field(default=None, max_length=80)
priceDescription: str | None = Field(default=None, max_length=500)
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
isActive: bool | None = None
sortOrder: int | None = Field(default=None, ge=0)
@@ -358,6 +379,11 @@ class DetailPatch(BaseModel):
raise ValueError("字段不能为空")
return normalize_detail_gallery(value)
@field_validator("pricePeopleRange", "priceDescription", mode="before")
@classmethod
def normalize_optional_price_text_fields(cls, value: str | None) -> str | None:
return normalize_optional_price_text(value)
@field_validator("conciergeAdvisorId", mode="before")
@classmethod
def normalize_optional_concierge_advisor_id(cls, value: str | None) -> str | None:
+4
View File
@@ -80,6 +80,10 @@ def public_detail_dict(detail, concierge_advisor=None) -> dict:
"excluded": detail.excluded or [],
"notes": detail.notes or [],
"gallery": [resolve_media_url(image) for image in (detail.gallery or [])],
"priceStartingValue": detail.priceStartingValue,
"priceUnit": detail.priceUnit,
"pricePeopleRange": detail.pricePeopleRange,
"priceDescription": detail.priceDescription,
"conciergeAdvisor": public_concierge_advisor_dict(concierge_advisor) if concierge_advisor else None,
}
+1
View File
@@ -14,6 +14,7 @@ services:
OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-}
OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-}
OSS_ENDPOINT: ${OSS_ENDPOINT:-}
OSS_PUBLIC_BASE_URL: ${OSS_PUBLIC_BASE_URL:-}
OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-}
ports:
- "4000:4000"
+2
View File
@@ -5,6 +5,7 @@ def test_oss_settings_are_loaded_from_prefixed_environment(monkeypatch):
monkeypatch.setenv("OSS_ACCESS_KEY_ID", "example-access-key-id")
monkeypatch.setenv("OSS_ACCESS_KEY_SECRET", "example-access-key-secret")
monkeypatch.setenv("OSS_ENDPOINT", "oss-cn-example.aliyuncs.com")
monkeypatch.setenv("OSS_PUBLIC_BASE_URL", "https://example-bucket.oss-cn-example.aliyuncs.com")
monkeypatch.setenv("OSS_BUCKET_NAME", "example-bucket")
settings = Settings(_env_file=None)
@@ -12,6 +13,7 @@ def test_oss_settings_are_loaded_from_prefixed_environment(monkeypatch):
assert settings.oss_access_key_id == "example-access-key-id"
assert settings.oss_access_key_secret == "example-access-key-secret"
assert settings.oss_endpoint == "oss-cn-example.aliyuncs.com"
assert settings.oss_public_base_url == "https://example-bucket.oss-cn-example.aliyuncs.com"
assert settings.oss_bucket_name == "example-bucket"
@@ -0,0 +1,28 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0037_detail_price_config.py"
SPEC = spec_from_file_location("detail_price_config_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_price_columns_are_added_only_when_missing():
assert MIGRATION.missing_price_columns(set()) == {
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}
assert MIGRATION.missing_price_columns({"priceStartingValue", "priceUnit"}) == {
"pricePeopleRange",
"priceDescription",
}
assert MIGRATION.missing_price_columns({
"priceStartingValue",
"priceUnit",
"pricePeopleRange",
"priceDescription",
}) == set()
+36
View File
@@ -77,6 +77,10 @@ def make_detail(**overrides):
"excluded": ["往返大交通"],
"notes": ["请准备轻便雨具。"],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"isActive": True,
"sortOrder": 0,
"conciergeAdvisorId": None,
@@ -124,11 +128,33 @@ def test_detail_schema_trims_text_and_array_items():
excluded=[" 往返大交通 "],
notes=[" 请准备雨具。 "],
gallery=[" https://example.test/family-water.jpg "],
priceStartingValue=1.68,
priceUnit="person",
pricePeopleRange=" 2-4人 ",
priceDescription=" 价格以最终确认方案为准。 ",
)
assert detail.key == "family-water"
assert detail.highlights == ["核心景观串联", "小团出行"]
assert detail.gallery == ["https://example.test/family-water.jpg"]
assert detail.priceStartingValue == 1.68
assert detail.priceUnit == "person"
assert detail.pricePeopleRange == "2-4人"
assert detail.priceDescription == "价格以最终确认方案为准。"
def test_detail_schema_rejects_unknown_price_unit():
with pytest.raises(ValidationError):
DetailCreate(
key="family-water",
eyebrow="玩法推荐",
duration="5天4晚",
title="亲子玩水",
subtitle="贵州路线",
intro="介绍",
priceStartingValue=1.68,
priceUnit="month",
)
def test_detail_schema_rejects_invalid_gallery_url():
@@ -188,6 +214,10 @@ def test_public_detail_returns_frontend_contract_without_auth():
"excluded": ["往返大交通"],
"notes": ["请准备轻便雨具。"],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"conciergeAdvisor": None,
},
}
@@ -294,6 +324,10 @@ def test_admin_detail_create_normalizes_key_and_appends():
"excluded": [],
"notes": [],
"gallery": ["https://example.test/family-water.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
},
)
finally:
@@ -301,6 +335,8 @@ def test_admin_detail_create_normalizes_key_and_appends():
assert response.status_code == 201
assert response.json()["data"]["key"] == "family-water"
assert fake_db.added[0].priceStartingValue == 1.68
assert fake_db.added[0].priceUnit == "person"
assert fake_db.added[0].sortOrder == 3
@@ -9,3 +9,9 @@ def test_api_compose_forwards_wechat_miniapp_settings_from_host_environment():
assert "WECHAT_MINIAPP_APPID: ${WECHAT_MINIAPP_APPID:-}" in compose
assert "WECHAT_MINIAPP_SECRET: ${WECHAT_MINIAPP_SECRET:-}" in compose
def test_api_compose_forwards_oss_public_base_url_from_host_environment():
compose = COMPOSE_PATH.read_text(encoding="utf-8")
assert "OSS_PUBLIC_BASE_URL: ${OSS_PUBLIC_BASE_URL:-}" in compose
+24
View File
@@ -40,6 +40,7 @@ def test_resolve_media_url_only_signs_the_configured_oss_host(monkeypatch):
oss_access_key_id="test-access-key",
oss_access_key_secret="test-access-secret",
oss_endpoint="oss-cn-guangzhou.aliyuncs.com",
oss_public_base_url="https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com",
oss_bucket_name="one-feel-ota-data",
),
)
@@ -55,6 +56,29 @@ def test_resolve_media_url_only_signs_the_configured_oss_host(monkeypatch):
assert media_urls.resolve_media_url(external_url) == external_url
def test_resolve_media_url_rewrites_legacy_internal_oss_host_before_signing(monkeypatch):
monkeypatch.setattr(
media_urls,
"get_settings",
lambda: SimpleNamespace(
oss_access_key_id="test-access-key",
oss_access_key_secret="test-access-secret",
oss_endpoint="oss-cn-guangzhou-internal.aliyuncs.com",
oss_public_base_url="https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com",
oss_bucket_name="one-feel-ota-data",
),
)
monkeypatch.setattr(media_urls, "time", lambda: 1_800_000_000)
legacy_url = "https://one-feel-ota-data.oss-cn-guangzhou-internal.aliyuncs.com/admin/image.webp"
signed = media_urls.resolve_media_url(legacy_url)
parsed = urlsplit(signed)
assert parsed.netloc == "one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com"
assert parsed.path == "/admin/image.webp"
assert parse_qs(parsed.query)["OSSAccessKeyId"] == ["test-access-key"]
def test_resolve_media_fields_signs_common_image_fields(monkeypatch):
monkeypatch.setattr(
"app.serializers.resolve_media_url",
+58
View File
@@ -0,0 +1,58 @@
from io import BytesIO
from types import SimpleNamespace
from app.routers import admin as admin_router
class FakeOssResponse:
status = 200
def read(self, _size: int) -> bytes:
return b""
class FakeOssConnection:
instances: list["FakeOssConnection"] = []
def __init__(self, host: str, timeout: int):
self.host = host
self.timeout = timeout
self.request_path: str | None = None
self.__class__.instances.append(self)
def request(self, _method, path, **_kwargs) -> None:
self.request_path = path
def getresponse(self) -> FakeOssResponse:
return FakeOssResponse()
def close(self) -> None:
pass
def test_upload_uses_internal_endpoint_but_returns_public_url(monkeypatch):
FakeOssConnection.instances.clear()
monkeypatch.setattr(
admin_router,
"oss_settings",
lambda: SimpleNamespace(
oss_access_key_id="test-access-key",
oss_access_key_secret="test-access-secret",
oss_endpoint="oss-cn-guangzhou-internal.aliyuncs.com",
oss_public_base_url="https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com",
oss_bucket_name="one-feel-ota-data",
),
)
monkeypatch.setattr(admin_router.http.client, "HTTPSConnection", FakeOssConnection)
url = admin_router.upload_image_to_oss(
BytesIO(b"image"),
"admin/general/image.webp",
"image/webp",
5,
)
connection = FakeOssConnection.instances[0]
assert connection.host == "one-feel-ota-data.oss-cn-guangzhou-internal.aliyuncs.com"
assert connection.request_path == "/admin/general/image.webp"
assert url == "https://one-feel-ota-data.oss-cn-guangzhou.aliyuncs.com/admin/general/image.webp"
+14
View File
@@ -0,0 +1,14 @@
from redis.cluster import key_slot
from app.redis_session import RedisAdminSessionStore, hash_refresh_token
def test_redis_session_rotation_keys_share_cluster_slot():
store = RedisAdminSessionStore.__new__(RedisAdminSessionStore)
keys = [
store._session_key("session-1"),
store._refresh_key(hash_refresh_token("refresh-1")),
store._refresh_key(hash_refresh_token("refresh-2")),
]
assert len({key_slot(key.encode("utf-8")) for key in keys}) == 1
+9
View File
@@ -0,0 +1,9 @@
# 测试环境
# VITE_API_BASE_URL="http://8.138.234.141:4000"
# 本地开发环境
# VITE_API_BASE_URL="http://127.0.0.1:4000"
# 生产环境
VITE_API_BASE_URL="https://bizwanderq.nianxx.com"
+4
View File
@@ -124,6 +124,10 @@ export type PublicDetail = {
excluded: string[];
notes: string[];
gallery: string[];
priceStartingValue: number | null;
priceUnit: "person" | "day" | "group" | null;
pricePeopleRange: string;
priceDescription: string;
conciergeAdvisor: PublicConciergeAdvisor | null;
};
@@ -24,8 +24,9 @@
<view class="ml-auto flex min-w-0 items-center gap-2">
<view class="min-w-0">
<text class="block text-[10px] text-[#9b9188]">参考起价</text>
<text class="mt-0.5 block whitespace-nowrap text-[18px] font-bold leading-none text-[#e96635]">¥{{
price }}万</text>
<text v-if="props.priceStartingValue !== null" class="mt-0.5 block whitespace-nowrap text-[18px] font-bold leading-none text-[#e96635]">¥{{
formatWan(props.priceStartingValue) }}万{{ priceUnitSuffix }}</text>
<text v-else class="mt-0.5 block whitespace-nowrap text-[15px] font-semibold leading-none text-[#a59b92]">价格待确认</text>
</view>
<view
v-if="showConcierge"
@@ -37,16 +38,27 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { formatWan } from "@/lib/data";
import type { PublicDetail } from "@/lib/types";
withDefaults(defineProps<{
price: number;
const props = withDefaults(defineProps<{
priceStartingValue: number | null;
priceUnit: PublicDetail["priceUnit"];
favorite: boolean;
showConcierge?: boolean;
}>(), {
priceStartingValue: null,
priceUnit: null,
showConcierge: true,
});
const priceUnitLabel = computed(() => {
if (!props.priceUnit) return "";
return { person: "人", day: "天", group: "团" }[props.priceUnit];
});
const priceUnitSuffix = computed(() => (priceUnitLabel.value ? ` / ${priceUnitLabel.value}` : ""));
defineEmits<{
call: [];
concierge: [];
@@ -31,6 +31,10 @@ function createRouteFallback(route: PlayRoute): DetailPresentation {
"最终行程以出行日期、人数和资源确认结果为准。",
],
gallery: Array.from(new Set([route.image, ...sharedGallery])).slice(0, 5),
priceStartingValue: null,
priceUnit: null,
pricePeopleRange: "",
priceDescription: "",
conciergeAdvisor: null,
};
}
@@ -61,6 +65,19 @@ function cleanGallery(value: unknown) {
);
}
function cleanPriceStartingValue(value: unknown, fallback: number | null) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return fallback;
return Number(value.toFixed(2));
}
function cleanPriceUnit(value: unknown, fallback: PublicDetail["priceUnit"]): PublicDetail["priceUnit"] {
return value === "person" || value === "day" || value === "group" ? value : fallback;
}
function cleanOptionalText(value: unknown, fallback: string) {
return cleanText(value) || fallback;
}
export function findWanfaRouteDetailFallback(routeId: string) {
return routeFallbacks.get(routeId) ?? null;
}
@@ -78,6 +95,10 @@ export function normalizeDetailPresentation(
const intro = cleanText(source.intro) || fallback?.intro || "";
const gallery = cleanGallery(source.gallery);
const conciergeAdvisor = normalizeConciergeAdvisor(source.conciergeAdvisor);
const priceStartingValue = cleanPriceStartingValue(source.priceStartingValue, fallback?.priceStartingValue ?? null);
const priceUnit = cleanPriceUnit(source.priceUnit, fallback?.priceUnit ?? null);
const pricePeopleRange = cleanOptionalText(source.pricePeopleRange, fallback?.pricePeopleRange ?? "");
const priceDescription = cleanOptionalText(source.priceDescription, fallback?.priceDescription ?? "");
if (!key || !eyebrow || !duration || !title || !subtitle || !intro) {
return fallback;
@@ -95,6 +116,10 @@ export function normalizeDetailPresentation(
excluded: cleanList(source.excluded).length ? cleanList(source.excluded) : fallback?.excluded ?? [],
notes: cleanList(source.notes).length ? cleanList(source.notes) : fallback?.notes ?? [],
gallery: gallery.length ? gallery : fallback?.gallery ?? [],
priceStartingValue,
priceUnit,
pricePeopleRange,
priceDescription,
conciergeAdvisor,
};
}
+19 -2
View File
@@ -43,7 +43,8 @@
</scroll-view>
<DetailActionBar
:price="1.68"
:price-starting-value="presentation?.priceStartingValue ?? null"
:price-unit="presentation?.priceUnit ?? null"
:favorite="false"
:show-concierge="Boolean(presentation?.conciergeAdvisor)"
@call="goCall"
@@ -59,6 +60,7 @@ import { computed, ref } from "vue";
import { onLoad, onShareAppMessage } from "@dcloudio/uni-app";
import ConciergeContactSheet from "@/components/ConciergeContactSheet.vue";
import { fetchPublicDetail } from "@/lib/api";
import { formatWan } from "@/lib/data";
import { goBack } from "@/lib/navigation";
import { rememberRecentlyViewed, syncRecentlyViewed } from "@/lib/recentlyViewed";
import { SUPPORT_PHONE } from "@/lib/types";
@@ -82,7 +84,22 @@ const errorMessage = ref("");
const selectedAdvisor = ref<ConciergeAdvisor | null>(null);
const heroImage = computed(() => presentation.value?.gallery[0] ?? "");
const priceParagraphs = computed(() => ["最终价格会根据出行日期、人数和资源确认结果生成。"]);
const priceParagraphs = computed(() => {
const current = presentation.value;
if (!current) return ["价格将根据出行日期、人数和资源确认结果生成。"];
const paragraphs: string[] = [];
if (current.priceStartingValue !== null) {
const unitLabels = { person: "人", day: "天", group: "团" } as const;
const unit = current.priceUnit ? ` / ${unitLabels[current.priceUnit]}` : "";
const peopleRange = current.pricePeopleRange ? `,适用${current.pricePeopleRange}` : "";
paragraphs.push(`参考起价:¥${formatWan(current.priceStartingValue)}万${unit}${peopleRange}`);
} else {
paragraphs.push("价格待确认,最终价格会根据出行日期、人数和资源确认结果生成。");
}
if (current.priceDescription) paragraphs.push(current.priceDescription);
return paragraphs;
});
function parseRouteId(value: unknown) {
if (typeof value !== "string") return "";
@@ -20,6 +20,10 @@ describe("wanfa route detail presentation", () => {
excluded: [" 往返大交通 "],
notes: [" 请准备雨具。 "],
gallery: [" https://example.test/cover.jpg "],
priceStartingValue: 1.68,
priceUnit: "person",
pricePeopleRange: " 2-4人 ",
priceDescription: " 价格以最终确认方案为准。 ",
conciergeAdvisor: null,
}),
).toEqual({
@@ -34,6 +38,10 @@ describe("wanfa route detail presentation", () => {
excluded: ["往返大交通"],
notes: ["请准备雨具。"],
gallery: ["https://example.test/cover.jpg"],
priceStartingValue: 1.68,
priceUnit: "person",
pricePeopleRange: "2-4人",
priceDescription: "价格以最终确认方案为准。",
conciergeAdvisor: null,
});
});
+2 -2
View File
@@ -6,7 +6,7 @@
管家顾问资料管理的字段、图片、排序与删除约束见 [concierge-api.md](./concierge-api.md)。该文档是本主契约的管家领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI-Vue`。
详情展示内容的字段、图片、排序与商品领域隔离约束见 [detail-api.md](./detail-api.md)。该文档是本主契约的详情领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI-Vue`。
详情展示内容及路线参考价格字段、图片、排序与商品领域隔离约束见 [detail-api.md](./detail-api.md)。该文档是本主契约的详情领域补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI-Vue`。
首页和用车站点模块的字段、单例、排序与删除约束见 [module-config-api.md](./module-config-api.md)。
@@ -80,7 +80,7 @@
`GET /api/admin/media-assets?pageNum=1&pageSize=20` 按创建时间倒序分页返回素材,`pageSize` 最大为 `200`,响应为 `data: { items, total, pageNum, pageSize }`。管理端翻页时必须重新请求接口,不在前端截取固定数量的素材。
媒体上传成功响应中的 `data.url` 是可直接用于图片回显的 HTTP(S) URL。OSS 私有读场景下,该 URL 会包含短时 GET 签名;管理端应直接使用返回值,后续 Admin/Public API 响应会重新生成签名。该接口只写入素材库,不会自动绑定首页配置;绑定首页轮播或用车卡片后,仍需提交对应的站点配置保存接口。
媒体上传成功响应中的 `data.url` 是可直接用于图片回显的公网 HTTP(S) URL。服务端通过 `OSS_ENDPOINT` 连接 OSS 上传,通过 `OSS_PUBLIC_BASE_URL` 生成并持久化客户端访问地址;ACK 可使用内网 Endpoint 上传,但不得把内网 Host 返回给客户端。OSS 私有读场景下,该 URL 会包含短时 GET 签名;管理端应直接使用返回值,后续 Admin/Public API 响应会重新生成签名。历史记录中的同 Bucket 内网 URL 会在响应时切换到公网 Host 后重新签名。该接口只写入素材库,不会自动绑定首页配置;绑定首页轮播或用车卡片后,仍需提交对应的站点配置保存接口。
## 站点模块
+30 -6
View File
@@ -11,12 +11,14 @@
- 详情页识别键、标题眉标、出行时长和标题文案。
- 详情介绍、行程亮点、费用包含、费用不含和注意事项。
- 详情页图片画廊及图片顺序。
- 详情页参考价格:价格起始值、单位、适用人数范围和价格说明。
- 详情页可选的联系管家顾问 ID;顾问资料仍由管家领域维护。
- 启用状态和详情列表顺序。
本接口不负责:
- 商品、商品价格、商品库存或商品详情表。
- 订单或预订价格计算;本接口中的价格仅用于路线详情展示。
- Product、ProductImage 或任何商品外键。
- 订单、预订、收藏、评价或线索。
- 管家顾问的头像、二维码、服务详情和管家 CRUD;详情只保存顾问 ID。
@@ -30,6 +32,7 @@
`detailPresentation.ts` 是前台展示适配器,不是持久化模型:
- `eyebrow`、`duration`、`title`、`subtitle`、`intro`、`highlights`、`included`、`excluded`、`notes`、`gallery` 组成最终展示对象。
- `priceStartingValue`、`priceUnit`、`pricePeopleRange`、`priceDescription` 组成可选的参考价格展示对象。
- 当前实现优先消费 Public API 返回的最终展示字段。
- 接口失败、字段不完整或详情未配置时,MiniAPP 按路线 ID 使用本地网络图片和模拟文案兜底。
@@ -76,6 +79,10 @@ type DetailPresentation = {
excluded: string[];
notes: string[];
gallery: string[];
priceStartingValue: number | null;
priceUnit: "person" | "day" | "group" | null;
pricePeopleRange: string;
priceDescription: string;
};
type DetailRecord = DetailPresentation & {
@@ -101,8 +108,12 @@ type PublicConciergeAdvisor = {
qrImage: string;
};
type DetailCreate = DetailPresentation & {
type DetailCreate = Omit<DetailPresentation, "priceStartingValue" | "priceUnit" | "pricePeopleRange" | "priceDescription"> & {
key: string;
priceStartingValue?: number | null;
priceUnit?: "person" | "day" | "group" | null;
pricePeopleRange?: string | null;
priceDescription?: string | null;
conciergeAdvisorId?: string | null;
isActive?: boolean;
sortOrder?: number;
@@ -135,6 +146,10 @@ type DetailReorderRequest = {
| `excluded` | `string[]` | 是 | “费用不含”列表,保留数组顺序。 |
| `notes` | `string[]` | 是 | “注意事项”列表,保留数组顺序。 |
| `gallery` | `string[]` | 是 | 详情图片 URL 列表,按展示顺序返回;建议最多 6 张以匹配当前前台逻辑。 |
| `priceStartingValue` | `number \| null` | 否 | 参考起始值,单位为万元;未配置时为 `null`,不参与订单或报价计算。 |
| `priceUnit` | `"person" \| "day" \| "group" \| null` | 否 | 价格展示单位,分别对应“人”“天”“团”;未配置价格时为 `null`。 |
| `pricePeopleRange` | `string` | 否 | 适用人数范围,例如“2-4人”;仅用于展示。 |
| `priceDescription` | `string` | 否 | 价格补充说明,例如“价格以最终确认方案为准”。 |
| `conciergeAdvisorId` | `string \| null` | 否 | 关联的管家顾问 ID;不建立数据库外键,空字符串保存为 `null`。 |
| `isActive` | `boolean` | 响应必填 | 是否进入已发布前台内容,创建默认 `true`。 |
| `sortOrder` | `number` | 响应必填 | 非负整数,数值越小越靠前;创建时未传则追加到末尾。 |
@@ -173,6 +188,10 @@ Authorization: Bearer <admin-jwt>
"excluded": ["往返大交通及个人消费"],
"notes": ["贵州多山多雨,请准备防滑鞋和轻便雨具。"],
"gallery": ["https://example.test/assets/detail-01.jpg"],
"priceStartingValue": 1.68,
"priceUnit": "person",
"pricePeopleRange": "2-4人",
"priceDescription": "价格以最终确认方案为准。",
"isActive": true,
"sortOrder": 0,
"createdAt": "2026-01-01T00:00:00Z",
@@ -303,13 +322,14 @@ POST /api/admin/media-assets/upload
Admin UI 应按以下方式调用:
1. 玩法页加载时同时调用玩法分类、`GET /api/admin/details` 和 `GET /api/admin/concierge/advisors`;详情编辑器嵌入现有路线编辑抽屉,不新增侧边菜单。
2. 新增和编辑表单维护眉标、时长、标题、副标题、介绍和四组列表文案;保存路线时以已保存的路线 ID 作为详情 `key`。
2. 新增和编辑表单维护眉标、时长、标题、副标题、介绍、价格配置和四组列表文案;保存路线时以已保存的路线 ID 作为详情 `key`。
3. `highlights`、`included`、`excluded`、`notes` 使用可增删的重复字段编辑器,提交时保留数组顺序,不拼接成换行字符串。
4. 使用图片上传接口维护 `gallery`,支持新增、删除和调整图片顺序。
5. 上移或下移详情时提交完整详情 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。
6. 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。
7. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存、上传或排序进行中禁用重复提交。
8. 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单、价格或预订字段;联系管家使用顾问 ID 选择器;详情保存失败时明确提示路线摘要已保存、详情需要重试。
5. 在详情图片后维护价格起始值、价格单位、适用人数范围和价格说明;价格未配置时提交空值,不能写入前台固定价格。
6. 上移或下移详情时提交完整详情 ID 列表,不直接修改本地 `sortOrder` 后假设保存成功。
7. 删除前要求二次确认;删除成功后以接口返回或重新查询的数据更新列表。
8. 处理 `401`、`404`、`409`、`422` 和 `5xx`,保存、上传或排序进行中禁用重复提交。
9. 详情编辑器不得出现 Product ID、ProductImage ID、库存、订单或预订字段;联系管家使用顾问 ID 选择器;详情保存失败时明确提示路线摘要已保存、详情需要重试。
建议的 Admin UI API 封装函数:
@@ -339,6 +359,10 @@ const presentation: DetailPresentation = {
excluded: record.excluded,
notes: record.notes,
gallery: record.gallery,
priceStartingValue: publicDetail.priceStartingValue,
priceUnit: publicDetail.priceUnit,
pricePeopleRange: publicDetail.pricePeopleRange,
priceDescription: publicDetail.priceDescription,
conciergeAdvisor: publicDetail.conciergeAdvisor ?? null,
};
```
+1 -1
View File
@@ -52,7 +52,7 @@
- `GET /api/admin/site-config` 返回启用和停用的完整记录,前端负责显示状态。
- `sortOrder` 为从 `0` 开始的非负整数,后端负责重新规范化。
- 图片字段保存最终 HTTP(S) URL,不接受 base64;管理端上传组件通过媒体上传接口先取得 URL,再提交配置。OSS 私有读场景下,接口响应会为 OSS 图片 URL 临时追加短时 GET 签名,供管理端和 Public API 回显;后端会在每次响应时重新签名,签名参数不应由客户端自行拼接或长期缓存。
- 图片字段保存最终公网 HTTP(S) URL,不接受 base64;管理端上传组件通过媒体上传接口先取得 URL,再提交配置。后端使用 `OSS_ENDPOINT` 上传、使用 `OSS_PUBLIC_BASE_URL` 生成客户端访问地址,不能持久化 OSS 内网 Host。OSS 私有读场景下,接口响应会为 OSS 图片 URL 临时追加短时 GET 签名,供管理端和 Public API 回显;后端会在每次响应时重新签名,签名参数不应由客户端自行拼接或长期缓存。历史内网 URL 在响应时会自动切换到公网 Host。
- `POST /api/admin/media-assets/upload` 只创建素材库记录,不会自动修改 `heroSlides` 或 `vehicleOptions`。将图片用于首页配置时,必须把返回的 `data.url` 写入对应编辑表单,并继续提交对应的 `POST` 或 `PATCH /api/admin/site-config/{module}/{id}`;成功后才会在 `GET /api/admin/site-config` 中返回该图片。
- 首页玩法推荐、团队共创和极境视界保留独立的新增、编辑、删除、启停和排序能力,保存后由 Public API 直接提供给 MiniAPP。
- 旧需求页主视觉、特色卡片、需求表单、体验推荐、用车服务说明等表结构已由后续 Alembic 迁移删除,不能在新代码中重新声明或调用。
+5 -1
View File
@@ -182,6 +182,10 @@ type PublicDetail = {
excluded: string[];
notes: string[];
gallery: string[];
priceStartingValue: number | null;
priceUnit: "person" | "day" | "group" | null;
pricePeopleRange: string;
priceDescription: string;
conciergeAdvisor: {
avatar: string;
name: string;
@@ -303,7 +307,7 @@ type HomeWildArchive = {
站点模块通常包含 `id`、`createdAt`、`updatedAt`、`isActive` 和 `sortOrder`。客户端按 `sortOrder` 消费排序模块,不依赖固定 ID。
图片字段(如 `image`、`gallery`、`avatar`、`qrImage`)始终返回可直接请求的 HTTP(S) URL。OSS 配置为私有读时,WonderQ-Admin 会在响应中生成短时 GET 签名 URL;客户端应直接使用返回值,不应持久化或自行修改签名参数。
图片字段(如 `image`、`gallery`、`avatar`、`qrImage`)始终返回可直接请求的公网 HTTP(S) URL。OSS 上传可使用内网 Endpoint,但响应地址统一使用 `OSS_PUBLIC_BASE_URL`;历史内网 URL 会在响应时自动切换到公网 Host。OSS 配置为私有读时,WonderQ-Admin 会在响应中生成短时 GET 签名 URL;客户端应直接使用返回值,不应持久化或自行修改签名参数。
旧需求页主视觉、特色卡片和需求表单已移除;需求页面只通过 `POST /api/public/leads` 提交实时线索,不再读取已删除的站点配置表。
+1
View File
@@ -81,6 +81,7 @@ test_container="$(docker create \
"$API_IMAGE:$IMAGE_TAG" \
-m pytest)"
docker cp WonderQ-Admin/tests/. "$test_container:/app/tests"
docker cp WonderQ-Admin/docker-compose.yml "$test_container:/app/docker-compose.yml"
docker start --attach "$test_container"
docker rm "$test_container" >/dev/null
test_container=""