无头N卡Linux打游戏指南前段时间我把内存卖了，然后V100就自然而然吃灰了。偶然的机会，我在并夕夕刷到了便宜的PCIe延长线，x4转x16接口，只需要28包邮，还是30cm的。很快啊，啪的一下，我就下单了。我打算还是之前玩V100那个配方，直接外接电源同步启动来解决V100的供电问题（因为这次电脑已经有了一个RTX1080Ti电老虎了），和之前的玩法一样。到祸后，我赶紧上机测试，启动倒是没有任何问题，没想到这个X99寨板马上就给我来了个下马威。主板设置开机的时候，电脑先运行了一会，然后就掉电了，大概两秒后重新上电，然后直接给我来了一个贴脸报错：大概意思是：PCIe MMIO（内存映射输入输出）空间不足，只能禁用一部分PCIe设备进入系统后，果不其然V100初始化失败了。需要解决这个问题的话，要开启主板的Above 4G Decoding和Re-size BAR Support（可选），并且禁用主板的CSM。搞定之后，进系统，两张显卡都正常识别了：[chocola@Neko-X99 ~]$ nvidia-smi Fri Mar 20 16:34:32 2026 +-----------------------------------------------------------------------------------------+ | NVIDIA-SMI 580.82.09 Driver Version: 580.82.09 CUDA Version: 13.0 | +-----------------------------------------+------------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | | | MIG M. | |=========================================+========================+======================| | 0 Tesla V100-SXM2-16GB Off | 00000000:01:00.0 Off | 0 | | N/A 37C P0 24W / 300W | 8MiB / 16384MiB | 0% Default | | | | N/A | +-----------------------------------------+------------------------+----------------------+ | 1 NVIDIA GeForce RTX 2080 Ti Off | 00000000:03:00.0 On | N/A | | 40% 40C P0 18W / 250W | 897MiB / 11264MiB | 1% Default | | | | N/A | +-----------------------------------------+------------------------+----------------------+也是感慨啊，当时刚拿到V100的时候，给它和RTX2080Ti拍了一张同框的照片，现在这下是电脑同框了（）驱动版本提醒：如果是Turing以下的架构，例如Volta架构，Pascal架构和Maxwell架构，驱动版本建议使用580，575，570这几个版本，550也不错。至于更老的Kepler架构，最后支持的是470版本，太老了，很多东西可能都没法玩了，就不太建议了。Turing及以上，可以试试看新驱动。Xorg 下游戏性能测试这里先说一个有趣的现象，我在测试的时候，发现了一个非常玄学的问题：如果是Wayland，默认调用的是GPU0，也就是V100。对！你没听错，默认就给你调用无头卡，估计可能是因为设备顺序问题。但是如果Xorg，那就是默认调用GPU1，也就是当前的显示设备。我这里先说一下Xorg下的性能表现，然后再去喷有大病的Wayland。调用V100跑游戏首先，如前所述，在Xorg环境下，游戏是默认调用显示的显卡进行渲染的，这时候我们就需要手动去指定渲染设备。在steam的游戏属性里面的启动选项，加上这个启动参数：DXVK_FILTER_DEVICE_NAME="Tesla V100" VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/nvidia_icd.json __NV_PRIME_RENDER_OFFLOAD=1 __VK_LAYER_NV_optimus=NVIDIA_only %command%加上后，就可以调用V100进行渲染了，对于使用Proton运行的游戏有效。DXVK_FILTER_DEVICE_NAME的逻辑是：如果这个环境变量包含的字符串出现在Vulkan设备名称中，DXVK就会强行选中该设备。如果你填 Tesla V100，而你的全称是 Tesla V100-SXM2-16GB，它可以匹配成功，因为前者是后者的子集。但如果你填错了（比如多了一个空格或字母拼错），匹配就会失败，DXVK 会退而求其次选择默认设备。设备名称以vulkaninfo命令获取到的信息为准：[chocola@Neko-X99 ~]$ vulkaninfo | grep deviceName deviceName = Tesla V100-SXM2-16GB deviceName = NVIDIA GeForce RTX 2080 Ti这里就填Tesla V100。效果测试有点出乎意料，我的V100是用了延长线，跑在PCIe 3.0 x4的工作状态下的，但是性能释放也很可以。霍格沃兹之遗，算是压力比较大的游戏了，除了光追基本上开最高了，不用FSR，直接原生分辨率跑，能有这个表现：基本上PCIe的交换速度跑到了1.5-1.6G/s，我的屏幕是2K屏，帧数能到80-90帧，相当不错了。这么看PCIe 3.0 x4 确实没有拖后腿。原生的Linux游戏，CS2，2K最高画质：效果表现也不错，别的游戏就不测了，不是重点重点是要对比Wayland和Xorg的表现。这个显卡，不开光追的话，甚至比我的RTX2080Ti还强一点。这意味着，即使是P104-100这种PCIe 1.0 x4 速度只有1G/s的矿渣，1080P的屏幕下打游戏应该也不会有太大问题，带宽的开销主要是画面传输。不得不说，Linux确实是这类无头卡的救星，可以直接调用，性能损失也不算大。但是，仅限于Xorg，Wayland就是另一个故事了。Wayland 下游戏性能测试省流：一坨大便！调用V100跑游戏在我的测试环境下，V100因为是GPU0，所以直接就是默认调用了，甚至不需要我设置。反而我需要关心的是，怎么调用我的输出显卡RTX2080Ti打游戏，多少有点抽象。指定设备的方法和前面的是一样的：DXVK_FILTER_DEVICE_NAME="Tesla V100" VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/nvidia_icd.json __NV_PRIME_RENDER_OFFLOAD=1 __VK_LAYER_NV_optimus=NVIDIA_only %command%效果测试经过测试，无论是霍格沃兹之遗还是CS2，帧数都只有十几帧，而且显卡利用率完全上不去。甚至移动视角会有严重的画面撕裂，Wayland不是说拒绝画面撕裂吗？这很招笑了。以及实际的帧数和显卡利用率，基本上是没法玩了。多显卡，Wayland你就用吧，一用一个不吱声，果然三年过去了，还是那个Bugland。出现这种情况，因为我现在没有AMD的显卡进行验证，所以不知道我应该是要Fuck NVIDIA还是Fuck Wayland。还有一个我想Fuck的，是新版KDE 6，没事为什么要把Xorg移除支持列表？Wayland你都没完全做好，你怎么敢的？！总结Linux下的无头N卡可玩性挺高的，前提是用Xorg而不是用Wayland。之前我用P104-100遇到帧数极低的问题，一开始我还以为是显卡的问题，唯独没想到是Wayland的问题，因为那时候测试用的最新版Manjaro KDE，已经没得选了，必须Wayland。希望我这篇踩坑性质的指南记录能帮到你：玩无头N卡，珍爱硬件，远离Wayland！
让AI指挥AI画图我玩了一段时间的ComfyUI之后，突然有一个想法：既然都是生成提示词来绘制画面，为什么我不能让LLM全自动为我生成提示词，然后生成画面后再让模型进行审核呢？我很早之前就了解到ComfyUI是有API接口的，只需要调用就可以，而Ollama本身就支持调用。这意味着，我只需要写一个中间沟通的桥梁。让两个AI对接互撕就可以了。说干就干，我打算用我之前拿来跑SheepIt的P104-100双卡渲染机来做这件事情。毕竟矿卡嘛，就应该有矿卡的样子——没日没夜高强度工作，直到寿终正寝。都是哥布林洞穴里面出来的玩意，RTX2080Ti我会让她好好当作正常的显卡用，至于职业矿渣P104-100，你就做一辈子矿卡吧！模型选型由于P104-100的显存只有8GB，所以太大的模型是跑不了的。所幸，我想玩的NetaYume v4画图模型，是有分体版本的，可以在8G显存的显卡上面完美运行，那接下来选型的重点，就是多模态模型的选择了：主要是能输入图片的模型，毕竟需要模型对图片进行把关。由于显存限制，模型应该选择 7B-8B 的模型会比较合适，因为既能充分利用显存，又能提供还可以的智商，不至于太傻。看了一下Ollama官网上带视觉功能的最近的模型，比较适合的，主要有这几个选择：Qwen3.5 4B (9B试过了，显存不够用，会卸载到CPU导致速度奇慢）Qwen3-VL 8BGemma3 4BMinistral-3 8BMinicpm-V 8B目前我只尝试使用了Qwen3-VL 8B进行测试，效果还可以，提示词要求的格式服从性尚可，虽然也会偶尔给你发癫乱来导致产生一些废图。另外，我还尝试了一下MiniCPM-V 8B，这个是最开始Gemini推荐的，发现这个模型对画面的评审能力，似乎确实是要比Qwen3-VL 8B要强得多，很多画面崩坏的细节都能察觉到。对接ComfyUI API至于如何部署ComfyUI和Ollama，我就不再赘述了，前面的文章都有讲过，让我们直接步入正题。踩大坑记录一开始我以为给API提交的JSON文件，就是保存在ComfyUI的user/default/workflows/目录下的JSON，然后发现这么尝试提交都是失败。后面去问了Gemini，才知道是不一样的。对于提交给API的JSON，需要打开高级设置，然后在文件中导出工作流。具体的开启流程是：点击ComfyUI面板右侧的齿轮图标（设置），勾选开启 ‘Enable Dev mode Options’（启用开发者模式选项）。开启后，面板上才会多出一个 ‘Save (API Format)’ 的按钮。点击它导出的 JSON 才是能给 API 用的。步骤如图所示：正确的JSON应该是长这样的：{ "1": { "inputs": { "vae_name": "vae.safetensors" }, "class_type": "VAELoader", "_meta": { "title": "加载VAE" } }, "2": { "inputs": { "clip_name": "gemma_2_2b_fp16.safetensors", "type": "stable_diffusion", "device": "default" }, "class_type": "CLIPLoader", "_meta": { "title": "加载CLIP" } }, "3": { "inputs": { "unet_name": "NetaYumev4_unet.safetensors", "weight_dtype": "default" }, "class_type": "UNETLoader", "_meta": { "title": "UNet加载器" } }, "4": { "inputs": { "shift": 4, "model": [ "3", 0 ] }, "class_type": "ModelSamplingAuraFlow", "_meta": { "title": "采样算法（AuraFlow）" } }, "8": { "inputs": { "seed": 927904769887424, "steps": 30, "cfg": 4.0, "sampler_name": "res_multistep", "scheduler": "linear_quadratic", "denoise": 1, "model": [ "4", 0 ], "positive": [ "6:7", 0 ], "negative": [ "7:7", 0 ], "latent_image": [ "9", 0 ] }, "class_type": "KSampler", "_meta": { "title": "K采样器" } }, "9": { "inputs": { "width": 1920, "height": 1088, "batch_size": 1 }, "class_type": "EmptySD3LatentImage", "_meta": { "title": "空Latent图像（SD3）" } }, "10": { "inputs": { "samples": [ "8", 0 ], "vae": [ "1", 0 ] }, "class_type": "VAEDecode", "_meta": { "title": "VAE解码" } }, "12": { "inputs": { "filename_prefix": "NetaYume_v4", "images": [ "10", 0 ] }, "class_type": "SaveImage", "_meta": { "title": "保存图像" } }, "6:23": { "inputs": { "value": "You are an assistant designed to generate high-quality images with the highest degree of image-text alignment based on structural summary. <Prompt Start> " }, "class_type": "PrimitiveStringMultiline", "_meta": { "title": "System prompt" } }, "6:24": { "inputs": { "value": "一幅充满温馨氛围的高质量室内场景二次元插画，主角是 NEKOPARA 作品里面的 #chocola_nekopara ，画面采用的画风风格是 @sayori_(neko_works) 的画风风格，与猫娘乐园系列游戏CG画风类似。\n作品为人物特写，人物站在画面中心偏右的位置，要求能够表现出她的可爱气质。她脸上挂着满足的笑容，可爱的眼睛在闪闪发亮。她的眼睛瞳孔是橄榄形的，符合她的猫娘人设。她的两条双马尾都各绑着一条白色丝带发饰。她身穿淡粉色的洋装，露出可爱的猫耳朵。她面向画面，双手手指交叉托住下巴，手肘放在桌子上，坐在一张欧式复古的沙发椅上，正在看着桌子上各种精美的糕点和甜品。她的尾巴在身后立起，末端卷曲。\n画面的前景是摆满桌面各式精美的糕点和甜点。\n画面的整体是一个温馨的女仆咖啡厅，背景装饰以淡雅的壁纸和古典家具为主。\n时间是下午，阳光透过窗户洒进房间，给整个场景增添了一种温暖的感觉。\n" }, "class_type": "PrimitiveStringMultiline", "_meta": { "title": "Prompt" } }, "6:22": { "inputs": { "string_a": [ "6:23", 0 ], "string_b": [ "6:24", 0 ], "delimiter": "" }, "class_type": "StringConcatenate", "_meta": { "title": "连接" } }, "6:7": { "inputs": { "text": [ "6:22", 0 ], "clip": [ "2", 0 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "CLIP Text Encode (Positive Prompt)" } }, "7:22": { "inputs": { "string_a": [ "7:23", 0 ], "string_b": [ "7:24", 0 ], "delimiter": "" }, "class_type": "StringConcatenate", "_meta": { "title": "连接" } }, "7:23": { "inputs": { "value": "You are an assistant designed to generate anime images based on textual prompts. <Prompt Start> " }, "class_type": "PrimitiveStringMultiline", "_meta": { "title": "System prompt" } }, "7:24": { "inputs": { "value": "blurry, worst quality, low quality, jpeg artifacts, signature, watermark, username, error, deformed hands, bad anatomy, extra limbs, poorly drawn hands, poorly drawn face, mutation, deformed, extra eyes, extra arms, extra legs, malformed limbs, fused fingers, too many fingers, long neck, cross-eyed, bad proportions, missing arms, missing legs, extra digit, fewer digits, cropped, multiple tails, " }, "class_type": "PrimitiveStringMultiline", "_meta": { "title": "System prompt" } }, "7:7": { "inputs": { "text": [ "7:22", 0 ], "clip": [ "2", 0 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "CLIP Text Encode (Negative Prompt)" } } }编写对接程序整个程序我打算是使用Python来编写，在Gemini的代写下，我搞定了和ComfyUI对接的Python程序comfyui_client.py：import json import random import uuid import urllib.request import urllib.parse import json import time import websocket # 需要执行: pip install websocket-client class ComfyUIClient: def __init__(self, server_address: str = "127.0.0.1:8188"): self.server_address = server_address self.client_id = str(uuid.uuid4()) # 你要求的固定系统提示词 self.positive_prefix = "Drawn by @sayori_(neko_works)." self.negative_prefix = "" def load_workflow(self, workflow_path: str = "NetaYume_Example.json") -> dict: """读取 API 格式的工作流文件""" with open(workflow_path, 'r', encoding='utf-8') as f: return json.load(f) def queue_prompt(self, prompt: dict) -> dict: """将准备好的工作流发送到 ComfyUI""" p = {"prompt": prompt, "client_id": self.client_id} data = json.dumps(p).encode('utf-8') req = urllib.request.Request(f"http://{self.server_address}/api/prompt", data=data) response = urllib.request.urlopen(req) return json.loads(response.read()) def get_history(self, prompt_id: str) -> dict: """获取生成历史（包含图片信息）""" with urllib.request.urlopen(f"http://{self.server_address}/api/history/{prompt_id}") as response: return json.loads(response.read()) def get_image(self, filename: str, subfolder: str, folder_type: str) -> bytes: """从 ComfyUI 下载图片""" data = {"filename": filename, "subfolder": subfolder, "type": folder_type} url_values = urllib.parse.urlencode(data) with urllib.request.urlopen(f"http://{self.server_address}/view?{url_values}") as response: return response.read() def generate_image(self, user_positive: str, user_negative: str = "", seed: int = None, save_path: str = "output.png"): """主执行流程""" # 1. 加载并修改工作流 prompt_data = self.load_workflow() if seed is None: seed = random.randint(0, 2**32 - 1) # --------------------------------------------------------- # 2. 注入参数 (基于 NetaYume_Example.json 的节点 ID) # --------------------------------------------------------- # 节点 8 是 KSampler prompt_data["8"]["inputs"]["seed"] = seed # 节点 6:24 是正向提示词输入框 prompt_data["6:24"]["inputs"]["value"] = self.positive_prefix + user_positive # 节点 7:24 是负向提示词输入框 prompt_data["7:24"]["inputs"]["value"] = self.negative_prefix + user_negative print(f"[*] 准备提交任务...") print(f"[-] 随机种子: {seed}") # 3. 连接 WebSocket ws = websocket.WebSocket() ws.connect(f"ws://{self.server_address}/ws?clientId={self.client_id}") # 4. 提交任务 queue_response = self.queue_prompt(prompt_data) prompt_id = queue_response.get("prompt_id") print(f"[*] 任务已提交，Prompt ID: {prompt_id}") print(f"[*] 正在生成中，请稍候...") # 5. 监听 WebSocket 知道任务完成 while True: out = ws.recv() if isinstance(out, str): message = json.loads(out) # 打印进度条 (可选) if message['type'] == 'progress': data = message['data'] print(f"\r[-] 渲染进度: {data['value']}/{data['max']}", end="") # 如果收到 executing 消息且 node 为 None，说明当前 client 的任务队列已执行完毕 if message['type'] == 'executing': data = message['data'] if data['node'] is None and data.get('prompt_id') == prompt_id: print("\n[*] 渲染完成！") break # 跳出监听循环 # 断开 WebSocket ws.close() # 6. 获取图片结果并保存 history = self.get_history(prompt_id) # 提取当前 prompt_id 的输出数据 outputs = history[prompt_id].get("outputs", {}) for node_id, node_output in outputs.items(): if "images" in node_output: for image_info in node_output["images"]: # 下载图片 image_data = self.get_image( image_info["filename"], image_info["subfolder"], image_info["type"] ) # 写入本地文件 with open(save_path, "wb") as f: f.write(image_data) print(f"[*] 图片已保存至: {save_path}") # 如果只需一张图，这里可以 return return save_path # ========================================== # 测试运行 # ========================================== if __name__ == "__main__": client = ComfyUIClient(server_address="127.0.0.1:8188") # 你只需要输入你想要的核心画面描述，系统提示词会自动拼接到前面 my_positive = "1girl, chocola_nekopara, sitting on a vintage sofa, detailed cafe background, masterpiece, best quality" my_negative = "blurry, worst quality, low quality, jpeg artifacts, deformed" result_path = client.generate_image( user_positive=my_positive, user_negative=my_negative, save_path="my_generated_anime.png" # 图片保存路径 )其中，利用了websocket来获取图片输出进度，以便第一时间把生成的图片丢给视觉模型进行评价和提示词改进。对接Ollama API这里需要注意一个点，为了避免P104-100来回卸载和装载模型，我们需要给Ollama设置模型显存常驻，启动的脚本应该是这样的：export HOME=/home/rin/Ollama/data/ export CUDA_VISIBLE_DEVICES=1 export OLLAMA_HOST=0.0.0.0:11434 export OLLAMA_KEEP_ALIVE=-1 ./bin/ollama serve另外，因为是双卡，ComfyUI默认调用GPU0，那我们就需要让Ollama调用GPU1，避免和ComfyUI进行争抢，两张P104-100可以各司其职，一张画图，一张跑模型。转接板接上的那个PCIe 1.0 x1 的P104-100只拿来跑Ollama并且模型常驻，这样就可以避开带宽的劣势。然后就是编写对接Ollama的API，这个倒是没有遇到什么坑，最开始在Qwen的帮助下也顺利完成了vision_agent.py：import requests import base64 import json from typing import List, Dict, Union, Tuple, Optional class VisionAgent: """ 专门用于处理视觉理解和文本生成的 Agent。 支持两种模式： 1. VL 模式：接收图片和文本，进行视觉问答（用于评审）。 2. Text 模式：仅接收文本，进行纯文本推理（用于剧本家）。 """ def __init__(self, ollama_url: str = "http://127.0.0.1:11434", default_model: str = "qwen3-vl:8b"): self.url = f"{ollama_url}/api/chat" self.default_model = default_model # 初始化空的消息历史，用于维护上下文 self.messages: List[Dict] = [] # --- 功能一：构建系统提示词 --- def set_system_prompt(self, role: str, task: str = "") -> str: """ 预设几种常用的系统角色，方便调用。 你可以根据需要扩展这个字典。 """ prompts = { "critic": ( "你是一位严苛的世界顶级二次元插画视觉评审专家。你的任务是严格审核图片质量和动态表现。\n" "【评审标准】（触发以下任何一条即为不合格）：\n" "1. 肢体崩坏：请拿放大镜检查双手、双脚的关节和手指！只要手指扭曲、数量不对，或存在多余肢体，立即判不合格。\n" "2. 动态死板：角色动作像个僵硬的木偶，直挺挺地站着/坐着。\n" "3. 依赖道具：老套地让角色手里拿着某物，缺乏自然的肢体互动。\n" "4. 尾巴异常：角色尾巴缺失、画成了两条及以上尾巴，或与身体连接处畸形。\n" "【强制回复格式】（极其重要）：\n" "请你先用一两句中文简短地分析画面的缺陷（例如：“左手手指融合畸形”、“没有画出尾巴”）。\n" "分析完毕后，请另起一行，严格使用 [RESULT] 标签输出你的最终决定：\n" "情况 A：如果图片完美，请输出：[RESULT] PASS\n" "情况 B：如果不合格，请基于下方原始提示词重写，优化动作和环境。角色名称必须使用 #chocola_nekopara 。请输出：[RESULT] <纯英文提示词>\n" "以下是生成当前图片的原始提示词，请开始你的评审：" ), "scriptwriter": ( "你是一个极具创造力的二次元插画画师。你的任务是根据用户需求，生成高质量的纯英文自然语言提示词。\n" "【核心动作要求】（极重要）：\n" "1. 拒绝死板：严禁老套的罚站/端坐姿势！\n" "2. 拒绝手持道具：绝对禁止总是让角色手里拿着物品！\n" "3. 肢体互动：请为角色设计自然、生动、与环境深度互动的全身动作（例如：双手向后撑在草地上、身体前倾趴在栏杆上远眺、单手高举在远处打招呼、蜷缩在沙发里、在半空中轻盈跳跃等）。\n" "【画面构成逻辑】：\n" "请依照“构图视角 -> 角色具体肢体动作 -> 角色表情与服装 -> 背景细节与光影效果”的逻辑进行全英文自然语言描述。释放你的想象力，让画面具有故事感！不要包含负面词汇。\n" "【强制输出格式】（必须严格遵守）：\n" "1. 提示词角色名称必须是 #chocola_nekopara ，不要描述固有的身体结构（如发色、猫耳等），但必须详细描述服装、神态表情，以及四肢和猫尾巴的具体动态。\n" "2. 你的回复必须是一段纯粹的英文提示词文本。绝对禁止输出任何中文、标题、说明、分析或礼貌性问候！你的输出必须能直接丢到画图模型中运行！" ), "default": "你是一个有用的助手。" } return prompts.get(role, task) # --- 功能二：上下文管理逻辑 --- def add_message(self, role: str, content: str, image_path: Optional[str] = None): """ 向上下文消息列表中添加一条消息。 role: 'user', 'assistant', 'system' image_path: 如果有图片，将其转为 Base64 嵌入。 """ message = {"role": role, "content": content} # 如果有图片，添加 images 字段 if image_path: message["images"] = [self._image_to_base64(image_path)] self.messages.append(message) def clear_context(self): """清空上下文，开始新的对话任务。""" self.messages = [] def get_context(self) -> List[Dict]: """获取当前的上下文，用于发送请求。""" return self.messages.copy() # --- 功能三：发起请求与返回结果 --- def chat(self, content: str, image_path: Optional[str] = None, system_role: Optional[str] = None, model: Optional[str] = None, keep_context: bool = False) -> str: """ 核心交互函数。 参数: content: 用户输入的文本。 image_path: 可选，要分析的图片路径。 system_role: 可选，指定系统预设角色（如 'critic', 'scriptwriter'）。 model: 可选，覆盖默认模型。 keep_context: 是否保留本次对话到历史中（用于多轮迭代）。 返回: 模型的文本回复。 """ # 1. 构建消息列表 # 如果指定了 system_role，每次都把 System Prompt 放在最前面（Ollama 有时会忽略历史中的 System） temp_messages = [] if system_role: system_prompt = self.set_system_prompt(system_role) temp_messages.append({"role": "system", "content": system_prompt}) # 如果需要保持上下文，加入历史记录 if keep_context: temp_messages.extend([msg for msg in self.messages if msg['role'] != 'system']) # 避免重复 System else: # 仅本次对话，不依赖历史 temp_messages.append({"role": "user", "content": content}) if image_path: temp_messages[-1]["images"] = [self._image_to_base64(image_path)] # 2. 准备 Payload payload = { "model": model or self.default_model, "messages": temp_messages, "stream": False } # 3. 发送请求 try: response = requests.post(self.url, json=payload, timeout=300) # 增加超时时间，图片处理较慢 if response.status_code == 200: reply = response.json()["message"]["content"] # 4. 上下文管理：如果开启 keep_context，保存这次交互 if keep_context: self.add_message("user", content, image_path) self.add_message("assistant", reply) return reply else: raise Exception(f"HTTP {response.status_code}: {response.text}") except requests.exceptions.RequestException as e: return f"请求错误: {str(e)}" # --- 辅助函数：图片转 Base64 --- @staticmethod def _image_to_base64(image_path: str) -> str: with open(image_path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") # --- 简化接口（供外部直接调用，不需要维护对象状态）--- def simple_review(image_path: str, question: str = "", model: str = "qwen3-vl:8b") -> str: """ 简单的评审接口，用于快速测试。 """ agent = VisionAgent(default_model=model) # 如果没有提供具体问题，使用 Critic 角色的默认逻辑 prompt = question if question else "请评审这张图片。如果完美，请回复 PASS；否则请指出修改意见。" return agent.chat(content=prompt, image_path=image_path, system_role="critic") if __name__ == "__main__": # 测试代码 print("正在测试 Vision Agent...") # 测试 1: 纯文本 - 剧本家模式 agent = VisionAgent() prompt = agent.chat(content="我需要画一张人物插画，主角是来自猫娘乐园的巧克力，你可以发挥想象进行构图，但是要求画面明亮，清新。", system_role="scriptwriter", keep_context=False) print(f"生成的提示词: {prompt}") # 测试 2: 视觉评审模式 (请确保当前目录有一张 test.jpg 图片) result = simple_review("av.jpg", "{}".format(prompt)) print(result)但是到了后面，我才意识到，真正难于驾驭的，是LLM本身——因为即使你在系统提示词已经强制规定了输出格式，它有时候还是会给你乱来！对接与缝合有了两个调用的模块，我们现在可以开始对接和缝合工作了。但是，我发现导致程序不稳定和异常的，永远是Ollama那边运行的视觉语言模型，因为输出是真的不可控。有时候他给你输出的提示词夹杂了一大堆废话，ComfyUI那边就会画出奇奇怪怪的东西，还浪费了时间和算力。在提示词服从性这一块，虽然说Qwen系列确实在各种测评中表现突出，但是也是会给你耍脾气乱来的，这方面确实比较玄学。这是最终缝合起来调用的main.py:import os import time from PIL import Image from vision_agent import VisionAgent from comfyui_client import ComfyUIClient import random # 将约束列表定义在顶部，方便随时添加新点子 POSE_MODIFIERS = [ "【动作要求】：角色必须双手空空（不能拿任何物品），双手正在做某种表达情绪的手势（如伸懒腰、托腮、双手叉腰、整理头发等）。", "【环境互动】：角色的身体必须与环境发生物理接触（例如：背靠在大树上、上半身趴在咖啡桌上、慵懒地躺在花海中、单手扶着墙壁）。", "【动态抓拍】：这是一个极具动感的抓拍瞬间（例如：正在奔跑、在半空中轻盈跳跃、被风吹得失去平衡、听见呼唤后的转身回眸）。", "【日常情绪】：这是一个极其放松的日常瞬间（例如：慵懒地瘫坐在复古沙发里、双手抱膝蜷缩在角落、趴在窗台上看着外面发呆）。", "【独特视角】：视角非常独特（例如：从下往上的仰视、从上往下的俯视、透过窗户的窥视），角色的肢体语言需要配合这种视角展开。" ] def resize_image_for_vision(input_path: str, output_path: str, target_size=(960, 540)): """将生成的原图缩放并转换为 JPEG，以节省视觉大模型的处理时间和 token""" try: with Image.open(input_path) as img: # 使用 LANCZOS 重采样算法保证缩放后的画面质量 resized_img = img.resize(target_size, Image.Resampling.LANCZOS) # 转换为 RGB (如果原图是带有 Alpha 透明通道的 PNG，直接存 JPG 会报错) if resized_img.mode in ("RGBA", "P"): resized_img = resized_img.convert("RGB") # 降低一点质量存为 JPEG，体积更小 resized_img.save(output_path, format="JPEG", quality=85) return True except Exception as e: print(f"[-] 图片缩放失败: {e}") return False def run_creative_pipeline(base_idea: str): # 初始化客户端 agent = VisionAgent() comfy_client = ComfyUIClient() # 固定的负面提示词 negative_prompt = "blurry, worst quality, low quality, jpeg artifacts, signature, watermark, username, error, deformed hands, bad anatomy, extra limbs, poorly drawn hands, poorly drawn face, mutation, deformed, extra eyes, extra arms, extra legs, malformed limbs, fused fingers, too many fingers, long neck, cross-eyed, bad proportions, missing arms, missing legs, extra digit, fewer digits, cropped, multiple tails" max_retries = 3 # 最外层循环：如果 10 次拉扯失败，则从头开始新一轮 while True: # --- 核心改动：在这里进行随机突变 --- selected_modifier = random.choice(POSE_MODIFIERS) current_idea_with_modifier = f"{base_idea}\n\n{selected_modifier}" print("\n" + "="*60) print("[*] 开始全新的插画创作流程！") print("="*60) # 1. 剧本家构思初始提示词 print("[*] 正在请求剧本家构思初始画面...") current_prompt = agent.chat( content=f"请帮我构思一个插画提示词，核心需求是：{current_idea_with_modifier}", system_role="scriptwriter", keep_context=False ) print(f"\n[+] 初始提示词:\n{current_prompt}\n") # =================【新增：剧本家错误拦截】================= if current_prompt.startswith("请求错误:"): print("[!] Ollama 响应超时或出错。让显卡休息 5 秒钟，准备重试本轮抽卡...") time.sleep(5) continue # 跳过下面的所有步骤，直接回到 while True 开头重新抽卡 # ========================================================== success = False # 2. 内层循环：生成、缩放、评审（最多拉扯 10 次） for attempt in range(1, max_retries + 1): print("-" * 50) print(f"[*] 迭代回合: 第 {attempt}/{max_retries} 次尝试") # 定义本次生成的文件路径 original_img_path = f"output_highres_v{attempt}.png" resized_img_path = f"output_review_v{attempt}.jpg" # --- 步骤 A: 生成 1080p 原图 --- print("[-] 正在调用 ComfyUI 绘制高清插画...") comfy_client.generate_image( user_positive=current_prompt, user_negative=negative_prompt, save_path=original_img_path ) if not os.path.exists(original_img_path): print("[!] 图像生成似乎失败了，跳过本次审核...") continue # --- 步骤 B: 缩放图片 --- print("[-] 正在将原图缩放为 960x540 交给评审专家...") if not resize_image_for_vision(original_img_path, resized_img_path): continue # --- 步骤 C: 视觉评审 --- print("[-] 评审专家正在拿着放大镜检查画面细节...") critic_question = f"{current_prompt}" review_result = agent.chat( content=critic_question, image_path=resized_img_path, system_role="critic", keep_context=False # 每次评审都是独立客观的，不看历史 ) print(f"\n[+] 评审专家反馈:\n{review_result}\n") # =================【新增：评审专家错误拦截】================= if review_result.startswith("请求错误:"): print("[!] 评审阶段 Ollama 响应超时或出错，跳过本回合拉扯，直接进入下一次尝试...") time.sleep(5) continue # 跳过本回合剩余判断，直接进入 for attempt in range 的下一次循环 # ========================================================== # =================【新增：解析带有思维链的回复】================= # 找到 [RESULT] 标签并提取后面的内容 if "[RESULT]" in review_result: final_decision = review_result.split("[RESULT]")[-1].strip() else: # 兜底：如果模型忘了输出标签，强行把它的回复当做提示词 final_decision = review_result.strip() # ========================================================== # --- 步骤 D: 判断结果 --- # 判断切割后的内容是否仅仅是 PASS (加个长度限制防止误判) if "PASS" in final_decision.upper() and len(final_decision) < 10: print(f"[!!!] 恭喜！本次创作通过了魔鬼评审！") print(f"[!!!] 最终成品高清大图保留在: {original_img_path}") success = True break else: print("[-] 专家不满意，已提取新的修改版提示词，进入下一轮迭代...") # 将切出来的纯英文提示词喂给下一轮 current_prompt = final_decision # 3. 检查内层循环退出原因 if success: print("[*] 整个工作流完美结束。") print("[*] 准备从头开始下一张插画构思...") time.sleep(2) else: print(f"\n[!] 已经反复拉扯了 {max_retries} 次，模型陷入了死胡同。") print("[*] 放弃当前的创意方向，准备从头开始重新构思...") time.sleep(2) if __name__ == "__main__": # 你可以在这里输入你想要的最初始、最粗略的创意想法 my_idea = "我需要你帮我构思一张二次元风格插画。主角是来自猫娘乐园的巧克力，需要确保画面富有美感，颜色搭配准确，角色表现自然。你可以尝试异世界魔法、日常、科幻、幻想、欧式等多种题材，鼓励有意义的创新。" run_creative_pipeline(my_idea)这里调用的时候，我也发现一个问题：如果是放开不做任何限制，LLM就会懒得动脑，基本上都是描述角色站着，手里拿个东西这样的图，毫无动感，角色就像罚站。所以后面在Gemini的帮助下，设置了强制条件，不允许罚站了，必须要画面富有动感，有层次。（果然要AI来压迫AI，Gemini直接说是Qwen偷懒，难绷）事已至此，基本上使用Ollama运行VLM指挥ComfyUI画图就跑通了。不过我也发现一些问题，运行到后面，即使是作画崩坏了，Qwen3-VL 8B还是无脑给PASS了，即使在系统提示词加上了严苛的限制条件，依然如此。虽然说有时候也能打回，但是那已经是作画崩坏很严重的情况下了。对于MiniCPM-V 8B，情况就要好不少，很多画面细节的崩坏都能抓的出来，还算是可以。当然了，我这部分代码中安排的提示词是专门去画巧克力相关的插画的，如果需要去画别的角色，需要按需修改对应的提示词让模型生成对应的提示词。成果展示我就挑一些画的比较好的图展示一下吧，出好图的概率很低，真的说得上是百里挑一。当然，细看肯定还是有问题的，但是第一眼观感还可以。哎，目前AI这一块还是玄学多，不确定性还是有点大了。我现在也是驾驭不住，如果有更好的思路，欢迎在评论区交流。
利用Cloudflare Pages搭建媒体库实战前段时间极客湾因为公开揭露手机厂商的龌龊行径——媒体特调机内幕，导致其视频被全网下架。随后极客湾开放视频转载权限，全网随即上演了补档大战，来表达对厂商捂嘴的不满。我也是拿到了极客湾的视频原文件，看了一眼，好家伙，8.4GB，还是4K 60FPS的视频，这要分享还有点费流量。在平常的一次去吃午饭的路上，我走在校园的道路上，突然灵光一闪：如果我能把这个视频放到Cloudflare Pages，岂不美哉？！但是，我突然想起来了Cloudflare Pages免费版的限制：25MB单文件大小——这几乎无法放大型的视频。不过，有一句诗说得好：“山穷水尽疑无路，柳暗花明又一村。”那既然限制文件大小，那我把视频文件切开，每一个块符合大小限制，不就行了吗？！而免费版的Cloudflare Pages单个项目文件数量限制为20000个，也就是理论上可以放20000x25MB=5000000MB，也就是大约488GB——这已经比一个500G机械硬盘的实际可用空间465GB还大了！最重要的是，Cloudflare并不限制项目数量，理论上对于个人来说约等于无限容量了！说干就干，我随即开始了理论的验证。验证想法可行性通过查阅资料，我了解到现代的浏览器可以利用hls.js实现对切分后视频的播放，目前早就有了成熟的方案，且各种视频平台就在用。需要的视频形态是一个.m3u8的视频索引文件，外加一大堆视频切割的碎片.ts文件，而把普通的视频转成这样的工作，ffmpeg就能干！这下不就把技术路线跑通了嘛，赶紧验证一下想法：ffmpeg -hwaccel cuda -hwaccel_output_format cuda -i 零售机_游戏性能大横评_2026.mp4 \ -c:v h264_nvenc -preset p6 -profile:v high -level:v 5.2 \ -rc vbr -b:v 18M -maxrate 24M -bufsize 36M \ -g 180 -keyint_min 180 -sc_threshold 0 \ -c:a aac -b:a 192k \ -hls_time 3 -hls_playlist_type vod -hls_segment_filename "chunk_%04d.ts" \ video-0.m3u8通过调整每个块的时间，确保每个块都不超过25MB，符合Cloudflare Pages的文件大小限制。然后再写一个html用于播放： 极客湾-手机2026游戏性能大横评（补档）4K 60FPS 准备加载视频... 把转化后的视频碎片放到html同目录下的video-data文件夹，本地启动Nginx设定好网站目录，浏览器打开，果然可以正常播放。这下就好玩了！这里还有极客湾的测试视频，也是我方案验证阶段的效果，可以去看看：极客湾-手机2026游戏性能大横评（补档）4K 60FPS重要提醒需要注意的是，执行下面的程序之前，需要把你的Cloudflare账户的ID和令牌导入到命令行环境中，例如：export CLOUDFLARE_API_TOKEN="www.nekopara.uk" export CLOUDFLARE_ACCOUNT_ID="www.nekopara.uk"请替换为你自己的真实 Token 和 Account ID。其中创建API Token时，正确的选择权限选项是：账户 > Cloudflare Pages > 编辑还有一个最重要的提醒，这些操作其实不是正常的Pages产品使用方式，存在账户封禁或限制使用等风险，请务必使用新创建的独立账号进行操作，以免造成不必要的损失！尝试把成果上传Pages但是，在这一步，我就踩坑了，还是踩了大坑！正当我尝试把转化好的文件夹通过Cloudflare Pages的部署页面上传时，我遇到了大坑：上传大概进行半个小时后，网页疯狂弹出API 403报错。一开始我还以为是网络的问题，开启了魔法网络环境，结果问题依旧。这时我意识到，上传的API应该是有临时Token超时机制的，当文件数量太多，体积太大，上传的时间变长了，就会触发这个问题。似乎这一切无解了？我能做出来符合文件大小限制的静态网页目录，却无法完整上传。但是，在看了Cloudflare的提示后，我发现了一个可能的路径：用wrangler进行部署——这个可以支持上传20000个文件的项目。而网页上传限制为1000个文件。于是乎，我就安装了wrangler进行尝试：因为wrangler需要nodejs环境，我们先安装nodejs:pacman -S nodejs npm然后在安装wrangler，系统级安装命令如下：npm install -g wrangler当然，也可以在当前目录进行安装，这个更推荐：npx wrangler安装完成后，我们来进行上传：npx wrangler pages deploy ./page --project-name geekwan因为网速比较慢，我就留着电脑上传，自己去吃饭了。结果回来一看还是报错403，那看来这个接口不但是网页会存在时间限制，用命令行工具也会。难道说。。。真的无解了吗？在LLM的帮助下，我快速实现了这个测试脚本：import os import shutil import subprocess import time from pathlib import Path # ================= 配置区域 ================= # 需要执行的特定 Bash 命令 (例如：处理当前目录下的所有文件) # 注意：命令应该设计为处理当前源文件夹中的文件，或者你需要根据实际逻辑调整命令参数 BASH_COMMAND = "npx wrangler pages deploy ./page --project-name geekwan" # 源文件夹路径 SOURCE_DIR = Path("/run/media/chocola/3T-DATA02/Projects/Pages/tmp") # 目标文件夹路径 (如果不存在会自动创建) TARGET_DIR = Path("/run/media/chocola/3T-DATA02/Projects/Pages/page/video-data") # 每次循环移动的文件数量 FILES_PER_BATCH = 30 # 最大循环次数 (设置为 None 表示直到源文件夹为空为止) MAX_LOOPS = None # =========================================== def run_bash_command(cmd): """执行 Bash 命令并返回是否成功""" print(f"\n[执行命令] {cmd}") try: # shell=True 允许运行带管道、重定向等的复杂 bash 命令 result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) print("[命令输出]:", result.stdout.strip()) return True except subprocess.CalledProcessError as e: print(f"[命令失败]: {e}") if e.stderr: print("[错误信息]:", e.stderr.strip()) return False def move_files(src, dst, count): """从 src 移动 count 个文件到 dst""" # 获取源文件夹中的所有文件 (排除子文件夹，只处理文件) # listdir() 顺序不固定，如果需要特定顺序(如按时间)，请使用 sorted(os.listdir(...), key=...) files = [f for f in src.iterdir() if f.is_file()] if not files: return 0 # 限制移动数量 files_to_move = files[:count] moved_count = 0 for file_path in files_to_move: target_path = dst / file_path.name try: shutil.move(str(file_path), str(target_path)) print(f"[已移动] {file_path.name} -> {dst}") moved_count += 1 except Exception as e: print(f"[移动失败] {file_path.name}: {e}") return moved_count def main(): # 确保目录存在 SOURCE_DIR.mkdir(parents=True, exist_ok=True) TARGET_DIR.mkdir(parents=True, exist_ok=True) loop_count = 0 while True: # 检查是否达到最大循环次数 if MAX_LOOPS is not None and loop_count >= MAX_LOOPS: print("\n[结束] 已达到最大循环次数。") break # 检查源文件夹是否为空 current_files = [f for f in SOURCE_DIR.iterdir() if f.is_file()] if not current_files: print(f"\n=== 第 {loop_count + 1} 轮循环 ===") success = run_bash_command(BASH_COMMAND) print("\n[结束] 源文件夹已空，任务完成。") break print(f"\n=== 第 {loop_count + 1} 轮循环 ===") # 1. 执行特定的 Bash 命令 # 假设命令是处理当前源文件夹中的数据。如果命令依赖于具体文件名， # 你可能需要在命令中动态插入文件名，或者让命令自行发现文件。 success = run_bash_command(BASH_COMMAND) if not success: print("[警告] 命令执行失败，跳过本次文件移动，稍后重试或退出？(此处选择继续下一轮，也可改为 break)") # 如果命令失败至关重要，可以取消下面这行的注释来停止脚本 # break time.sleep(2) # 失败后稍微等待 loop_count += 1 continue # 2. 移动文件 moved = move_files(SOURCE_DIR, TARGET_DIR, FILES_PER_BATCH) if moved == 0: print("没有文件可移动。") loop_count += 1 # 可选：每轮循环之间的间隔 # time.sleep(1) if __name__ == "__main__": main()我先观察好大概每个切片多大，设置好每次移动的文件数量，就让程序每执行一次推送命令之前，移动一部分文件回到目标文件夹。当命令运行结束后，再次搬运内容，直到临时文件夹搬空。经过验证，方案完全可行，我也成功把页面部署到了Cloudflare Pages，接下来我就该考虑如何让这个部署行为自动化了。实现思路当我确定搞自动化的时候，我就已经想好了应该怎么安排了：首先，写一套配置文件驱动的前端UI静态文件，通过传入文件目录结构的配置文件，让这个UI能够导航并浏览目录的内容。一个模块负责转化，把媒体库大于25MB的内容压制到25MB以内，按照原始目录结构整理，并且整理成目录结构配置文件供前端UI使用。另一个模块负责把文件进行上传和部署，根据预设的规则调配好每次部署的文件大小，避免上传时间超时导致403。前端实现不得不说 Gemini 3 Pro 确实是目前前端的神，几下就弄出来了简洁高效的代码。但光有代码还不够，得把逻辑理顺，毕竟我们要搞的是一个能“自动导航”的媒体库，而不是死板的单页播放。核心思路其实很清晰：动态加载 + 流式播放。目录结构的动态加载既然文件是切分后上传的，传统的文件夹浏览肯定行不通，浏览器可不会帮你把几千个 .ts 碎片渲染成列表。我们需要一个“地图”，也就是之前提到的目录结构配置文件（比如 index.json）。这个 JSON 文件由后端脚本生成，记录了所有可用媒体的元数据：图片路径，音频路径，视频的标题和对应的 .m3u8 索引路径，甚至还包括字幕文件的路径。前端的任务就是 fetch 这个 JSON，然后动态渲染出文件列表。我写了一个简单的递归渲染逻辑，支持多级目录嵌套，这样哪怕你的媒体库深不见底，也能一层层点进去：async function fetchIndex() { try { const response = await fetch('./index.json'); const data = await response.json(); const defaultItem = findDefaultMedia(data); if (defaultItem) { playMedia(defaultItem); } directoryStack = [{ name: "媒体资产库", children: data }]; renderCurrentDirectory(); } catch (error) { console.error("加载文件索引失败:", error); fileTreeEl.innerHTML = ' 索引加载失败，请确保使用本地服务器运行 '; } }这样一来，用户看到的就是一个类似播放器文件列表的整洁界面，点击文件夹进入子目录，点击视频直接开看。HLS 视频流与字幕的完美融合重头戏来了：如何在一个页面里既流畅播放切分的 .ts 视频流，又能挂载外挂字幕（.vtt）？hls.js 的强大之处就在于它对字幕原生的支持。我们不需要自己解析字幕文件，只需要在配置 Hls 实例时，把字幕轨道喂给它就行。我在之前的播放逻辑上做了升级，增加了一个字幕轨道的管理器。当视频加载时，它会同时拉取字幕文件，并允许用户在播放器原生控制条里切换字幕语言或关闭字幕：function playMedia(item) { const { type, src, subtitles } = item; placeholder.style.display = 'none'; videoViewer.style.display = 'none'; audioViewer.style.display = 'none'; imageViewer.style.display = 'none'; videoViewer.pause(); audioViewer.pause(); if (hls) { hls.destroy(); hls = null; } if (type === 'video') { videoViewer.style.display = 'block'; videoViewer.querySelectorAll('track').forEach(t => t.remove()); if (subtitles && subtitles.length > 0) { subtitles.forEach(sub => { const track = document.createElement('track'); track.kind = 'subtitles'; track.label = sub.label; track.srclang = sub.srclang; track.src = sub.src; videoViewer.appendChild(track); if (sub.default) { track.track.mode = 'showing'; } }); } loadVideo(src); } else if (type === 'audio') { audioViewer.style.display = 'block'; audioViewer.src = src; audioViewer.play().catch(e => console.log(e)); } else if (type === 'image') { imageViewer.style.display = 'block'; imageViewer.src = src; } } function loadVideo(src) { if (Hls.isSupported() && src.endsWith('.m3u8')) { hls = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 }); hls.loadSource(src); hls.attachMedia(videoViewer); hls.on(Hls.Events.MANIFEST_PARSED, () => { videoViewer.play().catch(e => console.log(e)); }); } else { videoViewer.src = src; videoViewer.play().catch(e => console.log(e)); } }最后的拼图至此，前端的骨架已经搭好了：入口：加载 index.json，渲染出漂亮的媒体库网格。导航：点击文件夹动态刷新列表，实现无限层级浏览。播放：点击视频，hls.js 接管，自动加载切片流和内嵌字幕轨道。剩下的就是加一点 CSS 动画让切换更顺滑，再适配一下移动端的触摸手势。看着原本躺在硬盘里吃灰的几百 GB 视频，现在变成了一个个可以在云端随意点播、带字幕、还能倍速播放的在线资源，甚至画质还能吊打B站！ MediaPages ☰ 媒体库 媒体资产库 Powered by MediaPages 请在左侧选择文件进行预览 接下来，就要把这套前端逻辑和之前的自动化上传脚本彻底打通，实现“丢进文件夹，自动变网站”的终极梦想了。转换模块这个模块也是开发过程中碰壁比较多的模块，因为涉及媒体的转化和大小控制，一不小心就超了，需要反复调试。在调试的过程中，我发现对于1080P 60帧的高质量番剧，每个切片6秒，180帧一个关键帧是很不错的选择。既可以保证每个文件有一定的大小不至于太过于碎片化占用上传文件数，又可以确保当番剧到了画面变化较大的地方，单个切片仍然可以控制在25MB的大小限制内。对应的参数为hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '6', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ]而对于4K，则应该设置每个切片3秒，同样是180帧一个关键帧。hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '24M', '-bufsize', '36M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ]注意'-hls_time', '3'这里，你也可以根据你的需要去尝试不同的参数，以及码率的设置部分'-b:v', '18M', '-maxrate', '24M', '-bufsize', '36M',，我这里只是提供一个建议性的数值。毕竟是小工具，代码量不大，自己改改就行。程序的大体逻辑就是，对于文件进行分别的处理，小于25MB的部分直接复制，如果是MKV的视频转化成浏览器支持的MP4视频。而大于25MB的音频和图片文件则直接进行压缩，确保大小符合要求。视频就进行上面说的切分转换。具体的音频转化函数：def compress_audio(input_path, output_path): logging.info(f"Starting precise audio compression: {input_path}") temp_output = output_path + '.tmp' try: duration = get_audio_duration(input_path) orig_bitrate = get_audio_bitrate(input_path) if duration <= 0: duration = 60 safe_target_bytes = 23.5 * MB calculated_kbps = int((safe_target_bytes * 8) / (duration * 1000)) target_kbps = calculated_kbps if orig_bitrate > 0: target_kbps = min(target_kbps, orig_bitrate) target_kbps = min(target_kbps, 320) target_kbps = max(target_kbps, 32) while True: cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:a', 'libmp3lame', '-b:a', f'{target_kbps}k', '-f', 'mp3', temp_output ] subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL) final_size = os.path.getsize(temp_output) if final_size <= MAX_SIZE or target_kbps <= 32: shutil.move(temp_output, output_path) break target_kbps = int(target_kbps * 0.8) if target_kbps < 32: target_kbps = 32 except Exception as e: logging.error(f"Error compressing audio {input_path}: {e}") shutil.copy2(input_path, output_path) finally: if os.path.exists(temp_output): os.remove(temp_output)小体积视频转化函数：def convert_video_to_mp4(input_path, output_path): """用于处理小于 MAX_SIZE 的视频，仅进行标准 MP4 转码以确保浏览器兼容性""" logging.info(f"Standardizing small video to MP4: {input_path}") target_output = os.path.splitext(output_path)[0] + '.mp4' hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-tune', 'hq', '-profile:v', 'high', '-cq', '22', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) return True except subprocess.CalledProcessError: sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:v', 'libx264', '-preset', 'slow', '-crf', '22', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) return True except Exception as e: logging.error(f"Failed to convert {input_path}: {e}") if os.path.exists(target_output): os.remove(target_output) return False大体积视频转化函数：def compress_video(input_path, output_dir): """采用 HLS 切片方式处理视频，支持全格式和 CUDA 硬件加速""" input_dir_path = os.path.dirname(input_path) base_name = os.path.splitext(os.path.basename(input_path))[0] video_folder = os.path.join(output_dir, base_name) os.makedirs(video_folder, exist_ok=True) logging.info(f"Starting high-performance HLS processing: {input_path}") output_playlist = os.path.join(video_folder, f"{base_name}-0.m3u8") hls_segments = os.path.join(video_folder, "chunk_%04d.ts") # 基于用户提供的高性能 CUDA 优化参数 hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '6', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.warning(f"CUDA failed, falling back to CPU for {base_name}. Error: {e}") sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale=format=yuv420p', '-c:v', 'libx264', '-preset', 'medium', '-crf', '20', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) # 智能字幕处理 (保持原逻辑不变) process_subtitles(input_dir_path, base_name, video_folder, input_path)以及对应可能的字幕处理：def process_subtitles(input_dir_path, base_name, video_folder, input_video_path): """提取并转换关联字幕""" try: source_items = os.listdir(input_dir_path) for item in source_items: if item.startswith(base_name) and item.lower().endswith(tuple(SUBTITLE_EXTENSIONS)): if item == os.path.basename(input_video_path): continue subtitle_file = os.path.join(input_dir_path, item) sub_name_without_ext = os.path.splitext(item)[0] suffix = re.sub(r'^[\.\-\_]+', '', sub_name_without_ext[len(base_name):]) if not suffix: suffix = "Default" vtt_filename = f"{base_name}_{suffix}.vtt" vtt_path = os.path.join(video_folder, vtt_filename) if item.lower().endswith('.ass'): filtered_subtitle = os.path.join(video_folder, f"temp_{suffix}.ass") try: with open(subtitle_file, 'r', encoding='utf-8') as f_in: lines = f_in.readlines() with open(filtered_subtitle, 'w', encoding='utf-8') as f_out: for line in lines: if line.startswith('Dialogue:'): parts = line.split(',', 9) if len(parts) >= 4: style_lower = parts[3].lower() words = re.sub(r'[^a-z0-9]', ' ', style_lower).split() if any(x in words for x in ['jp', 'jpn']) or '日' in style_lower or 'romaji' in style_lower: continue f_out.write(line) subprocess.run(['ffmpeg', '-y', '-i', filtered_subtitle, vtt_path], check=True, stderr=subprocess.DEVNULL) finally: if os.path.exists(filtered_subtitle): os.remove(filtered_subtitle) else: subprocess.run(['ffmpeg', '-y', '-i', subtitle_file, vtt_path], check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.error(f"Subtitle error for {base_name}: {e}")字幕处理主要是针对动画的，去除日文字幕，防止转化为VTT字幕后混乱。整理清楚逻辑后，我们就得到了convert.py:import os import sys import shutil import subprocess import argparse import json from PIL import Image import math import re import logging # 配置日志 logging.basicConfig( level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) # 常量定义 MB = 1024 * 1024 MAX_SIZE = 24 * MB # 24MB # 支持的媒体类型 IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg'} VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.webm'} SUBTITLE_EXTENSIONS = {'.ass', '.srt', '.vtt'} def get_audio_duration(file_path): cmd = [ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', file_path ] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if result.returncode == 0: return float(result.stdout.strip()) return 60 def get_audio_bitrate(file_path): cmd = [ 'ffprobe', '-v', 'error', '-show_entries', 'stream=bit_rate', '-of', 'default=noprint_wrappers=1:nokey=1', file_path ] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if result.returncode == 0: bitrate_str = result.stdout.strip().split('\n')[0] if bitrate_str and bitrate_str != 'N/A': return int(float(bitrate_str) / 1000) return 0 def compress_image(input_path, output_path): logging.info(f"Starting image compression: {input_path}") try: img = Image.open(input_path) if img.mode != 'RGB': img = img.convert('RGB') ext = os.path.splitext(output_path)[1].lower() if ext not in {'.jpg', '.jpeg', '.png', '.gif', '.webp'}: output_path = os.path.splitext(output_path)[0] + '.webp' quality = 95 while quality >= 20: img.save(output_path, 'WEBP', quality=quality) if os.path.getsize(output_path) <= MAX_SIZE: return quality -= 5 scale = 0.9 while scale > 0.3: new_size = (int(img.width * scale), int(img.height * scale)) img_resized = img.resize(new_size, Image.LANCZOS) quality = 95 while quality >= 20: img_resized.save(output_path, 'WEBP', quality=quality) if os.path.getsize(output_path) <= MAX_SIZE: return quality -= 5 scale -= 0.1 img.save(output_path, 'WEBP', quality=20) except Exception as e: logging.error(f"Image compression error: {e}") shutil.copy2(input_path, output_path) def compress_audio(input_path, output_path): logging.info(f"Starting precise audio compression: {input_path}") temp_output = output_path + '.tmp' try: duration = get_audio_duration(input_path) orig_bitrate = get_audio_bitrate(input_path) if duration <= 0: duration = 60 safe_target_bytes = 23.5 * MB calculated_kbps = int((safe_target_bytes * 8) / (duration * 1000)) target_kbps = calculated_kbps if orig_bitrate > 0: target_kbps = min(target_kbps, orig_bitrate) target_kbps = min(target_kbps, 320) target_kbps = max(target_kbps, 32) while True: cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:a', 'libmp3lame', '-b:a', f'{target_kbps}k', '-f', 'mp3', temp_output ] subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL) final_size = os.path.getsize(temp_output) if final_size <= MAX_SIZE or target_kbps <= 32: shutil.move(temp_output, output_path) break target_kbps = int(target_kbps * 0.8) if target_kbps < 32: target_kbps = 32 except Exception as e: logging.error(f"Error compressing audio {input_path}: {e}") shutil.copy2(input_path, output_path) finally: if os.path.exists(temp_output): os.remove(temp_output) def convert_video_to_mp4(input_path, output_path): """用于处理小于 MAX_SIZE 的视频，仅进行标准 MP4 转码以确保浏览器兼容性""" logging.info(f"Standardizing small video to MP4: {input_path}") target_output = os.path.splitext(output_path)[0] + '.mp4' hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-tune', 'hq', '-profile:v', 'high', '-cq', '22', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) return True except subprocess.CalledProcessError: sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:v', 'libx264', '-preset', 'slow', '-crf', '22', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) return True except Exception as e: logging.error(f"Failed to convert {input_path}: {e}") if os.path.exists(target_output): os.remove(target_output) return False def compress_video(input_path, output_dir): """采用 HLS 切片方式处理视频，支持全格式和 CUDA 硬件加速""" input_dir_path = os.path.dirname(input_path) base_name = os.path.splitext(os.path.basename(input_path))[0] video_folder = os.path.join(output_dir, base_name) os.makedirs(video_folder, exist_ok=True) logging.info(f"Starting high-performance HLS processing: {input_path}") output_playlist = os.path.join(video_folder, f"{base_name}-0.m3u8") hls_segments = os.path.join(video_folder, "chunk_%04d.ts") # 基于用户提供的高性能 CUDA 优化参数 hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.warning(f"CUDA failed, falling back to CPU for {base_name}. Error: {e}") sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale=format=yuv420p', '-c:v', 'libx264', '-preset', 'medium', '-crf', '20', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) # 智能字幕处理 (保持原逻辑不变) process_subtitles(input_dir_path, base_name, video_folder, input_path) def process_subtitles(input_dir_path, base_name, video_folder, input_video_path): """提取并转换关联字幕""" try: source_items = os.listdir(input_dir_path) for item in source_items: if item.startswith(base_name) and item.lower().endswith(tuple(SUBTITLE_EXTENSIONS)): if item == os.path.basename(input_video_path): continue subtitle_file = os.path.join(input_dir_path, item) sub_name_without_ext = os.path.splitext(item)[0] suffix = re.sub(r'^[\.\-\_]+', '', sub_name_without_ext[len(base_name):]) if not suffix: suffix = "Default" vtt_filename = f"{base_name}_{suffix}.vtt" vtt_path = os.path.join(video_folder, vtt_filename) if item.lower().endswith('.ass'): filtered_subtitle = os.path.join(video_folder, f"temp_{suffix}.ass") try: with open(subtitle_file, 'r', encoding='utf-8') as f_in: lines = f_in.readlines() with open(filtered_subtitle, 'w', encoding='utf-8') as f_out: for line in lines: if line.startswith('Dialogue:'): parts = line.split(',', 9) if len(parts) >= 4: style_lower = parts[3].lower() words = re.sub(r'[^a-z0-9]', ' ', style_lower).split() if any(x in words for x in ['jp', 'jpn']) or '日' in style_lower or 'romaji' in style_lower: continue f_out.write(line) subprocess.run(['ffmpeg', '-y', '-i', filtered_subtitle, vtt_path], check=True, stderr=subprocess.DEVNULL) finally: if os.path.exists(filtered_subtitle): os.remove(filtered_subtitle) else: subprocess.run(['ffmpeg', '-y', '-i', subtitle_file, vtt_path], check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.error(f"Subtitle error for {base_name}: {e}") def build_index_tree(dir_path, output_root): children = [] try: items = sorted(os.listdir(dir_path)) except Exception: return children for item in items: full_path = os.path.join(dir_path, item) raw_rel_path = os.path.relpath(full_path, output_root).replace('\\', '/') web_src = f"./{raw_rel_path}" if os.path.isdir(full_path): try: folder_items = os.listdir(full_path) except: folder_items = [] m3u8_files = [f for f in folder_items if f.lower().endswith('.m3u8')] if m3u8_files: m3u8_file = m3u8_files[0] m3u8_rel = os.path.relpath(os.path.join(full_path, m3u8_file), output_root).replace('\\', '/') subtitles = [] vtt_files = [f for f in folder_items if f.lower().endswith('.vtt')] base_name = item for vtt in vtt_files: vtt_name_no_ext = os.path.splitext(vtt)[0] suffix = vtt_name_no_ext[len(base_name)+1:] if vtt_name_no_ext.startswith(base_name + "_") else vtt_name_no_ext label = suffix suffix_lower = suffix.lower() if any(x in suffix_lower for x in ['sc', 'chs', 'zh']): srclang, default = "zh", True elif any(x in suffix_lower for x in ['tc', 'cht', 'hant']): srclang, default = "zh-Hant", False else: srclang, default = "und", False vtt_rel = os.path.relpath(os.path.join(full_path, vtt), output_root).replace('\\', '/') subtitles.append({ "label": label, "srclang": srclang, "src": f"./{vtt_rel}", "default": default }) video_node = {"name": item, "type": "video", "src": f"./{m3u8_rel}"} if subtitles: video_node["subtitles"] = subtitles children.append(video_node) else: folder_children = build_index_tree(full_path, output_root) if folder_children: children.append({"name": item, "type": "folder", "children": folder_children}) else: ext = os.path.splitext(item)[1].lower() if item == 'index.json': continue if ext in AUDIO_EXTENSIONS: children.append({"name": item, "type": "audio", "src": web_src}) elif ext in IMAGE_EXTENSIONS: children.append({"name": item, "type": "image", "src": web_src}) elif ext in VIDEO_EXTENSIONS: # 注意：如果已经被 HLS 处理，它将作为文件夹存在，这里处理的是未被转码的小视频 children.append({"name": item, "type": "video", "src": web_src}) return children def generate_index(output_dir, set_default=True): logging.info("Generating nested JSON index...") root_nodes = build_index_tree(output_dir, output_dir) if set_default: for node in root_nodes: if node.get("type") == "video": node["isDefault"] = True break index_path = os.path.join(output_dir, 'index.json') with open(index_path, 'w', encoding='utf-8') as f: json.dump(root_nodes, f, indent=2, ensure_ascii=False) def main(): parser = argparse.ArgumentParser(description='Universal Media Processing Tool') parser.add_argument('input_dir', help='Input directory path') parser.add_argument('output_dir', help='Output directory path') parser.add_argument('--no-default', action='store_true') args = parser.parse_args() os.makedirs(args.output_dir, exist_ok=True) logging.info("--- Media Processing Tool Started ---") for root, _, files in os.walk(args.input_dir): rel_path = os.path.relpath(root, args.input_dir) out_root = os.path.join(args.output_dir, rel_path) os.makedirs(out_root, exist_ok=True) for file in files: input_path = os.path.join(root, file) output_path = os.path.join(out_root, file) ext = os.path.splitext(file)[1].lower() file_size = os.path.getsize(input_path) # 检查是否已处理过 (MP4 转换或 HLS 文件夹) if ext in VIDEO_EXTENSIONS: base_name = os.path.splitext(file)[0] if os.path.exists(os.path.join(out_root, base_name, f"{base_name}-0.m3u8")): continue # --- 核心处理逻辑 --- if ext in IMAGE_EXTENSIONS: if file_size <= MAX_SIZE: shutil.copy2(input_path, output_path) else: compress_image(input_path, output_path) elif ext in AUDIO_EXTENSIONS: if file_size <= MAX_SIZE: shutil.copy2(input_path, output_path) else: compress_audio(input_path, output_path) elif ext in VIDEO_EXTENSIONS: # 无论 MKV 还是 MP4，大于 MAX_SIZE 都进行 HLS 高性能切片 if file_size > MAX_SIZE: compress_video(input_path, out_root) else: # 小视频统一转成兼容性好的 MP4 convert_video_to_mp4(input_path, output_path) elif ext in SUBTITLE_EXTENSIONS: if ext != '.ass': # .ass 由 compress_video 内部处理，此处仅复制 srt/vtt shutil.copy2(input_path, output_path) # 清理残留 ass for root_dir, _, out_files in os.walk(args.output_dir): for f in out_files: if f.lower().endswith('.ass'): try: os.remove(os.path.join(root_dir, f)) except: pass generate_index(args.output_dir, set_default=not args.no_default) logging.info("--- Processing Complete ---") if __name__ == "__main__": main()这个文件是可以单独调用的，具体的方法如下：python convert.py '/run/media/chocola/3T-DATA02/[hyakuhuyu&VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu 3rd Season [Ma10p_1080p]/' '/run/media/chocola/3T-DATA02/wwwroot/MediaPages/test2'第一个目录是来源目录，第二个目录是输出目录。以及还有一个参数--no-default，如果加上则不会选取文件夹根目录第一个文件作为打开页面时的默认展示媒体。上传模块这个部分就相对简单了，无非就是最开始那个测试demo的增强版。但是我稍微优化了一下，因为考虑到每个媒体库处理后的结果肯定都不一样，所以我设计成了计算文件大小，按照设置的每次上传的大小分批次移动文件。思路大概是这样的：设置一个缓存文件夹，先把目标文件夹的内容搬到小于设置的阈值大小，执行一次上传，上传完成后从缓存文件夹搬回来阈值大小内的文件，再次执行上传，如此往复，直到上传完成。程序大概就长这样：upload.pyimport os import shutil import subprocess import argparse from pathlib import Path def get_directory_size_and_files(directory: Path): """ 计算目录下所有文件的总大小，并返回文件列表及其大小信息。 """ total_size = 0 file_list = [] for root, dirs, files in os.walk(directory): for name in files: filepath = Path(root) / name try: size = filepath.stat().st_size total_size += size rel_path = filepath.relative_to(directory) file_list.append((rel_path, size, filepath)) except OSError as e: print(f"无法获取文件大小 {filepath}: {e}") # 按文件大小降序排列 file_list.sort(key=lambda x: x[1], reverse=True) return total_size, file_list def move_files_with_structure(file_list, source_base: Path, dest_base: Path): """ 将文件列表中的文件连同其目录结构一起移动到新位置。 """ for i, (rel_path, size, _) in enumerate(file_list): src_file = source_base / rel_path dest_file = dest_base / rel_path if src_file.exists(): dest_file.parent.mkdir(parents=True, exist_ok=True) try: shutil.move(str(src_file), str(dest_file)) if (i + 1) % 500 == 0 or (i + 1) == len(file_list): print(f" -> 已移动 {i + 1}/{len(file_list)} 个文件...") except Exception as e: print(f"[移动失败] {src_file}: {e}") print(f" -> 文件移动操作完成。") def run_bash_command(cmd): """执行 Bash 命令并返回是否成功""" print(f"\n[执行命令] {cmd}") print(" -> 命令开始执行...") try: result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) print(" -> 命令执行完毕。") print("[命令输出]:", result.stdout.strip()) return True except subprocess.CalledProcessError as e: print(f"[命令失败]: {e}") if e.stderr: print("[错误信息]:", e.stderr.strip()) return False def run_deploy(target_dir, cache_dir, bash_cmd, threshold, resume=False): """ 接收外部传入的路径和参数执行部署逻辑 """ print(f"目标目录: {target_dir}") print(f"缓存目录: {cache_dir}") print(f"大小阈值: {threshold / (1024**2):.2f} MB") print(f"断点续传: {'开启' if resume else '关闭'}\n") if resume: # 断点续传模式：直接对目标文件夹当前的内容重试部署，跳过清空阶段 print("--- 恢复模式：尝试重新部署当前目标目录中的文件 ---") success = run_bash_command(bash_cmd) if not success: print("[错误] 恢复模式下部署重试失败，程序终止。请检查网络或重新运行。") return print(" -> 恢复部署成功，直接进入阶段二，继续从缓存搬运文件...") else: # 第一阶段：正常模式，将所有超出阈值的文件搬到缓存 print("--- 阶段一：清空目标目录 ---") initial_size, initial_files = get_directory_size_and_files(target_dir) print(f"初始目录总大小: {initial_size / (1024**2):.2f} MB") if initial_size > threshold: files_to_move_initially = [] size_moved = 0 min_size_to_free = initial_size - threshold print(f"需要释放至少 {min_size_to_free / (1024**2):.2f} MB 的空间") for rel_path, size, full_path in initial_files: if size_moved < min_size_to_free: files_to_move_initially.append((rel_path, size, full_path)) size_moved += size else: break print(f"准备搬运 {len(files_to_move_initially)} 个文件到缓存") move_files_with_structure(files_to_move_initially, target_dir, cache_dir) remaining_size_after_initial_move, _ = get_directory_size_and_files(target_dir) print(f"搬运后，目标目录剩余大小: {remaining_size_after_initial_move / (1024**2):.2f} MB") # 执行第一次命令 print("\n[步骤] 执行首次部署命令...") success = run_bash_command(bash_cmd) if not success: print("[错误] 首次命令执行失败，程序终止。") return else: print("初始大小已满足阈值要求，无需搬运。") print("\n[步骤] 执行初始部署命令...") success = run_bash_command(bash_cmd) if not success: print("[错误] 初始命令执行失败，程序终止。") return # 第二阶段：从缓存分批取回文件并执行命令 print("\n--- 阶段二：从缓存取回文件并部署 ---") loop_count = 0 while True: loop_count += 1 print(f"\n--- 取回循环 #{loop_count} ---") # 检查缓存是否还有文件 cached_size, cached_files = get_directory_size_and_files(cache_dir) if cached_size == 0: print("缓存目录已空，进入最终步骤。") break # 从缓存中选取不超过阈值的文件 files_to_return = [] size_to_return = 0 for rel_path, size, full_path in cached_files: if size_to_return + size <= threshold: files_to_return.append((rel_path, size, full_path)) size_to_return += size else: if len(files_to_return) == 0: print(f" -> 警告: 单个文件超过阈值，但仍将其取出。") files_to_return.append((rel_path, size, full_path)) size_to_return = size break print(f" -> 从缓存取回 {len(files_to_return)} 个文件，总大小 {size_to_return / (1024**2):.2f} MB") move_files_with_structure(files_to_return, cache_dir, target_dir) # 执行命令 print(f" -> 执行部署命令...") success = run_bash_command(bash_cmd) if not success: print("[错误] 命令执行失败，程序终止。如果是因为网络波动，可以加上 -r 参数重新运行来续传。") return # 第三阶段：当缓存为空时，执行最终命令 print("\n--- 阶段三：完成任务 ---") print("\n[最终步骤] 执行最终部署命令，此时所有文件都已回到目标目录。") success = run_bash_command(bash_cmd) if not success: print("[错误] 最终命令执行失败。") return print("\n任务完成！所有文件已处理并完成最终部署。") def main(): parser = argparse.ArgumentParser(description="Cloudflare Pages 分批上传部署工具") parser.add_argument("-t", "--target", required=True, help="目标文件夹目录 (需要部署的内容所在路径)") parser.add_argument("-c", "--cache", required=True, help="缓存文件夹目录 (用于上传过程中的中转存放)") parser.add_argument("-p", "--project", required=True, help="远端 Cloudflare Pages 项目名称") parser.add_argument("--threshold", type=int, default=512, help="单次上传阈值(MB)，默认 512") # 新增的断点续传参数 parser.add_argument("-r", "--resume", action="store_true", help="启用断点续传：优先上传当前目标文件夹内剩余的文件，再继续从缓存搬运") args = parser.parse_args() target_dir = Path(args.target).resolve() cache_dir = Path(args.cache).resolve() if not target_dir.exists(): print(f"[错误] 目标目录不存在: {target_dir}") sys.exit(1) # 确保缓存目录存在 cache_dir.mkdir(parents=True, exist_ok=True) # 动态拼接 wrangler 部署命令 bash_cmd = f"npx wrangler pages deploy {target_dir} --project-name {args.project}" threshold_bytes = args.threshold * 1024 * 1024 print(f"--- 启动独立部署脚本 ---") print(f"远端项目: {args.project}") run_deploy(target_dir, cache_dir, bash_cmd, threshold_bytes, resume=args.resume) if __name__ == "__main__": main()和convert.py，这个upload.py也是可以进行单独调用的，调用实例：python upload.py -t "/run/media/chocola/3T-DATA02/Projects/Pages/work/dist" -c "/run/media/chocola/3T-DATA02/Projects/Pages/work/tmp" -p "frieren-anime" --threshold 1024其中的参数：-t 上传的目标文件夹-c 用于文件搬运缓存的文件夹-p Cloudflare Pages 项目名称--threshold 每次上传的大小，可选参数，默认512，单位为MB。眼尖的朋友应该也注意到了，我为了应对网络不稳定造成的上传中断，提供了恢复模式。如果是以恢复模式启动，也就是-r参数，就会先在目标文件夹执行一次上传操作，然后再去缓存文件夹按照设定的每次上传文件的阈值大小复制文件。python upload.py -t "/run/media/chocola/3T-DATA02/Projects/Pages/work/dist" -c "/run/media/chocola/3T-DATA02/Projects/Pages/work/tmp" -p "onimai-anime" --threshold 2048 -r主程序主程序主要就是调用这两个模块进行媒体库的处理和上传，以及在上传之前把静态的UI资源文件复制到目标文件夹并做好网页标题命名工作：import argparse import subprocess import sys import shutil from pathlib import Path # 导入同目录下的 upload 模块 import upload def run_shell_command(cmd, ignore_error=False): """辅助函数：运行 shell 命令""" print(f"执行命令: {cmd}") try: subprocess.run(cmd, shell=True, check=True) return True except subprocess.CalledProcessError as e: if ignore_error: print(f"[提示] 命令未成功返回，但已设置为忽略 (可能仓库或相关配置已存在)。") return False else: print(f"\n[致命错误] 命令执行失败: {cmd}") sys.exit(1) def main(): parser = argparse.ArgumentParser(description="多媒体转换与自动分批部署脚手架") parser.add_argument("-s", "--source", required=True, help="目标文件夹目录 (需要转换的原始文件所在路径)") parser.add_argument("-w", "--workspace", required=True, help="临时处理文件夹目录 (工作区路径)") parser.add_argument("-p", "--project-name", required=True, help="远端 Cloudflare Pages 的项目/仓库名称") # 新增的可选参数：网页标题 parser.add_argument("-t", "--title", help="自定义网页标题 (可选，例如: 某科学的超电磁炮)") # 可选参数，默认使用 512MB 阈值 parser.add_argument("--threshold", type=int, default=512, help="单次上传阈值(MB)，默认 512") args = parser.parse_args() source_dir = Path(args.source).resolve() workspace_dir = Path(args.workspace).resolve() if not source_dir.exists(): print(f"[错误] 源目录不存在: {source_dir}") sys.exit(1) # 临时目录里的两个文件夹 dist_dir = workspace_dir / "dist" # 1. 转换兼上传的文件夹 tmp_dir = workspace_dir / "tmp" # 2. 用于中转的 tmp 文件夹 # 确保目录结构存在 dist_dir.mkdir(parents=True, exist_ok=True) tmp_dir.mkdir(parents=True, exist_ok=True) print("\n" + "="*50) print("🚀 第一阶段：格式转换与压缩 (调用 convert.py)") print("="*50) # 调用 convert.py convert_cmd = f'"{sys.executable}" convert.py "{source_dir}" "{dist_dir}"' run_shell_command(convert_cmd) print("\n" + "="*50) print("🚀 附加阶段：复制静态资源并设置网页标题") print("="*50) # 获取 static 文件夹路径（假设它与 main.py 在同级目录下） static_dir = Path(__file__).parent / "static" if not static_dir.exists(): print(f"[警告] 找不到 static 目录: {static_dir}，跳过静态资源复制。") else: print(f"正在复制 {static_dir} 下的所有文件到 {dist_dir} ...") # 复制 static 目录下的所有内容到 dist_dir (dirs_exist_ok=True 允许覆盖写入) shutil.copytree(static_dir, dist_dir, dirs_exist_ok=True) print("静态资源复制完成。") # 如果传入了自定义标题参数，则处理 index.html if args.title: index_html_path = dist_dir / "index.html" if index_html_path.exists(): print(f"正在修改网页标题为: {args.title} - MediaPages") try: with open(index_html_path, "r", encoding="utf-8") as f: html_content = f.read() old_title_tag = " MediaPages " new_title_tag = f" {args.title} - MediaPages " if old_title_tag in html_content: html_content = html_content.replace(old_title_tag, new_title_tag) with open(index_html_path, "w", encoding="utf-8") as f: f.write(html_content) print("✅ 标题修改成功！") else: print(f"[警告] 在 index.html 中未找到特定的 `{old_title_tag}` 标签，标题替换失败。") except Exception as e: print(f"[错误] 修改 index.html 失败: {e}") else: print(f"[警告] 找不到 {index_html_path}，无法修改标题。") else: print("未提供自定义标题参数 (-t)，保留默认标题。") print("\n" + "="*50) print(f"🚀 第二阶段：创建远端仓库 [{args.project_name}]") print("="*50) # 创建 Cloudflare Pages 项目 create_cmd = f"npx wrangler pages project create {args.project_name} --production-branch main" run_shell_command(create_cmd, ignore_error=True) print("\n" + "="*50) print("🚀 第三阶段：分批上传与部署 (调用 upload.py)") print("="*50) # 动态组装部署命令 deploy_cmd = f"npx wrangler pages deploy {dist_dir} --project-name {args.project_name}" threshold_bytes = args.threshold * 1024 * 1024 print(f"转换输出目录: {dist_dir}") print(f"中转缓存目录: {tmp_dir}") print(f"部署命令: {deploy_cmd}") # 调用修改后的 upload.py 主函数 upload.run_deploy( target_dir=dist_dir, cache_dir=tmp_dir, bash_cmd=deploy_cmd, threshold=threshold_bytes ) print("\n" + "="*50) print("🧹 第四阶段：清理临时工作区") print("="*50) # 直接将整个 tmp_dir 连带里面的所有空文件夹结构一起销毁 if tmp_dir.exists(): shutil.rmtree(tmp_dir) print(f"已彻底删除中转临时目录: {tmp_dir}") print("\n🎉 全部流程执行完毕！") if __name__ == "__main__": main()这个主程序的调用示例如下：python main.py -s "/run/media/chocola/3T-DATA02/[Airota&VCB-Studio&LoliHouse] Toaru Kagaku no Railgun T [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work2" -p "railgun-t" -t "某科学的超电磁炮 T" --threshold 2048其中的参数：-s 上传的目标媒体库原始文件夹-w 转化工作文件夹，用于存放转化后的文件夹和搬运的缓存文件夹-p Cloudflare Pages 项目名称--threshold 每次上传的大小，可选参数，默认512，单位为MB。至此，开发的工作已经告一段落。多项目顺序处理技巧既然已经使用了这个程序，那想必你已经是有多个媒体库的了。人守着一直等待上传完成再去执行下一个命令肯定不合理，我这里给一个技巧——使用bash脚本在程序工作目录创建一个.sh文件，内容参考下面的内容：python main.py -s "/run/media/chocola/3T-DATA02/[Airota&VCB-Studio&LoliHouse] Toaru Kagaku no Railgun T [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work2" -p "railgun-t" -t "某科学的超电磁炮 T" --threshold 2048 python main.py -s "/run/media/chocola/3T-DATA02/[hyakuhuyu&VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu 3rd Season [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work3" -p "re0-3rd" -t "从零开始的异世界生活 第三季" --threshold 2048全套代码获取相关的代码我已经放在GitHub仓库：github.com/Chocola-X/MediaPages项目采用AGPLv3协议开源，但是程序生成的内容不受协议限制，可自由使用和部署。
高性能IP查询服务端开发日志之前使用IP信息查询的服务也不少了，观察了不少的接口，大部分返回的数据都是json格式的数据，于是我就在想，我能不能也自己开发一个类似的查询工具玩一下？说干就干，在想清楚了软件的架构设计后，我决定开工。架构设计与编程语言选型在架构设计方面，我打算进行前后端彻底解耦，前端为静态网页，后端为纯API程序。对于前端，我计划利用JS来从API获取信息并更新页面对于后端，就做最纯粹的API服务，根据查询请求响应JSON字符串在亲眼见证过PHP，Python这类动态解释型语言的效率噩梦后，我打算使用GO语言进行开发，理由很简单：GO是编译型语言，运行效率比解释型语言要高得多。GO天生就适合开发后端，抗并发能力强。相比于C语言，GO语言要更适合上手。构建前端不得不说，就目前的情况，Gemini仍然是前端之神，我直接让Gemini根据我的想法设计了一套网页，还是相当不错的：代码如下： 猫娘IP地址查询 🐱 🐾 猫娘IP地址查询 您的 IP 地址为 正在获取喵... 🔍 查询喵 🐾 🌍 国家 - 🗺️ 省份 - 🏙️ 城市 - 🏢 ISP 运营商 - 🧭 经纬度 - 💡 开发者 API 说明 🛠️ API 调用说明 × 您可以通过发送 GET 请求到我们的接口来获取 IP 详细信息喵： GET https://api.maoniang.example/v1/ip?address={ip} 📦 返回数据格式示例 (JSON): { "code": 200, "msg": "success", "data": { "country": "美国", "province": "加利福尼亚州", "city": "圣克拉拉", "isp": "阿里云", "latitude": "37.355701", "longitude": "-121.955002" } } 我知道了喵 🐾 但是仔细观察，你会发现使用了实时构建的 Tailwind CSS ，其实这样做对于一个静态页面不太友好，于是我让他按照 Tailwind CSS 的样式，原生帮我写了一版样式。因为处理后代码变多了，我就不在这里放出来了。最后静态文件就三个：[chocola@Neko-X99 static]$ tree . ├── favicon.png ├── index.html └── main.css 1 directory, 3 files后续还有界面的美化，目前确定是这样了：变可爱了，有没有？后端设计因为项目的定位是轻量，所以我不打算依赖外部的数据库。再加上IP查询这个业务场景几乎全是读操作，所以使用SQLite是没问题的，读取这方面没有瓶颈。为了实现高效的 IP 查询，数据库表结构的设计至关重要。核心思路是将文本格式的 IP 地址转换为无符号 32 位整数（uint32），这样不仅运算效率高，也便于进行范围比较。数据库表（例如 ip_info）主要包含三个字段：network_start (INTEGER): 存储该 IP 段的起始地址，以 uint32 格式保存。这是查询的核心，我们会根据这个字段建立索引（CREATE INDEX idx_network_start ON ip_info (network_start);），以确保查询性能。network_end (INTEGER): 存储该 IP 段的结束地址，同样以 uint32 格式保存。用于在查找到候选记录后，进行二次验证，确保目标 IP 确实落在该网段内。ip_info_json (TEXT): 将该 IP 段对应的地理位置、运营商等详细信息，序列化成 JSON 字符串后存储在此字段。这种方案灵活度高，可以方便地增减返回的字段，而无需修改表结构。查询时，我们首先将待查询的 IP 转换为整数，然后利用 WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1 这样的 SQL 语句，借助索引快速定位到最接近且小于等于目标 IP 的那条记录。接着，再用 network_end 字段验证目标 IP 是否在该记录的范围内，从而得出最终结果。这种“先定位，后校验”的方式，配合索引，能够将查询耗时稳定在微秒级别。测试环境：CPU：i5-2410M内存：4GB DDR3 1600MHz硬盘：希捷 320G SATA2 机械硬盘Nginx：模拟真实环境，开启SSL进行反代第一版代码package main import ( "database/sql" "encoding/json" "flag" "fmt" "log" "net" "net/http" "strings" _ "github.com/mattn/go-sqlite3" ) var db *sql.DB // 定义完全匹配前端需求的数据结构 type IPInfo struct { IP string `json:"ip"` Country string `json:"country"` Province string `json:"province"` City string `json:"city"` ISP string `json:"isp"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` } // 定义标准的 API 响应格式 type APIResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data"` // 用 interface{} 以便在出错时返回 nil } // 辅助函数：获取客户端真实 IP func getClientIP(r *http.Request) string { ip := r.Header.Get("X-Real-IP") if ip == "" { ip = r.Header.Get("X-Forwarded-For") } if ip == "" { ip, _, _ = net.SplitHostPort(r.RemoteAddr) } // X-Forwarded-For 可能是逗号分隔的多个IP，取第一个真实的 ip = strings.Split(ip, ",")[0] // 清理可能包含的空格，提升健壮性 return strings.TrimSpace(ip) } func apiHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // 提取查询参数并去除两端空白字符 queryIPStr := strings.TrimSpace(r.URL.Query().Get("ip")) targetIP := queryIPStr if targetIP == "" { targetIP = getClientIP(r) } // 【安全防线 1】：严格限制为合法的 IPv4 格式。任何注入代码都会在这里被直接拦截。 parsedIP := net.ParseIP(targetIP) if parsedIP == nil || parsedIP.To4() == nil { json.NewEncoder(w).Encode(APIResponse{Code: 400, Msg: "非法的 IPv4 地址喵！", Data: nil}) return } // 【安全防线 2】：转为 uint32 整型。彻底杜绝字符型注入。 ipInt := uint32(parsedIP.To4()[0])<<24 | uint32(parsedIP.To4()[1])<<16 | uint32(parsedIP.To4()[2])<<8 | uint32(parsedIP.To4()[3]) var infoJSON string var networkEnd uint32 // 🌟 记得提前声明这个变量喵 // 【性能优化核心】：利用索引极速向下查找最近的一条记录 err := db.QueryRow(` SELECT network_end, ip_info_json FROM ip_info WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1`, ipInt).Scan(&networkEnd, &infoJSON) if err != nil { if err == sql.ErrNoRows { json.NewEncoder(w).Encode(APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) } else { // 避免将底层的数据库错误直接暴露给前端（防止信息泄露） log.Printf("数据库查询错误: %v\n", err) json.NewEncoder(w).Encode(APIResponse{Code: 500, Msg: "数据库查询出错了喵", Data: nil}) } return } // 【关键逻辑校验】：虽然找到了最近的起始 IP，但必须确认目标 IP 是否在这个网段的覆盖范围内 if ipInt > networkEnd { json.NewEncoder(w).Encode(APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) return } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { log.Printf("JSON 解析错误: %v\n", err) json.NewEncoder(w).Encode(APIResponse{Code: 500, Msg: "数据解析失败喵", Data: nil}) return } result := IPInfo{ IP: targetIP, Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } json.NewEncoder(w).Encode(APIResponse{ Code: 200, Msg: "success", Data: result, }) } func main() { // ================= 定义启动参数 ================= // flag.String("参数名", "默认值", "说明文字") dbPath := flag.String("db", "ip_info.db", "SQLite 数据库文件路径") port := flag.String("port", "8080", "API 服务监听端口") // 解析命令行参数 flag.Parse() var err error // 使用参数指定的数据库路径 db, err = sql.Open("sqlite3", *dbPath) if err != nil { log.Fatal("无法打开数据库: ", err) } defer db.Close() http.HandleFunc("/ipinfo", apiHandler) // 拼接监听地址 addr := fmt.Sprintf(":%s", *port) fmt.Printf("猫娘纯净 API 服务启动于 %s 喵... 🐾\n", addr) fmt.Printf("当前使用的数据库文件: %s\n", *dbPath) log.Fatal(http.ListenAndServe(addr, nil)) }测试下来直接就踩了一个大坑：因为没有设置索引，数据库查询效率非常低下，以至于到了难以置信的慢。通过这个命令：CREATE INDEX idx_network_start ON ip_info (network_start);建立好索引后，查询速度直接起飞。从原来每秒只能处理10个请求飙升至每秒可以处理5000+个请求，性能直接翻了500倍！太夸张了！第二版代码虽然第一版代码的性能已经可以做到很优秀，但是我还是希望尝试进一步优化，那就是——数据库存入内存！而而且我不但要存入内存，还要将其转化为Go原生的数据存储格式，进一步优化性能。package main import ( "database/sql" "encoding/json" "flag" "fmt" "log" "net" "net/http" "sort" "strings" "time" _ "github.com/mattn/go-sqlite3" ) var db *sql.DB // --- 全局配置与缓存 --- var ( ipCache []IPRule // 全量内存切片 useMemoryCache bool // 内存模式开关 enableDetailLog bool // 详细日志开关 ) type IPRule struct { NetworkStart uint32 NetworkEnd uint32 Info IPInfo } type IPInfo struct { IP string `json:"ip"` Country string `json:"country"` Province string `json:"province"` City string `json:"city"` ISP string `json:"isp"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` } type APIResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data"` } // 辅助函数：获取客户端真实 IP func getClientIP(r *http.Request) string { ip := r.Header.Get("X-Real-IP") if ip == "" { ip = r.Header.Get("X-Forwarded-For") } if ip == "" { ip, _, _ = net.SplitHostPort(r.RemoteAddr) } ip = strings.Split(ip, ",")[0] return strings.TrimSpace(ip) } // 统一处理响应和日志打印 func sendJSONResponse(w http.ResponseWriter, clientIP, targetIP string, resp APIResponse) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") // 将结构体序列化为 JSON 字节 respBytes, err := json.Marshal(resp) if err != nil { log.Printf("响应序列化失败: %v", err) http.Error(w, `{"code":500,"msg":"系统内部错误","data":null}`, http.StatusInternalServerError) return } // 如果开启了日志，就在这里统一输出 if enableDetailLog { log.Printf("[访问日志] 来源IP: %-15s | 查询IP: %-15s | 结果: %s", clientIP, targetIP, string(respBytes)) } // 写入响应 w.Write(respBytes) } func loadDataToMemory() error { log.Println("正在将数据库载入内存，请稍候喵...") startTime := time.Now() rows, err := db.Query(`SELECT network_start, network_end, ip_info_json FROM ip_info ORDER BY network_start ASC`) if err != nil { return err } defer rows.Close() for rows.Next() { var start, end uint32 var infoJSON string if err := rows.Scan(&start, &end, &infoJSON); err != nil { return err } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { continue } info := IPInfo{ Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } ipCache = append(ipCache, IPRule{ NetworkStart: start, NetworkEnd: end, Info: info, }) } log.Printf("载入完成！共加载了 %d 条规则，耗时 %v 喵！", len(ipCache), time.Since(startTime)) return nil } func apiHandler(w http.ResponseWriter, r *http.Request) { clientIP := getClientIP(r) // 提取实际访问者的 IP，用于日志记录 queryIPStr := strings.TrimSpace(r.URL.Query().Get("ip")) targetIP := queryIPStr if targetIP == "" { targetIP = clientIP } parsedIP := net.ParseIP(targetIP) if parsedIP == nil || parsedIP.To4() == nil { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 400, Msg: "非法的 IPv4 地址喵！", Data: nil}) return } ipInt := uint32(parsedIP.To4()[0])<<24 | uint32(parsedIP.To4()[1])<<16 | uint32(parsedIP.To4()[2])<<8 | uint32(parsedIP.To4()[3]) if useMemoryCache { idx := sort.Search(len(ipCache), func(i int) bool { return ipCache[i].NetworkStart > ipInt }) if idx > 0 { rule := ipCache[idx-1] if ipInt <= rule.NetworkEnd { result := rule.Info result.IP = targetIP sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 200, Msg: "success", Data: result}) return } } sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "内存库里没有找到这个 IP 喵~", Data: nil}) } else { var infoJSON string var networkEnd uint32 err := db.QueryRow(` SELECT network_end, ip_info_json FROM ip_info WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1`, ipInt).Scan(&networkEnd, &infoJSON) if err != nil { if err == sql.ErrNoRows { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) } else { log.Printf("数据库查询错误: %v\n", err) sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 500, Msg: "数据库查询出错了喵", Data: nil}) } return } if ipInt > networkEnd { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) return } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { log.Printf("JSON 解析错误: %v\n", err) sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 500, Msg: "数据解析失败喵", Data: nil}) return } result := IPInfo{ IP: targetIP, Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 200, Msg: "success", Data: result}) } } func main() { dbPath := flag.String("db", "ip_info.db", "SQLite 数据库文件路径") port := flag.String("port", "8080", "API 服务监听端口") memFlag := flag.Bool("mem", false, "是否开启全量内存模式（内存换取极致性能喵~）") logFlag := flag.Bool("log", false, "是否开启详细访问日志输出") flag.Parse() useMemoryCache = *memFlag enableDetailLog = *logFlag // 赋值给全局变量 var err error db, err = sql.Open("sqlite3", *dbPath) if err != nil { log.Fatal("无法打开数据库: ", err) } defer db.Close() if useMemoryCache { if err := loadDataToMemory(); err != nil { log.Fatal("致命错误：无法将数据加载到内存喵: ", err) } } http.HandleFunc("/ipinfo", apiHandler) addr := fmt.Sprintf(":%s", *port) fmt.Printf("猫娘 API 服务启动于 %s 喵...\n", addr) fmt.Printf("当前数据库文件: %s\n", *dbPath) if useMemoryCache { fmt.Println("当前运行模式: [极致性能] 全量内存 + 二分查找") } else { fmt.Println("当前运行模式: [省内存] SQLite 实时查询 (可加 -mem=true 提速)") } if enableDetailLog { fmt.Println("详细访问日志: 已开启 (将在控制台打印每次请求细节)") } else { fmt.Println("详细访问日志: 未开启 (可加 -log=true 开启)") } log.Fatal(http.ListenAndServe(addr, nil)) }另外，我还加上了日志输出的功能，方便进行API调用的监控，可以防范滥用。经过测试，在把数据库存进内存后，可以抗的并发请求从 5000+ 请求/秒 提升到了 7000+ 请求/秒，提升了40%！不过我也观察到，此时Nginx的CPU占用率明显上升，大约占据了40%+的CPU，而Go程序本身的CPU占用率则从50%左右降低到了35%！不过有点奇怪的是，大约有20%的CPU占用率不知道是被什么程序占据了，BTOP没有显示出来。其实在第一版代码的时候，Nginx的CPU占用已经挺高了，请求数量提升后更高。值得注意的是，两个版本的服务端进行极限压力测试的时候，整机的CPU占用率几乎都是100%。不知道各位对这个性能表现感觉如何？分析：“消失”的20% CPU占用去哪了？在测试中，我观察到一个有趣的现象：Nginx 占用了约 40%，Go 程序占用了约 35%，加起来只有 75% 左右，但整机 CPU 却已经打满（100%）。剩下的 20% 去哪了？难道被猫娘偷吃了吗？其实，这并非 BUG，而是高性能网络服务的典型特征——内核态开销（System CPU）。当 QPS 达到 7000+ 时，服务器每秒需要处理数万个网络数据包的接收、发送、上下文切换以及系统调用。这些操作并不发生在 Nginx 或 Go 程序的“用户态”代码中，而是直接由 Linux 内核 接管处理：网络协议栈处理：TCP/IP 包的解析、校验、重组。软中断（SoftIRQs）：网卡中断后的数据搬运。线程调度：在数千个并发连接间快速切换 CPU 时间片。结论：这“消失”的 20% 实际上是被 操作系统内核 消耗掉了，用于支撑如此高频率的网络吞吐。这也侧面证明了我们的程序优化已经非常到位，瓶颈不再在于应用层代码，而在于底层的网络 IO 处理能力。如果再想提升，可能就需要考虑内核参数调优（如调整 TCP 缓冲区、开启 RSS 多队列网卡等）或者升级更强的单核 CPU 了喵！提升程序的兼容性因为我们的项目依赖了go-sqlite3，这要求必须开启CGO进行编译。如果在一般的Linux发行版（如Ubuntu、CentOS）上直接编译，默认会动态链接系统的C语言标准库（glibc）。这必然会导致把程序放到不同年代、不同发行版的服务器上运行时，出现“找不到glibc版本”等兼容性报错。为了达到“一次编译，到处运行”的完美兼容性，我们需要进行纯静态链接打包。但是，在普通发行版上强行静态编译 glibc 是非常痛苦且容易踩坑的。因此，我们需要借助musl这个极其精简的C标准库。使用musl的典型代表就是Alpine Linux（这也是老朋友了，我的云服务器基本上都是这个发行版）。我们可以非常优雅地在 Alpine 环境下进行全静态编译操作：先安装必要的 C 语言编译工具链：apk add go gcc musl-dev进入代码目录后，初始化模块并拉取依赖：go mod init github.com/Chocola-X/NekoIPinfo go mod tidy最后，使用“静态编译参数”进行编译：CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-linkmode external -extldflags -static -s -w" -o neko-ip-api main.go这样编译出来的二进制文件内部已经打包了所有需要的底层库。你把它丢到绝大部分 x86_64 架构的 Linux 发行版上，都可以做到直接开箱即用！结尾项目已经开源到了Github：NekoIPinfo并且我也自己搭建了查询服务，感兴趣的可以来玩玩：NekoIPinfo Demo
使用Python把CAD高程图实体化为3D模型因为设计课程的需要，我需要为场地建立一个直观的3D模型进行形体推敲。但是这次老师给的CAD图纸只有高程点，等高线是缺失的，所以我希望是利用这些高程点的信息进行自动化建模，快速生成场地地形模型。转换CAD首先，因为CAD比较凌乱，我们需要整理出一个只包含了高程点的CAD图，并且导出为DXF格式，方便后续的Python程序进行对接。原CAD：处理后的CAD:处理完成后，导出DXF格式的文件，为Python程序化建模做准备。转化程序因为blender的Python环境不方便安装别的库，所以处理程序分为两个部分：第一部分使用系统安装的Python环境进行处理，使用json格式保存转好后的中间结果，也就是每个点的坐标.第二部分使用Blender的Python环境，读取已经处理好的地形信息json文件，进行建模。首先是第一部分的处理dxf文件：import ezdxf import json import re import os # --- 配置文件路径 --- DXF_FILE_PATH = "Drawing2.dxf" JSON_OUTPUT_PATH = "topography.json" def dxf_to_json(dxf_path, json_path): print(f"正在读取 DXF 文件: {dxf_path} ...") if not os.path.exists(dxf_path): print(f"错误：找不到文件 '{dxf_path}'。") return try: doc = ezdxf.readfile(dxf_path) except Exception as e: print(f"读取 DXF 失败: {e}") return msp = doc.modelspace() points = [] # 用于存储坐标的列表 for entity in msp.query('TEXT MTEXT'): try: text_str = entity.dxf.text # 提取数字（支持负数和小数） match = re.search(r'[-+]?\d*\.?\d+', text_str) if match: z = float(match.group()) x = entity.dxf.insert.x y = entity.dxf.insert.y # 将坐标作为一个小列表存入总列表 points.append([x, y, z]) except Exception: continue if points: # 将数据转储为 JSON 格式 with open(json_path, 'w', encoding='utf-8') as f: json.dump(points, f, indent=2) # indent=2 让 JSON 文件格式化，方便人类阅读 print(f"✅ 成功！共提取了 {len(points)} 个坐标点。") print(f"数据已保存为 JSON 文件: {json_path}") else: print("❌ 提取失败：未找到有效数据。") # 运行脚本 dxf_to_json(DXF_FILE_PATH, JSON_OUTPUT_PATH)以上代码在系统的Python环境运行，记得安装对应的Python库ezdxf。然后是在Blender里面运行的Python代码：import bpy import json import mathutils # --- 配置参数 --- JSON_FILE_PATH = "/run/media/chocola/3T-DATA02/设计/场地模型/topography.json" def create_terrain_from_json(filepath): # 1. 读取 JSON 数据 try: with open(filepath, 'r', encoding='utf-8') as f: points = json.load(f) except Exception as e: print(f"❌ 读取 JSON 失败: {e}") return if not points: print("❌ JSON 文件中没有数据！") return print(f"成功读取 {len(points)} 个坐标点，开始处理...") # 2. 计算边界框中心，处理坐标过大导致的浮点精度问题 min_x = min(p[0] for p in points) max_x = max(p[0] for p in points) min_y = min(p[1] for p in points) max_y = max(p[1] for p in points) center_x = (min_x + max_x) / 2.0 center_y = (min_y + max_y) / 2.0 verts_3d = [] verts_2d = [] # Delaunay 算法只需要 2D 平面坐标 for p in points: # 减去中心点坐标，将整个地形移至原点 (0,0,0) 附近 cx = p[0] - center_x cy = p[1] - center_y cz = p[2] verts_3d.append((cx, cy, cz)) verts_2d.append(mathutils.Vector((cx, cy))) # 3. 执行 2D 德劳内三角剖分 print("正在计算地形拓扑结构...") # delaunay_2d_cdt 返回值包含: (vertices, edges, faces, is_valid) # 我们只需要提取面 (faces) 的索引数据 try: result = mathutils.geometry.delaunay_2d_cdt(verts_2d, [], [], 0, 0.0001) faces = result[2] except Exception as e: print(f"❌ 三角剖分计算失败: {e}") return # 4. 创建 Blender 网格并赋予数据 mesh = bpy.data.meshes.new("Terrain_Mesh") # from_pydata 极其高效：直接通过顶点列表和面列表生成网格 mesh.from_pydata(verts_3d, [], faces) # 开启平滑着色 for poly in mesh.polygons: poly.use_smooth = True mesh.update() # 5. 生成物体并放入场景 obj = bpy.data.objects.new("Site_Terrain", mesh) bpy.context.collection.objects.link(obj) # 选中生成的物体 bpy.ops.object.select_all(action='DESELECT') bpy.context.view_layer.objects.active = obj obj.select_set(True) print(f"✅ 地形生成完毕！") print(f"提示: 原 CAD 坐标中心点已偏移：X={center_x:.2f}, Y={center_y:.2f}") # 运行脚本 create_terrain_from_json(JSON_FILE_PATH)最终效果按顺序执行了两个程序后，你的Blender里面应该就已经出现了对应的地形文件了。当然，是单面的模型，后续可以进行实体化操作等按需调整。还有一个需要提醒的是，生成的模型是三角面的模型，并不是Blender建模推荐的四边形建模，并且布线并不规整。所以这个模型是能用，但是不方便修改。
CR8809路由器刷机记录在用爱快的Q6000路由器替换掉小米CR8809路由器后，CR8809就一直是吃灰状态。这几天突然心血来潮，想给这玩意刷个机，体验体验OpenWrt。对于这个路由器的刷机指南，各种论坛已经有不少，特别是恩山无线论坛，有很多教程。可惜现在这个论坛感觉对没有注册的访客越来越封闭，不好获取信息了，现在的情况是连图片都不能显示了。而且注册还要钱，我寻思着你网站广告也不少啊，怎么敢要钱的？哎，不说废话了，教程开始。根据论坛的信息：CR880X系列：联通的CR8806、移动的CR8808、电信的CR8809。三个运营商的硬件大差不差一样的，固件有点区别但是能互刷。主板按发售时间分为M81、M79 A版、M79 B版三种。M79 B版的CPU为IPQ5018，其他两款是IPQ5000。M79 B版无法刷大分区uboot。 TTL在右上角的是IPQ5000，在底部的是IPQ5018。并且这个系列的路由器可以和Redmi AX3000路由器的刷机包通刷，因为使用的硬件方案是一样的，就是运营商定制换皮罢了。我的路由器是CR8809，通过观察主板丝印发现是M79的主板。丝印位于主板重置按钮右侧，上网查一下拆机图就知道在那了。可以免拆看到，但是有点费劲：用手机的手电筒照进最靠近重置按钮的网口，然后通过正面的散热孔观察主板重置按钮右侧的区域，就能看到丝印了。很不幸，我这个路由器是M79的主板，但是不幸中的万幸是方案是IPQ5000，可以刷大分区！爆开小米路由器的SSH我们需要用到的工具是：xmir-patcher也可以从github下载：git clone https://github.com/openwrt-xiaomi/xmir-patcher下载完成后，进入工具目录，执行run.sh./run.sh Looking in indexes: https://pypi.mirrors.ustc.edu.cn/simple/ Ignoring ssh2-python312: markers 'python_version >= "3.12"' don't match your environment Requirement already satisfied: charset_normalizer in ./venv/lib/python3.10/site-packages (from -r requirements.txt (line 1)) (3.3.2) Requirement already satisfied: idna in ./venv/lib/python3.10/site-packages (from -r requirements.txt (line 2)) (3.7) Requirement already satisfied: urllib3 in ./venv/lib/python3.10/site-packages (from -r requirements.txt (line 3)) (2.2.2) Requirement already satisfied: certifi in ./venv/lib/python3.10/site-packages (from -r requirements.txt (line 4)) (2024.6.2) Requirement already satisfied: requests in ./venv/lib/python3.10/site-packages (from -r requirements.txt (line 5)) (2.32.3) Requirement already satisfied: pyftpdlib in ./venv/lib/python3.10/site-packages (from -r requirements.txt (line 6)) (1.5.10) Requirement already satisfied: ssh2-python>=1.0.0 in ./venv/lib/python3.10/site-packages (from -r requirements.txt (line 7)) (1.0.0) [notice] A new release of pip available: 22.3.1 -> 24.1.1 [notice] To update, run: pip install --upgrade pip ========================================================== Xiaomi MiR Patcher 1 - Set IP-address (current value: 192.168.31.1) 2 - Connect to device (install exploit) 3 - Read full device info 4 - Create full backup 5 - Install EN/RU languages 6 - Install Breed bootloader 7 - Install firmware (from directory "firmware") 8 - {{{ Other functions }}} 9 - Reboot device 0 - Exit Select: 先选择1,设置路由器的IP地址,例如192.168.1.1然后选择2,进行ssh开启操作，需要输入管理界面的密码，等待两分钟左右即可完成，默认的用户名和密码都是root建议尽快更换密码，使用passwd命令更换密码注意，如果出现no matching host key type found. Their offer: ssh-rsa报错，请使用：ssh -oHostKeyAlgorithms=+ssh-rsa root@192.168.31.1刷入不死U-Boot我这里刷的是论坛里面给的合并分区的不死U-Boot:首先登陆进路由器，使用cat /proc/mtd命令确认好分区：root@NekoRouter:~# cat /proc/mtd dev: size erasesize name mtd0: 00080000 00020000 "0:SBL1" mtd1: 00080000 00020000 "0:MIBIB" mtd2: 00040000 00020000 "0:BOOTCONFIG" mtd3: 00040000 00020000 "0:BOOTCONFIG1" mtd4: 00100000 00020000 "0:QSEE" mtd5: 00100000 00020000 "0:QSEE_1" mtd6: 00040000 00020000 "0:DEVCFG" mtd7: 00040000 00020000 "0:DEVCFG_1" mtd8: 00040000 00020000 "0:CDT" mtd9: 00040000 00020000 "0:CDT_1" mtd10: 00040000 00020000 "0:APPSBLENV" mtd11: 00140000 00020000 "0:APPSBL" mtd12: 00120000 00020000 "0:APPSBL_1" mtd13: 00100000 00020000 "0:ART" mtd14: 00080000 00020000 "0:TRAINING" mtd15: 00100000 00020000 "bdata" mtd16: 00080000 00020000 "crash" mtd17: 00080000 00020000 "crash_log" mtd18: 02400000 00020000 "rootfs" mtd19: 02400000 00020000 "rootfs_1" mtd20: 01f00000 00020000 "overlay" mtd21: 00d80000 00020000 "data" mtd22: 00364000 0001f000 "kernel" mtd23: 0158e000 0001f000 "ubi_rootfs" mtd24: 01b20000 0001f000 "rootfs_data" mtd25: 000a0000 0001f000 "data_ignor_reset" root@NekoRouter:~#确认好分区号和给出的命令一致，刷错会直接寄。解压压缩文件后，我们需要用到三个文件，分别是：MIBIB.bin，APPSBL.bin，APPSBL1.bin。我们可以使用scp把这个文件夹的内容上传到路由器的/tmp目录：scp -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa -O -r ./* root@192.168.1.1:/tmp/上传完成后，开始刷写/dev/mtd1：mtd erase /dev/mtd1 mtd write /tmp/MIBIB.bin /dev/mtd1刷写完成后断电重启路由器，然后再次上传文件：scp -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa -O -r ./* root@192.168.1.1:/tmp/上传完成后，刷剩下的分区：mtd erase /dev/mtd11 mtd write /tmp/APPSBL.bin /dev/mtd11 mtd erase /dev/mtd12 mtd write /tmp/APPSBL1.bin /dev/mtd12完成后，直接给路由器断电，然后用回形针顶住重置按钮后插电，观察到Internet的蓝灯闪烁就可以松开了。这个不死U-Boot是可以自己分配DHCP的IP的，U-Boot的Web界面IP是192.168.10.1，电脑成功分配到IP后，浏览器能顺利打开这个界面，就成功了一大半了。U-Boot刷机失败排查然而，正当我觉得一切顺利的时候，现实却给我泼了一盆冷水：上传固件上传完成后浏览器直接连接被重置：我下意识以为是不是给我刷废了，导致U-Boot的不死无法正常工作。我先后尝试了换Windows的电脑，用旧版的FireFox浏览器，限制网口速率，但是就是反复出现这个问题。旧版的FireFox倒是不会连接被重置了，但是提示格式不对，刷机失败。正当我觉得百思不得其解的时候，我问了一下我同学，因为之前他刷过路由器。他说让我换Chrome可能就好了，有可能是浏览器兼容性问题。然后我抱着死马当活马医的态度，试了一下：Firefox，我日你先人！刷机包体验汇总成功后，我先后尝试了几个不同的刷机包，了解到目前对于这个路由器的OpenWrt系统主要有两种：QSDK内核的OpenWrt：编译的架构为ARMv7l，也就是ARM32，OpenWrt的版本是OpenWrt21，相对较老，软件偏少。内核有4.4和5.4两个版本，支持NSS Offload功能，可以在大流量时显著降低CPU的NAT负载。实测Steam下载游戏，跑54MB/s的下载速度时，CPU占用只有10%。项目地址：github.com/hzyitc/openwrt-redmi-ax3000Mainline内核的OpenWrt：编译的架构为ARMv8l，也就是ARM64，OpenWrt的版本是OpenWrt24，其实是ImmortalWrt24，比较新，实用软件多。内核为6.6，不支持NSS Offload功能，大流量时CPU的NAT负载会比较高。实测Steam下载游戏，跑54MB/s的下载速度时，CPU占用达到70%。项目地址：github.com/kmiit/Redmi_AX3000_immortalwrt另外还有用主线内核5.4和5.15的版本，我没有尝试过，感兴趣可以去试试。项目地址和我给的QSDK内核的OpenWrt项目地址相同。两个版本就看你取舍了，我建议是想要压榨硬件极致性能的话选QSDK内核的，希望当小型服务器玩，挂各种服务的话，选Mainline内核的。哦，对了，刷机包要选择.ubi后缀的，例如：immortalwrt-qualcommax-ipq50xx-redmi_ax3000-squashfs-factory.ubi
家里云服务优化：统一Web服务端口，减小攻击面
统一家里云的Web服务端口之前很长的一段时间，我的家里云部署的服务都是一个服务监听一个端口，管理相对比较混乱一点，而且因为监听的端口较多，且服务直接暴露，安全风险会更高一些。例如9898端口由Jellyfin监听提供服务，8848端口由Filebrowser监听提供服务，8086端口由NextCloud监听提供服务...这样子虽然在内网访问的情况下方便管理，但是也带来了多端口，多个后端服务直接暴露，管理相对混乱。而且还有一个问题，就是Filebrowser直接监听端口，如果浏览器发送的是http请求，也就是默认在地址栏输入域名+端口时的请求，会直接返回一段文字：client sent an HTTP request to an HTTPS server，然后需要再去地址栏在http后面加上个s，就很操蛋。而对于nginx服务器，则可以直接加上这条规则error_page 497 https://$host:8848$request_uri;即可实现自动跳转，体验要好不少。外加上我家里云其实有专门解析的域名，可以设置不同的子域名用于区分服务，并且我因为我一年多在网站上面的实践，也有了实操经验。所以，我决定把我家里云的Web服务全部统一服务端口，通过vhost进行区分，也就是利用https的sni进行区分，顺带再做一个回落的默认页面来防止低级的IP地址扫描器，还有可能存在的运营商对用户运行的服务的扫描。回落页面设置首先是回落页面设置，这也就是用于掩人耳目的设置，当域名不匹配时，就会返回这个内容。配置文件为/etc/nginx/conf.d/default.conf listen 8848 ssl default_server; listen [::]:8848 ssl default_server; server_name _; # 这是一个通配符，表示任何未匹配的主机名 ssl_certificate /home/chocola/data/certs/bt_cert.pem; ssl_certificate_key /home/chocola/data/certs/bt_cert.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES256-GCM-SHA384'; root /var/www/; # 静态文件的位置 index index.html; # 默认主页 location / { try_files $uri $uri/ =404; # 尝试提供请求的文件或目录，如果不存在则返回404 } # 处理错误页面 error_page 404 /404.html; location = /404.html { internal; } error_page 497 /index.html; location = /index.html { internal; # 只允许内部重定向访问 root /var/www; add_header Content-Type text/html; } }记得在对应的目录放置回落页面index.html和修改证书路径位置。以及在虚拟机安装宝塔面板后，拿宝塔面板的自签名证书文件和宝塔的回落页面放到你的服务器上做回落页面，让别人误以为你是个笨蛋在用宝塔（误）进行反代配置安装好Nginx后，我们就可以开始配置各种服务的反代配置了，只需要确保原始IP可以正常传递，并且后端服务支持使用传递的真实IP。Jellyfin这官方给了详细的Nginx反代配置模板，只需要稍加改动就可以正常使用。配置文件为/etc/nginx/conf.d/jellyfin-proxy.confserver { listen 8848 ssl http2; #IPV4 listen [::]:8848 ssl http2; #IPv6 server_name jellyfin.nekopara.uk; error_page 497 https://$host:8848$request_uri; ## The default `client_max_body_size` is 1M, this might not be enough for some posters, etc. client_max_body_size 32M; # Comment next line to allow TLSv1.0 and TLSv1.1 if you have very old clients ssl_protocols TLSv1.3 TLSv1.2; ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; # use a variable to store the upstream proxy set $jellyfin 127.0.0.1; # Security / XSS Mitigation Headers add_header X-Content-Type-Options "nosniff"; # Permissions policy. May cause issues with some clients add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always; # Content Security Policy # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP # Enforces https content and restricts JS/CSS to origin # External Javascript (such as cast_sender.js for Chromecast) must be whitelisted. add_header Content-Security-Policy "default-src https: data: blob: ; img-src 'self' https://* ; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; font-src 'self'"; location / { # Proxy main Jellyfin traffic proxy_pass http://$jellyfin:8096; 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; proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Host $http_host; # Disable buffering when the nginx proxy gets very resource heavy upon streaming proxy_buffering off; } location /socket { # Proxy Jellyfin Websockets traffic proxy_pass http://$jellyfin:8096; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; 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; proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Host $http_host; } }Filebrowser对于这个应用，官方没有给Nginx的反代配置，于是我自己写一个：配置文件为/etc/nginx/conf.d/filebrowser-proxy.confserver { listen 8848 ssl http2; listen [::]:8848 ssl http2; server_name file.nekopara.uk nas.nekopara.uk; # 自动跳转 497 错误 error_page 497 https://$host:8848$request_uri; ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; # 优化后的 SSL 配置 ssl_session_timeout 1d; # 延长会话时间，提高性能 ssl_session_cache shared:SSL:10m; # 启用 SSL 缓存 ssl_session_tickets off; # 现代安全协议：只允许 TLS 1.2 和 1.3 ssl_protocols TLSv1.2 TLSv1.3; # 使用高性能且安全的加密套件 ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers on; location / { client_max_body_size 512m; # 关键优化：Filebrowser 传输大文件或流媒体时，缓冲区过小会导致中断 proxy_buffering off; proxy_request_buffering off; proxy_set_header Host $http_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; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # 根据 server_name 转发到不同端口（或者分两个 server 块写） if ($host = "file.nekopara.uk") { proxy_pass http://127.0.0.1:8001; } if ($host = "nas.nekopara.uk") { proxy_pass http://127.0.0.1:8002; } } }Filebrowser在配置的过程中踩了一个大坑，因为其他服务都开了http2，一开始Filebrowser的配置文件没开，因为我就沿用了我云服务器上面的配置文件，结果浏览器加载报错：NS_ERROR_NET_PARTIAL_TRANSFER和NS_ERROR_NET_INTERRUPT。对应的修复方式是添加这两个参数解决：proxy_buffering off;proxy_request_buffering off;这个问题折磨了我好久，并且是我配置完成NextCloud后才发现的爆雷。qBittorrent虽然官方给了Nginx反代的配置文件，但是也遇到了坑：反代后打开页面空白，什么都没有。经过排查是需要把 启用 Host header 属性验证 这个选项关掉，具体位置在：选项 > Web UI > Web 用户界面（远程控制） > 验证 > 启用 Host header 属性验证关掉后就正常了。配置文件我就用了官方的模板，反代的配置文件为/etc/nginx/conf.d/qbittorrent-proxy.confserver { listen 8848 ssl; server_name qb.nekopara.fun; error_page 497 https://$host:8848$request_uri; # 修改为你的ssl证书位置 ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; location / { proxy_pass http://127.0.0.1:8003/; proxy_http_version 1.1; # headers recognized by qBittorrent proxy_set_header Host $proxy_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; # optionally, you can adjust the POST request size limit, to allow adding a lot of torrents at once #client_max_body_size 100M; # No longer required since qBittorrent v5.1: # Since v4.2.2, is possible to configure qBittorrent # to set the "Secure" flag for the session cookie automatically. # However, that option does nothing unless using qBittorrent's built-in HTTPS functionality. # For this use case, where qBittorrent itself is using plain HTTP # (and regardless of whether or not the external website uses HTTPS), # the flag must be set here, in the proxy configuration itself. # Note: If this flag is set while the external website uses only HTTP, this will cause # the login mechanism to not work without any apparent errors in console/network resulting in "auth loops". proxy_cookie_path / "/; Secure"; } }Web服务器迁移因为本身这个服务器上面也运行有两个传统的网站架构应用，需要把Web服务器从Apache换成Nginx。NextCloudNextCloud本身在我家里云早就存在，甚至可以说我配置这个家里云很大程度就是为了用NextCloud。但是之前部署用的是Apache作为Web服务器而不是Nginx。现在因为需要Nginx在前面做一层反代，自然没必要运行两个独立的Web服务器，于是就考虑让Nginx来负责NextCloud的流量。而且Nginx相比于Apache也要更加轻量。对于NextCloud，官方给的配置文件就可以直接用，但是存在一个严重的问题：上传速度极慢！对应的问题是配置中设置：fastcgi_request_buffering on;改成：fastcgi_request_buffering off;问题解决，原因是禁用 FastCGI 请求缓冲可避免大文件上传时内存堆积，提升流式上传性能。扫除障碍后，NextCloud也平滑切换。配置文件为/etc/nginx/conf.d/nextcloud.conf# Version 2024-07-17 upstream php-handler { server 127.0.0.1:10082; #server unix:/run/php/php8.2-fpm.sock; } # Set the `immutable` cache control options only for assets with a cache busting `v` argument map $arg_v $asset_immutable { "" ""; default ", immutable"; } server { listen 8848 ssl http2; #IPV4 listen [::]:8848 ssl http2; #IPv6 server_name nextcloud.nekopara.uk; error_page 497 https://$host:8848$request_uri; # Path to the root of your installation root /opt/nextcloud; # Use Mozilla's guidelines for SSL/TLS settings # https://mozilla.github.io/server-side-tls/ssl-config-generator/ ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; # Prevent nginx HTTP Server Detection server_tokens off; # HSTS settings # WARNING: Only add the preload option once you read about # the consequences in https://hstspreload.org/. This option # will add the domain to a hardcoded list that is shipped # in all major browsers and getting removed from this list # could take several months. #add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always; # set max upload size and increase upload timeout: client_max_body_size 512M; client_body_timeout 300s; fastcgi_buffers 64 4K; # Enable gzip but do not remove ETag headers gzip on; gzip_vary on; gzip_comp_level 4; gzip_min_length 256; gzip_proxied expired no-cache no-store private no_last_modified no_etag auth; gzip_types application/atom+xml text/javascript application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/wasm application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy; # Pagespeed is not supported by Nextcloud, so if your server is built # with the `ngx_pagespeed` module, uncomment this line to disable it. #pagespeed off; # The settings allows you to optimize the HTTP2 bandwidth. # See https://blog.cloudflare.com/delivering-http-2-upload-speed-improvements/ # for tuning hints client_body_buffer_size 512k; # HTTP response headers borrowed from Nextcloud `.htaccess` add_header Referrer-Policy "no-referrer" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Permitted-Cross-Domain-Policies "none" always; add_header X-Robots-Tag "noindex, nofollow" always; add_header X-XSS-Protection "1; mode=block" always; # Remove X-Powered-By, which is an information leak fastcgi_hide_header X-Powered-By; # Set .mjs and .wasm MIME types # Either include it in the default mime.types list # and include that list explicitly or add the file extension # only for Nextcloud like below: include mime.types; types { text/javascript mjs; application/wasm wasm; } # Specify how to handle directories -- specifying `/index.php$request_uri` # here as the fallback means that Nginx always exhibits the desired behaviour # when a client requests a path that corresponds to a directory that exists # on the server. In particular, if that directory contains an index.php file, # that file is correctly served; if it doesn't, then the request is passed to # the front-end controller. This consistent behaviour means that we don't need # to specify custom rules for certain paths (e.g. images and other assets, # `/updater`, `/ocs-provider`), and thus # `try_files $uri $uri/ /index.php$request_uri` # always provides the desired behaviour. index index.php index.html /index.php$request_uri; # Rule borrowed from `.htaccess` to handle Microsoft DAV clients location = / { if ( $http_user_agent ~ ^DavClnt ) { return 302 /remote.php/webdav/$is_args$args; } } location = /robots.txt { allow all; log_not_found off; access_log off; } # Make a regex exception for `/.well-known` so that clients can still # access it despite the existence of the regex rule # `location ~ /(\.|autotest|...)` which would otherwise handle requests #/var/www/nextcloud for `/.well-known`. location ^~ /.well-known { # The rules in this block are an adaptation of the rules # in `.htaccess` that concern `/.well-known`. location = /.well-known/carddav { return 301 /remote.php/dav/; } location = /.well-known/caldav { return 301 /remote.php/dav/; } location /.well-known/acme-challenge { try_files $uri $uri/ =404; } location /.well-known/pki-validation { try_files $uri $uri/ =404; } # Let Nextcloud's API for `/.well-known` URIs handle all other # requests by passing them to the front-end controller. return 301 /index.php$request_uri; } # Rules borrowed from `.htaccess` to hide certain paths from clients location ~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/) { return 404; } location ~ ^/(?:\.|autotest|occ|issue|indie|db_|console) { return 404; } # Ensure this block, which passes PHP files to the PHP process, is above the blocks # which handle static assets (as seen below). If this block is not declared first, # then Nginx will encounter an infinite rewriting loop when it prepends `/index.php` # to the URI, resulting in a HTTP 500 error response. location ~ \.php(?:$|/) { # Required for legacy support rewrite ^/(?!index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|ocs-provider\/.+|.+\/richdocumentscode(_arm64)?\/proxy) /index.php$request_uri; fastcgi_split_path_info ^(.+?\.php)(/.*)$; set $path_info $fastcgi_path_info; try_files $fastcgi_script_name =404; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $path_info; fastcgi_param HTTPS on; fastcgi_param modHeadersAvailable true; # Avoid sending the security headers twice fastcgi_param front_controller_active true; # Enable pretty urls fastcgi_pass php-handler; fastcgi_intercept_errors on; fastcgi_request_buffering off; # Required as PHP-FPM does not support chunked transfer encoding and requires a valid ContentLength header. fastcgi_max_temp_file_size 0; } # Serve static files location ~ \.(?:css|js|mjs|svg|gif|ico|jpg|png|webp|wasm|tflite|map|ogg|flac|mp4|webm)$ { try_files $uri /index.php$request_uri; # HTTP response headers borrowed from Nextcloud `.htaccess` add_header Cache-Control "public, max-age=15778463$asset_immutable"; add_header Referrer-Policy "no-referrer" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Permitted-Cross-Domain-Policies "none" always; add_header X-Robots-Tag "noindex, nofollow" always; add_header X-XSS-Protection "1; mode=block" always; access_log off; # Optional: Don't log access to assets } location ~ \.(otf|woff2?)$ { try_files $uri /index.php$request_uri; expires 7d; # Cache-Control policy borrowed from `.htaccess` access_log off; # Optional: Don't log access to assets } # Rule borrowed from `.htaccess` location /remote { return 301 /remote.php$request_uri; } location / { try_files $uri $uri/ /index.php$request_uri; } }Typecho对，没错，虽然我博客现在已经迁移上云了，但是本地还是运行有一份独立的副本的。这主要是作为备份用途，平时也不会有访问流量，但是当云服务器遇到灾难性问题，本地仍然有一份可靠的副本。每写一篇新的文章发布，我都会用家里云从云服务器拉取最新的网站数据。同时这份副本也是做好了随时提供服务的准备的：配置好了Cloudflare的源服务器证书，随时可以通过Cloudflare切换解析套Cloudflare接管流量。当然，因为这只是作为备份和应急，不需要这么多防护规则，所以配置文件相对也简单得多。配置文件为/etc/nginx/conf.d/typecho.confserver { listen 8848 ssl http2; server_name www.nekopara.uk; # SSL certificate and key paths ssl_certificate /home/chocola/data/certs/cloudflare_certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/cloudflare_certs/nekopara_uk.key; # SSL settings (optional but recommended for security) ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; # Root directory of the website root /home/typecho; index index.html index.php; client_max_body_size 16M; # Serve static files directly location / { try_files $uri $uri/ /index.html /index.php?$args; } # PHP processing location ~ \.php$ { fastcgi_pass 127.0.0.1:10082; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # Deny access to hidden files location ~ /\.ht { deny all; } }完工验收当确认通过新的域名+端口访问服务都没问题后，就可以优雅地把防火墙之前开放的各种端口都关闭，并且停止Apache的进程。以及确保这些被反代的服务端关闭HTTPS监听，只监听本地回环127.0.0.1的HTTP请求。因为如果本地回环还是TLS的话会造成不必要的性能损耗，且本地回环足够安全，也没有TLS加密的必要，TLS终止于Nginx就可以了。至此，家里云服务架构优化完美收工。
萌化FileBrowser随着FileBrowser的版本逐渐迭代到2.57.1，我发现我当初写的版本不少地方效果已经失效，于是我打算优化自定义的样式表代码来适配新版的FileBrowser。因为跨的版本比较大，改动也是比较大的，甚至这个个性化自定义配置都有不小的改变。于是我打算重新彻底优化一下。果然，事情总是动态发展的，上一版我调教的终极美化，这次要进行修补更新了。文件目录结构说明新版的filebrowser页面风格定制化style文件夹目录结构如下：[chocola@Neko-X99 linux-amd64-filebrowser]$ tree . ├── CHANGELOG.md ├── filebrowser ├── filebrowser.db ├── LICENSE ├── README.md └── style ├── custom.css ├── img │ ├── background.jpg │ ├── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon.ico │ │ └── favicon.svg │ ├── JCuYuan.woff2 │ ├── login.jpg │ └── logo.svg ├── index.html └── manifest.json 4 directories, 17 filesfilebrowser相比于之前的版本，主要是图标数量进行了简化。而我这次的改进，有以下几点：使用了woff2格式的字体，减少页面加载流量和等待时间增加了提示框样式设置为文件预览添加了毛玻璃背景效果为新增的数值条增加样式适配优化弹窗背景，增加毛玻璃效果为文本编辑器添加半透明背景效果为新增的文件邮件菜单添加主题样式优化登陆界面信息提示的样式大概就这些吧，还有一些细枝末节的部分，我没有详细列出，有一些效果是因为代码改动牵一发动全身，修改反反复复搞了快一天，总之这个filebrowser主题改动不小。效果展示接下来我会用图片逐个展示修改后的效果。登陆界面：这个改动不算大，只是适配了一下新版的消息框。文件页面：适配了右键菜单的效果。文件选择效果（这部分没变）：下载弹窗背景毛玻璃模糊化：新建文件操作也是同理：优化后的文本编辑器样式：优化后的图片预览效果：对新的数字选择器进行样式适配：优化信息提示框：萌化方法萌化方法并不困难，只需要下载我准备好的style.zip压缩包，解压到filebrowser的目录后应用自定义样式就可以了，前文也有目录树说明了，详细操作可以去看这篇文章：个性化Filebrowser界面，NAS也要Kawaii~~~!唯一要注意的是需要下载FileBrowser的2.57.1版本，因为这版界面美化是针对这个版本写的。对于custom.css，我已经在不少位置都做了注释，想自己按需修改的话看注释照着改也不难。
使用Systemd托管挂机服务随着我对Linux系统的理解逐渐加深，外加上这一年来管理服务器越来越习惯命令行，我逐渐感觉我之前在家里云部署的Mcsmanager应用管理面板是多余的，因为Tmux和Systemd都可以做到，并且因为不需要运行一个nodejs，还更省资源。所以，我下定决心把服务迁移到Systemd。让Systemd以普通用户身份运行服务之前我使用Mcsmanager而不是Systemd的原因，就是觉得Systemd配置比较麻烦，并且默认以root身份运行服务，我感觉很不安全。于是进行多次配置后，用以下的配置文件启动了Mcsmanager：/etc/systemd/system/mcsm-web.service[Unit] Description=MCSManager Web [Service] WorkingDirectory=/opt/mcsmanager/web ExecStart=/usr/bin/node app.js ExecReload=/bin/kill -s HUP $MAINPID ExecStop=/bin/kill -s QUIT $MAINPID Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" User=chocola [Install] WantedBy=multi-user.target /etc/systemd/system/mcsm-daemon.service[Unit] Description=MCSManager Daemon [Service] WorkingDirectory=/opt/mcsmanager/daemon ExecStart=/usr/bin/node app.js ExecReload=/bin/kill -s HUP $MAINPID ExecStop=/bin/kill -s QUIT $MAINPID Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" User=chocola [Install] WantedBy=multi-user.target本来配置用的就是用户身份，我在想，为什么不直接就用Systemd以用户身份帮我挂着服务，这样既节省了性能，而且省去了Mcsmanager也可以减少潜在的攻击面。既然目前面板能这样运行，说明未来应用应该也可以。为应用设置Systemd配置文件在经历了QQ机器人封号，MC服务器没人玩，DDNS的工作实际上已经由路由器承担后，之前使用Mcsmanager挂着的服务就只剩下了这几个：两个Filebrowser文件管理，Jellyfin媒体服务器，以及偶尔需要启动的qBittorrent。除此之外，其他服务都是长期关闭的状态，这也为我做出转变提供了契机。并且使用Systemd管理服务还有一个优势：可以让应用隔离开敏感数据，即使应用本身存在漏洞沦陷了，也不会影响到核心的敏感数据，这点是Mcsmanager给不了的。那，接下来我们来进行配置吧！Filebrowser因为这个服务算是我这个家里云最核心的服务了，也就是对应的自建NAS服务，所以我把程序本体迁移到了/opt目录下，并且做好隔离。实际上有两个Filebrowser实例，一个是管理一般文件，一个是管理重要文件，是分开的。/etc/systemd/system/filebrowser.service这个配置文件对应的是管理一般文件的实例：[Unit] Description=Filebrowser01 [Service] WorkingDirectory=/opt/filebrowser ExecStart=/opt/filebrowser/filebrowser ExecReload=/bin/kill -s HUP $MAINPID ExecStop=/bin/kill -s QUIT $MAINPID Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" User=chocola Group=chocola # 严格沙盒 ProtectSystem=strict ProtectHome=tmpfs NoNewPrivileges=true ReadWritePaths=/opt/filebrowser/ ReadWritePaths=/home/chocola/data/files/ # 显式禁止访问核心敏感路径 InaccessiblePaths=/root /home/chocola/.ssh /home/chocola/.bash_history /home/typecho /opt/nextcloud /opt/filebrowser_personal /home/chocola/.acme.sh /mnt # 仅放行启动必需的系统映射 BindReadOnlyPaths=/bin /lib /lib64 /usr /etc /proc /sys /dev/shm [Install] WantedBy=multi-user.target/etc/systemd/system/filebrowser02.service这个配置文件对应的是管理重要文件的实例：[Unit] Description=Filebrowser Personal [Service] WorkingDirectory=/opt/filebrowser_personal ExecStart=/opt/filebrowser_personal/filebrowser ExecReload=/bin/kill -s HUP $MAINPID ExecStop=/bin/kill -s QUIT $MAINPID Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" User=chocola Group=chocola # 严格沙盒 ProtectSystem=strict ProtectHome=tmpfs NoNewPrivileges=true ReadWritePaths=/opt/filebrowser_personal ReadWritePaths=/mnt/4T-RAID1/data/ # 显式禁止访问核心敏感路径 InaccessiblePaths=/root /home/chocola/.ssh /home/chocola/.bash_history /home/typecho /opt/nextcloud /home/chocola/.acme.sh # 仅放行启动必需的系统映射 BindReadOnlyPaths=/bin /lib /lib64 /usr /etc /proc /sys /dev/shm [Install] WantedBy=multi-user.target配置的重点在于InaccessiblePaths，这个直接让应用“看不见”这些目录，达到隔离的目的。我们只需要把一些敏感目录添加到这个参数，就可以极大的增强安全性。Jellyfin这个是我的媒体库，因为相比于Filebrowser，这个服务要复杂的多，也可能有更多潜在的安全漏洞，这也是我更想隔离的对象。于是我写了/etc/systemd/system/jellyfin.service这个配置文件：[Unit] Description=Jellyfin Media Server [Service] User=chocola Group=chocola WorkingDirectory=/home/chocola/data/apps/Jellyfin ExecStart=/home/chocola/data/apps/Jellyfin/run.sh # 严格沙盒 ProtectSystem=strict ProtectHome=tmpfs NoNewPrivileges=true # 显式禁止访问核心敏感路径 InaccessiblePaths=/root /home/chocola/.ssh /home/chocola/.bash_history /opt /mnt /home/typecho # 仅放行启动必需的系统映射 BindReadOnlyPaths=/bin /lib /lib64 /usr /etc /proc /sys /dev/shm # 精确的业务路径（不要直接写父目录） ReadWritePaths=/home/chocola/data/apps/Jellyfin ReadWritePaths=/home/chocola/.local/share/jellyfin ReadWritePaths=/home/chocola/.config/jellyfin ReadWritePaths=/home/chocola/data/files/Music ReadWritePaths=/home/chocola/data/files/Download # 限制进程只能看到自己的临时文件 PrivateTmp=true [Install] WantedBy=multi-user.target配置文件直接禁止了它访问/mnt目录，因为这是我数据盘挂载的地方，也是敏感数据所在地。qBittorrent这个我并不是一直挂着，而是按需启动。并且在系统官方仓库也有这个软件，配置文件相对简单：/etc/systemd/system/qbittorrent-nox.service[Unit] Description=qBittorrent-nox After=network.target [Service] Type=simple User=chocola Group=chocola WorkingDirectory=/home/chocola ExecStart=/usr/bin/qbittorrent-nox --webui-port=10086 # 显式禁止自动重启 Restart=no # 可选：定义干净的停止信号（qbittorrent-nox 支持 SIGTERM） KillSignal=SIGTERM TimeoutStopSec=30 # 严格沙盒 ProtectSystem=strict ProtectHome=tmpfs NoNewPrivileges=true # 显式禁止访问核心敏感路径 InaccessiblePaths=/root /home/chocola/.ssh /home/chocola/.bash_history /opt /mnt /home/typecho # 仅放行启动必需的系统映射 BindReadOnlyPaths=/bin /lib /lib64 /usr /etc /proc /sys /dev/shm # 精确的业务路径（不要直接写父目录） ReadWritePaths=/home/chocola/.config/qBittorrent ReadWritePaths=/home/chocola/data/files/Download # 限制进程只能看到自己的临时文件 PrivateTmp=true NoNewPrivileges=true [Install] WantedBy=multi-user.target对于qBittorrent-nox，我的需求是按需运行，也就是只要在Cockpit管理面板上直接点点鼠标就可以启动服务，和之前用Mcsmanager差别不大，甚至Cockpit的话还能和其他系统设置一起聚合管理。验证限制配置生效这里以Jellyfin为例获取Jellyfin的PID：PID=$(pgrep -n jellyfin)尝试使用该进程的权限访问受限内容：nsenter -t $PID -m ls /mnt/如果结果如下：[root@Neko-Server ~]# PID=$(pgrep -n jellyfin) [root@Neko-Server ~]# nsenter -t $PID -m ls /mnt/ [root@Neko-Server ~]# ls /mnt/ 4T-RAID1 ssd小结通过这样一波操作，我取得了三赢的结果：减少了一个不必要的中间管理层服务，节省了服务器资源减少了一个潜在的攻击面，增强服务器的安全性严格控制应用权限，做到了基础的纵深防御或许，过段时间，我感觉运行没什么问题后会彻底移除掉Mcsmanager吧。这是一个很好的工具，但是我目前找到了更优雅的方案，也是时候说再见了。也有可能，后面还需要Mcsmanager的时候，我将会把它关在权限的笼子里，确保我的数据安全。
在并夕夕购入猫娘乐园巧克力手办偶然的机会，刷并夕夕的时候，我发现了一个无盒的巧克力手办，原价200多，我看到的时候，只需要90。说是微瑕，但是保证是正版的手办，我当时买的时候大意了，没细看商品图，啪一下就下单了。结果到货后，发现少了头发一边的丝带。。。《微瑕》，嗯，《微瑕》（不过除此之外，好像真没什么问题，整体挺好的，确实是正品的手办，细节没什么大问题。果然，价格腰斩是有原因的（贴几张图吧！