Compare commits

..

12 Commits

Author SHA1 Message Date
shiran 5882064295 Localize bulk upload script help 2026-05-23 17:22:48 +08:00
shiran be09bf6f0a Add bulk spot upload script 2026-05-23 17:21:00 +08:00
shiran f1ce992d69 feat(server): 添加默认管理员自动创建功能
- 在 .env.example 中添加默认管理员相关配置项
- 在 docker-compose.yml 中添加默认管理员环境变量映射
- 在 server/app/core/config.py 中定义默认管理员配置
- 创建 server/app/db/bootstrap.py 文件实现默认管理员创建逻辑
- 在 server/app/main.py 的生命周期中集成默认管理员确保功能
- 更新 README.md 文档说明新的管理员配置方式

新配置项包括:DEFAULT_ADMIN_ENABLED、DEFAULT_ADMIN_PHONE、
DEFAULT_ADMIN_EMAIL、DEFAULT_ADMIN_PASSWORD、DEFAULT_ADMIN_NICKNAME
和 DEFAULT_ADMIN_SYNC_PASSWORD。
2026-05-09 19:00:02 +08:00
shiran 3f3acf834d feat(map): 添加地图定位功能的条件渲染控制
为了解决H5平台地图定位显示的问题,在index页面和pick-location页面中添加了
mapShowLocation响应式变量,并通过条件编译确保仅在非H5平台启用地图定位功能,
避免H5环境下出现定位相关的兼容性问题。
2026-05-09 18:49:42 +08:00
shiran a493d1bcf6 feat(server): 统一配置文件管理并增强环境变量配置
- 将服务器配置文件合并到根目录的 .env.example 中,移除 server/.env.example
- 为 PostgreSQL、Redis、MinIO/S3、Server、前端构建和Nginx 配置添加详细注释
- 新增 S3_REGION、S3_PUBLIC_URL 和 SENTRY_DSN 环境变量配置
- 修改配置加载逻辑以正确读取根目录下的 .env 文件
- 更新 docker-compose.yml 以包含新增的环境变量
2026-05-09 18:33:33 +08:00
shiran 0afd5bbb2c fix(api): 统一API端点路径格式
统一所有API端点路径末尾添加斜杠,确保与后端API规范一致,
包括收藏、通知、景点和用户相关接口。
2026-05-09 18:23:42 +08:00
shiran f2b4beed0a feat(config): 更新环境配置和Nginx设置以支持动态端口配置
- 修改 .env.example 文件中的客户端API基础路径配置
- 将Dockerfile中的nginx.conf复制到模板目录以支持环境变量
- 在nginx配置中使用环境变量替换硬编码端口
- 为API文档路径(/docs、/redoc、/openapi.json)添加代理配置
- 移除硬编码的服务器地址,改用相对路径配置
- 更新docker-compose.yml以传递内部端口环境变量
- 简化nginx反向代理配置,移除冗余的服务器块配置
2026-05-09 18:08:16 +08:00
shiran 9de0a56afa feat(config): 添加环境变量配置支持动态镜像和端口设置
- 在 .env.example 中添加 SERVER_BASE_IMAGE、SERVER_INTERNAL_PORT 等配置项
- 添加前端构建相关的 NODE_IMAGE 和 NGINX_IMAGE 配置
- 添加客户端 H5 和原生应用的 API 基础路径配置
- 支持动态端口配置以提高部署灵活性

feat(docker): 更新 Dockerfile 使用参数化镜像和端口配置

- 修改 server/Dockerfile 支持动态基础镜像和内部端口
- 更新 admin-web/Dockerfile 使用参数化镜像配置
- 修改 clients/Dockerfile 支持客户端多环境配置参数
- 所有 Dockerfile 现在使用 ARG 参数进行灵活配置

feat(nginx): 优化 Nginx 配置支持动态端口代理

- 更新 Nginx 配置文件使用环境变量定义监听端口
- 配置三个独立的服务端口分别处理客户端、管理后台和服务器API
- 添加完整的代理头信息设置以支持正确的请求转发
- 使用 Nginx 环境变量实现灵活的服务间通信

feat(deploy): 完善 docker-compose.yml 的环境变量集成

- 更新 docker-compose.yml 文件以使用新的环境变量配置
- 配置服务健康检查使用动态端口
- 设置 Nginx 容器环境变量以支持模板化配置
- 修复服务间通信端口使用环境变量替代硬编码值
2026-05-09 18:03:38 +08:00
shiran 19db342507 feat(server): 添加pip镜像源配置支持
添加清华PyPI镜像源配置选项到环境变量文件,包括
PIP_INDEX_URL、PIP_TRUSTED_HOST和PIP_DEFAULT_TIMEOUT参数,
用于加速Docker构建过程中的包安装速度。

在docker-compose.yml中将这些参数作为构建参数传递给
Docker容器,并在Dockerfile中接收并设置为环境变量,
同时移除pip install的--no-cache-dir参数以利用缓存。
2026-05-09 17:44:22 +08:00
shiran 3df8fcacbc feat(config): 添加镜像版本环境变量配置
为 Docker 镜像添加可配置的环境变量,包括 PostGIS、Redis、MinIO 和 Nginx,
使镜像版本可以通过 .env 文件进行自定义配置。

BREAKING CHANGE: 现在需要在 .env 文件中设置镜像版本变量。
2026-05-09 17:14:02 +08:00
shiran a01e08d72f Merge branch 'main' of https://gitea.s1f.ren/shiran/CosScene 2026-05-09 16:51:30 +08:00
shiran 5c9420af2a feat(docker): 添加docker-compose版本声明
为docker-compose.yml文件添加version "3.8"声明,
确保使用兼容的compose文件格式版本。
2026-05-09 16:50:50 +08:00
24 changed files with 766 additions and 160 deletions
+92 -6
View File
@@ -1,35 +1,121 @@
# PostgreSQL
# PostgreSQL 数据库配置
# PostGIS 镜像地址,可改为私有镜像仓库地址。
POSTGRES_IMAGE=postgis/postgis:17-3.5
# PostgreSQL 用户名。
POSTGRES_USER=shiran
# PostgreSQL 密码,生产环境必须修改。
POSTGRES_PASSWORD=change-me-postgres-password
# PostgreSQL 数据库名。
POSTGRES_DB=ciyuan_viewfinder
# 容器内 PostgreSQL 数据目录。
POSTGRES_PGDATA=/var/lib/postgresql/data/pgdata
# Redis
# Redis 配置
# Redis 镜像地址,可改为私有镜像仓库地址。
REDIS_IMAGE=redis:7-alpine
# 后端连接 Redis 的地址,Docker Compose 内使用服务名 redis。
REDIS_URL=redis://redis:6379/0
# MinIO / S3
# MinIO / S3 对象存储配置
# MinIO 镜像地址,可改为私有镜像仓库地址。
MINIO_IMAGE=minio/minio:latest
# MinIO 管理员用户名。
MINIO_ROOT_USER=minioadmin
# MinIO 管理员密码,生产环境必须修改。
MINIO_ROOT_PASSWORD=change-me-minio-password
# S3 兼容服务地址,Docker Compose 内使用服务名 minio。
S3_ENDPOINT=http://minio:9000
# S3 访问密钥。
S3_ACCESS_KEY=minioadmin
# S3 访问密钥密码,通常与 MinIO 管理员密码保持一致。
S3_SECRET_KEY=change-me-minio-password
# S3 存储桶名称。
S3_BUCKET=ciyuan-viewfinder
# S3 区域,MinIO 可留空。
S3_REGION=
# S3 公网访问地址,留空时由后端按默认规则生成。
S3_PUBLIC_URL=
# Server
# 后端镜像与运行配置
# 后端 Dockerfile 基础镜像。
SERVER_BASE_IMAGE=python:3.12-slim
# 后端容器内部监听端口。
SERVER_INTERNAL_PORT=8000
# pip 包下载源,网络不稳定时可改为内网源。
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# pip 信任主机,通常与 PIP_INDEX_URL 的域名一致。
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
# pip 下载超时时间,单位秒。
PIP_DEFAULT_TIMEOUT=120
# 后端异步数据库连接串,Docker Compose 内使用服务名 postgres。
DATABASE_URL=postgresql+asyncpg://shiran:change-me-postgres-password@postgres:5432/ciyuan_viewfinder
# 后端同步数据库连接串,用于迁移等同步场景。
DATABASE_URL_SYNC=postgresql://shiran:change-me-postgres-password@postgres:5432/ciyuan_viewfinder
# JWT 签名密钥,生产环境必须改成高强度随机字符串。
SECRET_KEY=change-me-before-production
# 访问令牌有效期,单位分钟。
ACCESS_TOKEN_EXPIRE_MINUTES=43200
# 刷新令牌有效期,单位天。
REFRESH_TOKEN_EXPIRE_DAYS=60
# 存储后端类型,可选 local 或 s3。
STORAGE_BACKEND=local
# 本地上传文件在后端容器内的保存路径。
LOCAL_STORAGE_PATH=/app/uploads
# 腾讯地图 API Key,没有地图相关能力时可留空。
TENCENT_MAP_KEY=
# Sentry DSN,留空表示不启用错误上报。
SENTRY_DSN=
# 后端日志级别。
LOG_LEVEL=INFO
# 是否输出 JSON 格式日志。
LOG_JSON=false
# 是否在后端启动时自动创建默认管理员。
DEFAULT_ADMIN_ENABLED=true
# 默认管理员手机号,可用于登录管理端。
DEFAULT_ADMIN_PHONE=13900000001
# 默认管理员邮箱,也可用于登录管理端。
DEFAULT_ADMIN_EMAIL=admin@ciyuan.local
# 默认管理员密码,生产环境必须修改。
DEFAULT_ADMIN_PASSWORD=admin123456
# 默认管理员昵称。
DEFAULT_ADMIN_NICKNAME=系统管理员
# 启动时是否把已有默认管理员密码同步为 DEFAULT_ADMIN_PASSWORD。
DEFAULT_ADMIN_SYNC_PASSWORD=true
# Frontend build
# 管理端前端构建配置
# 管理端构建阶段 Node 基础镜像。
ADMIN_WEB_NODE_IMAGE=node:20-alpine
# 管理端运行阶段 Nginx 基础镜像。
ADMIN_WEB_NGINX_IMAGE=nginx:1.27-alpine
# 管理端容器内部监听端口。
ADMIN_WEB_INTERNAL_PORT=80
# 管理端 API 基础路径,浏览器侧使用相对路径走当前域名反代。
VITE_API_BASE=/api/v1
# Nginx host ports
# 客户端前端构建配置
# 客户端构建阶段 Node 基础镜像。
CLIENTS_NODE_IMAGE=node:20-alpine
# 客户端运行阶段 Nginx 基础镜像。
CLIENTS_NGINX_IMAGE=nginx:1.27-alpine
# 客户端容器内部监听端口。
CLIENTS_INTERNAL_PORT=80
# H5 客户端 API 基础路径。
VITE_CLIENT_H5_API_BASE=/api/v1
# H5 客户端资源服务域名,留空表示使用相对路径。
VITE_CLIENT_H5_SERVER_ORIGIN=
# 非 H5 客户端 API 基础路径。
VITE_CLIENT_NATIVE_API_BASE=/api/v1
# 非 H5 客户端资源服务域名,留空表示使用相对路径。
VITE_CLIENT_NATIVE_SERVER_ORIGIN=
# 外层 Nginx 网关配置
# 外层 Nginx 镜像地址,可改为私有镜像仓库地址。
NGINX_IMAGE=nginx:1.27-alpine
# 外层 Nginx 客户端入口内部监听端口。
NGINX_CLIENT_INTERNAL_PORT=80
# 外层 Nginx 管理端入口内部监听端口。
NGINX_ADMIN_INTERNAL_PORT=81
# 客户端入口映射到宿主机的端口。
CLIENT_WEB_PORT=5173
# 管理端入口映射到宿主机的端口。
ADMIN_WEB_PORT=5174
+6 -3
View File
@@ -1,4 +1,7 @@
FROM node:20-alpine AS build
ARG ADMIN_WEB_NODE_IMAGE=node:20-alpine
ARG ADMIN_WEB_NGINX_IMAGE=nginx:1.27-alpine
FROM ${ADMIN_WEB_NODE_IMAGE} AS build
WORKDIR /app
@@ -11,9 +14,9 @@ RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
FROM ${ADMIN_WEB_NGINX_IMAGE}
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+30 -3
View File
@@ -1,12 +1,12 @@
server {
listen 80;
listen ${ADMIN_WEB_INTERNAL_PORT};
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://server:8000/api/;
proxy_pass http://server:${SERVER_INTERNAL_PORT}/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -15,11 +15,38 @@ server {
}
location /uploads/ {
proxy_pass http://server:8000/uploads/;
proxy_pass http://server:${SERVER_INTERNAL_PORT}/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location = /docs {
proxy_pass http://server:${SERVER_INTERNAL_PORT}/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /redoc {
proxy_pass http://server:${SERVER_INTERNAL_PORT}/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://server:${SERVER_INTERNAL_PORT}/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { clearAdminToken, getAdminToken, setAdminToken, setAdminUser } from './a
const API_BASE
= import.meta.env.VITE_API_BASE
|| (import.meta.env.DEV ? 'http://127.0.0.1:8000/api/v1' : '/api/v1')
|| '/api/v1'
type RequestError = Error & { status?: number }
+16 -3
View File
@@ -1,17 +1,30 @@
FROM node:20-alpine AS build
ARG CLIENTS_NODE_IMAGE=node:20-alpine
ARG CLIENTS_NGINX_IMAGE=nginx:1.27-alpine
FROM ${CLIENTS_NODE_IMAGE} AS build
WORKDIR /app
ENV UNI_INPUT_DIR=/app
ARG VITE_CLIENT_H5_API_BASE=/api/v1
ARG VITE_CLIENT_H5_SERVER_ORIGIN=
ARG VITE_CLIENT_NATIVE_API_BASE=http://10.0.10.11:8000/api/v1
ARG VITE_CLIENT_NATIVE_SERVER_ORIGIN=http://10.0.10.11:8000
ENV VITE_CLIENT_H5_API_BASE=${VITE_CLIENT_H5_API_BASE} \
VITE_CLIENT_H5_SERVER_ORIGIN=${VITE_CLIENT_H5_SERVER_ORIGIN} \
VITE_CLIENT_NATIVE_API_BASE=${VITE_CLIENT_NATIVE_API_BASE} \
VITE_CLIENT_NATIVE_SERVER_ORIGIN=${VITE_CLIENT_NATIVE_SERVER_ORIGIN}
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build:h5
FROM nginx:1.27-alpine
FROM ${CLIENTS_NGINX_IMAGE}
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist/build/h5 /usr/share/nginx/html
EXPOSE 80
+1 -1
View File
@@ -1,6 +1,6 @@
import { get, post, del } from "@/utils/request";
export const getFavorites = (params) => get("/favorites", params);
export const getFavorites = (params) => get("/favorites/", params);
export const addFavorite = (spotId) => post(`/favorites/${spotId}`);
+1 -1
View File
@@ -1,6 +1,6 @@
import { get, post } from "@/utils/request";
export const getNotifications = (params) => get("/notifications", params);
export const getNotifications = (params) => get("/notifications/", params);
export const getUnreadCount = () => get("/notifications/unread-count");
+2 -2
View File
@@ -1,13 +1,13 @@
import { get, post, put, del } from "@/utils/request";
import { API_BASE } from "@/utils/config";
export const getSpots = (params) => get("/spots", params);
export const getSpots = (params) => get("/spots/", params);
export const getNearbySpots = (params) => get("/spots/nearby", params);
export const getSpotDetail = (id) => get(`/spots/${id}`);
export const createSpot = (data) => post("/spots", data);
export const createSpot = (data) => post("/spots/", data);
export const updateSpot = (id, data) => put(`/spots/${id}`, data);
+1 -1
View File
@@ -11,4 +11,4 @@ export const changePassword = (data) => post("/users/me/change-password", data);
export const getUserInfo = (userId) => get(`/users/${userId}`);
export const getUserSpots = (userId, params) =>
get("/spots", { creator_id: userId, ...params });
get("/spots/", { creator_id: userId, ...params });
+30 -3
View File
@@ -1,12 +1,12 @@
server {
listen 80;
listen ${CLIENTS_INTERNAL_PORT};
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://server:8000/api/;
proxy_pass http://server:${SERVER_INTERNAL_PORT}/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -15,11 +15,38 @@ server {
}
location /uploads/ {
proxy_pass http://server:8000/uploads/;
proxy_pass http://server:${SERVER_INTERNAL_PORT}/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location = /docs {
proxy_pass http://server:${SERVER_INTERNAL_PORT}/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /redoc {
proxy_pass http://server:${SERVER_INTERNAL_PORT}/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://server:${SERVER_INTERNAL_PORT}/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
+6 -1
View File
@@ -48,6 +48,11 @@ const selectedSpot = ref(null);
const locating = ref(false);
const mapCtx = ref(null);
const userLocation = ref(null);
const mapShowLocation = ref(false);
// #ifndef H5
mapShowLocation.value = true;
// #endif
const truncate = (str, len = 8) =>
str && str.length > len ? str.slice(0, len) + "…" : str;
@@ -390,7 +395,7 @@ onMounted(() => {
:longitude="mapCenter.longitude"
:markers="markers"
:scale="12"
:show-location="true"
:show-location="mapShowLocation"
:enable-zoom="true"
:enable-scroll="true"
@markertap="onMarkerTap"
+6 -1
View File
@@ -7,6 +7,11 @@ const longitude = ref(116.39747);
const address = ref("移动地图选择位置");
const currentCity = ref("");
const mapCtx = ref(null);
const mapShowLocation = ref(false);
// #ifndef H5
mapShowLocation.value = true;
// #endif
const keyword = ref("");
const searchResults = ref([]);
@@ -129,7 +134,7 @@ const confirmLocation = () => {
:latitude="latitude"
:longitude="longitude"
:scale="16"
show-location
:show-location="mapShowLocation"
@regionchange="onRegionChange"
/>
<view class="center-pin">
+5 -4
View File
@@ -1,11 +1,12 @@
// #ifdef H5
const API_BASE = "/api/v1";
const SERVER_ORIGIN = window.location.origin;
const API_BASE = import.meta.env.VITE_CLIENT_H5_API_BASE || "/api/v1";
const SERVER_ORIGIN = import.meta.env.VITE_CLIENT_H5_SERVER_ORIGIN || "";
// #endif
// #ifndef H5
const API_BASE = "http://10.0.10.11:8000/api/v1";
const SERVER_ORIGIN = "http://10.0.10.11:8000";
const API_BASE =
import.meta.env.VITE_CLIENT_NATIVE_API_BASE || "/api/v1";
const SERVER_ORIGIN = import.meta.env.VITE_CLIENT_NATIVE_SERVER_ORIGIN || "";
// #endif
export { API_BASE, SERVER_ORIGIN };
+49 -11
View File
@@ -1,6 +1,8 @@
version: "3.8"
services:
postgres:
image: postgis/postgis:17-3.5
image: "${POSTGRES_IMAGE}"
container_name: ciyuan-postgres
environment:
POSTGRES_USER: "${POSTGRES_USER}"
@@ -23,7 +25,7 @@ services:
- ciyuan-net
redis:
image: redis:7-alpine
image: "${REDIS_IMAGE}"
container_name: ciyuan-redis
expose:
- "6379"
@@ -39,7 +41,7 @@ services:
- ciyuan-net
minio:
image: minio/minio:latest
image: "${MINIO_IMAGE}"
container_name: ciyuan-minio
command: server /data --console-address ":9001"
environment:
@@ -57,8 +59,15 @@ services:
server:
build:
context: ./server
args:
SERVER_BASE_IMAGE: "${SERVER_BASE_IMAGE}"
PIP_INDEX_URL: "${PIP_INDEX_URL}"
PIP_TRUSTED_HOST: "${PIP_TRUSTED_HOST}"
PIP_DEFAULT_TIMEOUT: "${PIP_DEFAULT_TIMEOUT}"
SERVER_INTERNAL_PORT: "${SERVER_INTERNAL_PORT}"
container_name: ciyuan-server
environment:
SERVER_INTERNAL_PORT: "${SERVER_INTERNAL_PORT}"
DATABASE_URL: "${DATABASE_URL}"
DATABASE_URL_SYNC: "${DATABASE_URL_SYNC}"
REDIS_URL: "${REDIS_URL}"
@@ -71,11 +80,20 @@ services:
S3_ACCESS_KEY: "${S3_ACCESS_KEY}"
S3_SECRET_KEY: "${S3_SECRET_KEY}"
S3_BUCKET: "${S3_BUCKET}"
S3_REGION: "${S3_REGION}"
S3_PUBLIC_URL: "${S3_PUBLIC_URL}"
TENCENT_MAP_KEY: "${TENCENT_MAP_KEY}"
SENTRY_DSN: "${SENTRY_DSN}"
LOG_LEVEL: "${LOG_LEVEL}"
LOG_JSON: "${LOG_JSON}"
DEFAULT_ADMIN_ENABLED: "${DEFAULT_ADMIN_ENABLED}"
DEFAULT_ADMIN_PHONE: "${DEFAULT_ADMIN_PHONE}"
DEFAULT_ADMIN_EMAIL: "${DEFAULT_ADMIN_EMAIL}"
DEFAULT_ADMIN_PASSWORD: "${DEFAULT_ADMIN_PASSWORD}"
DEFAULT_ADMIN_NICKNAME: "${DEFAULT_ADMIN_NICKNAME}"
DEFAULT_ADMIN_SYNC_PASSWORD: "${DEFAULT_ADMIN_SYNC_PASSWORD}"
expose:
- "8000"
- "${SERVER_INTERNAL_PORT}"
volumes:
- server_uploads:/app/uploads
depends_on:
@@ -91,7 +109,7 @@ services:
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/', timeout=5)",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:${SERVER_INTERNAL_PORT}/', timeout=5)",
]
interval: 15s
timeout: 10s
@@ -105,10 +123,15 @@ services:
build:
context: ./admin-web
args:
ADMIN_WEB_NODE_IMAGE: "${ADMIN_WEB_NODE_IMAGE}"
ADMIN_WEB_NGINX_IMAGE: "${ADMIN_WEB_NGINX_IMAGE}"
VITE_API_BASE: "${VITE_API_BASE}"
container_name: ciyuan-admin-web
environment:
ADMIN_WEB_INTERNAL_PORT: "${ADMIN_WEB_INTERNAL_PORT}"
SERVER_INTERNAL_PORT: "${SERVER_INTERNAL_PORT}"
expose:
- "80"
- "${ADMIN_WEB_INTERNAL_PORT}"
depends_on:
server:
condition: service_healthy
@@ -119,9 +142,19 @@ services:
clients:
build:
context: ./clients
args:
CLIENTS_NODE_IMAGE: "${CLIENTS_NODE_IMAGE}"
CLIENTS_NGINX_IMAGE: "${CLIENTS_NGINX_IMAGE}"
VITE_CLIENT_H5_API_BASE: "${VITE_CLIENT_H5_API_BASE}"
VITE_CLIENT_H5_SERVER_ORIGIN: "${VITE_CLIENT_H5_SERVER_ORIGIN}"
VITE_CLIENT_NATIVE_API_BASE: "${VITE_CLIENT_NATIVE_API_BASE}"
VITE_CLIENT_NATIVE_SERVER_ORIGIN: "${VITE_CLIENT_NATIVE_SERVER_ORIGIN}"
container_name: ciyuan-clients
environment:
CLIENTS_INTERNAL_PORT: "${CLIENTS_INTERNAL_PORT}"
SERVER_INTERNAL_PORT: "${SERVER_INTERNAL_PORT}"
expose:
- "80"
- "${CLIENTS_INTERNAL_PORT}"
depends_on:
server:
condition: service_healthy
@@ -130,13 +163,18 @@ services:
- ciyuan-net
nginx:
image: nginx:1.27-alpine
image: "${NGINX_IMAGE}"
container_name: ciyuan-nginx
environment:
NGINX_CLIENT_INTERNAL_PORT: "${NGINX_CLIENT_INTERNAL_PORT}"
NGINX_ADMIN_INTERNAL_PORT: "${NGINX_ADMIN_INTERNAL_PORT}"
CLIENTS_INTERNAL_PORT: "${CLIENTS_INTERNAL_PORT}"
ADMIN_WEB_INTERNAL_PORT: "${ADMIN_WEB_INTERNAL_PORT}"
ports:
- "${CLIENT_WEB_PORT}:80"
- "${ADMIN_WEB_PORT}:81"
- "${CLIENT_WEB_PORT}:${NGINX_CLIENT_INTERNAL_PORT}"
- "${ADMIN_WEB_PORT}:${NGINX_ADMIN_INTERNAL_PORT}"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./docker/nginx/default.conf:/etc/nginx/templates/default.conf.template:ro
depends_on:
server:
condition: service_healthy
+4 -88
View File
@@ -1,51 +1,9 @@
server {
listen 80;
listen ${NGINX_CLIENT_INTERNAL_PORT};
server_name _;
location /api/ {
proxy_pass http://server:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /uploads/ {
proxy_pass http://server:8000/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location = /docs {
proxy_pass http://server:8000/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /redoc {
proxy_pass http://server:8000/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://server:8000/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://clients:80;
proxy_pass http://clients:${CLIENTS_INTERNAL_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -55,53 +13,11 @@ server {
}
server {
listen 81;
listen ${NGINX_ADMIN_INTERNAL_PORT};
server_name _;
location /api/ {
proxy_pass http://server:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /uploads/ {
proxy_pass http://server:8000/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location = /docs {
proxy_pass http://server:8000/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /redoc {
proxy_pass http://server:8000/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://server:8000/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://admin-web:80;
proxy_pass http://admin-web:${ADMIN_WEB_INTERNAL_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-22
View File
@@ -1,22 +0,0 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/ciyuan_viewfinder
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/ciyuan_viewfinder
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=change-me-to-a-random-secret-key
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# Storage: "local" or "s3" (s3 compatible with MinIO/OSS/COS)
STORAGE_BACKEND=local
LOCAL_STORAGE_PATH=./uploads
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=ciyuan-viewfinder
S3_REGION=
S3_PUBLIC_URL=
# Tencent Maps API Key (apply at https://lbs.qq.com/)
TENCENT_MAP_KEY=
SENTRY_DSN=
+15 -4
View File
@@ -1,16 +1,27 @@
FROM python:3.12-slim
ARG SERVER_BASE_IMAGE=python:3.12-slim
FROM ${SERVER_BASE_IMAGE}
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
ARG PIP_INDEX_URL
ARG PIP_TRUSTED_HOST
ARG PIP_DEFAULT_TIMEOUT=120
ARG SERVER_INTERNAL_PORT=8000
ENV PIP_INDEX_URL=${PIP_INDEX_URL} \
PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST} \
PIP_DEFAULT_TIMEOUT=${PIP_DEFAULT_TIMEOUT} \
SERVER_INTERNAL_PORT=${SERVER_INTERNAL_PORT}
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
EXPOSE ${SERVER_INTERNAL_PORT}
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${SERVER_INTERNAL_PORT}"]
+5 -3
View File
@@ -1,6 +1,8 @@
# 后端默认管理员账号
- 管理后台登录账号(手机号):`13900000001`
- 管理后台登录密码:`demo123456`
后端启动时会根据根目录 `.env` 自动确保默认管理员存在。
> 说明:以上为 `seed_demo_data.py` 初始化的演示管理员账号(role=`admin`)。
- 管理后台登录账号:`DEFAULT_ADMIN_PHONE``DEFAULT_ADMIN_EMAIL`
- 管理后台登录密码:`DEFAULT_ADMIN_PASSWORD`
默认模板值为 `13900000001 / admin123456`。生产环境请修改根目录 `.env`
+31 -1
View File
@@ -7,7 +7,7 @@ from app.core.deps import get_current_active_user, get_db
from app.models.spot import Spot
from app.models.tag import SpotTag, Tag
from app.models.user import User
from app.schemas.tag import TagOut
from app.schemas.tag import TagCreate, TagOut
router = APIRouter()
@@ -16,6 +16,14 @@ class TagAttach(BaseModel):
tag_id: int
def _assert_admin_role(user: User) -> None:
if user.role not in ("admin", "moderator"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin permission required",
)
@router.get("/tags", response_model=list[TagOut])
async def list_tags(
sort: str = Query(default="hot", regex="^(hot|name)$"),
@@ -31,6 +39,28 @@ async def list_tags(
return result.scalars().all()
@router.post("/tags", response_model=TagOut, status_code=status.HTTP_201_CREATED)
async def create_tag(
payload: TagCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tag name is required")
existing = await db.execute(select(Tag).where(Tag.name == name))
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tag already exists")
tag = Tag(name=name, category=payload.category, is_active=True)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
@router.post("/spots/{spot_id}/tags", status_code=status.HTTP_201_CREATED)
async def add_tag_to_spot(
spot_id: int,
+18 -1
View File
@@ -1,6 +1,16 @@
from pathlib import Path
from pydantic_settings import BaseSettings
_config_path = Path(__file__).resolve()
_env_file = (
_config_path.parents[3] / ".env"
if _config_path.parents[2].name == "server"
else _config_path.parents[2] / ".env"
)
class Settings(BaseSettings):
DATABASE_URL: str
DATABASE_URL_SYNC: str
@@ -24,8 +34,15 @@ class Settings(BaseSettings):
LOG_JSON: bool = False
LOG_LEVEL: str = "INFO"
DEFAULT_ADMIN_ENABLED: bool = True
DEFAULT_ADMIN_PHONE: str = "13900000001"
DEFAULT_ADMIN_EMAIL: str = "admin@ciyuan.local"
DEFAULT_ADMIN_PASSWORD: str = "admin123456"
DEFAULT_ADMIN_NICKNAME: str = "系统管理员"
DEFAULT_ADMIN_SYNC_PASSWORD: bool = True
class Config:
env_file = ".env"
env_file = _env_file
settings = Settings()
+65
View File
@@ -0,0 +1,65 @@
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
from app.db.session import sync_engine
from app.models.user import User
def ensure_default_admin() -> None:
if not settings.DEFAULT_ADMIN_ENABLED:
return
phone = settings.DEFAULT_ADMIN_PHONE.strip() or None
email = settings.DEFAULT_ADMIN_EMAIL.strip() or None
if not phone and not email:
raise RuntimeError("DEFAULT_ADMIN_PHONE or DEFAULT_ADMIN_EMAIL is required")
filters = []
if phone:
filters.append(User.phone == phone)
if email:
filters.append(User.email == email)
with Session(sync_engine) as session:
user = session.execute(select(User).where(or_(*filters))).scalar_one_or_none()
if user is None:
user = User(
phone=phone,
email=email,
password_hash=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
nickname=settings.DEFAULT_ADMIN_NICKNAME,
identity="both",
role="admin",
is_active=True,
)
session.add(user)
session.commit()
return
changed = False
expected = {
"phone": phone,
"email": email,
"nickname": settings.DEFAULT_ADMIN_NICKNAME,
"identity": "both",
"role": "admin",
"is_active": True,
}
for field, value in expected.items():
if value is not None and getattr(user, field) != value:
setattr(user, field, value)
changed = True
if settings.DEFAULT_ADMIN_SYNC_PASSWORD and not verify_password(
settings.DEFAULT_ADMIN_PASSWORD,
user.password_hash,
):
user.password_hash = get_password_hash(settings.DEFAULT_ADMIN_PASSWORD)
changed = True
if changed:
session.add(user)
session.commit()
+2
View File
@@ -14,6 +14,7 @@ from app.api.v1.router import v1_router
from app.core.config import settings
from app.core.deps import get_current_active_user, get_db
from app.core.storage import init_storage
from app.db.bootstrap import ensure_default_admin
from app.db.migrations import run_startup_migrations
from app.models.spot import Spot
from app.models.tag import Tag
@@ -44,6 +45,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
await asyncio.to_thread(run_startup_migrations)
await asyncio.to_thread(ensure_default_admin)
app.state.redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
app.state.storage = init_storage()
yield
+344
View File
@@ -0,0 +1,344 @@
"""
通过管理端 API 批量上传地点。
JSON 输入结构:
[
{
"title": "示例地点",
"city": "上海",
"longitude": 121.4737,
"latitude": 31.2304,
"description": "地点描述",
"transport": "地铁可达",
"best_time": "傍晚",
"difficulty": "",
"is_free": true,
"audit_status": "approved",
"tag_ids": ["街拍", "城市"],
"images": ["./images/a.jpg", "https://example.com/b.jpg"]
}
]
CSV 使用相同字段名。列表字段可用分号、逗号或竖线分隔,
例如:images=./a.jpg;./b.jpgtag_ids=街拍,城市。
"""
from __future__ import annotations
import argparse
import csv
import json
import mimetypes
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
DEFAULT_BASE_URL = os.getenv("CIYUAN_API_BASE_URL", "http://localhost:8000/api/v1")
DEFAULT_ACCOUNT = os.getenv("CIYUAN_ADMIN_ACCOUNT", "13900000001")
DEFAULT_PASSWORD = os.getenv("CIYUAN_ADMIN_PASSWORD", "admin123456")
SPOT_FIELDS = {
"title",
"city",
"longitude",
"latitude",
"description",
"transport",
"best_time",
"difficulty",
"is_free",
"price_min",
"price_max",
"audit_status",
"reject_reason",
"creator_id",
"image_urls",
"tag_ids",
}
@dataclass
class UploadResult:
index: int
title: str
ok: bool
spot_id: int | None = None
error: str | None = None
class TagResolver:
def __init__(self, client: httpx.Client, *, create_missing: bool = True) -> None:
self.client = client
self.create_missing = create_missing
self._cache: dict[str, int] | None = None
def resolve_many(self, names: list[str]) -> list[int]:
tag_ids: list[int] = []
for name in names:
tag_ids.append(self.resolve_one(name))
return list(dict.fromkeys(tag_ids))
def resolve_one(self, name: str) -> int:
normalized = normalize_tag_name(name)
cache = self._load_cache()
if normalized in cache:
return cache[normalized]
if not self.create_missing:
raise ValueError(f"tag not found: {name}")
tag_id = self._create_tag(name.strip())
cache[normalized] = tag_id
return tag_id
def _load_cache(self) -> dict[str, int]:
if self._cache is not None:
return self._cache
response = self.client.get("/tags", params={"sort": "name"})
response.raise_for_status()
self._cache = {
normalize_tag_name(str(item.get("name") or item.get("title") or "")): int(item["id"])
for item in response.json()
if item.get("id") and (item.get("name") or item.get("title"))
}
return self._cache
def _create_tag(self, name: str) -> int:
response = self.client.post("/tags", json={"name": name})
if response.status_code == 409:
self._cache = None
cache = self._load_cache()
normalized = normalize_tag_name(name)
if normalized in cache:
return cache[normalized]
response.raise_for_status()
data = response.json()
print(f"[TAG] created {name} -> tag_id={data['id']}")
return int(data["id"])
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="通过管理端 API 批量上传地点。", add_help=False)
parser._positionals.title = "位置参数"
parser._optionals.title = "可选参数"
parser.add_argument("-h", "--help", action="help", help="显示帮助信息并退出。")
parser.add_argument("input", type=Path, help="JSON 或 CSV 数据文件路径。")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API 基础地址,默认:%(default)s")
parser.add_argument("--account", default=DEFAULT_ACCOUNT, help="管理员手机号或邮箱。")
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="管理员密码。")
parser.add_argument("--creator-id", type=int, help="数据中未填写 creator_id 时使用的默认创建者用户 ID。")
parser.add_argument(
"--audit-status",
default="approved",
choices=["pending", "approved", "rejected", "deleted"],
help="默认审核状态:pending=待审核,approved=已通过,rejected=已驳回,deleted=已删除。默认:%(default)s",
)
parser.add_argument("--timeout", type=float, default=30.0, help="单次请求超时时间,单位秒。默认:%(default)s")
parser.add_argument("--dry-run", action="store_true", help="只校验并打印提交内容,不登录、不上传图片、不创建地点。")
parser.add_argument("--stop-on-error", action="store_true", help="任意一条数据失败后立即停止。")
return parser.parse_args()
def load_items(path: Path) -> list[dict[str, Any]]:
if not path.exists():
raise FileNotFoundError(f"input file not found: {path}")
suffix = path.suffix.lower()
if suffix == ".json":
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
data = data.get("items") or data.get("spots")
if not isinstance(data, list):
raise ValueError("JSON input must be a list or an object with items/spots list")
return [normalize_item(item, path.parent) for item in data]
if suffix == ".csv":
with path.open("r", encoding="utf-8-sig", newline="") as f:
return [normalize_item(row, path.parent) for row in csv.DictReader(f)]
raise ValueError("unsupported input format, use .json or .csv")
def normalize_item(raw: dict[str, Any], input_dir: Path) -> dict[str, Any]:
item = {k: v for k, v in raw.items() if v not in ("", None)}
for key in ("longitude", "latitude", "price_min", "price_max"):
if key in item:
item[key] = float(item[key])
for key in ("creator_id",):
if key in item:
item[key] = int(item[key])
if "is_free" in item:
item["is_free"] = parse_bool(item["is_free"])
tag_values = item.get("tag_ids", item.get("tags", []))
item["tag_names"] = parse_string_list(tag_values)
item.pop("tags", None)
item.pop("tag_ids", None)
image_urls = parse_string_list(item.get("image_urls", []))
images = parse_string_list(item.get("images", []))
item["image_sources"] = [resolve_local_path(src, input_dir) for src in [*image_urls, *images]]
return item
def parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "y", "on", "", "免费"}
def parse_string_list(value: Any) -> list[str]:
if value is None or value == "":
return []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
text = str(value).strip()
for sep in (";", "|", ","):
if sep in text:
return [part.strip() for part in text.split(sep) if part.strip()]
return [text] if text else []
def normalize_tag_name(value: str) -> str:
return value.strip().lower()
def resolve_local_path(source: str, input_dir: Path) -> str:
if is_url(source):
return source
path = Path(source)
if not path.is_absolute():
path = input_dir / path
return str(path)
def is_url(value: str) -> bool:
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def login(client: httpx.Client, account: str, password: str) -> tuple[str, int]:
response = client.post("/admin/auth/login", json={"account": account, "password": password})
response.raise_for_status()
data = response.json()
return data["access_token"], int(data["user"]["id"])
def upload_image(client: httpx.Client, path: Path) -> str:
if not path.exists():
raise FileNotFoundError(f"image file not found: {path}")
content_type = mimetypes.guess_type(path.name)[0] or "image/jpeg"
with path.open("rb") as f:
response = client.post(
"/upload/image",
files={"file": (path.name, f, content_type)},
)
response.raise_for_status()
return str(response.json()["url"])
def build_payload(
client: httpx.Client,
item: dict[str, Any],
creator_id: int,
audit_status: str,
*,
upload_files: bool,
tag_resolver: TagResolver | None,
) -> dict[str, Any]:
payload = {key: item[key] for key in SPOT_FIELDS if key in item}
payload.setdefault("creator_id", creator_id)
payload.setdefault("audit_status", audit_status)
payload.setdefault("is_free", True)
if tag_resolver is None:
payload["tag_ids"] = []
if item.get("tag_names"):
payload["_tag_names"] = item["tag_names"]
else:
payload["tag_ids"] = tag_resolver.resolve_many(item.get("tag_names", []))
image_urls: list[str] = []
for source in item.get("image_sources", []):
if is_url(source):
image_urls.append(source)
elif not upload_files:
image_urls.append(source)
else:
image_urls.append(upload_image(client, Path(source)))
payload["image_urls"] = image_urls
missing = [key for key in ("title", "city", "longitude", "latitude", "creator_id") if key not in payload]
if missing:
raise ValueError(f"missing required fields: {', '.join(missing)}")
return payload
def create_spot(client: httpx.Client, payload: dict[str, Any]) -> int:
response = client.post("/admin/spots", json=payload)
response.raise_for_status()
return int(response.json()["id"])
def run() -> int:
args = parse_args()
items = load_items(args.input)
results: list[UploadResult] = []
with httpx.Client(base_url=args.base_url.rstrip("/"), timeout=args.timeout) as client:
token: str | None = None
current_admin_id = args.creator_id
if not args.dry_run:
token, admin_id = login(client, args.account, args.password)
current_admin_id = current_admin_id or admin_id
client.headers.update({"Authorization": f"Bearer {token}"})
elif current_admin_id is None:
current_admin_id = 0
tag_resolver = None if args.dry_run else TagResolver(client)
for index, item in enumerate(items, start=1):
title = str(item.get("title", f"row-{index}"))
try:
payload = build_payload(
client,
item,
current_admin_id,
args.audit_status,
upload_files=not args.dry_run,
tag_resolver=tag_resolver,
)
if args.dry_run:
tag_names = payload.pop("_tag_names", None)
if tag_names:
payload["tag_ids"] = tag_names
print(json.dumps(payload, ensure_ascii=False, indent=2))
results.append(UploadResult(index=index, title=title, ok=True))
continue
spot_id = create_spot(client, payload)
print(f"[OK] #{index} {title} -> spot_id={spot_id}")
results.append(UploadResult(index=index, title=title, ok=True, spot_id=spot_id))
except Exception as exc:
message = format_error(exc)
print(f"[FAIL] #{index} {title}: {message}")
results.append(UploadResult(index=index, title=title, ok=False, error=message))
if args.stop_on_error:
break
ok_count = sum(1 for item in results if item.ok)
fail_count = len(results) - ok_count
print(f"Done. total={len(results)} ok={ok_count} failed={fail_count}")
return 1 if fail_count else 0
def format_error(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
body = exc.response.text
return f"HTTP {exc.response.status_code}: {body}"
return str(exc)
if __name__ == "__main__":
raise SystemExit(run())
+36
View File
@@ -0,0 +1,36 @@
[
{
"title": "示例取景地",
"city": "上海",
"longitude": 121.4737,
"latitude": 31.2304,
"description": "适合街拍与城市感构图。",
"transport": "地铁到站后步行约 10 分钟。",
"best_time": "傍晚蓝调时刻",
"difficulty": "低",
"is_free": true,
"audit_status": "approved",
"tag_ids": ["街拍", "城市夜景"],
"images": [
"./images/example.jpg"
]
},
{
"title": "收费影棚示例",
"city": "上海",
"longitude": 121.4801,
"latitude": 31.2352,
"description": "适合室内棚拍和商业主题拍摄。",
"transport": "地铁到站后打车约 8 分钟。",
"best_time": "预约时段",
"difficulty": "中",
"is_free": false,
"price_min": 199,
"price_max": 499,
"audit_status": "approved",
"tag_ids": ["影棚", "室内"],
"images": [
"./images/studio.jpg"
]
}
]