Compare commits
6
Commits
14e020b781
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78287a944 | ||
|
|
cd007e2f6a | ||
|
|
30c2e44dc0 | ||
|
|
feec6ab80f | ||
|
|
f18971a430 | ||
|
|
680e0b9799 |
No files matched your search
@@ -3,7 +3,6 @@ dist/
|
||||
**/public/
|
||||
apps/*/dist/
|
||||
.pnpm-store/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
**/.venv/
|
||||
|
||||
Vendored
+1
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -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 +1 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
coverage/
|
||||
.prisma/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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
@@ -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` 提交实时线索,不再读取已删除的站点配置表。
|
||||
|
||||
|
||||
@@ -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=""
|
||||
|
||||
Reference in new issue
Block a user