Compare commits
12 Commits
ff6c8d6bd9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5882064295 | |||
| be09bf6f0a | |||
| f1ce992d69 | |||
| 3f3acf834d | |||
| a493d1bcf6 | |||
| 0afd5bbb2c | |||
| f2b4beed0a | |||
| 9de0a56afa | |||
| 19db342507 | |||
| 3df8fcacbc | |||
| a01e08d72f | |||
| 5c9420af2a |
+92
-6
@@ -1,35 +1,121 @@
|
|||||||
# PostgreSQL
|
# PostgreSQL 数据库配置
|
||||||
|
# PostGIS 镜像地址,可改为私有镜像仓库地址。
|
||||||
|
POSTGRES_IMAGE=postgis/postgis:17-3.5
|
||||||
|
# PostgreSQL 用户名。
|
||||||
POSTGRES_USER=shiran
|
POSTGRES_USER=shiran
|
||||||
|
# PostgreSQL 密码,生产环境必须修改。
|
||||||
POSTGRES_PASSWORD=change-me-postgres-password
|
POSTGRES_PASSWORD=change-me-postgres-password
|
||||||
|
# PostgreSQL 数据库名。
|
||||||
POSTGRES_DB=ciyuan_viewfinder
|
POSTGRES_DB=ciyuan_viewfinder
|
||||||
|
# 容器内 PostgreSQL 数据目录。
|
||||||
POSTGRES_PGDATA=/var/lib/postgresql/data/pgdata
|
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
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# MinIO / S3
|
# MinIO / S3 对象存储配置
|
||||||
|
# MinIO 镜像地址,可改为私有镜像仓库地址。
|
||||||
|
MINIO_IMAGE=minio/minio:latest
|
||||||
|
# MinIO 管理员用户名。
|
||||||
MINIO_ROOT_USER=minioadmin
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
# MinIO 管理员密码,生产环境必须修改。
|
||||||
MINIO_ROOT_PASSWORD=change-me-minio-password
|
MINIO_ROOT_PASSWORD=change-me-minio-password
|
||||||
|
# S3 兼容服务地址,Docker Compose 内使用服务名 minio。
|
||||||
S3_ENDPOINT=http://minio:9000
|
S3_ENDPOINT=http://minio:9000
|
||||||
|
# S3 访问密钥。
|
||||||
S3_ACCESS_KEY=minioadmin
|
S3_ACCESS_KEY=minioadmin
|
||||||
|
# S3 访问密钥密码,通常与 MinIO 管理员密码保持一致。
|
||||||
S3_SECRET_KEY=change-me-minio-password
|
S3_SECRET_KEY=change-me-minio-password
|
||||||
|
# S3 存储桶名称。
|
||||||
S3_BUCKET=ciyuan-viewfinder
|
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=postgresql+asyncpg://shiran:change-me-postgres-password@postgres:5432/ciyuan_viewfinder
|
||||||
|
# 后端同步数据库连接串,用于迁移等同步场景。
|
||||||
DATABASE_URL_SYNC=postgresql://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
|
SECRET_KEY=change-me-before-production
|
||||||
|
# 访问令牌有效期,单位分钟。
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=43200
|
ACCESS_TOKEN_EXPIRE_MINUTES=43200
|
||||||
|
# 刷新令牌有效期,单位天。
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS=60
|
REFRESH_TOKEN_EXPIRE_DAYS=60
|
||||||
|
# 存储后端类型,可选 local 或 s3。
|
||||||
STORAGE_BACKEND=local
|
STORAGE_BACKEND=local
|
||||||
|
# 本地上传文件在后端容器内的保存路径。
|
||||||
LOCAL_STORAGE_PATH=/app/uploads
|
LOCAL_STORAGE_PATH=/app/uploads
|
||||||
|
# 腾讯地图 API Key,没有地图相关能力时可留空。
|
||||||
TENCENT_MAP_KEY=
|
TENCENT_MAP_KEY=
|
||||||
|
# Sentry DSN,留空表示不启用错误上报。
|
||||||
|
SENTRY_DSN=
|
||||||
|
# 后端日志级别。
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
# 是否输出 JSON 格式日志。
|
||||||
LOG_JSON=false
|
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
|
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
|
CLIENT_WEB_PORT=5173
|
||||||
|
# 管理端入口映射到宿主机的端口。
|
||||||
ADMIN_WEB_PORT=5174
|
ADMIN_WEB_PORT=5174
|
||||||
|
|||||||
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -11,9 +14,9 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
+30
-3
@@ -1,12 +1,12 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen ${ADMIN_WEB_INTERNAL_PORT};
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://server:8000/api/;
|
proxy_pass http://server:${SERVER_INTERNAL_PORT}/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -15,11 +15,38 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
proxy_pass http://server:8000/uploads/;
|
proxy_pass http://server:${SERVER_INTERNAL_PORT}/uploads/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { clearAdminToken, getAdminToken, setAdminToken, setAdminUser } from './a
|
|||||||
|
|
||||||
const API_BASE
|
const API_BASE
|
||||||
= import.meta.env.VITE_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 }
|
type RequestError = Error & { status?: number }
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -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
|
WORKDIR /app
|
||||||
ENV UNI_INPUT_DIR=/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* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build:h5
|
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
|
COPY --from=build /app/dist/build/h5 /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { get, post, del } from "@/utils/request";
|
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}`);
|
export const addFavorite = (spotId) => post(`/favorites/${spotId}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { get, post } from "@/utils/request";
|
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");
|
export const getUnreadCount = () => get("/notifications/unread-count");
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
import { get, post, put, del } from "@/utils/request";
|
import { get, post, put, del } from "@/utils/request";
|
||||||
import { API_BASE } from "@/utils/config";
|
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 getNearbySpots = (params) => get("/spots/nearby", params);
|
||||||
|
|
||||||
export const getSpotDetail = (id) => get(`/spots/${id}`);
|
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);
|
export const updateSpot = (id, data) => put(`/spots/${id}`, data);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,4 +11,4 @@ export const changePassword = (data) => post("/users/me/change-password", data);
|
|||||||
export const getUserInfo = (userId) => get(`/users/${userId}`);
|
export const getUserInfo = (userId) => get(`/users/${userId}`);
|
||||||
|
|
||||||
export const getUserSpots = (userId, params) =>
|
export const getUserSpots = (userId, params) =>
|
||||||
get("/spots", { creator_id: userId, ...params });
|
get("/spots/", { creator_id: userId, ...params });
|
||||||
|
|||||||
+30
-3
@@ -1,12 +1,12 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen ${CLIENTS_INTERNAL_PORT};
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://server:8000/api/;
|
proxy_pass http://server:${SERVER_INTERNAL_PORT}/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -15,11 +15,38 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
proxy_pass http://server:8000/uploads/;
|
proxy_pass http://server:${SERVER_INTERNAL_PORT}/uploads/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ const selectedSpot = ref(null);
|
|||||||
const locating = ref(false);
|
const locating = ref(false);
|
||||||
const mapCtx = ref(null);
|
const mapCtx = ref(null);
|
||||||
const userLocation = ref(null);
|
const userLocation = ref(null);
|
||||||
|
const mapShowLocation = ref(false);
|
||||||
|
|
||||||
|
// #ifndef H5
|
||||||
|
mapShowLocation.value = true;
|
||||||
|
// #endif
|
||||||
|
|
||||||
const truncate = (str, len = 8) =>
|
const truncate = (str, len = 8) =>
|
||||||
str && str.length > len ? str.slice(0, len) + "…" : str;
|
str && str.length > len ? str.slice(0, len) + "…" : str;
|
||||||
@@ -390,7 +395,7 @@ onMounted(() => {
|
|||||||
:longitude="mapCenter.longitude"
|
:longitude="mapCenter.longitude"
|
||||||
:markers="markers"
|
:markers="markers"
|
||||||
:scale="12"
|
:scale="12"
|
||||||
:show-location="true"
|
:show-location="mapShowLocation"
|
||||||
:enable-zoom="true"
|
:enable-zoom="true"
|
||||||
:enable-scroll="true"
|
:enable-scroll="true"
|
||||||
@markertap="onMarkerTap"
|
@markertap="onMarkerTap"
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const longitude = ref(116.39747);
|
|||||||
const address = ref("移动地图选择位置");
|
const address = ref("移动地图选择位置");
|
||||||
const currentCity = ref("");
|
const currentCity = ref("");
|
||||||
const mapCtx = ref(null);
|
const mapCtx = ref(null);
|
||||||
|
const mapShowLocation = ref(false);
|
||||||
|
|
||||||
|
// #ifndef H5
|
||||||
|
mapShowLocation.value = true;
|
||||||
|
// #endif
|
||||||
|
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const searchResults = ref([]);
|
const searchResults = ref([]);
|
||||||
@@ -129,7 +134,7 @@ const confirmLocation = () => {
|
|||||||
:latitude="latitude"
|
:latitude="latitude"
|
||||||
:longitude="longitude"
|
:longitude="longitude"
|
||||||
:scale="16"
|
:scale="16"
|
||||||
show-location
|
:show-location="mapShowLocation"
|
||||||
@regionchange="onRegionChange"
|
@regionchange="onRegionChange"
|
||||||
/>
|
/>
|
||||||
<view class="center-pin">
|
<view class="center-pin">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const API_BASE = "/api/v1";
|
const API_BASE = import.meta.env.VITE_CLIENT_H5_API_BASE || "/api/v1";
|
||||||
const SERVER_ORIGIN = window.location.origin;
|
const SERVER_ORIGIN = import.meta.env.VITE_CLIENT_H5_SERVER_ORIGIN || "";
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifndef H5
|
// #ifndef H5
|
||||||
const API_BASE = "http://10.0.10.11:8000/api/v1";
|
const API_BASE =
|
||||||
const SERVER_ORIGIN = "http://10.0.10.11:8000";
|
import.meta.env.VITE_CLIENT_NATIVE_API_BASE || "/api/v1";
|
||||||
|
const SERVER_ORIGIN = import.meta.env.VITE_CLIENT_NATIVE_SERVER_ORIGIN || "";
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
export { API_BASE, SERVER_ORIGIN };
|
export { API_BASE, SERVER_ORIGIN };
|
||||||
|
|||||||
+49
-11
@@ -1,6 +1,8 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:17-3.5
|
image: "${POSTGRES_IMAGE}"
|
||||||
container_name: ciyuan-postgres
|
container_name: ciyuan-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: "${POSTGRES_USER}"
|
POSTGRES_USER: "${POSTGRES_USER}"
|
||||||
@@ -23,7 +25,7 @@ services:
|
|||||||
- ciyuan-net
|
- ciyuan-net
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: "${REDIS_IMAGE}"
|
||||||
container_name: ciyuan-redis
|
container_name: ciyuan-redis
|
||||||
expose:
|
expose:
|
||||||
- "6379"
|
- "6379"
|
||||||
@@ -39,7 +41,7 @@ services:
|
|||||||
- ciyuan-net
|
- ciyuan-net
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: "${MINIO_IMAGE}"
|
||||||
container_name: ciyuan-minio
|
container_name: ciyuan-minio
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
@@ -57,8 +59,15 @@ services:
|
|||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
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
|
container_name: ciyuan-server
|
||||||
environment:
|
environment:
|
||||||
|
SERVER_INTERNAL_PORT: "${SERVER_INTERNAL_PORT}"
|
||||||
DATABASE_URL: "${DATABASE_URL}"
|
DATABASE_URL: "${DATABASE_URL}"
|
||||||
DATABASE_URL_SYNC: "${DATABASE_URL_SYNC}"
|
DATABASE_URL_SYNC: "${DATABASE_URL_SYNC}"
|
||||||
REDIS_URL: "${REDIS_URL}"
|
REDIS_URL: "${REDIS_URL}"
|
||||||
@@ -71,11 +80,20 @@ services:
|
|||||||
S3_ACCESS_KEY: "${S3_ACCESS_KEY}"
|
S3_ACCESS_KEY: "${S3_ACCESS_KEY}"
|
||||||
S3_SECRET_KEY: "${S3_SECRET_KEY}"
|
S3_SECRET_KEY: "${S3_SECRET_KEY}"
|
||||||
S3_BUCKET: "${S3_BUCKET}"
|
S3_BUCKET: "${S3_BUCKET}"
|
||||||
|
S3_REGION: "${S3_REGION}"
|
||||||
|
S3_PUBLIC_URL: "${S3_PUBLIC_URL}"
|
||||||
TENCENT_MAP_KEY: "${TENCENT_MAP_KEY}"
|
TENCENT_MAP_KEY: "${TENCENT_MAP_KEY}"
|
||||||
|
SENTRY_DSN: "${SENTRY_DSN}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL}"
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
LOG_JSON: "${LOG_JSON}"
|
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:
|
expose:
|
||||||
- "8000"
|
- "${SERVER_INTERNAL_PORT}"
|
||||||
volumes:
|
volumes:
|
||||||
- server_uploads:/app/uploads
|
- server_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -91,7 +109,7 @@ services:
|
|||||||
"CMD",
|
"CMD",
|
||||||
"python",
|
"python",
|
||||||
"-c",
|
"-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
|
interval: 15s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
@@ -105,10 +123,15 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./admin-web
|
context: ./admin-web
|
||||||
args:
|
args:
|
||||||
|
ADMIN_WEB_NODE_IMAGE: "${ADMIN_WEB_NODE_IMAGE}"
|
||||||
|
ADMIN_WEB_NGINX_IMAGE: "${ADMIN_WEB_NGINX_IMAGE}"
|
||||||
VITE_API_BASE: "${VITE_API_BASE}"
|
VITE_API_BASE: "${VITE_API_BASE}"
|
||||||
container_name: ciyuan-admin-web
|
container_name: ciyuan-admin-web
|
||||||
|
environment:
|
||||||
|
ADMIN_WEB_INTERNAL_PORT: "${ADMIN_WEB_INTERNAL_PORT}"
|
||||||
|
SERVER_INTERNAL_PORT: "${SERVER_INTERNAL_PORT}"
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "${ADMIN_WEB_INTERNAL_PORT}"
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -119,9 +142,19 @@ services:
|
|||||||
clients:
|
clients:
|
||||||
build:
|
build:
|
||||||
context: ./clients
|
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
|
container_name: ciyuan-clients
|
||||||
|
environment:
|
||||||
|
CLIENTS_INTERNAL_PORT: "${CLIENTS_INTERNAL_PORT}"
|
||||||
|
SERVER_INTERNAL_PORT: "${SERVER_INTERNAL_PORT}"
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "${CLIENTS_INTERNAL_PORT}"
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -130,13 +163,18 @@ services:
|
|||||||
- ciyuan-net
|
- ciyuan-net
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.27-alpine
|
image: "${NGINX_IMAGE}"
|
||||||
container_name: ciyuan-nginx
|
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:
|
ports:
|
||||||
- "${CLIENT_WEB_PORT}:80"
|
- "${CLIENT_WEB_PORT}:${NGINX_CLIENT_INTERNAL_PORT}"
|
||||||
- "${ADMIN_WEB_PORT}:81"
|
- "${ADMIN_WEB_PORT}:${NGINX_ADMIN_INTERNAL_PORT}"
|
||||||
volumes:
|
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:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1,51 +1,9 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen ${NGINX_CLIENT_INTERNAL_PORT};
|
||||||
server_name _;
|
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 / {
|
location / {
|
||||||
proxy_pass http://clients:80;
|
proxy_pass http://clients:${CLIENTS_INTERNAL_PORT};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -55,53 +13,11 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 81;
|
listen ${NGINX_ADMIN_INTERNAL_PORT};
|
||||||
server_name _;
|
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 / {
|
location / {
|
||||||
proxy_pass http://admin-web:80;
|
proxy_pass http://admin-web:${ADMIN_WEB_INTERNAL_PORT};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -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
@@ -1,16 +1,27 @@
|
|||||||
FROM python:3.12-slim
|
ARG SERVER_BASE_IMAGE=python:3.12-slim
|
||||||
|
FROM ${SERVER_BASE_IMAGE}
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
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
@@ -1,6 +1,8 @@
|
|||||||
# 后端默认管理员账号
|
# 后端默认管理员账号
|
||||||
|
|
||||||
- 管理后台登录账号(手机号):`13900000001`
|
后端启动时会根据根目录 `.env` 自动确保默认管理员存在。
|
||||||
- 管理后台登录密码:`demo123456`
|
|
||||||
|
|
||||||
> 说明:以上为 `seed_demo_data.py` 初始化的演示管理员账号(role=`admin`)。
|
- 管理后台登录账号:`DEFAULT_ADMIN_PHONE` 或 `DEFAULT_ADMIN_EMAIL`
|
||||||
|
- 管理后台登录密码:`DEFAULT_ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
默认模板值为 `13900000001 / admin123456`。生产环境请修改根目录 `.env`。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from app.core.deps import get_current_active_user, get_db
|
|||||||
from app.models.spot import Spot
|
from app.models.spot import Spot
|
||||||
from app.models.tag import SpotTag, Tag
|
from app.models.tag import SpotTag, Tag
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.tag import TagOut
|
from app.schemas.tag import TagCreate, TagOut
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -16,6 +16,14 @@ class TagAttach(BaseModel):
|
|||||||
tag_id: int
|
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])
|
@router.get("/tags", response_model=list[TagOut])
|
||||||
async def list_tags(
|
async def list_tags(
|
||||||
sort: str = Query(default="hot", regex="^(hot|name)$"),
|
sort: str = Query(default="hot", regex="^(hot|name)$"),
|
||||||
@@ -31,6 +39,28 @@ async def list_tags(
|
|||||||
return result.scalars().all()
|
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)
|
@router.post("/spots/{spot_id}/tags", status_code=status.HTTP_201_CREATED)
|
||||||
async def add_tag_to_spot(
|
async def add_tag_to_spot(
|
||||||
spot_id: int,
|
spot_id: int,
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
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):
|
class Settings(BaseSettings):
|
||||||
DATABASE_URL: str
|
DATABASE_URL: str
|
||||||
DATABASE_URL_SYNC: str
|
DATABASE_URL_SYNC: str
|
||||||
@@ -24,8 +34,15 @@ class Settings(BaseSettings):
|
|||||||
LOG_JSON: bool = False
|
LOG_JSON: bool = False
|
||||||
LOG_LEVEL: str = "INFO"
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = _env_file
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -14,6 +14,7 @@ from app.api.v1.router import v1_router
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.deps import get_current_active_user, get_db
|
from app.core.deps import get_current_active_user, get_db
|
||||||
from app.core.storage import init_storage
|
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.db.migrations import run_startup_migrations
|
||||||
from app.models.spot import Spot
|
from app.models.spot import Spot
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
@@ -44,6 +45,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await asyncio.to_thread(run_startup_migrations)
|
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.redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
app.state.storage = init_storage()
|
app.state.storage = init_storage()
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -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.jpg,tag_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())
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user