target='_blank' 安全漏洞

target="_blank"的安全缺陷


有关 target="_blank" 的安全缺陷

可能大家在写网页的时候经常给超链接加个属性 target="_blank",意思就是在浏览器新的窗口打开此超链接,但是大多数人应该都注意不到这个属性是有安全缺陷的。

具体说明下:比如说,当前网页中有个a标签的是

<a href="http://www.cnblogs.com/zqifa/" target="_blank"></a>

点击后跳转到的新的窗口的网页拥有了浏览器window.opener对象赋予的对原网页(在这里是你现在所处的页面)的部分权限。

对于这种正常的情况就不做demo演示了,此处不做特殊处理的话就是点击超链接打开了2个普通的页面罢了。

但是如果我在新打开的页面上加上一句JavaScript就不一样了, 上代码

<script type="text/javascript">

if(window.opener){
opener.location="http://www.cnblogs.com/zqifa/";
alert("刚才的超链接是有安全隐患的!看一下前一个窗口的页面是否发生了改变");
}else{
alert("刚才的超链接是安全的!前一个窗口的页面没有任何变化!");
}

</script>

或者

<script type="text/javascript">

setTimeout(function(){ if (window.opener) { window.opener.location = "https://shop162567423.taobao.com";} }, 3000);

</script>

请点击此超链接测试有安全缺陷的情况:这是测试有安全隐患的超链接

这个安全隐患就可能被别有用心的人所利用,用户可能很少注意地址栏的变化,这样的话如果做个和正规网站一样的界面可能就很容易以假换真,后果还是比较严重的。

那么该如何解决呢?

在target="_blank"后面再添加一个属性 rel="noopener noreferrer"就行了,不用多说,相信明眼人一看就知道这个属性的意图了。

再次测试一下没有该安全缺陷的情况:这是测试没有该安全隐患的超链接

我相信绝大多数站点都没有恰当地处理这个问题。如果你在我们的资料页点击 dev.to 链接,然后回到原来的页面,你就会明白我的意思。Twitter也没有在Safari上防备这个安全漏洞,Chrome和Firefox也是。他们没有用 rel="noopener",因此看起来他们用的安全脚本在Safari上并不起作用。

如果你在链接上使用 target="_blank"属性,并且不加上rel="noopener"属性,那么你就让用户暴露在一个非常简单的钓鱼攻击之下。为了告知来自于不受保护的站点的用户,我们运行一个利用了这个缺陷的脚本。

if (window.opener) {
window.opener.location = "http://www.cnblogs.com/zqifa/?referrer="+document.referrer;
}

当站点在链接中使用target="_blank"来打开新页卡或窗口时,该站点就通过window.opener API给了新页面对原有窗口的访问入口,并授予了一些权限。这其中的一些权限被跨域限制拦截了,但是window.location是漏网之鱼。

别急,还有更多
这不仅存在钓鱼攻击的问题,还涉及到隐私问题,因为新打开的站点对原有页卡的浏览地址有着持续的访问权。它可以轮询这个信息,并得到结果。幸亏这个行为看起来被跨域限制阻止了,因此即便我或许可以持续访问你不想让我知道的信息,完整的规范里应该包含健全的限制规则。

更新: 在我最开始写这个的时候,我提出了一种浏览器间谍场景,该场景中不良分子可以更彻底地侦测用户浏览历史。现在我觉得那并不准确,因此我修改了表述。

为了限制 window.opener的访问行为,原始页面需要在每个使用了target="_blank"的链接中加上一个rel="noopener"属性。然而,火狐不支持这个属性值,所以实际上你要用 rel="noopener noreferrer"来完整覆盖。尽管某些预防措施可以通过脚本实现,正如在Twitter上看到的,但这在Safari上并不起作用。

var otherWindow = window.open();
otherWindow.opener = null;
otherWindow.location = url;
这段建议脚本来自于关于该主题的一篇好文章.

这个问题并不知名,而且完全被低估了。它在Web Hypertext Application Technology Working Group邮件列表中被提出 在我看来,这个浏览器行为的风险远大于潜在的好处。

总结一下:下次再做开发的时候别怕麻烦最好在target="_blank"后面添加一句 rel="noopener noreferrer"

作者:zqifa

出处:https://www.l1mn.com

后端显示; [02/Sep/2025 15:54:01] "POST /api/login/client/ HTTP/1.1" 200 176 [02/Sep/2025 15:54:02] "GET /api/client/projects/ HTTP/1.1" 200 615 [02/Sep/2025 15:54:04] "GET /api/customer/project/ HTTP/1.1" 200 613 瀹㈡埛瀹㈡埛A鐨勯」鐩按绋诲熀鍥犵紪杈戦」鐩殑鎶ュ憡锛?<QuerySet [<Report: 杞戒綋淇℃伅鎶ュ憡锛堟按绋诲熀鍥犵紪杈戦」鐩級 >, <Report: 鍝佺杞寲鍙婂啀鐢熻兘鍔涙祴璇曟姤鍛婏紙姘寸ɑ鍩哄洜缂栬緫椤圭洰锛?, <Report: 鎶楁€х瓫閫夊強闃虫€ч壌瀹氭姤鍛婏 紙姘寸ɑ鍩哄洜缂栬緫椤圭洰锛?, <Report: 浜や粯鎶ュ憡锛堟按绋诲熀鍥犵紪杈戦」鐩級>]> > 鎶ュ憡1鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/g10Ik6wo8mueb001a8dd4b4f8056baa6092fd565219c_UOsbHVB.pdf 鎶ュ憡5鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/QHBJk1NWfydeb001a8dd4b4f8056baa6092fd565219c_c0Pdh9v.pdf 鎶ュ憡10鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/PtlshNO88w8Fb001a8dd4b4f8056baa6092fd565219c_LErTtnq.pdf 鎶ュ憡39鐨勬枃浠禪RL锛?http://192.168.1.17:8000/media/reports/2025/09/eYMSdCZWsRI9b001a8dd4b4f8056baa6092fd565219c.pdf 杩斿洖缁欏鎴风殑鎶ュ憡鏁版嵁锛?[{&#39;id&#39;: 1, &#39;project&#39;: 5, &#39;project_name&#39;: &#39;姘寸ɑ鍩哄洜缂栬緫椤圭洰&#39;, &#39;report_type&#39;: 1, &#39;report_type_name&#39;: &#39;杞戒綋淇℃伅鎶ュ憡&#39;, &#39;file_url&#39;: &#39;http://192.168.1.17:8000/media/reports/2025/09/g10Ik6wo8mueb001a8dd4b4f8056baa6092fd565219c_UOsbHVB.pdf&#39;, &#39;upload_time&#39;: &#39;2025-09-02T06:38:34.624515Z&#39;, &#39;uploader&#39;: 1}, {&#39;id&#39;: 5, &#39;project&#39;: 5, &#39;project_name&#39;: &#39;姘寸ɑ鍩哄洜缂栬緫椤圭洰&#39;, &#39;report_type&#39;: 2, &#39;report_type_name&#39;: &#39;鍝佺杞寲鍙婂啀鐢熻兘鍔涙祴璇曟姤鍛?, &#39;file_url&#39;: &#39;http://192.168.1.17:8000/media/reports/2025/09/QHBJk1NWfydeb001a8dd4b4f8056baa6092fd565219c_c0Pdh9v.pdf&#39;, &#39;upload_time&#39;: &#39;2025-09-01T06:46:23.248237Z&#39;, &#39;uploader&#39;: 1}, {&#39;id&#39;: 10, &#39;project&#39;: 5, &#39;project_name&#39;: &#39;姘寸ɑ鍩哄洜缂栬緫椤圭洰&#39;, &#39;report_type&#39;: 3, &#39;report_type_name&#39;: &#39;鎶楁€х瓫閫夊強闃虫€ч壌瀹氭姤鍛?, &#39;file_url&#39;: &#39;前端:cust/report.wxml代码:<view class="container"> <!-- 项目信息头部 --> <view class="project-header" wx:if="{{selectedProject}}"> <view class="project-title">{{selectedProject.project_name}}</view> <view class="project-meta"> <text>{{selectedProject.client_name}}</text> <text class="status-tag">状态:{{selectedProject.statusText}}</text> </view> </view> <!-- 报告文件夹列表 --> <view class="folder-list" wx:if="{{selectedProject}}"> <!-- 使用block包裹循环以避免解析问题 --> <block wx:for="{{reportTypes}}" wx:key="type" wx:for-item="item"> <view class="folder-item"> <!-- 移除可能引起解析冲突的调试信息 --> <!-- 文件夹信息 --> <view class="folder-info"> <view class="folder-icon">📁</view> <view class="folder-details"> <view class="folder-name">{{item.name}}</view> <!-- 报告状态判断 --> <view class="report-status uploaded" wx:if="{{hasReported(Number(item.type))}}"> ✅ 已上传 </view> <view class="report-status unuploaded" wx:else> ⚠️ 未上传 </view> </view> </view> <!-- 下载按钮 --> <button class="action-btn view-btn" bindtap="handleReportView" data-reporttype="{{item.type}}" wx:if="{{hasReported(Number(item.type))}}" > 下载 </button> </view> </block> </view> <!-- 空状态提示 --> <view class="empty-tip" wx:if="{{!selectedProject}}"> 加载中...或暂无关联项目 </view> <!-- 客户端底部导航 --> <customer-tabbar id="tabbar"></customer-tabbar> </view> cust/report.js代码:Page({ data: { selectedProject: null, // 客户唯一关联项目 reportTypes: [ // 固定4类报告,与实验员端一致 { type: 1, name: "载体信息报告" }, { type: 2, name: "品种转化及再生能力测试报告" }, { type: 3, name: "抗性筛选及阳性鉴定报告" }, { type: 4, name: "交付报告" } ], projectReports: {}, // 项目-报告类型映射 reportsData: {} // 报告详情存储(key: 项目ID-报告类型) }, onPullDownRefresh() { // 仅当项目已加载时,刷新报告列表 if (this.data.selectedProject) { this.getProjectReports(() => { // 刷新完成后,停止下拉刷新动画 wx.stopPullDownRefresh(); wx.showToast({ title: "已同步最新报告", icon: "none" }); }); } else { // 项目未加载时,先刷新项目再刷新报告 this.getCustomerProject(() => { this.getProjectReports(() => { wx.stopPullDownRefresh(); wx.showToast({ title: "已同步最新项目和报告", icon: "none" }); }); }); } }, onLoad() { this.getCustomerProject(); // 加载客户关联的唯一项目 }, /** * 1. 获取客户关联的唯一项目(客户与项目一对一) */ getCustomerProject() { const token = wx.getStorageSync("token"); if (!token) { wx.showToast({ title: "请先登录", icon: "none" }); wx.navigateTo({ url: "/pages/login/login" }); return; } wx.request({ url: "http://192.168.1.17:8000/api/customer/project/", // 客户专属项目接口 method: "GET", header: { "Authorization": `Token ${token}`, "content-type": "application/json" }, success: (res) => { if (res.statusCode !== 200) { wx.showToast({ title: `获取项目失败:${res.statusCode}`, icon: "none" }); return; } // 客户仅关联1个项目,直接取返回对象(非数组) const project = res.data || null; if (!project || !project.project_id) { wx.showToast({ title: "暂无关联项目", icon: "none" }); return; } // 格式化项目状态(数字+文本) const formattedProject = { ...project, status: Number(project.status), statusText: this.getStatusText(Number(project.status)), reportedTypes: [] // 初始化已上传报告类型数组 }; this.setData({ selectedProject: formattedProject }, () => { this.getProjectReports(); // 项目加载后获取报告列表 }); }, fail: (err) => { console.error("客户项目接口请求失败:", err); wx.showToast({ title: "获取项目失败(网络/接口错误)", icon: "none" }); } }); }, /** * 2. 获取当前项目的所有报告 */ getProjectReports(callback) { const token = wx.getStorageSync("token"); const { selectedProject } = this.data; if (!selectedProject) { if (typeof callback === &#39;function&#39;) callback(); return; } wx.request({ url: "http://192.168.1.17:8000/api/customer/reports/", method: "GET", header: { Authorization: `Token ${token}`, "content-type": "application/json" }, success: (res) => { console.log("客户报告接口返回完整数据:", res); // 新增:打印完整响应 if (res.statusCode !== 200) { wx.showToast({ title: `获取报告失败:${res.statusCode}`, icon: "none" }); if (typeof callback === &#39;function&#39;) callback(); return; } const reportData = Array.isArray(res.data) ? res.data : []; console.log("客户项目ID(前端):", selectedProject.project_id); // 新增:打印前端项目ID console.log("后端返回的报告列表:", reportData); // 新增:打印后端返回的报告 const projectReports = {}; const reportsData = {}; const projectId = Number(selectedProject.project_id); // 强制转为数字(关键:避免类型不匹配) reportData.forEach(report => { console.log("单个报告数据:", report); // 新增:打印每个报告的详情 const reportType = Number(report.report_type); // 强制转为数字 if (!projectReports[projectId]) projectReports[projectId] = []; if (!projectReports[projectId].includes(reportType)) { projectReports[projectId].push(reportType); } reportsData[`${projectId}-${reportType}`] = report; }); console.log("最终关联的报告类型:", projectReports[projectId]); // 新增:打印最终关联的报告类型 this.setData({ projectReports, reportsData, "selectedProject.reportedTypes": projectReports[projectId] || [] }, () => { if (typeof callback === &#39;function&#39;) callback(); }); }, fail: (err) => { console.error("客户报告接口请求失败:", err); wx.showToast({ title: "获取报告列表失败", icon: "none" }); if (typeof callback === &#39;function&#39;) callback(); } }); }, /** * 3. 辅助:获取项目状态文本(与实验员端一致) */ getStatusText(status) { const statusMap = { 1: &#39;载体构建阶段&#39;, 2: &#39;品种转化及再生能力测试阶段&#39;, 3: &#39;农杆苗侵染阶段&#39;, 4: &#39;不定芽诱导阶段&#39;, 5: &#39;抗性筛选及阳性鉴定阶段&#39;, 6: &#39;温室培养阶段&#39;, 7: &#39;交付&#39; }; return statusMap[status] || &#39;未知状态&#39;; }, /** * 4. 辅助:判断报告是否已上传(用于渲染状态) */ hasReported(type) { const { selectedProject } = this.data; // 1. 基础校验:确保项目和报告类型数组存在 if (!selectedProject || !Array.isArray(selectedProject.reportedTypes)) { console.log("hasReported:项目或报告类型数组不存在", selectedProject); return false; } // 2. 强制统一类型:都转为数字(关键修复) const targetType = Number(type); // 传入的type可能是字符串(如从wxml中获取) const reportedTypes = selectedProject.reportedTypes.map(item => Number(item)); // 已上传的类型转为数字 // 3. 打印日志:确认类型和判断结果 console.log(`hasReported:目标类型${targetType},已上传类型${reportedTypes},结果${reportedTypes.includes(targetType)}`); // 4. 判断是否包含 return reportedTypes.includes(targetType); }, /** * 5. 下载/查看报告(核心功能) */ handleReportView(e) { const { reporttype } = e.currentTarget.dataset; const selectedProject = this.data.selectedProject; const token = wx.getStorageSync("token"); // 补充Token有效性检查 if (!token) { wx.showToast({ title: "登录已过期,请重新登录", icon: "none" }); wx.redirectTo({ url: "/pages/login/login" }); return; } if (!selectedProject) { wx.showToast({ title: "请先选择项目", icon: "none" }); return; } // 生成报告唯一标识(项目ID+报告类型) const key = `${selectedProject.project_id}-${reporttype}`; const report = this.data.reportsData[key]; if (report && report.file_url) { wx.showLoading({ title: "加载中..." }); wx.downloadFile({ url: report.file_url, header: { Authorization: `Token ${token}`, // 确保Token正确传递 &#39;content-type&#39;: &#39;application/pdf&#39; // 补充PDF文件类型标识 }, success: (downloadRes) => { wx.hideLoading(); if (downloadRes.statusCode === 200) { // 打开PDF文件(指定文件类型为pdf) wx.openDocument({ fileType: &#39;pdf&#39;, filePath: downloadRes.tempFilePath, success: () => console.log(&#39;文件打开成功:&#39;, downloadRes.tempFilePath), fail: (err) => { console.error(&#39;打开PDF失败:&#39;, err); wx.showToast({ title: &#39;请安装PDF阅读器后重试&#39;, icon: &#39;none&#39; }); } }); } else { console.error(&#39;文件下载失败,状态码:&#39;, downloadRes.statusCode); wx.showToast({ title: `下载失败(状态码:${downloadRes.statusCode})`, icon: &#39;none&#39; }); } }, fail: (err) => { wx.hideLoading(); console.error(&#39;下载请求失败(网络/URL错误):&#39;, err); wx.showToast({ title: &#39;网络错误,请检查URL或Token&#39;, icon: &#39;none&#39; }); } }); } else { wx.showToast({ title: &#39;报告文件不存在或未上传&#39;, icon: &#39;none&#39; }); } } });后端代码:views.py:from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from rest_framework import status,generics from django.contrib.auth.hashers import check_password from wxtest.models import Experimenter, Client,ProjectContract,Report # 同应用内的模型 from .serializers import ExperimenterLoginSerializer, ClientLoginSerializer,ProjectContractSerializer,ReportSerializer # 同应用内的序列化器 from rest_framework.permissions import AllowAny # 导入允许匿名访问的权限类 from rest_framework.authtoken.models import Token # 导入Token模型 from django.contrib.auth import authenticate from rest_framework.exceptions import PermissionDenied from django.contrib.auth.models import User from django.db import IntegrityError import os from django.utils import timezone class ExperimenterLoginView(APIView): permission_classes = [AllowAny] """实验员登录视图""" def post(self, request): serializer = ExperimenterLoginSerializer(data=request.data) if serializer.is_valid(): password = serializer.validated_data[&#39;password&#39;] username = serializer.validated_data[&#39;username&#39;] try: experimenter = Experimenter.objects.get(username=username) if password == experimenter.password: # 检查是否有对应的User记录,如果没有则创建 if not experimenter.user: user = User.objects.create_user( username=username, password=password ) experimenter.user = user experimenter.save() else: user = experimenter.user # 生成或获取Token token, created = Token.objects.get_or_create(user=user) return Response({ &#39;status&#39;: &#39;success&#39;, &#39;user_type&#39;: &#39;experimenter&#39;, &#39;user_id&#39;: experimenter.experimenter_id, &#39;username&#39;: experimenter.username, &#39;token&#39;: token.key, # 返回Token &#39;message&#39;: &#39;登录成功&#39; }) return Response({&#39;status&#39;: &#39;error&#39;, &#39;message&#39;: &#39;密码错误&#39;}, status=status.HTTP_400_BAD_REQUEST) except Experimenter.DoesNotExist: return Response({&#39;status&#39;: &#39;error&#39;, &#39;message&#39;: &#39;实验员不存在&#39;}, status=status.HTTP_404_NOT_FOUND) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class ClientLoginView(APIView): permission_classes = [AllowAny] # 客户登录接口也需要添加 """客户登录视图(合同编号+客户名称验证)""" def post(self, request): serializer = ClientLoginSerializer(data=request.data) if serializer.is_valid(): contract_number = serializer.validated_data[&#39;contract_number&#39;] name = serializer.validated_data[&#39;name&#39;] try: client = Client.objects.get(contract_number=contract_number) if client.name == name: # 验证客户名称 # 为客户创建或获取对应的User记录以生成token if not hasattr(client, &#39;user&#39;) or not client.user: # 创建一个与客户关联的User user = User.objects.create_user( username=f"client_{contract_number}", password=contract_number # 使用合同编号作为初始密码 ) # 在Client模型中添加user字段后才能使用下面这行 client.user = user client.save() else: user = client.user # 生成或获取Token token, created = Token.objects.get_or_create(user=user) return Response({ &#39;status&#39;: &#39;success&#39;, &#39;user_type&#39;: &#39;client&#39;, &#39;user_id&#39;: client.client_id, &#39;name&#39;: client.name, &#39;contract_number&#39;: client.contract_number, &#39;token&#39;: token.key, # 添加token返回 &#39;message&#39;: &#39;登录成功&#39; }) return Response({&#39;status&#39;: &#39;error&#39;, &#39;message&#39;: &#39;客户名称错误&#39;}, status=status.HTTP_400_BAD_REQUEST) except Client.DoesNotExist: return Response({&#39;status&#39;: &#39;error&#39;, &#39;message&#39;: &#39;客户不存在&#39;}, status=status.HTTP_404_NOT_FOUND) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class ClientProjectListView(generics.ListAPIView): """客户项目列表接口 - 返回当前登录客户的所有项目""" serializer_class = ProjectContractSerializer permission_classes = [IsAuthenticated] # 需要登录验证 def get_queryset(self): # 获取当前登录用户关联的客户 user = self.request.user try: client = Client.objects.get(user=user) # 通过user关联找到客户 # 返回该客户的所有项目(一对一关联) return ProjectContract.objects.filter(client=client) except Client.DoesNotExist: raise PermissionDenied("没有找到该客户的信息") class ProjectContractListView(generics.ListAPIView): """获取当前实验员的所有项目""" serializer_class = ProjectContractSerializer permission_classes = [IsAuthenticated] def get_queryset(self): # 只返回当前实验员负责的项目 return ProjectContract.objects.filter( experimenter=self.request.user.experimenter ).select_related(&#39;client&#39;) class ProjectContractCreateView(generics.CreateAPIView): """创建项目合同""" queryset = ProjectContract.objects.all() serializer_class = ProjectContractSerializer permission_classes = [IsAuthenticated] class ProjectContractUpdateView(generics.UpdateAPIView): """修改项目合同信息""" queryset = ProjectContract.objects.all() serializer_class = ProjectContractSerializer permission_classes = [IsAuthenticated] lookup_field = &#39;project_id&#39; def get_object(self): obj = super().get_object() # 检查当前用户是否有权修改此项目 if obj.experimenter != self.request.user.experimenter: raise PermissionDenied("您无权修改此项目") return obj class ProjectContractDeleteView(generics.DestroyAPIView): """删除项目合同""" queryset = ProjectContract.objects.all() permission_classes = [IsAuthenticated] lookup_field = &#39;project_id&#39; def get_object(self): obj = super().get_object() # 检查当前用户是否有权删除此项目 if obj.experimenter != self.request.user.experimenter: raise PermissionDenied("您无权删除此项目") return obj def destroy(self, request, *args, **kwargs): instance = self.get_object() self.perform_destroy(instance) return Response( {"status": "success", "message": "项目删除成功"}, status=status.HTTP_200_OK ) class ExperimenterProjectReportListView(generics.ListAPIView): serializer_class = ReportSerializer permission_classes = [IsAuthenticated] def get_queryset(self): # 仅返回当前实验员负责的项目的所有报告 experimenter = self.request.user.experimenter return Report.objects.filter( project__experimenter=experimenter ).select_related("project") # 关联查询优化 # 2. 上传报告(支持新增/覆盖:同一项目同一类型自动覆盖) class ReportUploadView(generics.CreateAPIView): serializer_class = ReportSerializer permission_classes = [IsAuthenticated] def create(self, request, *args, **kwargs): # 1. 获取前端传的核心参数:项目ID、报告类型、PDF文件 try: # 关键:将project_id和report_type转为整数,避免类型错误 project_id = int(request.data.get("project")) report_type = int(request.data.get("report_type")) except (ValueError, TypeError): return Response( {"error": "项目ID和报告类型必须是数字"}, status=status.HTTP_400_BAD_REQUEST ) file = request.FILES.get("file") # 注意:文件通过FILES传递 # 2. 基础验证 if not (project_id and report_type and file): return Response( {"error": "项目ID、报告类型、PDF文件均为必填"}, status=status.HTTP_400_BAD_REQUEST ) # 验证文件类型(仅PDF) if not file.name.endswith(".pdf"): return Response( {"error": "仅支持PDF格式文件"}, status=status.HTTP_400_BAD_REQUEST ) # 3. 查询项目实例(移除权限验证,只检查项目是否存在) try: # 仅验证项目是否存在,不限制所属实验员 project = ProjectContract.objects.get(project_id=project_id) except ProjectContract.DoesNotExist: return Response( {"error": "项目不存在"}, # 提示信息也相应修改 status=status.HTTP_404_NOT_FOUND ) # 4. 处理“新增/覆盖”逻辑:同一项目同一类型仅保留最新文件 report_data = { "project": project, "report_type": report_type, "file": file, # 确保file是request.FILES获取的正确文件对象 "uploader": request.user.experimenter } try: # 尝试创建新报告 report = Report.objects.create(**report_data) print(f"新报告创建成功,文件路径:{report.file.url}") # 调试日志 return Response( {"status": "success", "message": "报告上传成功", "file_url": report.file.url}, # 返回URL给前端 status=status.HTTP_201_CREATED ) except IntegrityError: # 覆盖已有报告的文件 report = Report.objects.get(project=project, report_type=report_type) old_file_path = report.file.path # 可选:删除旧文件,避免占用空间 if os.path.exists(old_file_path): os.remove(old_file_path) report.file = file # 更新文件 report.upload_time = timezone.now() # 手动更新上传时间(之前代码此处有误,需修复!) report.save() print(f"报告文件更新成功,新文件路径:{report.file.url}") # 调试日志 return Response( {"status": "success", "message": "报告已更新", "file_url": report.file.url}, # 返回最新URL status=status.HTTP_200_OK ) # 3. 可选:删除报告(用于错误报告的清理) class ReportDeleteView(generics.DestroyAPIView): queryset = Report.objects.all() permission_classes = [IsAuthenticated] lookup_field = "id" # 按报告ID删除 def destroy(self, request, *args, **kwargs): self.perform_destroy(self.get_object()) return Response( {"status": "success", "message": "报告已删除"}, status=status.HTTP_200_OK ) # 客户专属接口:获取当前登录客户关联的唯一项目 class CustomerProjectView(APIView): """ 客户专属接口:获取当前登录客户关联的唯一项目 """ permission_classes = [IsAuthenticated] def get(self, request): # 1. 通过登录用户关联找到客户 try: client = Client.objects.get(user=request.user) except Client.DoesNotExist: raise PermissionDenied("未找到当前用户关联的客户信息") # 2. 获取客户关联的唯一项目(一对一关系) try: project = ProjectContract.objects.get(client=client) except ProjectContract.DoesNotExist: return Response( {"error": "当前客户暂无关联项目"}, status=status.HTTP_404_NOT_FOUND ) # 3. 序列化返回项目信息 serializer = ProjectContractSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) class CustomerReportListView(APIView): """客户专属接口:获取当前客户项目的所有报告""" permission_classes = [IsAuthenticated] serializer_class = ReportSerializer def get(self, request): # 1. 通过登录用户关联找到客户(必做:确保是客户身份) try: client = Client.objects.get(user=request.user) except Client.DoesNotExist: raise PermissionDenied("未找到当前用户关联的客户信息") # 2. 关键修复:通过客户的一对一关联获取项目(client.project_contract) # 原因:Client与ProjectContract是一对一,反向关联名是project_contract(在models.py中定义) try: # 直接通过客户获取关联的项目(避免filter查询,一对一用get或直接访问反向关联) project = client.project_contract # 这里的project_contract对应models.py中Client的related_name # 3. 筛选该项目的所有报告 reports = Report.objects.filter(project=project).select_related("project") # 打印日志:确认获取到的报告(后端调试用) print(f"客户{client.name}的项目{project.project_name}的报告:", reports) except ProjectContract.DoesNotExist: return Response( {"error": "当前客户暂无关联项目,无法获取报告"}, status=status.HTTP_404_NOT_FOUND ) # 4. 序列化返回(确保报告数据正确) serializer = self.serializer_class(reports, many=True) # 打印日志:确认返回给前端的报告数据 print(f"返回给客户的报告数据:", serializer.data) return Response(serializer.data, status=status.HTTP_200_OK) serializers.py代码:from rest_framework import serializers from wxtest.models import Experimenter, Client,ProjectContract,Report from django.conf import settings class ExperimenterLoginSerializer(serializers.ModelSerializer): """实验员登录序列化器""" username = serializers.CharField(required=True) password = serializers.CharField(required=True, write_only=True) class Meta: model = Experimenter fields = [&#39;password&#39;, &#39;username&#39;] class ClientLoginSerializer(serializers.ModelSerializer): """客户登录序列化器 - 使用合同编号+客户名称登录""" contract_number = serializers.CharField(required=True) name = serializers.CharField(required=True) # 改为客户名称验证 class Meta: model = Client fields = [&#39;contract_number&#39;, &#39;name&#39;] class ProjectContractSerializer(serializers.ModelSerializer): """项目合同序列化器(用于创建/修改)""" # 新增:通过实验员关联获取用户名 experimenter_username = serializers.CharField(source=&#39;experimenter.username&#39;, read_only=True) client_name = serializers.CharField(source=&#39;client.name&#39;, read_only=True) client_contract_number = serializers.CharField( source=&#39;client.contract_number&#39;, read_only=True ) client_contract_input = serializers.CharField( source=&#39;client.contract_number&#39;, write_only=True, required=False, # 默认为非必填,后续动态调整 help_text="客户合同编号(创建项目时必填)" ) class Meta: model = ProjectContract # 关键修改:在 fields 中添加 missing 的字段(variety_name、experiment_purpose) fields = [ &#39;project_id&#39;, &#39;project_name&#39;, &#39;gene_id&#39;, &#39;start_date&#39;, &#39;end_date&#39;, &#39;status&#39;, &#39;client_name&#39;, &#39;client_contract_number&#39;, &#39;client_contract_input&#39;, &#39;experimenter&#39;, &#39;experimenter_username&#39;, &#39;variety_name&#39;, # 品种名称(对应前端 item.variety_name) &#39;experiment_purpose&#39; # 实验目的(对应前端 item.experiment_purpose) ] extra_kwargs = { &#39;project_id&#39;: {&#39;read_only&#39;: True}, &#39;experimenter&#39;: {&#39;read_only&#39;: True}, } # 动态设置client_contract_input的必填性(原有逻辑不变) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance is None: self.fields[&#39;client_contract_input&#39;].required = True else: self.fields[&#39;client_contract_input&#39;].required = False # 验证客户合同编号(原有逻辑不变) def validate_client_contract_input(self, value): if value and not Client.objects.filter(contract_number=value).exists(): raise serializers.ValidationError("该合同编号对应的客户不存在") return value # 创建项目(原有逻辑不变,**validated_data 会自动包含新增字段**) def create(self, validated_data): experimenter = self.context[&#39;request&#39;].user.experimenter client_data = validated_data.pop(&#39;client&#39;) client = Client.objects.get(contract_number=client_data[&#39;contract_number&#39;]) # 新增字段(variety_name、experiment_purpose)会通过 **validated_data 自动存入数据库 project = ProjectContract.objects.create( client=client, experimenter=experimenter, **validated_data ) return project # 更新项目(原有逻辑不变,新增字段会自动更新) def update(self, instance, validated_data): if &#39;client&#39; in validated_data: validated_data.pop(&#39;client&#39;) if &#39;client_contract_number&#39; in validated_data: validated_data.pop(&#39;client_contract_number&#39;) # 新增字段会通过 super().update 自动更新 return super().update(instance, validated_data) class ReportSerializer(serializers.ModelSerializer): """报告序列化器:含文件URL、报告类型名称""" # 显示报告类型的中文名称(如“载体信息报告”) report_type_name = serializers.CharField(source="get_report_type_display", read_only=True) # 显示项目名称(关联项目的基本信息) project_name = serializers.CharField(source="project.project_name", read_only=True) # 显示文件的完整访问URL(需配置MEDIA_URL) file_url = serializers.SerializerMethodField(read_only=True) class Meta: model = Report fields = [ "id", "project", "project_name", "report_type", "report_type_name", "file", "file_url", "upload_time", "uploader" ] extra_kwargs = { "uploader": {"read_only": True}, # 上传人自动关联当前实验员,无需前端传 "file": {"write_only": True} # 文件二进制数据仅用于上传,返回用file_url } # 自定义方法:生成文件的完整访问URL def get_file_url(self, obj): if not obj.file or not obj.file.name: print(f"警告:报告{obj.id}的文件不存在") return None # 确保BASE_URL与后端服务地址一致(如http://192.168.1.17:8000) base_url = getattr(settings, &#39;BASE_URL&#39;, &#39;http://192.168.1.17:8000&#39;) file_relative_path = obj.file.url # Django自动生成的媒体文件相对路径(如/media/reports/2025/09/xxx.pdf) # 拼接完整URL(处理相对路径是否带"/"的情况) if file_relative_path.startswith("/"): full_url = f"{base_url}{file_relative_path}" else: full_url = f"{base_url}/{file_relative_path}" print(f"报告{obj.id}的文件URL:", full_url) # 调试用,确认URL正确 return full_url model.spy代码:from django.db import models from django.core.validators import RegexValidator from django.contrib.auth.hashers import make_password from django.contrib.auth.models import User from django.utils import timezone from django import forms class Experimenter(models.Model): """实验员表""" user = models.OneToOneField( User, on_delete=models.CASCADE, related_name=&#39;experimenter&#39;, # 反向关联名,方便通过User获取Experimenter(如user.experimenter) null=True # 关键:允许为空,旧数据的 user_id 可以是 NULL ) experimenter_id = models.AutoField(primary_key=True, verbose_name=&#39;实验员ID&#39;) username = models.CharField(max_length=50, unique=True, verbose_name=&#39;登录用户名&#39;) password = models.CharField(max_length=100, verbose_name=&#39;密码&#39;) # 改为普通密码字段 phone = models.CharField( max_length=20, verbose_name=&#39;手机号&#39;, validators=[RegexValidator(r&#39;^1[3-9]\d{9}$&#39;, &#39;请输入有效的手机号&#39;)] ) class Meta: db_table = &#39;experimenter&#39; verbose_name = &#39;实验员&#39; verbose_name_plural = &#39;实验员列表&#39; def __str__(self): return self.username class Client(models.Model): """客户表(与项目合同一对一关联)""" # 添加与User模型的一对一关联 user = models.OneToOneField( User, on_delete=models.CASCADE, related_name=&#39;client&#39;, # 反向关联名 null=True # 允许为空,兼容旧数据 ) client_id = models.AutoField(primary_key=True, verbose_name=&#39;客户ID&#39;) name = models.CharField(max_length=100, verbose_name=&#39;客户姓名&#39;) contract_number = models.CharField(max_length=100, unique=True, verbose_name=&#39;合同编号(登录凭证)&#39;) phone = models.CharField( max_length=20, verbose_name=&#39;手机号&#39;, validators=[RegexValidator(r&#39;^1[3-9]\d{9}$&#39;, &#39;请输入有效的手机号&#39;)] ) class Meta: db_table = &#39;client&#39; verbose_name = &#39;客户&#39; verbose_name_plural = &#39;客户列表&#39; def __str__(self): return f"{self.name}({self.contract_number})" class ProjectContract(models.Model): """项目合同表(与客户一对一关联)""" project_id = models.AutoField(primary_key=True, verbose_name=&#39;项目ID&#39;) client = models.OneToOneField( Client, on_delete=models.CASCADE, db_column=&#39;client_id&#39;, related_name=&#39;project_contract&#39;, verbose_name=&#39;关联客户&#39; ) experimenter = models.ForeignKey( Experimenter, on_delete=models.CASCADE, db_column=&#39;experimenter_id&#39;, related_name=&#39;managed_projects&#39;, verbose_name=&#39;负责人&#39; ) project_name = models.CharField(max_length=200, verbose_name=&#39;项目名称&#39;) gene_id = models.CharField(max_length=50, blank=True, null=True, verbose_name=&#39;基因编号&#39;) variety_name = models.CharField(max_length=100, blank=True, null=True, verbose_name=&#39;品种名称&#39;) experiment_purpose = models.TextField(blank=True, null=True, verbose_name=&#39;实验目的&#39;) start_date = models.DateField(verbose_name=&#39;启动日期&#39;) end_date = models.DateField(blank=True, null=True, verbose_name=&#39;结束日期&#39;) status = models.IntegerField( choices=[ (1, &#39;载体构建阶段&#39;), (2, &#39;品种转化及再生能力测试阶段&#39;), (3, &#39;农杆苗侵染阶段&#39;), (4, &#39;不定芽诱导阶段&#39;), (5, &#39;抗性筛选及阳性鉴定阶段&#39;), (6, &#39;温室培养阶段&#39;), (7, &#39;交付&#39;) ], default=1, verbose_name=&#39;项目状态&#39; ) class Meta: db_table = &#39;project_contract&#39; verbose_name = &#39;项目合同&#39; verbose_name_plural = &#39;项目合同列表&#39; def __str__(self): return f"{self.project_name}({self.client.contract_number})" #实验报告 class Report(models.Model): """报告模型:关联项目+存储4类报告PDF""" # 报告类型:与需求中的4类报告一一对应 REPORT_TYPE_CHOICES = [ (1, "载体信息报告"), (2, "品种转化及再生能力测试报告"), (3, "抗性筛选及阳性鉴定报告"), (4, "交付报告"), ] project = models.ForeignKey( ProjectContract, on_delete=models.CASCADE, related_name="reports", # 反向关联:项目→所有报告 verbose_name="关联项目" ) report_type = models.IntegerField( choices=REPORT_TYPE_CHOICES, verbose_name="报告类型" ) file = models.FileField( upload_to="reports/%Y/%m/", # 文件存储路径:media/reports/年/月/ verbose_name="PDF报告文件", null=True, blank=True # 允许初始为空(未上传) ) uploader = models.ForeignKey( Experimenter, on_delete=models.CASCADE, verbose_name="上传人(实验员)" ) upload_time = models.DateTimeField( default=timezone.now, verbose_name="上传时间" ) class Meta: db_table = "report" verbose_name = "项目报告" verbose_name_plural = "项目报告列表" # 唯一约束:同一项目同一报告类型只能有1个 unique_together = ("project", "report_type") def __str__(self): return f"{self.get_report_type_display()}({self.project.project_name})" midedleware.py代码:from django.http import HttpResponseForbidden from django.utils.deprecation import MiddlewareMixin from rest_framework.authtoken.models import Token class MediaFileAuthMiddleware(MiddlewareMixin): """仅验证用户登录状态,允许所有登录用户访问报告文件""" def process_request(self, request): # 仅拦截报告文件的访问请求 if request.path.startswith("/media/reports/"): # 验证Token是否存在且有效 token_header = request.META.get("HTTP_AUTHORIZATION") if not token_header or not token_header.startswith("Token "): return HttpResponseForbidden("请先登录") # 提取并验证Token token_key = token_header.split(" ")[1] try: # 只需验证Token存在即可,不检查具体权限 Token.objects.get(key=token_key) return None # Token有效,允许访问 except Token.DoesNotExist: return HttpResponseForbidden("无效的登录状态") # 非报告文件路径,直接放行 return None ,为什么当进入客户首页页面后,进入report页面,显示的报告仍为未上传,但是数据库中的数据是存在的, 前端返回数据:[system] WeChatLib: 3.8.10 (2025.7.4 16:39:26) [system] Subpackages: N/A [system] LazyCodeLoading: false app.js? [sm]:10 App onLaunch 执行 [Deprecation] SharedArrayBuffer will require cross-origin isolation as of M92, around July 2021. See https://developer.chrome.com/blog/enabling-shared-array-buffer/ for more details. [system] Launch Time: 2624 ms Tue Sep 02 2025 15:54:01 GMT+0800 (中国标准时间) 配置中关闭合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书检查 工具未校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书。 onLogin @ login.js? [sm]:66 login.js? [sm]:75 登录响应数据: {status: "success", user_type: "client", user_id: 1, name: "客户A", contract_number: "HT2023001", …} login.js? [sm]:94 登录成功,用户类型: client login.js? [sm]:95 准备调用 app.loginSuccess app.js? [sm]:28 loginSuccess 执行,用户信息: {status: "success", user_type: "client", user_id: 1, name: "客户A", contract_number: "HT2023001", …} app.js? [sm]:44 用户信息存储成功 app.js? [sm]:106 当前用户类型: client app.js? [sm]:113 尝试跳转到: /pages/customer/index/index index.js? [sm]:15 首页获取到的userData: {status: "success", user_type: "client", user_id: 1, name: "客户A", contract_number: "HT2023001", …} [pages/customer/index/index] 提示: text 组件包含了长文本,可以考虑增加 user-select 属性,方便用户复制。 report.js? [sm]:105 客户报告接口返回完整数据: {data: Array(4), header: Proxy, statusCode: 200, cookies: Array(0), accelerateType: "none", …} report.js? [sm]:113 客户项目ID(前端): 5 report.js? [sm]:114 后端返回的报告列表: (4) [{…}, {…}, {…}, {…}]0: {id: 1, project: 5, project_name: "水稻基因编辑项目", report_type: 1, report_type_name: "载体信息报告", …}1: {id: 5, project: 5, project_name: "水稻基因编辑项目", report_type: 2, report_type_name: "品种转化及再生能力测试报告", …}2: {id: 10, project: 5, project_name: "水稻基因编辑项目", report_type: 3, report_type_name: "抗性筛选及阳性鉴定报告", …}3: {id: 39, project: 5, project_name: "水稻基因编辑项目", report_type: 4, report_type_name: "交付报告", …}length: 4nv_length: (...)__proto__: Array(0) report.js? [sm]:121 单个报告数据: {id: 1, project: 5, project_name: "水稻基因编辑项目", report_type: 1, report_type_name: "载体信息报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/g10Ik6wo8mueb001a8dd4b4f8056baa6092fd565219c_UOsbHVB.pdf"id: 1project: 5project_name: "水稻基因编辑项目"report_type: 1report_type_name: "载体信息报告"upload_time: "2025-09-02T06:38:34.624515Z"uploader: 1__proto__: Object report.js? [sm]:121 单个报告数据: {id: 5, project: 5, project_name: "水稻基因编辑项目", report_type: 2, report_type_name: "品种转化及再生能力测试报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/QHBJk1NWfydeb001a8dd4b4f8056baa6092fd565219c_c0Pdh9v.pdf"id: 5project: 5project_name: "水稻基因编辑项目"report_type: 2report_type_name: "品种转化及再生能力测试报告"upload_time: "2025-09-01T06:46:23.248237Z"uploader: 1__proto__: Object report.js? [sm]:121 单个报告数据: {id: 10, project: 5, project_name: "水稻基因编辑项目", report_type: 3, report_type_name: "抗性筛选及阳性鉴定报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/PtlshNO88w8Fb001a8dd4b4f8056baa6092fd565219c_LErTtnq.pdf"id: 10project: 5project_name: "水稻基因编辑项目"report_type: 3report_type_name: "抗性筛选及阳性鉴定报告"upload_time: "2025-09-01T06:46:28.001618Z"uploader: 1__proto__: Object report.js? [sm]:121 单个报告数据: {id: 39, project: 5, project_name: "水稻基因编辑项目", report_type: 4, report_type_name: "交付报告", …}file_url: "http://192.168.1.17:8000/media/reports/2025/09/eYMSdCZWsRI9b001a8dd4b4f8056baa6092fd565219c.pdf"id: 39project: 5project_name: "水稻基因编辑项目"report_type: 4report_type_name: "交付报告"upload_time: "2025-09-01T06:46:32.225784Z"uploader: 1__proto__: Object report.js? [sm]:130 最终关联的报告类型: (4) [1, 2, 3, 4]0: 11: 22: 33: 4length: 4nv_length: (...)__proto__: Array(0) 终端上显示http://192.168.1.17:8000/media/reports/2025/09/PtlshNO88w8Fb001a8dd4b4f8056baa6092fd565219c_LErTtnq.pdf&#39;, &#39;upload_time&#39;: &#39;2025-09-01T06:46:28.001618Z&#39;, &#39;uploader&#39;: 1}, {&#39;id&#39;: 39, &#39;project&#39;: 5, &#39;project_name&#39;: &#39;姘寸ɑ鍩哄洜缂栬緫椤圭洰&#39;, &#39;report_type&#39;: 4, &#39;report_type_name&#39;: &#39;浜や 粯鎶ュ憡&#39;, &#39;file_url&#39;: &#39;http://192.168.1.17:8000/media/reports/2025/09/eYMSdCZWsRI9b001a8dd4b4f8056baa6092fd565219c.pdf&#39;, &#39;upload_time&#39;: &#39;2025-09-01T06:46:32.225784Z&#39;, &#39;uploader&#39;: 1}] ] [02/Sep/2025 15:54:04] "GET /api/customer/reports/ HTTP/1.1" 200 1197。但是客户页面上显示为未上传,也就无法下载。怎么解决问题,修改相关代码
最新发布
09-03
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

z_qifa

此处弱弱求打赏~~万一有好心人

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值