1. 项目概述与架构选型
1.1 这系统到底要解决什么问题
先说说我做这个遥感影像共享系统的背景。前段时间,单位里攒了一大批历史遥感影像数据,从早期的航空影像到近年来的卫星数据,零零散散散落在各台机器的硬盘里。每次有人要用数据,都要找负责的老同事问某某地区、某某时间段的数据在哪个盘里,拷来拷去,版本还经常对不上,搞得大家都很痛苦。
我当时就想,能不能做个简单的共享平台,把影像资料集中管理起来。用户可以通过网页浏览缩略图、查元数据信息,需要的话直接在线下载原始文件。平台不需要做复杂的遥感算法,核心就三个功能:传上去(管理)、看得到(预览)、拿得到(下载)。明确这一点很重要,因为很多人一开始就把这类项目做大了,又是切片服务又是处理管线的,结果两三个月都交付不了。先把共享需求做扎实,后续再考虑扩展。
技术选型上,我最终定了 Python + Vue 这套组合。后端在 Django 和 Flask 之间斟酌了很久,最后保留了两套方案作为可选项。前端用 Vue 3 + Element Plus,地图预览用 OpenLayers,开发环境用 PyCharm 全家桶,基本可以覆盖从数据库设计到前端调试的全部工作。
1.2 Django 还是 Flask,我的取舍思路
这个系统我明明可以用一套框架做完,为什么要同时保留两种后端的可能?说实话,这取决于项目的实际形态。
Django 适合的场景是:项目需要快速上线,后台管理界面要开箱即用,用户体系、权限认证不想从头造轮子。Django Admin 能直接生成数据管理页面,配合自带 ORM,开发效率确实高。我做第一版原型时用 Django 只花了一周时间就把基础的数据管理、用户登录、文件上传跑通了,这效率是 Flask 比不了的。
但 Flask 适合的场景是:系统后续要嵌入其他平台,或者需要高度定制接口行为。Flask 非常轻,请求处理逻辑完全由自己掌控,对微服务化改造更友好。如果团队后续想把影像处理单独拆成服务,Flask 的代码结构更容易拆解。
最终我给两者的定位是这样的:如果团队主要写 Python、需要快速出成果,选 Django 版本;如果更在意接口灵活性和模块自治,选 Flask 版本。两者在数据库表和前端部分完全可以共用,真正不同的只是路由层和 ORM 层。文章里我会把两套实现都写清楚,任选其一即可。
2. 系统核心模块与数据库设计
2.1 功能模块拆解,别一上来就写代码
任何系统第一步都是想清楚模块边界。遥感影像共享系统,我最开始设计的模块图里只有三个大块,后来细化成了六个功能区域:
第一块是影像数据管理。包括影像上传、元数据录入、文件物理存储、元数据修改与删除。这一块是整个系统的地基,如果影像入库的时候元数据就录错了,后面检索和浏览都会出问题。
第二块是检索与浏览。用户按空间范围、时间范围、传感器类型、云量等条件检索影像,结果以列表或者地图标注的形式展示,要支持缩略图的快速预览。
第三块是权限与用户管理。分成管理员、数据上传者、普通浏览者三级角色。管理员能管理所有数据和用户,上传者只能管理自己上传的数据,浏览者只能看和下载。
第四块是下载管理。包括原始文件的下载、下载权限控制、下载记录留痕。有些数据涉密或者有版权要求,下载权限必须细分。
第五块是地图预览。集成 OpenLayers,把影像的空间范围叠加到底图上。用户点某个矢量范围就能看到影像轮廓并弹出元数据卡片。
第六块是日志与统计。记录上传、下载、登录等操作,后台统计最热门的影像资源和下载量排名。
模块拆完了,数据库表的设计才有依据。这里说一下,老博主们都懂得一个道理:编码之前设计好表结构,后面能省一半的返工量。
2.2 核心数据表设计,直接放出可用的 DDL
数据表我主要设计了五张核心表,其实遥感影像系统的数据库并不需要特别复杂,关键是字段要覆盖遥感数据的基本元数据标准。
第一张是用户表user,字段包括用户名、密码哈希、邮箱、角色、状态、创建时间。密码一定不要存明文,用 Django 自带的make_password方式,或者 Flask 里用werkzeug.security.generate_password_hash,都是成熟方案。
第二张是影像表image,最重要的元数据字段包括:
title:影像名称sensor_type:传感器类型(如 Sentinel-2、Landsat-8 或者无人机相机)acquisition_date:采集时间cloud_cover:云量百分比bbox:外包矩形坐标,用 PostGIS 的geometry类型更好resolution:空间分辨率band_count:波段数量file_path:原始文件存储路径thumbnail_path:缩略图路径file_size:文件大小uploader_id:上传者外键download_count:下载次数统计status:数据状态(待审核、已发布、已下线)
第三张是标签表tag和第四张是影像标签关联表image_tag,因为一张影像可能存在"城市""水体""灾前""灾后"等多个标签,而一个标签也会对应多个影像,所以必须做成多对多的关联关系。
第五张是下载记录表download_log,用来做留痕审计。每次下载都要记录谁在什么时候下载了哪个影像,这既是为了安全审计,也能用来做下载频次的热度统计。
贴一下影像表在两种框架下的建表差异。Django 的模型类写法如下:
# Django 版本 models.py from django.db import models from django.contrib.auth.models import User class Image(models.Model): title = models.CharField(max_length=200, verbose_name="影像名称") sensor_type = models.CharField(max_length=100, verbose_name="传感器类型") acquisition_date = models.DateField(verbose_name="采集日期") cloud_cover = models.FloatField(default=0, verbose_name="云量百分比") resolution = models.FloatField(verbose_name="空间分辨率(米)") band_count = models.IntegerField(default=1, verbose_name="波段数") file_path = models.FileField(upload_to="images/", verbose_name="原始文件") thumbnail_path = models.CharField(max_length=300, blank=True, verbose_name="缩略图路径") file_size = models.BigIntegerField(default=0, verbose_name="文件大小") uploader = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) download_count = models.IntegerField(default=0) status = models.CharField(max_length=20, default="published") created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.titleFlask + SQLAlchemy 的写法则长这样:
# Flask 版本 models.py from flask_sqlalchemy import SQLAlchemy from datetime import datetime db = SQLAlchemy() class Image(db.Model): __tablename__ = "image" id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) sensor_type = db.Column(db.String(100)) acquisition_date = db.Column(db.Date) cloud_cover = db.Column(db.Float, default=0) resolution = db.Column(db.Float) band_count = db.Column(db.Integer, default=1) file_path = db.Column(db.String(300)) thumbnail_path = db.Column(db.String(300)) file_size = db.Column(db.BigInteger, default=0) uploader_id = db.Column(db.Integer, db.ForeignKey("user.id")) download_count = db.Column(db.Integer, default=0) status = db.Column(db.String(20), default="published") created_at = db.Column(db.DateTime, default=datetime.now)空间范围字段我这里单独说一下。如果要做精确的空间查询,比如"找出与某个多边形相交的所有影像",最理想的方案是用 PostGIS 数据库,把 bbox 存储成geometry(Polygon, 4326)类型。但如果项目部署环境不允许装 PostGIS,退而求其次的做法是直接存四个浮点数min_lon, min_lat, max_lon, max_lat,空间查询时用范围重叠的条件判断。大部分共享系统的查询场景,其实四个浮点数已经够用了,性能也不差。
2.3 文件存储策略:磁盘目录结构怎么规划
这一节看似简单,其实坑最多。遥感影像通常都很大,一个 GeoTIFF 动辄几百 MB 到几个 GB,如果直接用数据库存二进制文件,数据库会直接被撑爆。正确做法是文件存磁盘,数据库只存路径。
我的目录规划是:存储根目录下按年份分一层,再按月分一层,文件名保留原始文件名并追加一段哈希值防止重名。
/storage/remote_sensing/ ├── 2023/ │ ├── 01/ │ │ ├── a3f9_sentinel2_20230115_b3.tif │ │ └── ... │ ├── 02/ │ └── ... └── 2024/ ├── ...这样做的好处是:第一,按时间归档符合影像数据的自然组织习惯,查某年某月的数据直接进目录翻就行;第二,追加哈希值可以避免不同用户上传同名文件互相覆盖。具体实现时,我写了一个生成存储路径的工具函数:
import os import hashlib from datetime import datetime def generate_storage_path(original_filename, upload_time=None): """生成存储路径:根目录/年/月/哈希_原始文件名""" upload_time = upload_time or datetime.now() year = upload_time.strftime("%Y") month = upload_time.strftime("%m") hash_suffix = hashlib.md5( f"{original_filename}{upload_time.timestamp()}".encode() ).hexdigest()[:8] safe_name = os.path.basename(original_filename).replace(" ", "_") return os.path.join("storage", year, month, f"{hash_suffix}_{safe_name}")上传时还需要注意一个问题:文件上传服务往往有大小限制。Nginx 默认上传大小是 1MB,Django 和 Flask 也有自己的上传限制,必须把这几层的限制都调大。Nginx 的client_max_body_size我直接设成了10g,后端代码里也要做相应的分块上传处理。分块上传的好处是:大文件传输中途断了不用从头开始,而且服务器内存不会被一次性吃满。
关于缩略图,我强烈建议上传成功后立刻用 Pillow 或者 GDAL 生成一张 JPEG 缩略图存到独立目录。缩略图不仅能大幅加快列表页的加载速度,也能减少服务器带宽压力。有个技巧:如果影像特别大,Pillow 直接打开原图重新采样可能会非常慢,这时候可以用 GDAL 的gdal_translate -outsize 512 512 -of JPEG命令行工具来生成,效率要高很多。
3. 后端 API 实现与技术要点
3.1 Django 方案:类视图 + DRF 的快速落地
如果用 Django 版本,我的经验是直接用 Django REST Framework(DRF)来做接口层,不要手写 JSONResponse。DRF 的序列化器、分页、权限控制都封装得很好,能省大量重复劳动。
登录认证我推荐用 JWT 方案。Django 项目装djangorestframework-simplejwt,配置起来非常快。注册好应用后,在settings.py里加入配置:
REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ( "rest_framework.permissions.IsAuthenticated", ), "DEFAULT_PAGINATION_CLASS": ( "rest_framework.pagination.PageNumberPagination" ), "PAGE_SIZE": 20, }影像上传的 API 用 DRF 的ModelViewSet稍加定制就能搞定。代码如下:
from rest_framework import viewsets, permissions, status from rest_framework.response import Response from .models import Image from .serializers import ImageSerializer from .utils import generate_storage_path, generate_thumbnail class ImageViewSet(viewsets.ModelViewSet): queryset = Image.objects.all() serializer_class = ImageSerializer permission_classes = [permissions.IsAuthenticated] def perform_create(self, serializer): image_file = self.request.FILES.get("file") storage_path = generate_storage_path(image_file.name) # 实际保存文件,并生成缩略图 saved_path = self.save_file_to_storage(image_file, storage_path) thumb_path = generate_thumbnail(saved_path) serializer.save( uploader=self.request.user, file_path=saved_path, thumbnail_path=thumb_path, file_size=image_file.size )这里要强调一下权限控制的小细节。默认情况下,普通用户只能看和自己上传相关的数据操作,管理员才有删除权限。我在get_queryset里做了过滤,同时自定义了一个DjangoModelPermissions子类来控制动作级别的权限。很多新手容易忽略这一点,结果普通用户也能调用 DELETE 接口,这是线上事故隐患。
3.2 Flask 方案:蓝图 + SQLAlchemy 的轻量组织
Flask 版本的实现思路大致相同,只是代码更显式。我用蓝图把路由拆分成了auth、image、download三个模块。登录认证仍然用 JWT,Flask 下推荐 PyJWT 库,加一个装饰器做视图保护:
from functools import wraps from flask import request, jsonify import jwt def token_required(f): @wraps(f) def decorated(*args, **kwargs): token = request.headers.get("Authorization", "").replace("Bearer ", "") if not token: return jsonify({"msg": "缺少认证令牌"}), 401 try: payload = jwt.decode(token, app.config["SECRET_KEY"], algorithms=["HS256"]) request.user_id = payload["user_id"] except jwt.ExpiredSignatureError: return jsonify({"msg": "令牌已过期"}), 401 except jwt.InvalidTokenError: return jsonify({"msg": "无效令牌"}), 401 return f(*args, **kwargs) return decorated文件上传接口在 Flask 里用request.files.get("file")拿到文件对象,配合werkzeug.utils.secure_filename做文件名安全过滤。这里有个坑:secure_filename会把中文文件名直接过滤成空的,所以我在生成存储路径时使用的是原始文件名加哈希的方式,不用secure_filename处理中文。
Flask 的下载接口可以用send_file实现,加上conditional=True参数以支持断点续传,这在下载大文件时非常关键。用户下载到一半断网了,有了断点续传就不需要从头再来。
3.3 检索接口的设计:多条件组合查询
影像检索是这个系统使用频率最高的接口,性能直接决定用户体验。我设计的检索接口支持以下参数:
keyword:模糊匹配影像名称start_date/end_date:限定采集时间范围sensor:按传感器类型筛选min_resolution/max_resolution:分辨率范围bbox:空间范围过滤
Django 版本用 Q 对象组合查询条件,Flask 版本则直接拼接 SQLAlchemy 的filter条件。必须注意的一点是:如果查询条件很多,不能把全部逻辑堆在一个视图函数里。我建议封装成独立的查询服务类,例如ImageQueryService,负责把前端传来的参数字典转换成 ORM 查询对象。
分页这块我也踩过坑:遥感影像列表页每条数据都是几百 KB 的 JSON?不,前提是要把大字段裁剪掉。序列化器返回时我只回传 id、标题、采集时间、云量、分辨率、缩略图 URL、外包矩形这些字段,原始文件的完整路径绝对不往列表接口里放。文件路径属于服务器内部信息,前端只需要通过专门的下载接口去拿文件,这也是安全防护的基本要求。
另外,列表接口一定要给缩略图 URL 加上 CDN 或者单独静态文件路径,不要让每次请求都动态走 Django/Flask 的视图去读缩略图文件,否则并发一高后端响应就崩了。正确做法是缩略图直接放在 Nginx 可以静态服务的目录下,URL 直接用/static/thumbs/xxx.jpg的形式。
4. Vue 前端实现与地图集成
4.1 项目初始化与页面结构规划
Vue 3 + Vite 是我目前比较推荐的前端组合。相比 Vue 2 + Webpack,Vite 冷启动速度快很多,本地调试体验特别好。项目创建可以直接用命令:
npm create vite@latest remote-sensing-web -- --template vue目录结构上,我习惯把路由、状态管理、API 请求单独分层:
src/ ├── api/ │ ├── auth.js │ ├── image.js │ └── download.js ├── router/ │ └── index.js ├── stores/ │ ├── user.js │ └── image.js ├── views/ │ ├── LoginView.vue │ ├── ImageListView.vue │ ├── ImageUploadView.vue │ └── ImageDetailView.vue └── components/ ├── MapPreview.vue └── ImageCard.vue页面规划上,登录页单独做,登录后的主框架包含左侧菜单栏、顶部用户信息栏、内容区三个部分。列表页是最核心的:上半部是筛选条件栏,中间是缩略图网格,右下角悬浮一个地图小窗,用于显示全选影像的空间分布。
4.2 OpenLayers 地图集成,坐标体系别搞错
遥感影像的空间位置展示必须用到地图组件。OpenLayers 在 Web 端加载矢量数据和栅格瓦片的能力非常成熟,比 Leaflet 更适合遥感场景,因为它在坐标系投影上更灵活。
集成时最容易踩的坑就是坐标系。后端存的坐标一般是 WGS84 经纬度(EPSG:4326),但 OpenLayers 默认的显示投影是 Web Mercator(EPSG:3857),如果用 4326 的坐标直接画到地图上,要素位置会偏移到海里。正确的做法是在创建图层时设置数据投影,让 OpenLayers 自动转换:
import Feature from "ol/Feature"; import Point from "ol/geom/Point"; import { fromLonLat } from "ol/proj"; // 后端返回的经纬度转换为地图坐标 const mapPoint = fromLonLat([longitude, latitude]); const feature = new Feature({ geometry: new Point(mapPoint) });对于影像外包矩形的展示,后端传过来的是[min_lon, min_lat, max_lon, max_lat],前端构造多边形坐标时要先转成四个角的坐标数组:
const coords = [ fromLonLat([minLon, minLat]), fromLonLat([maxLon, minLat]), fromLonLat([maxLon, maxLat]), fromLonLat([minLon, maxLat]), fromLonLat([minLon, minLat]), ];图层交互方面,点击地图上的影像矩形弹出一个 popup,显示影像名称、时间、分辨率,并提供"查看详情"按钮跳转到详情页。这个交互体验非常实用,用户在地图上选好区域,一眼就能看出哪些地方的影像覆盖比较密集。
地图底图我用的是一份免费的全球影像底图服务。这里提醒一点:如果系统部署在内网环境,外部底图服务访问不了,要准备 WMTS 服务的离线瓦片方案。很多人前期忽略了网络环境限制,上线了才傻眼。
4.3 前端鉴权与请求封装
前端需要处理 JWT 令牌的存储和自动续期问题。我的做法是登录成功后把access_token和refresh_token都存到 localStorage,在 axios 请求拦截器里统一加上Authorization: Bearer <access_token>头。响应拦截器里判断 401 状态码,自动用 refresh_token 去换取新的 access_token。
刷新令牌的时机是个细节问题:不能等 401 了才刷新,因为并发请求可能同时十几个,同时触发刷新逻辑会产生大量重复请求。我的解决方法是设置一个isRefreshing标志位,第一个触发的请求去刷新,其他请求先挂起排队,等刷新完成后重新执行原请求。
API 请求封装示例:
import axios from "axios"; import { useUserStore } from "@/stores/user"; const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 30000, }); service.interceptors.request.use((config) => { const userStore = useUserStore(); if (userStore.accessToken) { config.headers.Authorization = `Bearer ${userStore.accessToken}`; } return config; }); let isRefreshing = false; let pendingQueue = []; service.interceptors.response.use( (response) => response.data, async (error) => { const userStore = useUserStore(); const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { if (isRefreshing) { return new Promise((resolve, reject) => { pendingQueue.push({ resolve, reject }); }).then(() => service(originalRequest)); } originalRequest._retry = true; isRefreshing = true; try { const newToken = await userStore.refreshToken(); pendingQueue.forEach((item) => item.resolve()); pendingQueue = []; return service(originalRequest); } catch (refreshError) { pendingQueue.forEach((item) => item.reject(refreshError)); pendingQueue = []; userStore.logout(); window.location.href = "/login"; } finally { isRefreshing = false; } } return Promise.reject(error); } ); export default service;这里顺便提醒一句:JWT 虽然方便,但安全性完全依赖密钥管理和存储策略。不要把 JWT 密钥硬编码在代码里提交到代码仓库,线上环境密钥必须通过环境变量注入,定期轮换。
4.4 上传页面的高并发优化
上传遥感影像的文件体积大,请求时间跨度长,前端需要注意两个细节。第一,上传按钮必须设置 loading 状态和禁止重复提交,否则用户等得不耐烦连点几下会产生多个重复文件,后端的哈希命名虽然能避免冲突,但存储冗余是实打实的。第二,给用户展示上传进度条,用 axios 的onUploadProgress回调来实现:
const onUpload = async (file) => { const formData = new FormData(); formData.append("file", file); formData.append("title", form.title); formData.append("sensor_type", form.sensorType); formData.append("acquisition_date", form.acquisitionDate); await uploadImage(formData, (percent) => { progress.value = percent; }); };大文件上传建议还是做分片,尤其在网络质量不稳定的环境里。每片大小设成 5MB 或者 10MB,后端提供一个合并接口。但考虑到遥感影像的使用场景大多在专网或者内网,网络质量有保障,如果前期为了快速上线,整文件上传也能接受,分片可以作为 v2 版本的优化项。
5. 实际操作全流程复现
5.1 环境准备:PyCharm 配置要点
开发环境我是全程用 PyCharm 的。第一版做 Django 项目时,我直接用 PyCharm 专业版的 Django 项目模板创建项目,好处是工具自动帮你配置好 manage.py 的运行配置、虚拟环境和 Django 控制台,开箱即用。
具体环境版本我列个表供参考:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.10+ | 3.11 更好,3.8 以下别碰了 |
| Django | 4.2 LTS | 长期支持版本,稳定性优先 |
| Flask | 2.3+ | 配 Flask-SQLAlchemy 3.x |
| Vue | 3.4+ | Composition API 为主 |
| Vite | 5.x | 构建速度快 |
| OpenLayers | 9.x | 地图组件 |
| PostGIS | 3.4+ | 可选,用于空间查询 |
| PyCharm | 2023.3+ | 支持 Vue 插件 |
PyCharm 配置时有一个关键操作:要确保 Python 解释器指向的是虚拟环境,而不是系统的全局 Python。在设置里Project -> Python Interpreter选择New Virtualenv,Python 版本选 3.10。后期装依赖都通过pip install装到当前虚拟环境里,避免污染系统 Python。
前后端联调时,我会在 PyCharm 里同时打开前端项目和后端项目。前端项目需要在Run Configuration里增加一个 npm dev 运行项,然后开启 Vue 开发服务器(默认 5173 端口)。后端的 API 服务默认跑在 8000 端口。由于前后端端口不同,需要在 Vite 的配置里设置代理,把/api开头的请求转发到后端 8000 端口,规避跨域问题:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { "/api": { target: "http://localhost:8000", changeOrigin: true, }, }, }, });5.2 数据库初始化与空间数据扩展
数据库我推荐直接用 PostgreSQL + PostGIS。安装完 PostGIS 扩展后,在数据库中执行一句创建扩展的 SQL:
CREATE EXTENSION IF NOT EXISTS postgis;Django 项目要使用 PostGIS 的 GeometryField,需要安装django.contrib.gis,并在settings.py里把数据库引擎改成django.contrib.gis.db.backends.postgis。Flask 项目则通过geoalchemy2库支持 Geometry 字段类型。
表结构准备好了以后,Python 侧只需要正常执行数据库迁移命令。以 Django 为例:
python manage.py makemigrations python manage.py migrate如果你是第一次用 PostGIS,可能还需要手动建一个数据库并设置用户权限。我一般习惯用命令行建库:
createdb -U postgres remote_sensing_db psql -U postgres -d remote_sensing_db -c "CREATE EXTENSION postgis;"5.3 核心接口的实测数据与表现
系统开发完成后,我导入了一批测试数据做压力测试。测试环境是普通的 4 核 8G 的云服务器,数据库是 PostgreSQL,后端用的 Django。我导入了 1000 条带有 bbox 坐标的影像记录,模拟了列表检索接口的并发请求。
实测结果:单次列表请求的平均响应时间为 120ms 左右,其中数据库查询占了 60ms,序列化占了 40ms,网络传输占了 20ms。在 50 个并发用户的压力测试下,接口吞吐量约为每秒 300 个请求,CPU 占用率在 70% 左右,表现还算可以。如果数据量增长到十万级,需要在 bbox 字段上建空间索引,并把列表接口从同步查询改成分页游标模式。
5.4 Python 后端实现细节补充
Django 项目里,影像列表查询服务我推荐用select_related优化外键查询,避免每个影像都要单独查询一次用户表。处理云量和分辨率的数字范围过滤时,要注意前端传参的边界值,用gte和lte这类操作符加一到一个闭区间。
在返回给前端的数据结构里,统一用一个response_wrapper函数处理。这个函数保证每一项响应都包含固定的状态码、消息和数据三部分,前端遇到任何异常情况都能统一处理。
def success(data=None, message="ok"): return {"status": "success", "code": 200, "data": data, "message": message} def fail(message, code=400): return {"status": "error", "code": code, "data": None, "message": message}5.5 部署上线前要检查的安全项
这里整理几个部署前必查清单。
第一,DEBUG必须设为 False。Django 的 DEBUG=True 一旦打开,如果页面报错就会显示完整堆栈、服务器配置甚至数据库密码,线上绝对不能这么干。
第二,允许访问的主机列表要在INSTALLED_APPS里配置白名单规则,生产环境设置成*会有 DNS 重绑定风险。
第三,禁止在日志里打印查询语句中包含的敏感信息。Django ORM 的 query 日志在低版本中可能会把用户传入的参数带出来,需要做脱敏处理。
第四,批量下载接口必须限制单次导出量。我封装了一个后台异步任务,用户提交下载任务后去队列里排队,最大单次任务不超过 500 张影像,防止下载请求直接把服务器 IO 打满。
6. 常见问题与排查技巧实录
6.1 上传大文件时 502 报错
遇到最多的问题是:上传几百 MB 的影像文件时,请求执行到一半 Nginx 返回 502。排查发现是 Nginx 的代理超时时间太短,请求超过 60 秒就主动掐断了。解决方法是调大 Nginx 的超时配置:
client_max_body_size 10g; proxy_read_timeout 600s; proxy_send_timeout 600s; proxy_connect_timeout 60s;但这里要补充一个设计问题,仅仅调大超时是不够的。如果影像文件有好几个 GB,上传请求长时间占用一个后端 worker,其他请求就会被阻塞。解决办法如前所述上分块上传,或者把上传接口独立部署,或者采用异步上传任务的方式:前端先发出上传请求,后端把任务投递到队列,worker 后台慢慢接收文件流,前端轮询任务状态。
6.2 前端地图显示空白或者底图错位
多数原因是坐标系匹配问题。底图用了 Web Mercator 瓦片,而图层数据是 WGS84 坐标系没有转换。排查步骤是:先用浏览器开发者工具看一下网络面板,确认瓦片请求 URL 的 tile 坐标是否正常;再在浏览器控制台里调用 OpenLayers 的getCoordinateFromPixel接口,看鼠标点击位置对应的经纬度是否符合预期;最后检查图层配置里的projection属性和数据features的坐标是否是统一状态。
另外一个常见问题是底图公司壁瓦片服务有严格的防盗链设置,必须设置crossOrigin: "anonymous",否则在 Canvas 上绘制合成结果时会报跨域异常。若影像存在涉密要求,有必要用自建的 COS 或 Git 格式的瓦片服务替代。
6.3 缩略图不显示
一般有三个原因:一是缩略图生成失败,检查后端日志能发现 Pillow 或者 GDAL 的异常;二是静态文件路径配置问题,Django 里STATIC_URL和STATIC_ROOT没对应上;三是前端拼接 URL 的时候用了中文命名的文件,结果 URL 没有做 encodeURIComponent 编码。
我建议缩略图的 URL 统一由后端生成一个相对路径返回,前端永远不要去拼路径。后端可以额外附带一个 CDN 前缀的字段,便于后期迁移到云存储环境。
6.4 登录状态频繁丢失
JWT 的生命周期需要精细配置。ACCESS_TOKEN_LIFETIME我设置为 2 小时,REFRESH_TOKEN_LIFETIME设置为 7 天。如果有用户在系统中长时间操作,前端需要在令牌过期前主动刷新一次,而不是等过期后再刷新。
实际操作中,我用一个定时器在 access token 还剩 10 分钟过期时触发刷新。需要注意定时器的清理问题:Vue 组件卸载时必须clearInterval,否则切换页面时会积累多个刷新定时器,产生冗余请求。
6.5 数据库连接数被占满
这个问题在并发下载时特别容易出现。Django 和 Flask 的数据库连接池默认配置在低并发下没毛病,但每个下载任务如果长时间占用一个数据库连接,连接数会很快耗尽。解决办法是把数据库最大连接数调大(需考虑服务器的连接资源),同时在 ORM 层引入连接回收机制。以 Django 为例,可以在配置中设置CONN_MAX_AGE参数控制连接复用的生命周期。对于更重的场景,建议把下载记录和文件列表的查询拆到只读从库,主库只承担写入操作。
6.6 大目录下文件上传变慢的问题
这个问题发生在我导入了约一万个文件之后。操作系统对单目录的文件数有限制,超过一定数量后目录索引操作会显著变慢。我之前已经采取了按年月分层的存储方案,所以单目录文件数不会爆炸。但如果后期文件量极其大,建议再深一层按影像获取的地区编码(比如瓦片拓扑号或者国标行政区划码)继续拆分目录,分区策略越早设计越好,迁移存储结构是最痛苦的事。
7. 项目的后续扩展方向
7.1 影像在线处理服务
共享系统做完后,我强烈建议下一步加入在线处理能力。比如影像裁剪、波段组合、影像格式转换。把这些操作做成后台任务,通过消息队列调度。用户可以提交一个处理任务,设定参数后任务异步执行,执行完成后前端通知用户预览结果并下载。
这样系统就从一个单纯的"文件网盘"升级成了"影像工作台"。处理能力不需要很强,基础的 gdal_translate、gdalwarp 命令就能覆盖大部分需求。
7.2 基于时序的影像对比
遥感数据非常看重时序性。比如同一地区不同年份的影像做对比,就能很直观地展示城市扩张、水体变化等。可以在系统里增加"时间轴"视图:用户选定一个空间范围,系统拉取该范围内所有时间的影像,按时间顺序排列缩略图,点击播放按钮就可以连续播放。实现不复杂,只需要在地图组件上做定时切换 ImageLayer 的 source。
7.3 对接标准 OGC 服务
如果希望系统能够与专业遥感软件互相操作,可以暴露 WMS/WMTS 标准接口。GeoServer 是现成的方案,也可以直接用 Python 的mapnik或者rasterio库实现简单的 WMS 服务。做这个扩展的好处是,用户可以通过 QGIS、ArcGIS 等桌面软件直接加载系统里的影像图层。
我个人在实际操作中的体会是:遥感影像共享系统的难点不在技术框架本身,而在于对数据全生命周期的理解。许多刚入门的开发者花了很多时间折腾各种组件,反而忽略了最基础的文件存储管理、元数据规范性和权限控制。做这类系统,最重要的原则就是"稳住底座,再谈功能"。先把数据传进来不会丢、查得到、看得了、能安全地下载出去,这四条生命线做好了,基本上系统就成功了一大半。
最后再分享一个经验:开发过程中一定要准备一批小尺寸的测试影像,几百 KB 到几 MB 就好,不要一开始就用 1 个 GB 的卫星影像来调试。小文件能加快开发迭代速度,等所有流程跑通了再上真实数据,调试体验差别非常大。这个细节看着不起眼,实际帮我省了好多时间。