<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>技术分享随笔</title><description>不一定最快，但边走边想</description><link>https://47.113.107.125:80/</link><language>zh_CN</language><item><title>从零搭建AI小程序全栈实战（四）：模型训练</title><link>https://47.113.107.125:80/posts/ai-miniapp/04-%E6%A8%A1%E5%9E%8B%E8%AE%AD%E7%BB%83/</link><guid isPermaLink="true">https://47.113.107.125:80/posts/ai-miniapp/04-%E6%A8%A1%E5%9E%8B%E8%AE%AD%E7%BB%83/</guid><pubDate>Mon, 05 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;欢迎来到“从零搭建AI小程序全栈实战”系列文章！&lt;br /&gt;
此为本系列的第五篇文章，你可以&lt;a href=&quot;../01-%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87/&quot;&gt;点击跳转到第一篇文章&lt;/a&gt;在系列文章中快速跳转。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h1&gt;一、这篇文章将要做的&lt;/h1&gt;
&lt;h2&gt;1.1 回顾&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;上一篇我们完成了微信小程序前端，拍照上传→识别展示→历史记录全链路已通&lt;/li&gt;
&lt;li&gt;当前状态：小程序能跑通，但后端 /predict 返回的还是 fake_prediction，AI 是假的&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1.2 目标&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;训练一个真正的图像分类模型，替换掉假预测&lt;/li&gt;
&lt;li&gt;四个里程碑：
&lt;ul&gt;
&lt;li&gt;数据集准备与预处&lt;/li&gt;
&lt;li&gt;用 PyTorch 训练 ResNet18 分类模型&lt;/li&gt;
&lt;li&gt;用训练好的模型替换 Flask API&lt;/li&gt;
&lt;li&gt;调整 Spring Boot 后端适配新模型接口&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1.3 最终效果预览&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;小程序拍照后显示 &quot;organic（置信度: 93.21%）&quot; 而非 &quot;fake_prediction&quot;&lt;/li&gt;
&lt;li&gt;这一切跑在你自己的 GPU 上，不依赖任何第三方 AI 服务&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;二、 数据集准备与预处理&lt;/h1&gt;
&lt;h2&gt;2.1 选择数据集&lt;/h2&gt;
&lt;p&gt;我们使用 Kaggle 垃圾分类数据集&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;地址：&lt;a href=&quot;https://www.kaggle.com/datasets/techsash/waste-classification-data&quot;&gt;Waste Classification data | Kaggle&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;包含 2.5 万张图片，分 Organic（厨余）和 Recyclable（可回收）两类&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;二分类最简单，8G 显存轻松训练&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2.2 下载数据集并整理&lt;/h2&gt;
&lt;p&gt;在 Windows 上的 &lt;code&gt;G:\DEMO_Project\demo_assist\api-server\&lt;/code&gt; 中创建目录 &lt;code&gt;dataset&lt;/code&gt;、目录 &lt;code&gt;models&lt;/code&gt; 与文件 &lt;code&gt;train.py&lt;/code&gt; 。&lt;br /&gt;
创建后 &lt;code&gt;api-server&lt;/code&gt; 总体结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;G:\DEMO_Project\demo_assist\api-server\
├── dataset/
│   ├── train/
│   │   ├── organic/        # 厨余垃圾图片
│   │   └── recyclable/     # 可回收垃圾图片
│   └── test/
│       ├── organic/
│       └── recyclable/
├── models/                  # 训练好的模型存这里
├── train.py                 # 训练脚本
└── ....                     # 其他原有文件
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;三、模型训练脚本&lt;/h1&gt;
&lt;h2&gt;3.1 安装 PyTorch 依赖&lt;/h2&gt;
&lt;p&gt;打开 Anaconda Prompt，激活环境并安装新包：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;conda activate ai-env
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
pip install matplotlib tqdm pillow
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.2 编写训练脚本&lt;/h2&gt;
&lt;p&gt;编写 &lt;code&gt;train.py&lt;/code&gt; ，完整代码如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms, models
from torch.utils.data import DataLoader
import os
import matplotlib.pyplot as plt

# ========== 1. 配置参数 ==========
DATA_DIR = &apos;./dataset&apos;
MODEL_SAVE_PATH = &apos;./models/waste_classifier.pth&apos;
BATCH_SIZE = 32
EPOCHS = 10
LEARNING_RATE = 0.001
IMG_SIZE = 224
NUM_CLASSES = 2  # 根据你的类别数修改

device = torch.device(&apos;cuda&apos; if torch.cuda.is_available() else &apos;cpu&apos;)
print(f&apos;使用设备: {device}&apos;)

# ========== 2. 数据预处理 ==========
# 训练集：随机裁剪、翻转、旋转，增加数据多样性
train_transforms = transforms.Compose([
    transforms.RandomResizedCrop(IMG_SIZE),
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(15),
    transforms.ColorJitter(brightness=0.2, contrast=0.2),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# 测试集：只需要缩放和归一化
test_transforms = transforms.Compose([
    transforms.Resize((IMG_SIZE, IMG_SIZE)),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# ========== 3. 加载数据 ==========
train_dataset = datasets.ImageFolder(
    os.path.join(DATA_DIR, &apos;train&apos;), transform=train_transforms)
test_dataset = datasets.ImageFolder(
    os.path.join(DATA_DIR, &apos;test&apos;), transform=test_transforms)

train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)

# 自动获取类名映射
class_names = train_dataset.classes
print(f&apos;类别数量: {len(class_names)}, 类名: {class_names}&apos;)

# ========== 4. 构建模型 ==========
# 使用预训练的 ResNet18，只改最后的全连接层
model = models.resnet18(weights=&apos;DEFAULT&apos;)
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, NUM_CLASSES)
model = model.to(device)

# ========== 5. 损失函数和优化器 ==========
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)

# ========== 6. 训练循环 ==========
train_losses = []
test_accuracies = []

for epoch in range(EPOCHS):
    # --- 训练 ---
    model.train()
    running_loss = 0.0
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        running_loss += loss.item()

    avg_loss = running_loss / len(train_loader)
    train_losses.append(avg_loss)

    # --- 测试 ---
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in test_loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total
    test_accuracies.append(accuracy)

    print(f&apos;Epoch [{epoch+1}/{EPOCHS}], Loss: {avg_loss:.4f}, Test Accuracy: {accuracy:.2f}%&apos;)

# ========== 7. 保存模型 ==========
os.makedirs(&apos;models&apos;, exist_ok=True)
torch.save({
    &apos;model_state_dict&apos;: model.state_dict(),
    &apos;class_names&apos;: class_names,
    &apos;img_size&apos;: IMG_SIZE
}, MODEL_SAVE_PATH)
print(f&apos;模型已保存到 {MODEL_SAVE_PATH}&apos;)

# ========== 8. 绘制训练曲线 ==========
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.plot(train_losses)
plt.title(&apos;Training Loss&apos;)
plt.xlabel(&apos;Epoch&apos;)
plt.subplot(1, 2, 2)
plt.plot(test_accuracies)
plt.title(&apos;Test Accuracy&apos;)
plt.xlabel(&apos;Epoch&apos;)
plt.ylabel(&apos;%&apos;)
plt.tight_layout()
plt.savefig(&apos;./models/training_curve.png&apos;)
plt.show()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.3 开始训练&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cd &quot;G:\DEMO_Project\demo_assist\api-server\&quot;
python train.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正常输出类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;使用设备: cuda
类别数量: 2, 类名: [&apos;organic&apos;, &apos;recyclable&apos;]
Epoch [1/10], Loss: 0.3456, Test Accuracy: 85.23%
...
Epoch [10/10], Loss: 0.0213, Test Accuracy: 95.67%
模型已保存到 ./models/waste_classifier.pth
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们使用 &lt;code&gt;nvidia-smi&lt;/code&gt; 确认 GPU 在工作（显存占用约2~4GB）&lt;/p&gt;
&lt;p&gt;预计10 个 epoch 大约跑 15-30 分钟，时长取决于数据集大小。&lt;/p&gt;
&lt;h1&gt;四、使用真实模型替换 Flask API&lt;/h1&gt;
&lt;p&gt;现在 Flask API 里 &lt;code&gt;/predict&lt;/code&gt; 还在返回 &quot;fake_prediction&quot;，用刚训练好的模型替换掉。&lt;/p&gt;
&lt;h2&gt;4.1 改造 Flask API&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;api-server&lt;/code&gt; 文件夹 上找到之前创建的 &lt;code&gt;app.py&lt;/code&gt;，完整替换为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from flask import Flask, request, jsonify
import torch
import torch.nn as nn
from torchvision import transforms, models
from PIL import Image
import io
import os

app = Flask(__name__)

# ========== 1. 加载模型 ==========
MODEL_PATH = &apos;D:/ai-project/models/waste_classifier.pth&apos;
device = torch.device(&apos;cuda&apos; if torch.cuda.is_available() else &apos;cpu&apos;)

# 加载保存的模型信息
checkpoint = torch.load(MODEL_PATH, map_location=device)
class_names = checkpoint[&apos;class_names&apos;]
img_size = checkpoint.get(&apos;img_size&apos;, 224)

# 重建模型结构并加载权重
model = models.resnet18(weights=None)
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, len(class_names))
model.load_state_dict(checkpoint[&apos;model_state_dict&apos;])
model = model.to(device)
model.eval()

print(f&apos;模型已加载，类别: {class_names}, 设备: {device}&apos;)

# ========== 2. 预处理函数 ==========
transform = transforms.Compose([
    transforms.Resize((img_size, img_size)),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

def predict_image(image_bytes):
    &quot;&quot;&quot;输入图片字节流，返回预测类名和置信度&quot;&quot;&quot;
    image = Image.open(io.BytesIO(image_bytes)).convert(&apos;RGB&apos;)
    image_tensor = transform(image).unsqueeze(0).to(device)

    with torch.no_grad():
        outputs = model(image_tensor)
        probabilities = torch.nn.functional.softmax(outputs, dim=1)
        confidence, predicted_idx = torch.max(probabilities, 1)

    class_name = class_names[predicted_idx.item()]
    confidence_val = confidence.item()

    return class_name, confidence_val

# ========== 3. API 接口 ==========
@app.route(&apos;/health&apos;)
def health():
    return jsonify({
        &quot;status&quot;: &quot;ok&quot;,
        &quot;cuda&quot;: torch.cuda.is_available(),
        &quot;classes&quot;: class_names
    })

@app.route(&apos;/predict&apos;, methods=[&apos;POST&apos;])
def predict():
    # 方案A：接收图片URL（后端下载）
    if request.is_json:
        data = request.get_json()
        image_url = data.get(&apos;image_url&apos;, &apos;&apos;)
        if image_url.startswith(&apos;http&apos;):
            import requests
            response = requests.get(image_url, timeout=10)
            image_bytes = response.content
        else:
            # 本地路径
            with open(&apos;.&apos; + image_url, &apos;rb&apos;) as f:  # 路径前加 . 因为Flask工作目录问题
                image_bytes = f.read()
        class_name, confidence = predict_image(image_bytes)
        return jsonify({
            &quot;class&quot;: class_name,
            &quot;confidence&quot;: round(confidence, 4)
        })

    # 方案B：直接上传图片文件
    if &apos;file&apos; in request.files:
        file = request.files[&apos;file&apos;]
        image_bytes = file.read()
        class_name, confidence = predict_image(image_bytes)
        return jsonify({
            &quot;class&quot;: class_name,
            &quot;confidence&quot;: round(confidence, 4)
        })

    return jsonify({&quot;error&quot;: &quot;no image provided&quot;}), 400

if __name__ == &apos;__main__&apos;:
    app.run(host=&apos;0.0.0.0&apos;, port=5000, debug=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.2 本地测试新 API&lt;/h2&gt;
&lt;p&gt;在 Windows 端的 Powershell 上启动 Flask：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd &quot;G:\DEMO_Project\demo_assist\api-server\&quot;
python app.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用 Windows 端的浏览器访问 &lt;code&gt;http://localhost:5000/health&lt;/code&gt;，确认返回类名。&lt;/p&gt;
&lt;p&gt;在 服务器端 上用 &lt;code&gt;curl&lt;/code&gt; 测试：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST http://localhost:5000/predict \
  -F &quot;file=@D:/test_organic.jpg&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;期望返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;class&quot;: &quot;organic&quot;,
  &quot;confidence&quot;: 0.9578
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;五、Spring Boot 后端适配新模型&lt;/h1&gt;
&lt;p&gt;现在 Flask 返回的真结果格式变了，Spring Boot 后端需要做一点适配。&lt;/p&gt;
&lt;h2&gt;5.1 新增 DTO 类&lt;/h2&gt;
&lt;p&gt;原来的预测方法返回 &lt;code&gt;Map&amp;lt;String, Object&amp;gt;&lt;/code&gt;，现在需要解析新的字段。&lt;br /&gt;
让我们创建专门的 DTO 类来接收。&lt;/p&gt;
&lt;p&gt;新建 &lt;code&gt;src/main/java/com/example/demo/dto/AiPredictResult.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.dto;

public class AiPredictResult {
    private String clazz;      // 注意：class 是关键字，用 @JsonProperty
    private Double confidence;

    // Getter / Setter
    public String getClazz() { return clazz; }
    public void setClazz(String clazz) { this.clazz = clazz; }
    public Double getConfidence() { return confidence; }
    public void setConfidence(Double confidence) { this.confidence = confidence; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.2 修改 AiService&lt;/h2&gt;
&lt;p&gt;修改 &lt;code&gt;AiServce.java&lt;/code&gt; 中的调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public AiPredictResult predictByFile(MultipartFile file) throws IOException {
    // 把 MultipartFile 转成字节，发给 Flask
    // 注意：Spring Boot 在服务器上，笔记本 API 在 localhost:5000（通过 frp）
    String url = AI_BASE_URL + &quot;/predict&quot;;

    // 用 RestTemplate 上传文件
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.MULTIPART_FORM_DATA);

    // 先存为临时文件
    File tempFile = File.createTempFile(&quot;upload_&quot;, &quot;.jpg&quot;);
    file.transferTo(tempFile);

    MultiValueMap&amp;lt;String, Object&amp;gt; body = new LinkedMultiValueMap&amp;lt;&amp;gt;();
    body.add(&quot;file&quot;, new FileSystemResource(tempFile));

    HttpEntity&amp;lt;MultiValueMap&amp;lt;String, Object&amp;gt;&amp;gt; requestEntity = new HttpEntity&amp;lt;&amp;gt;(body, headers);
    ResponseEntity&amp;lt;AiPredictResult&amp;gt; response = restTemplate.postForEntity(
        url, requestEntity, AiPredictResult.class);

    // 清理临时文件
    tempFile.delete();

    return response.getBody();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.3 修改 Controller&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;ImageController&lt;/code&gt; 的 &lt;code&gt;uploadAndRecognize&lt;/code&gt; 方法中，调用新的 &lt;code&gt;predictByFile&lt;/code&gt; 并保存结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostMapping(&quot;/upload-and-recognize&quot;)
public Result&amp;lt;ImageRecord&amp;gt; uploadAndRecognize(@RequestParam(&quot;file&quot;) MultipartFile file) throws IOException {
    // 保存文件（原有逻辑）
    Files.createDirectories(Paths.get(uploadDir));
    String filename = System.currentTimeMillis() + &quot;_&quot; + file.getOriginalFilename();
    File dest = new File(uploadDir + &quot;/&quot; + filename);
    file.transferTo(dest);

    // 调用真实 AI 预测（新逻辑）
    AiPredictResult aiResult = aiService.predictByFile(file);
    String resultStr = aiResult.getClazz() + &quot; (置信度: &quot; + 
                       String.format(&quot;%.2f&quot;, aiResult.getConfidence() * 100) + &quot;%)&quot;;

    // 存数据库
    ImageRecord record = new ImageRecord(file.getOriginalFilename(), &quot;/uploads/&quot; + filename);
    record.setRecognitionResult(resultStr);
    return Result.success(repository.save(record));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.4 确保网络穿透正常&lt;/h2&gt;
&lt;p&gt;确保：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;笔记本上 Flask API 在跑（端口 5000）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;笔记本上 frpc 在跑，连上了服务器&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器上 frps 在跑&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在服务器上验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST http://localhost:5000/predict -F &quot;file=@一张图片.jpg&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回真实分类结果即可。&lt;/p&gt;
&lt;h1&gt;六、全链路验证&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;笔记本&lt;/strong&gt;：Flask + frpc 运行中&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;服务器&lt;/strong&gt;：jar 运行中&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;小程序&lt;/strong&gt;：拍照 → 上传 → 等待 2-3 秒 → 显示 &quot;recyclable (置信度: 95.67%)&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;历史记录&lt;/strong&gt;：能看到带有真实识别结果的记录&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;七、验证清单&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;数据集下载或自建完成，目录结构正确&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;train.py&lt;/code&gt; 训练完毕，测试准确率 &amp;gt; 85%（分类越少越容易达标）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;模型保存为 &lt;code&gt;.pth&lt;/code&gt; 文件，&lt;code&gt;class_names&lt;/code&gt; 正确保存&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Flask &lt;code&gt;/predict&lt;/code&gt; 接口能返回真实类名和置信度&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Spring Boot 能正确解析 AI 返回结果并存入数据库&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;小程序拍照后显示的不是 &quot;fake_prediction&quot;，而是 &quot;organic（置信度: 93.21%）&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>从零搭建AI小程序全栈实战（三）：微信小程序前端</title><link>https://47.113.107.125:80/posts/ai-miniapp/03-%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F%E5%89%8D%E7%AB%AF/</link><guid isPermaLink="true">https://47.113.107.125:80/posts/ai-miniapp/03-%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F%E5%89%8D%E7%AB%AF/</guid><pubDate>Sun, 04 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;欢迎来到“从零搭建AI小程序全栈实战”系列文章！&lt;br /&gt;
此为本系列的第四篇文章，你可以&lt;a href=&quot;../01-%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87/&quot;&gt;点击跳转到第一篇文章&lt;/a&gt;在系列文章中快速跳转。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h1&gt;一、这篇文章将要做的&lt;/h1&gt;
&lt;h2&gt;1.1 回顾&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;上一篇我们完成了后端工程化加固，现在 Spring Boot 有统一响应、异常处理、接口文档、数据校验&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当前状态：后端接口全部就绪，&lt;code&gt;/api/images/upload-and-recognize&lt;/code&gt; 能接收图片并返回识别结果&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1.2 目标&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;写一个微信小程序，把后端的能力&quot;装进手机&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;四个里程碑：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;小程序开发环境搭建 + 拍照页面&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;调用后端接口，打通上传→识别→展示&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;历史记录列表页&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;体验优化（图片压缩、加载动画、底部导航）&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;二、创建第一个小程序项目&lt;/h1&gt;
&lt;h2&gt;2.1 注册小程序账号与下载工具&lt;/h2&gt;
&lt;h4&gt;注册（如果还没有）&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;打开 &lt;a href=&quot;https://mp.weixin.qq.com/&quot;&gt;微信公众平台&lt;/a&gt;，点击“立即注册”，选择“小程序”。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;用未注册过公众平台的邮箱注册，按提示完成主体类型选择（个人即可）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;注册完成后，在「开发」→「开发管理」→「开发设置」页面，&lt;strong&gt;记下你的 AppID&lt;/strong&gt;（后面要用）。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;下载开发者工具&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;打开 &lt;a href=&quot;https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html&quot;&gt;微信开发者工具下载&lt;/a&gt;，下载 Windows 64 位稳定版。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;安装后打开，用刚刚注册的小程序绑定的微信扫码登录。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2.2 创建项目&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;点击「+」创建项目。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;填入：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;项目名称：&lt;code&gt;ai-mini-app&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;目录：选一个空文件夹&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;AppID：粘贴你在后台记下的 AppID&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;开发模式：小程序&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;模板选择：&lt;strong&gt;「JavaScript 基础模板」&lt;/strong&gt;（不要选 TypeScript，先降低复杂度）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;点击确定，你会看到默认模板生成的项目结构：&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;├── pages/
│   └── index/       首页
│       ├── index.js
│       ├── index.wxml
│       ├── index.wxss
│       └── index.json
├── app.js           全局逻辑
├── app.json         全局配置
├── app.wxss         全局样式
└── project.config.json
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.3 认识四个核心文件&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件类型&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;类比&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.wxml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;页面结构，写 HTML 类似的标签&lt;/td&gt;
&lt;td&gt;相当于网页的 HTML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.wxss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;页面样式，写 CSS&lt;/td&gt;
&lt;td&gt;相当于网页的 CSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;页面逻辑，处理数据、发请求&lt;/td&gt;
&lt;td&gt;相当于网页的 JavaScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;页面配置（如标题、导航栏颜色）&lt;/td&gt;
&lt;td&gt;微信特有的配置&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一个页面由这四个文件组成，名字必须完全相同。&lt;/p&gt;
&lt;h2&gt;2.4 改造首页&lt;/h2&gt;
&lt;p&gt;改写 &lt;code&gt;pages/index/index.wxml&lt;/code&gt;（清空原有内容，写入）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;view class=&quot;container&quot;&amp;gt;
  &amp;lt;!-- 标题 --&amp;gt;
  &amp;lt;view class=&quot;title&quot;&amp;gt;AI图像识别&amp;lt;/view&amp;gt;

  &amp;lt;!-- 图片展示区（拍照后显示在这里） --&amp;gt;
  &amp;lt;view class=&quot;image-area&quot;&amp;gt;
    &amp;lt;image wx:if=&quot;{{imageUrl}}&quot; src=&quot;{{imageUrl}}&quot; mode=&quot;aspectFit&quot; class=&quot;preview-image&quot;/&amp;gt;
    &amp;lt;view wx:else class=&quot;placeholder&quot;&amp;gt;
      &amp;lt;text&amp;gt;请点击下方按钮拍照&amp;lt;/text&amp;gt;
    &amp;lt;/view&amp;gt;
  &amp;lt;/view&amp;gt;

  &amp;lt;!-- 拍照按钮 --&amp;gt;
  &amp;lt;button class=&quot;camera-btn&quot; bindtap=&quot;takePhoto&quot;&amp;gt;
    📷 拍照识别
  &amp;lt;/button&amp;gt;

  &amp;lt;!-- 识别结果区 --&amp;gt;
  &amp;lt;view wx:if=&quot;{{result}}&quot; class=&quot;result-area&quot;&amp;gt;
    &amp;lt;text class=&quot;result-title&quot;&amp;gt;识别结果：&amp;lt;/text&amp;gt;
    &amp;lt;text class=&quot;result-text&quot;&amp;gt;{{result}}&amp;lt;/text&amp;gt;
  &amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改写 &lt;code&gt;pages/index/index.wxss&lt;/code&gt;（样式）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.container {
  padding: 40rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.title {
  font-size: 40rpx;
  font-weight: bold;
  margin-bottom: 40rpx;
}
.image-area {
  width: 100%;
  height: 400rpx;
  background: #f5f5f5;
  border-radius: 20rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 30rpx;
}
.preview-image {
  width: 100%;
  height: 100%;
  border-radius: 20rpx;
}
.placeholder {
  color: #999;
  font-size: 28rpx;
}
.camera-btn {
  width: 80%;
  background: #07c160;
  color: white;
  font-size: 32rpx;
  margin-bottom: 30rpx;
}
.result-area {
  width: 100%;
  padding: 30rpx;
  background: #fff;
  border-radius: 20rpx;
  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
}
.result-title {
  font-size: 30rpx;
  font-weight: bold;
  display: block;
  margin-bottom: 10rpx;
}
.result-text {
  font-size: 28rpx;
  color: #333;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改写 &lt;code&gt;pages/index/index.js&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Page({
  data: {
    imageUrl: &apos;&apos;,   // 拍照后的图片临时路径
    result: &apos;&apos;      // 识别结果文本
  },

  // 拍照方法
  takePhoto() {
    // 调用微信相机
    wx.chooseMedia({
      count: 1,
      mediaType: [&apos;image&apos;],
      sourceType: [&apos;camera&apos;],  // 只允许拍照
      success: (res) =&amp;gt; {
        const tempFilePath = res.tempFiles[0].tempFilePath
        this.setData({
          imageUrl: tempFilePath
        })
        // 拍照后直接提示（后续连接后端）
        wx.showToast({ title: &apos;拍照成功！&apos;, icon: &apos;success&apos; })
      },
      fail: (err) =&amp;gt; {
        console.error(&apos;拍照失败&apos;, err)
        wx.showToast({ title: &apos;拍照失败&apos;, icon: &apos;error&apos; })
      }
    })
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.5 预览效果&lt;/h2&gt;
&lt;p&gt;在开发者工具顶部点击「预览」→ 手机微信扫码，在真机上点击按钮看能否调起相机，并显示图片。&lt;br /&gt;
如果工具里没法调相机，先点「编译」确认页面显示正常即可。&lt;/p&gt;
&lt;h1&gt;三、发起HTTP请求连接后端&lt;/h1&gt;
&lt;h2&gt;3.1 配置服务器域名&lt;/h2&gt;
&lt;p&gt;在微信公众平台后台 →「开发」→「开发管理」→「开发设置」→「服务器域名」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;request 合法域名&lt;/strong&gt;：添加 &lt;code&gt;https://你的域名&lt;/code&gt; 或先用 &lt;code&gt;https://你的服务器IP&lt;/code&gt;（如果只有 IP，临时设置为 &lt;code&gt;https://12.34.56.78&lt;/code&gt;）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;uploadFile 合法域名&lt;/strong&gt;：同样添加你的服务器地址&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;开发阶段绕过域名校验&lt;/strong&gt;：在开发者工具右上角「详情」→「本地设置」→ 勾选「不校验合法域名、TLS 证书等」，否则本地 HTTP 请求发不出去。&lt;/p&gt;
&lt;h2&gt;3.2 封装网络请求工具&lt;/h2&gt;
&lt;p&gt;为了方便管理，统一封装请求。创建 &lt;code&gt;utils/api.js&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 你的服务器地址（阶段1部署的 Spring Boot）
const BASE_URL = &apos;http://你的服务器IP:8080&apos;

function request(url, method = &apos;GET&apos;, data = {}) {
  return new Promise((resolve, reject) =&amp;gt; {
    wx.request({
      url: BASE_URL + url,
      method: method,
      data: data,
      header: {
        &apos;Content-Type&apos;: &apos;application/json&apos;
      },
      success: (res) =&amp;gt; {
        if (res.statusCode === 200) {
          resolve(res.data)
        } else {
          reject(res.data)
        }
      },
      fail: (err) =&amp;gt; {
        reject(err)
      }
    })
  })
}

// 上传图片专用
function uploadImage(filePath) {
  return new Promise((resolve, reject) =&amp;gt; {
    wx.uploadFile({
      url: BASE_URL + &apos;/api/images/upload-and-recognize&apos;,
      filePath: filePath,
      name: &apos;file&apos;,
      success: (res) =&amp;gt; {
        try {
          const data = JSON.parse(res.data)
          if (data.code === 200) {
            resolve(data.data)
          } else {
            reject(data)
          }
        } catch(e) {
          reject({ message: &apos;解析失败&apos; })
        }
      },
      fail: (err) =&amp;gt; reject(err)
    })
  })
}

module.exports = { request, uploadImage }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.3 在页面中调用后端&lt;/h2&gt;
&lt;p&gt;改造 &lt;code&gt;pages/index/index.js&lt;/code&gt;，引入 api 模块，拍照后自动上传：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const api = require(&apos;../../utils/api&apos;)

Page({
  data: {
    imageUrl: &apos;&apos;,
    result: &apos;&apos;
  },

  takePhoto() {
    wx.chooseMedia({
      count: 1,
      mediaType: [&apos;image&apos;],
      sourceType: [&apos;camera&apos;],
      success: (res) =&amp;gt; {
        const tempFilePath = res.tempFiles[0].tempFilePath
        this.setData({ imageUrl: tempFilePath })

        // 显示上传中
        wx.showLoading({ title: &apos;识别中...&apos; })

        // 调用后端 AI 识别接口
        api.uploadImage(tempFilePath)
          .then(record =&amp;gt; {
            wx.hideLoading()
            this.setData({
              result: record.recognitionResult || &apos;识别完成&apos;
            })
            wx.showToast({ title: &apos;识别成功！&apos;, icon: &apos;success&apos; })
          })
          .catch(err =&amp;gt; {
            wx.hideLoading()
            console.error(&apos;识别失败&apos;, err)
            wx.showToast({ title: &apos;识别失败，请重试&apos;, icon: &apos;error&apos; })
          })
      },
      fail: (err) =&amp;gt; {
        console.error(&apos;拍照失败&apos;, err)
      }
    })
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.4 验证全链路&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;确保服务器上 jar 在跑，笔记本上 Flask API + frp 也在跑。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;小程序拍照 → 显示 loading → 返回结果（目前是 fake_prediction，但证明链路通）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;现在是 小程序 → Spring Boot → 笔记本 AI → 返回结果 的全链路打通时刻！&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;四、历史记录列表页&lt;/h1&gt;
&lt;h2&gt;4.1 创建 history 页面&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;pages&lt;/code&gt; 文件夹上右键 → 新建文件夹 &lt;code&gt;history&lt;/code&gt;，然后新建 Page（选四个文件）。&lt;/p&gt;
&lt;p&gt;修改以下文件&lt;/p&gt;
&lt;p&gt;&lt;code&gt;history.wxml&lt;/code&gt; :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;view class=&quot;container&quot;&amp;gt;
  &amp;lt;view class=&quot;title&quot;&amp;gt;历史记录&amp;lt;/view&amp;gt;

  &amp;lt;view wx:if=&quot;{{list.length === 0}}&quot; class=&quot;empty&quot;&amp;gt;暂无记录&amp;lt;/view&amp;gt;

  &amp;lt;view wx:for=&quot;{{list}}&quot; wx:key=&quot;id&quot; class=&quot;card&quot;&amp;gt;
    &amp;lt;image src=&quot;{{serverUrl}}{{item.imageUrl}}&quot; mode=&quot;aspectFill&quot; class=&quot;card-image&quot;/&amp;gt;
    &amp;lt;view class=&quot;card-info&quot;&amp;gt;
      &amp;lt;text class=&quot;card-name&quot;&amp;gt;{{item.originalFilename}}&amp;lt;/text&amp;gt;
      &amp;lt;text class=&quot;card-result&quot;&amp;gt;{{item.recognitionResult || &apos;未识别&apos;}}&amp;lt;/text&amp;gt;
      &amp;lt;text class=&quot;card-time&quot;&amp;gt;{{item.createTime}}&amp;lt;/text&amp;gt;
    &amp;lt;/view&amp;gt;
  &amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;history.js&lt;/code&gt; :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const api = require(&apos;../../utils/api&apos;)

Page({
  data: {
    list: [],
    serverUrl: &apos;http://你的服务器IP:8080&apos;  // 拼图片完整路径
  },

  onShow() {
    this.loadHistory()
  },

  loadHistory() {
    wx.showLoading({ title: &apos;加载中&apos; })
    api.request(&apos;/api/images&apos;, &apos;GET&apos;)
      .then(res =&amp;gt; {
        wx.hideLoading()
        if (res.code === 200) {
          this.setData({ list: res.data.reverse() })  // 最新的在前
        }
      })
      .catch(err =&amp;gt; {
        wx.hideLoading()
        console.error(err)
      })
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;history.wxss&lt;/code&gt; :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.container { padding: 20rpx; }
.title { font-size: 36rpx; font-weight: bold; padding: 20rpx; }
.empty { text-align: center; color: #999; margin-top: 100rpx; }
.card { display: flex; background: white; border-radius: 16rpx; padding: 20rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08); }
.card-image { width: 150rpx; height: 150rpx; border-radius: 12rpx; }
.card-info { flex: 1; margin-left: 20rpx; display: flex; flex-direction: column; justify-content: space-around; }
.card-name { font-size: 28rpx; font-weight: bold; }
.card-result { font-size: 24rpx; color: #07c160; }
.card-time { font-size: 22rpx; color: #999; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.2 注册页面&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;app.json&lt;/code&gt; 的 &lt;code&gt;pages&lt;/code&gt; 数组中注册：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;pages&quot;: [
  &quot;pages/index/index&quot;,
  &quot;pages/history/history&quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.3 添加底部导航栏&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;app.json&lt;/code&gt; 中添加 &lt;code&gt;tabBar&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;tabBar&quot;: {
  &quot;color&quot;: &quot;#999&quot;,
  &quot;selectedColor&quot;: &quot;#07c160&quot;,
  &quot;list&quot;: [
    {
      &quot;pagePath&quot;: &quot;pages/index/index&quot;,
      &quot;text&quot;: &quot;拍照&quot;,
      &quot;iconPath&quot;: &quot;images/camera.png&quot;,
      &quot;selectedIconPath&quot;: &quot;images/camera-active.png&quot;
    },
    {
      &quot;pagePath&quot;: &quot;pages/history/history&quot;,
      &quot;text&quot;: &quot;记录&quot;,
      &quot;iconPath&quot;: &quot;images/history.png&quot;,
      &quot;selectedIconPath&quot;: &quot;images/history-active.png&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;五、图片压缩&lt;/h1&gt;
&lt;h2&gt;5.0 为什么要压缩&lt;/h2&gt;
&lt;p&gt;手机拍照一张图通常 2-5 MB，服务器带宽小（2核2G），直接上传可能要 5-10 秒。压缩到 100KB 以内，秒传。&lt;/p&gt;
&lt;h2&gt;5.1 改造 &lt;code&gt;index.js&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;实现思路&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;takePhoto&lt;/code&gt; 拍照成功后，不直接上传，而是先调用 &lt;code&gt;wx.compressImage&lt;/code&gt; 压缩，压缩后的临时文件再上传。&lt;/p&gt;
&lt;p&gt;完整改造 &lt;code&gt;pages/index/index.js&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const api = require(&apos;../../utils/api&apos;)

Page({
  data: {
    imageUrl: &apos;&apos;,        // 压缩后的用于展示
    originalUrl: &apos;&apos;,     // 原始图（如果需要预览大图）
    result: &apos;&apos;,
    uploading: false     // 上传中状态，防重复点击
  },

  takePhoto() {
    // 防重复点击
    if (this.data.uploading) return

    wx.chooseMedia({
      count: 1,
      mediaType: [&apos;image&apos;],
      sourceType: [&apos;camera&apos;],
      success: (res) =&amp;gt; {
        const originalPath = res.tempFiles[0].tempFilePath

        // 先显示原图（给用户即时反馈）
        this.setData({ 
          imageUrl: originalPath,
          originalUrl: originalPath,
          result: &apos;&apos;  // 清空旧结果
        })

        // 压缩图片
        this.compressAndUpload(originalPath)
      },
      fail: (err) =&amp;gt; {
        console.error(&apos;拍照失败&apos;, err)
        wx.showToast({ title: &apos;拍照失败&apos;, icon: &apos;error&apos; })
      }
    })
  },

  // 压缩后上传
  compressAndUpload(filePath) {
    wx.showLoading({ title: &apos;压缩中...&apos; })

    wx.compressImage({
      src: filePath,
      quality: 60,           // 60%质量，肉眼几乎看不出差别
      success: (compressRes) =&amp;gt; {
        wx.hideLoading()
        // 用压缩后的图片展示（加载更快）
        this.setData({ imageUrl: compressRes.tempFilePath })
        // 上传并识别
        this.uploadAndRecognize(compressRes.tempFilePath)
      },
      fail: () =&amp;gt; {
        // 压缩失败直接用原图上传
        wx.hideLoading()
        this.uploadAndRecognize(filePath)
      }
    })
  },

  // 上传到后端识别
  uploadAndRecognize(filePath) {
    this.setData({ uploading: true })
    wx.showLoading({ title: &apos;识别中...&apos;, mask: true })

    api.uploadImage(filePath)
      .then(record =&amp;gt; {
        wx.hideLoading()
        this.setData({
          result: record.recognitionResult || &apos;识别完成&apos;,
          uploading: false
        })
        wx.showToast({ title: &apos;识别成功！&apos;, icon: &apos;success&apos; })
      })
      .catch(err =&amp;gt; {
        wx.hideLoading()
        this.setData({ uploading: false })
        console.error(&apos;识别失败&apos;, err)
        // 显示重试选项（任务3详讲）
        this.showRetryDialog(filePath)
      })
  },

  showRetryDialog(filePath) {
    wx.showModal({
      title: &apos;识别失败&apos;,
      content: &apos;网络似乎不太好，要重试吗？&apos;,
      confirmText: &apos;重试&apos;,
      cancelText: &apos;取消&apos;,
      success: (res) =&amp;gt; {
        if (res.confirm) {
          this.uploadAndRecognize(filePath)
        }
      }
    })
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关键点说明&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;quality: 60&lt;/code&gt;：平衡清晰度与大小，可调，80/60/40 根据实际压缩结果选。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;mask: true&lt;/code&gt;：loading 时阻止用户点其他按钮。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;uploading&lt;/code&gt; 状态：防止用户狂点按钮造成重复上传。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;六、前端系统优化&lt;/h1&gt;
&lt;h2&gt;6.1 加载状态与空状态完善&lt;/h2&gt;
&lt;h3&gt;6.1.1 历史记录页优化 &lt;code&gt;pages/history/history.js&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;const api = require(&apos;../../utils/api&apos;)

Page({
  data: {
    list: [],
    serverUrl: &apos;http://你的服务器IP:8080&apos;,
    loading: true,      // 首次加载状态
    error: false,       // 是否加载失败
    errorMsg: &apos;&apos;
  },

  onShow() {
    this.loadHistory()
  },

  loadHistory() {
    this.setData({ loading: true, error: false })

    api.request(&apos;/api/images&apos;, &apos;GET&apos;)
      .then(res =&amp;gt; {
        this.setData({ loading: false })
        if (res.code === 200) {
          this.setData({ list: res.data.reverse() })
          if (res.data.length === 0) {
            // 空状态由 wxml 处理
          }
        }
      })
      .catch(err =&amp;gt; {
        console.error(&apos;加载历史失败&apos;, err)
        this.setData({
          loading: false,
          error: true,
          errorMsg: &apos;加载失败，请检查网络&apos;
        })
      })
  },

  // 下拉刷新
  onPullDownRefresh() {
    this.loadHistory()
    wx.stopPullDownRefresh()
  },

  // 重试按钮
  retry() {
    this.loadHistory()
  }
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.1.2 历史记录页模板优化&lt;code&gt;pages/history/history.wxml&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;view class=&quot;container&quot;&amp;gt;
  &amp;lt;view class=&quot;title&quot;&amp;gt;历史记录&amp;lt;/view&amp;gt;

  &amp;lt;!-- 加载中 --&amp;gt;
  &amp;lt;view wx:if=&quot;{{loading}}&quot; class=&quot;status-box&quot;&amp;gt;
    &amp;lt;text&amp;gt;加载中...&amp;lt;/text&amp;gt;
  &amp;lt;/view&amp;gt;

  &amp;lt;!-- 加载失败 --&amp;gt;
  &amp;lt;view wx:elif=&quot;{{error}}&quot; class=&quot;status-box&quot;&amp;gt;
    &amp;lt;text class=&quot;error-text&quot;&amp;gt;{{errorMsg}}&amp;lt;/text&amp;gt;
    &amp;lt;button class=&quot;retry-btn&quot; bindtap=&quot;retry&quot;&amp;gt;重新加载&amp;lt;/button&amp;gt;
  &amp;lt;/view&amp;gt;

  &amp;lt;!-- 空状态 --&amp;gt;
  &amp;lt;view wx:elif=&quot;{{list.length === 0}}&quot; class=&quot;status-box&quot;&amp;gt;
    &amp;lt;text class=&quot;empty-icon&quot;&amp;gt;📷&amp;lt;/text&amp;gt;
    &amp;lt;text class=&quot;empty-text&quot;&amp;gt;还没有识别记录&amp;lt;/text&amp;gt;
    &amp;lt;text class=&quot;empty-hint&quot;&amp;gt;去首页拍张照片试试吧&amp;lt;/text&amp;gt;
  &amp;lt;/view&amp;gt;

  &amp;lt;!-- 列表 --&amp;gt;
  &amp;lt;view wx:else&amp;gt;
    &amp;lt;view wx:for=&quot;{{list}}&quot; wx:key=&quot;id&quot; class=&quot;card&quot; bindtap=&quot;previewImage&quot; data-url=&quot;{{serverUrl}}{{item.imageUrl}}&quot;&amp;gt;
      &amp;lt;image src=&quot;{{serverUrl}}{{item.imageUrl}}&quot; mode=&quot;aspectFill&quot; class=&quot;card-image&quot;/&amp;gt;
      &amp;lt;view class=&quot;card-info&quot;&amp;gt;
        &amp;lt;text class=&quot;card-name&quot;&amp;gt;{{item.originalFilename}}&amp;lt;/text&amp;gt;
        &amp;lt;text class=&quot;card-result&quot;&amp;gt;{{item.recognitionResult || &apos;未识别&apos;}}&amp;lt;/text&amp;gt;
        &amp;lt;text class=&quot;card-time&quot;&amp;gt;{{item.createTime}}&amp;lt;/text&amp;gt;
      &amp;lt;/view&amp;gt;
      &amp;lt;text class=&quot;arrow&quot;&amp;gt;&amp;gt;&amp;lt;/text&amp;gt;
    &amp;lt;/view&amp;gt;
  &amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.1.3 历史记录页样式补充&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;/* 状态占位 */
.status-box {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 100rpx 0;
}
.empty-icon { font-size: 80rpx; margin-bottom: 20rpx; }
.empty-text { font-size: 28rpx; color: #666; }
.empty-hint { font-size: 24rpx; color: #999; margin-top: 10rpx; }
.error-text { color: #e74c3c; font-size: 28rpx; margin-bottom: 20rpx; }
.retry-btn {
  background: #07c160;
  color: white;
  font-size: 26rpx;
  padding: 10rpx 40rpx;
  border-radius: 40rpx;
}
.arrow { color: #ccc; font-size: 28rpx; align-self: center; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6.2 错误处理&lt;/h2&gt;
&lt;p&gt;完善 &lt;code&gt;utils/api.js&lt;/code&gt;，加入超时和统一错误提示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const BASE_URL = &apos;http://你的服务器IP:8080&apos;

// 通用请求
function request(url, method = &apos;GET&apos;, data = {}, showError = true) {
  return new Promise((resolve, reject) =&amp;gt; {
    wx.request({
      url: BASE_URL + url,
      method: method,
      data: data,
      timeout: 10000,  // 10秒超时
      header: { &apos;Content-Type&apos;: &apos;application/json&apos; },
      success: (res) =&amp;gt; {
        if (res.statusCode === 200) {
          resolve(res.data)
        } else {
          if (showError) wx.showToast({ title: &apos;服务器错误&apos;, icon: &apos;none&apos; })
          reject(res.data)
        }
      },
      fail: (err) =&amp;gt; {
        if (showError) {
          wx.showToast({ title: &apos;网络连接失败&apos;, icon: &apos;none&apos; })
        }
        reject(err)
      }
    })
  })
}

// 上传图片
function uploadImage(filePath) {
  return new Promise((resolve, reject) =&amp;gt; {
    wx.uploadFile({
      url: BASE_URL + &apos;/api/images/upload-and-recognize&apos;,
      filePath: filePath,
      name: &apos;file&apos;,
      timeout: 30000,  // 上传给30秒
      success: (res) =&amp;gt; {
        try {
          const data = JSON.parse(res.data)
          if (data.code === 200) {
            resolve(data.data)
          } else {
            wx.showToast({ title: data.message || &apos;识别失败&apos;, icon: &apos;none&apos; })
            reject(data)
          }
        } catch(e) {
          reject({ message: &apos;数据解析失败&apos; })
        }
      },
      fail: (err) =&amp;gt; {
        wx.showToast({ title: &apos;上传失败&apos;, icon: &apos;none&apos; })
        reject(err)
      }
    })
  })
}

module.exports = { request, uploadImage }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6.3 完善UI细节&lt;/h2&gt;
&lt;h3&gt;6.3.1 图片预览&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;history.wxml&lt;/code&gt; 中，卡片已经绑定了 &lt;code&gt;bindtap=&quot;previewImage&quot;&lt;/code&gt;。现在在 &lt;code&gt;history.js&lt;/code&gt; 中加入方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 在 Page({}) 内部添加
previewImage(e) {
  const url = e.currentTarget.dataset.url
  wx.previewImage({
    urls: [url],       // 图片地址数组，可以多张左右滑动
    current: url       // 当前显示哪张
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;首页也想预览的话&lt;/strong&gt;，在 &lt;code&gt;index.wxml&lt;/code&gt; 的 &lt;code&gt;&amp;lt;image&amp;gt;&lt;/code&gt; 标签上加 &lt;code&gt;bindtap=&quot;previewImage&quot;&lt;/code&gt;，&lt;code&gt;index.js&lt;/code&gt; 中同样加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;previewImage() {
  wx.previewImage({
    urls: [this.data.originalUrl || this.data.imageUrl],
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.3.2 统一色调&lt;/h3&gt;
&lt;p&gt;选定一个主色，比如微信绿 &lt;code&gt;#07c160&lt;/code&gt;，所有按钮、高亮用这个色。在 &lt;code&gt;app.wxss&lt;/code&gt; 中定义全局变量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;page {
  --primary: #07c160;
  --danger: #e74c3c;
  --text: #333;
  --text-light: #999;
  --bg: #f5f5f5;
  background-color: var(--bg);
  font-size: 28rpx;
  color: var(--text);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.3.3 首页美化与细节交互&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;/* 全面美化版 index.wxss */
.container {
  padding: 30rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100vh;
}

.title {
  font-size: 42rpx;
  font-weight: bold;
  color: var(--primary);
  margin: 40rpx 0 30rpx;
  letter-spacing: 2rpx;
}

.image-area {
  width: 100%;
  height: 450rpx;
  background: white;
  border-radius: 24rpx;
  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 40rpx;
  overflow: hidden;
}

.preview-image {
  width: 100%;
  height: 100%;
  border-radius: 24rpx;
}

.placeholder {
  text-align: center;
  color: #bbb;
}

.placeholder-icon {
  font-size: 80rpx;
  display: block;
  margin-bottom: 16rpx;
}

.placeholder-text {
  font-size: 28rpx;
}

.camera-btn {
  width: 80%;
  height: 88rpx;
  background: linear-gradient(135deg, #07c160, #06ad56);
  color: white;
  font-size: 34rpx;
  font-weight: bold;
  border-radius: 44rpx;
  box-shadow: 0 8rpx 20rpx rgba(7, 193, 96, 0.3);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 40rpx;
}

.camera-btn:active {
  opacity: 0.8;
  transform: scale(0.98);
}

.result-box {
  width: 100%;
  background: white;
  border-radius: 24rpx;
  padding: 30rpx;
  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
}

.result-label {
  font-size: 26rpx;
  color: #999;
  margin-bottom: 12rpx;
}

.result-text {
  font-size: 32rpx;
  font-weight: bold;
  color: var(--primary);
  word-break: break-all;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应更新 &lt;code&gt;index.wxml&lt;/code&gt; 的占位区：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;view wx:else class=&quot;placeholder&quot;&amp;gt;
  &amp;lt;text class=&quot;placeholder-icon&quot;&amp;gt;📷&amp;lt;/text&amp;gt;
  &amp;lt;text class=&quot;placeholder-text&quot;&amp;gt;点击下方按钮拍照&amp;lt;/text&amp;gt;
&amp;lt;/view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以及结果区：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;view wx:if=&quot;{{result}}&quot; class=&quot;result-box&quot;&amp;gt;
  &amp;lt;text class=&quot;result-label&quot;&amp;gt;🔍 识别结果&amp;lt;/text&amp;gt;
  &amp;lt;text class=&quot;result-text&quot;&amp;gt;{{result}}&amp;lt;/text&amp;gt;
&amp;lt;/view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.3.4 底部导航图标生成&lt;/h3&gt;
&lt;p&gt;用 emoji 代替图标文件，或者去 &lt;a href=&quot;https://www.iconfont.cn/&quot;&gt;iconfont&lt;/a&gt; 下载免费图标（48x48 px）。&lt;/p&gt;
&lt;p&gt;如果 &lt;code&gt;app.json&lt;/code&gt; 中没图标，可以暂时注释掉 &lt;code&gt;iconPath&lt;/code&gt; 和 &lt;code&gt;selectedIconPath&lt;/code&gt;，只用文字：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;tabBar&quot;: {
  &quot;color&quot;: &quot;#999&quot;,
  &quot;selectedColor&quot;: &quot;#07c160&quot;,
  &quot;list&quot;: [
    {
      &quot;pagePath&quot;: &quot;pages/index/index&quot;,
      &quot;text&quot;: &quot;📷 拍照&quot;
    },
    {
      &quot;pagePath&quot;: &quot;pages/history/history&quot;,
      &quot;text&quot;: &quot;📋 记录&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;七、验收清单&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;小程序点击&quot;拍照识别&quot;能调起相机&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;拍照后图片显示在页面上，并上传到后端&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;后端返回识别结果，页面显示&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;历史记录页能展示所有识别过的记录&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;底部导航栏正常切换&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>从零搭建AI小程序全栈实战（二·下篇）：Java 后端工程化加固</title><link>https://47.113.107.125:80/posts/ai-miniapp/02b-java%E5%90%8E%E7%AB%AF%E6%89%93%E5%BA%95%E4%B8%8B/</link><guid isPermaLink="true">https://47.113.107.125:80/posts/ai-miniapp/02b-java%E5%90%8E%E7%AB%AF%E6%89%93%E5%BA%95%E4%B8%8B/</guid><pubDate>Sat, 03 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;欢迎来到“从零搭建AI小程序全栈实战”系列文章！&lt;br /&gt;
此为本系列的第三篇文章，你可以&lt;a href=&quot;../01-%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87/&quot;&gt;点击跳转到第一篇文章&lt;/a&gt;在系列文章中快速跳转。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h1&gt;一、这篇文章将要做的&lt;/h1&gt;
&lt;h2&gt;1.1 回顾&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;上一篇我们完成了CRUD、图片上传、调用AI推理&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;当前状态：接口能跑，但返回格式参差不齐，出错时直接500页面&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1.2 目标&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;让后端&quot;像个正经项目&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;四个加固项：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;统一响应格式——前端解析不再猜&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;全局异常处理——错误也返回JSON&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Swagger接口文档——在线可测试&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;数据校验——非法参数提前拦截&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;二、统一响应格式&lt;/h1&gt;
&lt;h2&gt;2.0 为什么需要统一格式&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;先看现在的问题&lt;/strong&gt;，你现在 &lt;code&gt;ImageController&lt;/code&gt; 的返回是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 返回 ImageRecord 对象
@PostMapping
public ImageRecord create(@RequestBody ImageRecord record) {
    return repository.save(record);
}

// 返回字符串
@DeleteMapping(&quot;/{id}&quot;)
public String delete(@PathVariable Long id) {
    repository.deleteById(id);
    return &quot;deleted&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前端收到的东西会像这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 调用创建接口可能返回：
{&quot;id&quot;:1, &quot;originalFilename&quot;:&quot;test.jpg&quot;, &quot;imageUrl&quot;:&quot;/uploads/test.jpg&quot;, ...}

// 调用删除接口可能返回：
&quot;deleted&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;前端同学不知道返回的到底是成功还是失败，只能猜&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;返回格式不统一，前端要写两套处理逻辑&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;万一出错，Spring Boot 直接扔个 500 页面的 HTML，前端根本没法解析&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;统一格式后&lt;/strong&gt;，无论是创建、查询、删除、所有接口都返回这种结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;code&quot;: 200,        // 200=成功，400=参数错误，500=服务器错误
  &quot;message&quot;: &quot;success&quot;, // 给人类看的提示
  &quot;data&quot;: {            // 真正的数据放在这里
    &quot;id&quot;: 1,
    &quot;originalFilename&quot;: &quot;test.jpg&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前端只需要写一套解析逻辑，看 &lt;code&gt;code&lt;/code&gt; 就能知道成败。&lt;/p&gt;
&lt;h2&gt;2.1 创建 Result 类&lt;/h2&gt;
&lt;p&gt;在服务器上创建 &lt;code&gt;src/main/java/com/example/demo/common/Result.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.common;

public class Result&amp;lt;T&amp;gt; {
    private int code;
    private String message;
    private T data;

    // 无参构造（必须，框架反射用）
    public Result() {}

    // 全参构造
    public Result(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    // ========== 静态工厂方法 ==========

    // 成功，带数据
    public static &amp;lt;T&amp;gt; Result&amp;lt;T&amp;gt; success(T data) {
        return new Result&amp;lt;&amp;gt;(200, &quot;success&quot;, data);
    }

    // 成功，不带数据（比如删除操作）
    public static &amp;lt;T&amp;gt; Result&amp;lt;T&amp;gt; success() {
        return new Result&amp;lt;&amp;gt;(200, &quot;success&quot;, null);
    }

    // 失败，自定义状态码和消息
    public static &amp;lt;T&amp;gt; Result&amp;lt;T&amp;gt; error(int code, String message) {
        return new Result&amp;lt;&amp;gt;(code, message, null);
    }

    // ========== Getter / Setter ==========
    public int getCode() { return code; }
    public void setCode(int code) { this.code = code; }

    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }

    public T getData() { return data; }
    public void setData(T data) { this.data = data; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;代码&lt;/th&gt;
&lt;th&gt;什么意思&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;泛型，&lt;code&gt;T&lt;/code&gt; 可以是 String、ImageRecord、List，复用同一个类&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;private T data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;实际返回的数据，类型由调用时决定&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;public static &amp;lt;T&amp;gt; Result&amp;lt;T&amp;gt; success(T data)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;静态方法，直接 &lt;code&gt;Result.success(某对象)&lt;/code&gt; 就能生成一个成功响应&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Result.error(400, &quot;参数错误&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;返回错误时用，前端看到 &lt;code&gt;code=400&lt;/code&gt; 就知道出问题了&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;2.2 改造 Controller&lt;/h2&gt;
&lt;p&gt;把 &lt;code&gt;ImageController&lt;/code&gt; 每个方法的返回值都包一层 &lt;code&gt;Result&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;2.2.1 导入 Result 类&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;ImageController.java&lt;/code&gt; 顶部加上：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import com.example.demo.common.Result;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2.2 新建记录——返回创建好的数据&lt;/h3&gt;
&lt;p&gt;原来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostMapping
public ImageRecord create(@RequestBody ImageRecord record) {
    return repository.save(record);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostMapping
public Result&amp;lt;ImageRecord&amp;gt; create(@RequestBody ImageRecord record) {
    ImageRecord saved = repository.save(record);
    return Result.success(saved);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2.3 查询列表——返回数组&lt;/h3&gt;
&lt;p&gt;原来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@GetMapping
public List&amp;lt;ImageRecord&amp;gt; list() {
    return repository.findAll();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@GetMapping
public Result&amp;lt;List&amp;lt;ImageRecord&amp;gt;&amp;gt; list() {
    List&amp;lt;ImageRecord&amp;gt; records = repository.findAll();
    return Result.success(records);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2.4 查询单个——不存在时返回错误&lt;/h3&gt;
&lt;p&gt;原来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@GetMapping(&quot;/{id}&quot;)
public ImageRecord getById(@PathVariable Long id) {
    return repository.findById(id).orElse(null);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@GetMapping(&quot;/{id}&quot;)
public Result&amp;lt;ImageRecord&amp;gt; getById(@PathVariable Long id) {
    return repository.findById(id)
        .map(Result::success)
        .orElse(Result.error(404, &quot;记录不存在&quot;));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2.5 删除——返回成功但不带数据&lt;/h3&gt;
&lt;p&gt;原来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@DeleteMapping(&quot;/{id}&quot;)
public String delete(@PathVariable Long id) {
    repository.deleteById(id);
    return &quot;deleted&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@DeleteMapping(&quot;/{id}&quot;)
public Result&amp;lt;Void&amp;gt; delete(@PathVariable Long id) {
    repository.deleteById(id);
    return Result.success();  // data = null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2.6 上传并识别——返回待识别结果的记录&lt;/h3&gt;
&lt;p&gt;原来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostMapping(&quot;/upload-and-recognize&quot;)
public ImageRecord uploadAndRecognize(@RequestParam(&quot;file&quot;) MultipartFile file) throws IOException {
    // ...上传和调用AI...
    return repository.save(record);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改后：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostMapping(&quot;/upload-and-recognize&quot;)
public Result&amp;lt;ImageRecord&amp;gt; uploadAndRecognize(@RequestParam(&quot;file&quot;) MultipartFile file) throws IOException {
    // ...上传和调用AI逻辑不变...
    ImageRecord saved = repository.save(record);
    return Result.success(saved);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.3 验证&lt;/h2&gt;
&lt;p&gt;继续使用 &lt;code&gt;curl&lt;/code&gt; 查看效果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 创建
curl -X POST http://localhost:8080/api/images \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;originalFilename&quot;:&quot;test.jpg&quot;, &quot;imageUrl&quot;:&quot;/uploads/test.jpg&quot;}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回会变成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;code&quot;: 200,
  &quot;message&quot;: &quot;success&quot;,
  &quot;data&quot;: {
    &quot;id&quot;: 1,
    &quot;originalFilename&quot;: &quot;test.jpg&quot;,
    &quot;imageUrl&quot;: &quot;/uploads/test.jpg&quot;,
    ...
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当我们尝试用一个不存在的编号查找图片时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 查询
curl http://localhost:8080/api/images/999
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时会返回：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;code&quot;: 404,
  &quot;message&quot;: &quot;记录不存在&quot;,
  &quot;data&quot;: null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;三、全局异常处理&lt;/h1&gt;
&lt;h2&gt;3.0 为什么我们需要这个&lt;/h2&gt;
&lt;p&gt;因为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;即使我们写得再好，运行时总有意想不到的异常&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不能让前端收到HTML格式的500错误页面&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在，让所有未捕获的异常和手动抛出的异常都返回统一格式的 &lt;code&gt;Result&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;创建 &lt;code&gt;src/main/java/com/example/demo/exception/GlobalExceptionHandler.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.exception;

import com.example.demo.common.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
        // 记录日志（实际项目用 log）
        e.printStackTrace();
        return Result.error(500, &quot;服务器内部错误：&quot; + e.getMessage());
    }

    // 可以针对性捕获，比如参数校验异常
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;之后再故意制造错误（如请求一个不存在的接口），返回的 JSON 会是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;code&quot;:500, &quot;message&quot;:&quot;服务器内部错误：...&quot;, &quot;data&quot;:null}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;四、Springdoc 接口文档&lt;/h1&gt;
&lt;h2&gt;4.0 为什么我们需要这个&lt;/h2&gt;
&lt;p&gt;现在你的后端有七八个接口了：健康检查、CRUD、上传、识别等等。你自己写的时候可能记得清清楚楚，但过两周再回来看，或者前端同学来问你&quot;这个接口参数是什么、返回什么&quot;，你大概率要翻半天代码才能答上来。&lt;/p&gt;
&lt;p&gt;Springdoc 的作用就是&lt;strong&gt;自动生成一份在线接口文档&lt;/strong&gt;，列出所有接口的地址、参数、返回格式，还能直接在页面上测试。省去了手动写文档的麻烦，也方便前后端联调。&lt;/p&gt;
&lt;h2&gt;4.1 添加依赖&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;pom.xml&lt;/code&gt; 的 &lt;code&gt;&amp;lt;dependencies&amp;gt;&lt;/code&gt; 中添加 SpringDoc（Spring Boot 3.x 对应的 Swagger 实现）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;org.springdoc&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;springdoc-openapi-starter-webmvc-ui&amp;lt;/artifactId&amp;gt;
    &amp;lt;version&amp;gt;2.3.0&amp;lt;/version&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;springdoc-openapi-starter-webmvc-ui&lt;/code&gt; 这个依赖已经内置了 Swagger UI，不需要额外引入任何 UI 包。&lt;/p&gt;
&lt;h2&gt;4.2 配置类（可选）&lt;/h2&gt;
&lt;p&gt;大多数情况下 springdoc 零配置就能跑，但加上配置类可以让文档页面更专业。&lt;/p&gt;
&lt;p&gt;新建 &lt;code&gt;src/main/java/com/example/demo/config/SpringDocConfig.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringDocConfig {
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title(&quot;AI 小程序后端 API&quot;)
                        .version(&quot;1.0&quot;)
                        .description(&quot;垃圾分类识别——图像上传、AI推理、记录管理&quot;));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;显示在文档页顶部的标题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;API 版本号&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;对整套接口的简要说明&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;4.3 访问文档&lt;/h2&gt;
&lt;p&gt;启动项目后，在浏览器打开：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://localhost:8080/swagger-ui/index.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你会看到一个可直接交互的 API 文档页面，列出了所有 Controller 的接口，还能&lt;strong&gt;在线填入参数、发送请求、查看响应&lt;/strong&gt;，不需要再开 Postman 或 curl。&lt;/p&gt;
&lt;h1&gt;五、数据校验&lt;/h1&gt;
&lt;h2&gt;5.1 添加依赖&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;pom.xml&lt;/code&gt; 的 &lt;code&gt;&amp;lt;dependencies&amp;gt;&lt;/code&gt; 中添加 &lt;code&gt;spring-boot-starter-validation&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;org.springframework.boot&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;spring-boot-starter-validation&amp;lt;/artifactId&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.2 在实体或DTO上加注解&lt;/h2&gt;
&lt;p&gt;比如限制文件名不能为空：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import jakarta.validation.constraints.NotBlank;

public class ImageRecord {
    // ...
    @NotBlank(message = &quot;文件名不能为空&quot;)
    private String originalFilename;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.3 Controller 参数加 &lt;code&gt;@Valid&lt;/code&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;@PostMapping
public Result&amp;lt;ImageRecord&amp;gt; create(@Valid @RequestBody ImageRecord record) {
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时如果请求不带 &lt;code&gt;originalFilename&lt;/code&gt;，会抛出 &lt;code&gt;MethodArgumentNotValidException&lt;/code&gt;，但会被我们的 &lt;code&gt;GlobalExceptionHandler&lt;/code&gt; 捕获并返回 500 错误。&lt;/p&gt;
&lt;p&gt;为了返回更友好的格式，可以在 &lt;code&gt;GlobalExceptionHandler&lt;/code&gt; 中单独处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import org.springframework.web.bind.MethodArgumentNotValidException;

@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleValidation(MethodArgumentNotValidException e) {
    String msg = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
    return Result.error(400, msg);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;六、重新打包部署&lt;/h1&gt;
&lt;p&gt;以下演示一次完整的打包部署过程。&lt;/p&gt;
&lt;h2&gt;6.0 前置条件假设&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Spring Boot&lt;/code&gt; 项目根目录位于 &lt;code&gt;G:\DEMO_Project\demo&lt;/code&gt; 。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;远程 Linux 服务器中 &lt;code&gt;JAVA&lt;/code&gt; 项目工作目录为 &lt;code&gt;/opt/app/&lt;/code&gt; 。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Spring Boot&lt;/code&gt; 项目中 &lt;code&gt;pom.xml&lt;/code&gt; 中为  &lt;code&gt;&amp;lt;version&amp;gt;1.0.0&amp;lt;/version&amp;gt;&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6.1 Maven 打包&lt;/h2&gt;
&lt;p&gt;打开 Intellij IDEA ，选择侧边栏中的 &lt;code&gt;Maven&lt;/code&gt;  ，选择 &lt;code&gt;执行Maven项目&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;输入 &lt;code&gt;mvn clean package&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;等待出现如下提示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[INFO] -------------------------------------------
[INFO] BUILD SUCCESS
[INFO] -------------------------------------------
[INFO] Total time:  4.368 s
[INFO] Finished at: 2026-05-20T21:39:41+08:00
[INFO] -------------------------------------------
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时 &lt;code&gt;Spring Boot&lt;/code&gt; 项目中出现新文件夹 &lt;code&gt;target&lt;/code&gt; 。&lt;/p&gt;
&lt;p&gt;在&lt;code&gt;G:\DEMO_Project\demo\target&lt;/code&gt; 文件夹下出现两个文件 &lt;code&gt;demo-1.0.0.jar&lt;/code&gt; 和 &lt;code&gt;demo-1.0.0.jar.original&lt;/code&gt; 和若干文件夹。&lt;/p&gt;
&lt;p&gt;注意，我们只需要 &lt;code&gt;demo-1.0.0.jar&lt;/code&gt; ，不需要带 &lt;code&gt;original&lt;/code&gt; 版本的文件。&lt;/p&gt;
&lt;h2&gt;6.2 传送文件&lt;/h2&gt;
&lt;p&gt;我们使用 &lt;code&gt;scp&lt;/code&gt; 传送我们打包好的 JAR 文件。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;首先&lt;/strong&gt;，让我们在 Windows 上切换我们的 &lt;code&gt;Powershell&lt;/code&gt; 工作目录。&lt;/p&gt;
&lt;p&gt;打开 &lt;code&gt;Powershell&lt;/code&gt; ，输入以下命令 ：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PS C:\Users\TerryC&amp;gt; cd &quot;G:\DEMO_Project\demo&quot;
PS G:\DEMO_Project\demo&amp;gt; scp target/demo-1.0.0.jar terryc@1.2.3.4:/opt/app
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;terryc&lt;/code&gt; 此处替换为你在远程服务器的用户名，如 &lt;code&gt;root&lt;/code&gt; 。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;@1.2.3.4&lt;/code&gt; 此处替换为你的服务器&lt;strong&gt;外网IP地址&lt;/strong&gt;，如 &lt;code&gt;@111.111.111.111&lt;/code&gt; 。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;:/opt/app&lt;/code&gt; 此处为你的应用工作目录，你的项目将会在这个目录启动，可替换为其他目录。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6.3 重建软链接，重启服务&lt;/h2&gt;
&lt;p&gt;在服务器上关闭 &lt;code&gt;demo&lt;/code&gt; 项目：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl stop demo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把我们传送到服务器上的 &lt;code&gt;demo-1.0.0.jar&lt;/code&gt; 链接到 &lt;code&gt;demo.service&lt;/code&gt; 的 &lt;code&gt;demo-current.jar&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ln -sf /opt/app/demo-1.0.0.jar /opt/app/demo-current.jar
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动并检查 &lt;code&gt;demo&lt;/code&gt; 项目的状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl start demo
sudo systemctl status demo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;出现 &lt;code&gt;Active: active (running)&lt;/code&gt; ，则为成功启动。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terryc@i23fFG3edMoPa321Z:~$ sudo systemctl status demo
● demo.service - Demo Application
     Loaded: loaded (/etc/systemd/system/demo.service; enabled; preset: enabled)
     Active: active (running) since Wed 2026-05-20 22:02:11 CST; 1s ago
   Main PID: 46722 (java)
      Tasks: 14 (limit: 1860)
     Memory: 46.6M (peak: 46.9M)
        CPU: 1.962s
     CGroup: /system.slice/demo.service
             └─46722 /usr/bin/java -Xms512m -Xmx1g -jar /opt/app/demo-current.jar


&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;七、验收清单&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;所有接口返回统一的 &lt;code&gt;Result&lt;/code&gt; 格式（已做）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;出错时返回 JSON 而不会出现 500 页面（全局异常处理）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;可以访问 &lt;code&gt;doc.html&lt;/code&gt; 看到接口文档&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;提交非法参数时收到 &lt;code&gt;code: 400&lt;/code&gt; 和具体错误信息&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>从零搭建AI小程序全栈实战（二·上篇）：Spring Boot 基础功能开发</title><link>https://47.113.107.125:80/posts/ai-miniapp/02a-java%E5%90%8E%E7%AB%AF%E6%89%93%E5%BA%95%E4%B8%8A/</link><guid isPermaLink="true">https://47.113.107.125:80/posts/ai-miniapp/02a-java%E5%90%8E%E7%AB%AF%E6%89%93%E5%BA%95%E4%B8%8A/</guid><pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;欢迎来到“从零搭建AI小程序全栈实战”系列文章！&lt;br /&gt;
此为本系列的第二篇文章，你可以&lt;a href=&quot;../01-%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87/&quot;&gt;点击跳转到第一篇文章&lt;/a&gt;在系列文章中快速跳转。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h1&gt;一、这篇文章将要做的&lt;/h1&gt;
&lt;h2&gt;1.1 回顾&lt;/h2&gt;
&lt;p&gt;上一篇文章中，我们把笔记本GPU推理服务和服务器Spring Boot骨架都跑起来了，网络也打通了。&lt;/p&gt;
&lt;p&gt;我们完成了以下目标：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;笔记本跑通GPU推理——驱动、CUDA、PyTorch、Flask&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器跑通Spring Boot——JDK、Demo应用&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;打通网络——frps、frpc、验证&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1.2 这一篇的目标&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;集成MySQL + 完整CRUD&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;图片上传接口&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;调用笔记本AI推理服务&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;二、集成 MySQL 与 完整 CRUD&lt;/h1&gt;
&lt;h2&gt;2.1 服务器端 安装 MySQL&lt;/h2&gt;
&lt;p&gt;在服务器上执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt install mysql-server -y
sudo mysql_secure_installation   # 按提示设置 root 密码，后面都选 Y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建数据库和用户：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mysql -u root -p
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;CREATE DATIF NOT EXISTS mini_app DEFAULT CHARACTER SET utf8mb4;
-- 把 &apos;your_password&apos; 改成自己的强密码，记住它
CREATE USER &apos;appuser&apos;@&apos;localhost&apos; IDENTIFIED BY &apos;your_password&apos;;
GRANT ALL PRIVILEGES ON mini_app.* TO &apos;appuser&apos;@&apos;localhost&apos;;
FLUSH PRIVILEGES;
EXIT;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.2 本地项目添加依赖&lt;/h2&gt;
&lt;p&gt;在你的 &lt;code&gt;pom.xml&lt;/code&gt; 里添加依赖（放在 &lt;code&gt;&amp;lt;dependencies&amp;gt;&lt;/code&gt; 里现有的 &lt;code&gt;spring-boot-starter-web&lt;/code&gt; 旁边）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;org.springframework.boot&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;spring-boot-starter-data-jpa&amp;lt;/artifactId&amp;gt;
&amp;lt;/dependency&amp;gt;
&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;com.mysql&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;mysql-connector-j&amp;lt;/artifactId&amp;gt;
    &amp;lt;scope&amp;gt;runtime&amp;lt;/scope&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.3 配置数据库连接&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;src/main/resources/application.properties&lt;/code&gt; 中加入数据库连接（注意替换密码）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;spring.datasource.url=jdbc:mysql://你的IP地址:3306/mini_app?useSSL=false&amp;amp;serverTimezone=Asia/Shanghai&amp;amp;allowPublicKeyRetrieval=true
spring.datasource.username=appuser
spring.datasource.password=你的密码
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.4 创建实体类 ImageRecord&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/main/java/com/example/demo/entity/ImageRecord.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = &quot;image_records&quot;)
public class ImageRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String originalFilename;

    @Column(columnDefinition = &quot;TEXT&quot;)
    private String imageUrl;        // 存储路径或base64
    private String recognitionResult;
    private LocalDateTime createTime;

    // 必须有无参构造
    public ImageRecord() {}

    // 快捷构造
    public ImageRecord(String originalFilename, String imageUrl) {
        this.originalFilename = originalFilename;
        this.imageUrl = imageUrl;
        this.createTime = LocalDateTime.now();
    }

    // Getter / Setter （IDE生成，篇幅限制省略，你全加上）
    // 至少需要：getId setId getOriginalFilename setOriginalFilename ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.5 创建 Repository 和 Controller&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/main/java/com/example/demo/repository/ImageRecordRepository.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.repository;
import com.example.demo.entity.ImageRecord;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImageRecordRepository extends JpaRepository&amp;lt;ImageRecord, Long&amp;gt; {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建 &lt;code&gt;src/main/java/com/example/demo/repository/ImageController.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.controller;
import com.example.demo.entity.ImageRecord;
import com.example.demo.repository.ImageRecordRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping(&quot;/api/images&quot;)
public class ImageController {
    @Autowired
    private ImageRecordRepository repository;

    @PostMapping
    public ImageRecord create(@RequestBody ImageRecord record) {
        return repository.save(record);
    }

    @GetMapping
    public List&amp;lt;ImageRecord&amp;gt; list() {
        return repository.findAll();
    }

    @GetMapping(&quot;/{id}&quot;)
    public ImageRecord getById(@PathVariable Long id) {
        return repository.findById(id).orElse(null);
    }

    @DeleteMapping(&quot;/{id}&quot;)
    public String delete(@PathVariable Long id) {
        repository.deleteById(id);
        return &quot;deleted&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.6 验证&lt;/h2&gt;
&lt;p&gt;在服务器通过 &lt;code&gt;curl&lt;/code&gt; 测试四个接口，确认数据库读写正常。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 创建一条记录
curl -X POST http://localhost:8080/api/images \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;originalFilename&quot;:&quot;test.jpg&quot;, &quot;imageUrl&quot;:&quot;/uploads/test.jpg&quot;}&apos;

# 查询所有
curl http://localhost:8080/api/images
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;三、添加图片上传接口&lt;/h1&gt;
&lt;h2&gt;3.1 配置文件上传参数&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;application.properties&lt;/code&gt; 追加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
file.upload-dir=./uploads
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.2 添加文件上传接口&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;ImageController&lt;/code&gt; 中追加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;

@Value(&quot;${file.upload-dir}&quot;)
private String uploadDir;

@PostMapping(&quot;/upload&quot;)
public ImageRecord upload(@RequestParam(&quot;file&quot;) MultipartFile file) throws IOException {
    // 保证目录存在
    Files.createDirectories(Paths.get(uploadDir));

    // 保存文件
    String filename = System.currentTimeMillis() + &quot;_&quot; + file.getOriginalFilename();
    File dest = new File(uploadDir + &quot;/&quot; + filename);
    file.transferTo(dest);

    // 存数据库
    ImageRecord record = new ImageRecord(file.getOriginalFilename(), &quot;/uploads/&quot; + filename);
    return repository.save(record);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.3 配置静态资源映射（让上传的图片可访问）&lt;/h2&gt;
&lt;p&gt;新建 &lt;code&gt;src/main/java/com/example/demo/config/WebConfig.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler(&quot;/uploads/**&quot;)
                .addResourceLocations(&quot;file:./uploads/&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.4 验证&lt;/h2&gt;
&lt;p&gt;我们使用 &lt;code&gt;curl&lt;/code&gt; 模拟上传：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST http://localhost:8080/api/images/upload \
  -F &quot;file=@/home/ubuntu/一张测试图片.jpg&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回结果里应该有 &lt;code&gt;imageUrl: &quot;/uploads/xxxx.jpg&quot;&lt;/code&gt;，浏览器访问 &lt;code&gt;http://服务器IP:8080/uploads/xxxx.jpg&lt;/code&gt; 能显示图片，这样就成功了。&lt;/p&gt;
&lt;h1&gt;四、调用 Windows端 AI推理服务&lt;/h1&gt;
&lt;h2&gt;4.1 确认 API 可达&lt;/h2&gt;
&lt;p&gt;先在服务器上验证穿透仍正常（笔记本端 frpc 要开着）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl http://localhost:5000/health
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回 &lt;code&gt;{&quot;cuda&quot;:true}&lt;/code&gt; 就 OK。&lt;/p&gt;
&lt;h2&gt;4.2 创建调用 Windows端 API 的服务&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/main/java/com/example/demo/service/AiService.java&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo.service;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;

@Service
public class AiService {
    private final RestTemplate restTemplate = new RestTemplate();
    private final String AI_BASE_URL = &quot;http://localhost:5000&quot;;

    public Map&amp;lt;String, Object&amp;gt; predict(Map&amp;lt;String, Object&amp;gt; input) {
        return restTemplate.postForObject(
            AI_BASE_URL + &quot;/predict&quot;, input, Map.class);
    }

    public Map&amp;lt;String, Object&amp;gt; health() {
        return restTemplate.getForObject(AI_BASE_URL + &quot;/health&quot;, Map.class);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.3 在 Controller 中串联&lt;/h2&gt;
&lt;p&gt;修改 &lt;code&gt;ImageController&lt;/code&gt;，注入 &lt;code&gt;AiService&lt;/code&gt; 并添加一个“上传即识别”的接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Autowired
private AiService aiService;

@PostMapping(&quot;/upload-and-recognize&quot;)
public ImageRecord uploadAndRecognize(@RequestParam(&quot;file&quot;) MultipartFile file) throws IOException {
    // 1. 保存文件（复用上面的上传逻辑）
    Files.createDirectories(Paths.get(uploadDir));
    String filename = System.currentTimeMillis() + &quot;_&quot; + file.getOriginalFilename();
    File dest = new File(uploadDir + &quot;/&quot; + filename);
    file.transferTo(dest);

    // 2. 调用笔记本 AI 推理
    Map&amp;lt;String, Object&amp;gt; aiInput = Map.of(&quot;imageUrl&quot;, &quot;/uploads/&quot; + filename);
    Map&amp;lt;String, Object&amp;gt; result = aiService.predict(aiInput);

    // 3. 存库
    ImageRecord record = new ImageRecord(file.getOriginalFilename(), &quot;/uploads/&quot; + filename);
    record.setRecognitionResult(result.toString());  // 实际项目解析具体字段
    return repository.save(record);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.4 全链路测试&lt;/h2&gt;
&lt;p&gt;我们使用 &lt;code&gt;curl&lt;/code&gt; 测试：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST http://localhost:8080/api/images/upload-and-recognize \
  -F &quot;file=@/home/ubuntu/一张测试图片.jpg&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果返回记录中包含 &lt;code&gt;recognitionResult&lt;/code&gt; 字段（即使是 &lt;code&gt;&quot;fake_prediction&quot;&lt;/code&gt; 也算成功），代表 &lt;strong&gt;Spring Boot → 笔记本 AI 调用链路已打通&lt;/strong&gt;。&lt;/p&gt;
&lt;h1&gt;五、打包部署到 Linux&lt;/h1&gt;
&lt;h2&gt;5.1 打包 JAR&lt;/h2&gt;
&lt;h3&gt;5.1.1 确认 JAR 文件名&lt;/h3&gt;
&lt;p&gt;在打包之前，先看一下项目最终生成的JAR叫什么名字。这个名称由 &lt;code&gt;pom.xml&lt;/code&gt; 中的三个标签决定：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;groupId&amp;gt;com.example&amp;lt;/groupId&amp;gt;
&amp;lt;artifactId&amp;gt;demo&amp;lt;/artifactId&amp;gt;
&amp;lt;version&amp;gt;0.0.1-SNAPSHOT&amp;lt;/version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;拼接规则：&lt;code&gt;{artifactId}-{version}.jar&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;所以你的JAR文件名叫：&lt;strong&gt;&lt;code&gt;demo-0.0.1-SNAPSHOT.jar&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;5.1.2 在 IDEA 中打包&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;IDEA 右侧边栏点击 &lt;strong&gt;Maven&lt;/strong&gt;（竖排的 &quot;m&quot; 图标）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;展开 &lt;code&gt;demo&lt;/code&gt; → &lt;code&gt;Lifecycle&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;先双击 &lt;strong&gt;clean&lt;/strong&gt;（清理上次构建的旧文件，避免混入过期内容）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;等待 &lt;code&gt;BUILD SUCCESS&lt;/code&gt;，再双击 &lt;strong&gt;package&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;控制台出现以下字样即打包成功：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;5.1.3 找到打包好的 JAR&lt;/h3&gt;
&lt;p&gt;打包完成后，JAR 文件位于项目的 &lt;code&gt;target&lt;/code&gt; 目录下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;你的项目目录/
├── src/
├── target/
│   ├── demo-0.0.1-SNAPSHOT.jar      ← 这个就是我们要的
│   └── ...
├── pom.xml
└── ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;在 IDEA 中直接查看&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;IDEA 左侧项目文件树，找到 &lt;code&gt;target&lt;/code&gt; 文件夹，展开即可看到 &lt;code&gt;demo-0.0.1-SNAPSHOT.jar&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;右键点击 JAR 文件 → &lt;strong&gt;Open In&lt;/strong&gt; → &lt;strong&gt;Explorer&lt;/strong&gt;（或 &lt;strong&gt;Finder&lt;/strong&gt;），可以直接在文件资源管理器中定位到该文件，方便下一步上传。&lt;/p&gt;
&lt;h2&gt;5.2 上传到服务器&lt;/h2&gt;
&lt;p&gt;:::tip&lt;/p&gt;
&lt;p&gt;如果服务器上没有 &lt;code&gt;/opt/app&lt;/code&gt; 目录，先 SSH 到服务器执行 &lt;code&gt;mkdir -p /opt/app&lt;/code&gt; 创建。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;p&gt;在 Powershell （Win + R 输入 &lt;code&gt;powershell&lt;/code&gt;）使用 &lt;code&gt;scp&lt;/code&gt; 命令上传服务器：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;scp target/demo-xxxx.jar terryc@1.2.3.4:/opt/app
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;terryc&lt;/code&gt; 此处替换为你的用户名，如 &lt;code&gt;root&lt;/code&gt; 。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;@1.2.3.4&lt;/code&gt; 此处替换为你的服务器&lt;strong&gt;外网IP地址&lt;/strong&gt;，如 &lt;code&gt;@111.111.111.111&lt;/code&gt; 。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;:/opt/app&lt;/code&gt; 此处为你的应用工作目录，你的项目将会在这个目录启动，可替换为其他目录。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5.3 在服务器上启动 Spring Boot 项目&lt;/h2&gt;
&lt;p&gt;以文件名 &lt;code&gt;/opt/app/demo-0.0.1-SNAPSHOT.jar&lt;/code&gt; 为例。&lt;/p&gt;
&lt;h3&gt;5.3.1 使用 nohup 启动&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;nohup&lt;/code&gt; 是最简单的后台启动方式，适合临时测试：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nohup java -jar /opt/app/demo-0.0.1-SNAPSHOT.jar &amp;gt; /opt/app/app.log 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;nohup&lt;/code&gt;：退出SSH后进程不中断&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;&amp;gt; app.log 2&amp;gt;&amp;amp;1&lt;/code&gt;：把日志输出到文件&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;：后台运行&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;让我们来验证Spring Boot项目是否启动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tail -f /opt/app/app.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到 &lt;code&gt;Started DemoApplication in ... seconds&lt;/code&gt; 即启动成功。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但这种方式有明显缺点&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;重启服务器后不会自动启动，需要手动执行&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;想停止只能手动 &lt;code&gt;ps -ef | grep java&lt;/code&gt; 找到PID然后 &lt;code&gt;kill&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;更新JAR包时步骤繁琐：停进程 → 换包 → 重启&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;时间久了容易忘记项目在哪个目录、用的哪个JAR&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以，下面我们换成更规范的方式。&lt;/p&gt;
&lt;h3&gt;5.3.2 使用 service 启动（推荐）&lt;/h3&gt;
&lt;h4&gt;5.3.2.0 为什么要推荐 service ？&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;对比项&lt;/th&gt;
&lt;th&gt;nohup&lt;/th&gt;
&lt;th&gt;systemd service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;开机自启&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ 需要手动设置脚本&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;systemctl enable&lt;/code&gt; 一行搞定&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;启停操作&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;找PID → kill&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl start/stop/restart&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;崩溃重启&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ 进程挂了就挂了&lt;/td&gt;
&lt;td&gt;✅ 配置 &lt;code&gt;Restart=on-failure&lt;/code&gt; 自动拉起&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;日志管理&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;自己用 &lt;code&gt;&amp;gt;&lt;/code&gt; 重定向&lt;/td&gt;
&lt;td&gt;&lt;code&gt;journalctl -u demo -f&lt;/code&gt; 统一查看&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;版本更新&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;手动替换JAR，容易出错&lt;/td&gt;
&lt;td&gt;配合软链接，换JAR后 &lt;code&gt;systemctl restart&lt;/code&gt; 即可&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;状态查看&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ps -ef | grep java&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl status demo&lt;/code&gt; 一眼看清&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;5.3.2.1 创建 demo.service 文件&lt;/h4&gt;
&lt;p&gt;在服务器上创建 service 配置文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo vim /etc/systemd/system/demo.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写入以下内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=Spring Boot Demo Application
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/app
ExecStart=/usr/bin/java -jar /opt/app/demo-current/demo-0.0.1-SNAPSHOT.jar
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键字段解释：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;After=network.target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;等网络就绪后再启动，避免数据库连不上&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Type=simple&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;标准类型，适用于Java这种前台进程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WorkingDirectory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;工作目录，上传的文件会存在这里&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ExecStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;启动命令，指向 /opt/app/demo-current.jar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Restart=on-failure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;进程崩溃时自动重启&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RestartSec=10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;等10秒再重启，避免频繁重启死循环&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WantedBy=multi-user.target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;多用户模式下自动启动&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;5.3.2.2 创建 demo-current.jar 软链接&lt;/h4&gt;
&lt;p&gt;以后更新JAR包时，新版本文件名会是 &lt;code&gt;demo-0.0.2-SNAPSHOT.jar&lt;/code&gt;、&lt;code&gt;demo-1.0.0.jar&lt;/code&gt; 等。如果 service 文件里写死了具体文件名，每次更新还要改service配置，太麻烦。&lt;/p&gt;
&lt;p&gt;用软链接的方式：&lt;strong&gt;service 永远指向 &lt;code&gt;demo-current.jar&lt;/code&gt;，更新时只需把软链接指向新版本JAR即可&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 创建软链接，指向当前版本的JAR
ln -s /opt/app/demo-0.0.1-SNAPSHOT.jar /opt/app/demo-current.jar
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行后，&lt;code&gt;/opt/app/demo-current.jar&lt;/code&gt; 就指向了真实的JAR文件。可以用 &lt;code&gt;ls -l&lt;/code&gt; 验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls -l /opt/app/demo-current.jar
# 输出：demo-current.jar -&amp;gt; /opt/app/demo-0.0.1-SNAPSHOT.jar
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;5.3.2.3 启动并启用服务&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 重新加载 service 配置
sudo systemctl daemon-reload

# 启动服务
sudo systemctl start demo

# 设置开机自启
sudo systemctl enable demo

# 查看状态
sudo systemctl status demo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到 &lt;code&gt;active (running)&lt;/code&gt; 即成功。&lt;/p&gt;
&lt;p&gt;常用管理命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl stop demo      # 停止
sudo systemctl restart demo   # 重启
sudo systemctl status demo    # 查看状态
journalctl -u demo -f         # 实时查看日志
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.4 验证部署成功&lt;/h2&gt;
&lt;p&gt;使用 &lt;code&gt;curl&lt;/code&gt; 对项目接口进行测试。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 服务器本地测试
curl http://localhost:8080/hello
# 应返回：Hello from Server!

curl http://localhost:8080/api/images
# 应返回已创建的图片记录列表

# 浏览器访问
http://你的服务器公网IP:8080/hello
http://你的服务器公网IP:8080/api/images
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;六、验收清单&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;GET/POST/DELETE &lt;code&gt;/api/images&lt;/code&gt; 四个接口全部正常&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;POST &lt;code&gt;/api/images/upload&lt;/code&gt; 能接收图片并返回可访问URL&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;POST &lt;code&gt;/api/images/upload-and-recognize&lt;/code&gt; 能调通AI并保存识别结果&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>从零搭建AI小程序全栈实战（一）：环境准备</title><link>https://47.113.107.125:80/posts/ai-miniapp/01-%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87/</link><guid isPermaLink="true">https://47.113.107.125:80/posts/ai-miniapp/01-%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87/</guid><pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;欢迎来到“从零搭建AI小程序全栈实战”系列文章！&lt;br /&gt;
此为本系列的第一篇文章。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h1&gt;零、写在第一篇文章之前&lt;/h1&gt;
&lt;h2&gt;0.1 总览&lt;/h2&gt;
&lt;p&gt;欢迎来到&quot;从零搭建AI小程序全栈实战&quot;系列。&lt;/p&gt;
&lt;p&gt;这是一套&lt;strong&gt;完整的项目驱动型教程&lt;/strong&gt;，不是零散的知识点堆砌。我们将从一台带GPU的笔记本和一台云服务器开始，一步一步搭建一个&lt;strong&gt;真正能用的垃圾分类识别小程序&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;本系列文章包括&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;标题&lt;/th&gt;
&lt;th&gt;技术步骤&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a&gt;从零搭建AI小程序全栈实战（一）&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;环境准备 笔记本GPU服务器内网穿透打通&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;../02a-java%E5%90%8E%E7%AB%AF%E6%89%93%E5%BA%95%E4%B8%8A/&quot;&gt;从零搭建AI小程序全栈实战（二·上篇）&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Spring Boot 基础功能开发 CRUD + 图片上传 + 调用 AI 推理&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;../02b-java%E5%90%8E%E7%AB%AF%E6%89%93%E5%BA%95%E4%B8%8B/&quot;&gt;从零搭建AI小程序全栈实战（二·下篇）&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;后端工程化加固 统一响应 + 异常处理 + 接口文档 + 数据校验&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;../03-%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F%E5%89%8D%E7%AB%AF/&quot;&gt;从零搭建AI小程序全栈实战（三）&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;微信小程序前端 拍照识别 历史记录全链路打通&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;../04-%E6%A8%A1%E5%9E%8B%E8%AE%AD%E7%BB%83/&quot;&gt;从零搭建AI小程序全栈实战（四）&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;模型训练 ResNet图像分类 Flask推理API部署&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;学完之后，你将拥有：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;一个&lt;strong&gt;自己训练的ResNet图像分类模型&lt;/strong&gt;，跑在笔记本GPU上&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一个&lt;strong&gt;Spring Boot业务后端&lt;/strong&gt;，能接收图片、调AI、存数据库&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一个&lt;strong&gt;微信小程序&lt;/strong&gt;，拍照上传，1秒出分类结果&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一条&lt;strong&gt;内网穿透通道&lt;/strong&gt;，笔记本在家，服务器在云端，网络互通&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这当然是一个玩具项目，也是一个麻雀虽小、五脏俱全的全栈AI应用。&lt;/p&gt;
&lt;h2&gt;0.2 你会获得什么&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;把 GPU 变成远程推理服务&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;独立搭建 Spring Boot 业务后端&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;微信小程序从拍照到出结果的全链路贯通&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;0.3 你需要准备什么&lt;/h2&gt;
&lt;h3&gt;硬件&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;一台带 NVIDIA 显卡的笔记本（GTX 1060 以上都能跑，不一定要 4060）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一台云服务器（2核2G，够跑Spring Boot）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;软件基础&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;会用基础的Linux命令行 例如 cd / ls / mkdir&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;写过一点 Python，知道 import 怎么用&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不需要了解 Java 和 Spring Boot&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不需要了解微信小程序&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;0.4 成品&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;一个能够识别有机物和可回收物的算法模型&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一个能够上传图片进行识别的微信小程序&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一个能够正常处理接口并转发请求的SpringBoot后端&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;一、这篇文章将要做的&lt;/h1&gt;
&lt;h2&gt;1.1 总览&lt;/h2&gt;
&lt;p&gt;学完这篇文章，你能让笔记本GPU对外提供API，同时服务器能够调用到它。&lt;/p&gt;
&lt;h2&gt;1.2 目标&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;笔记本跑通GPU推理——驱动、CUDA、PyTorch、Flask&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器跑通Spring Boot——JDK、Demo应用&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;打通网络——frps、frpc、验证&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;二、Windows环境&lt;/h1&gt;
&lt;h2&gt;2.1 配置 Nvidia 显卡&lt;/h2&gt;
&lt;h3&gt;2.1.1 安装nvidia-smi&lt;/h3&gt;
&lt;p&gt;打开 NVIDIA 官网下载对应驱动（Game Ready 即可），安装后重启。
在命令行执行 &lt;code&gt;nvidia-smi&lt;/code&gt;，确认驱动版本和 CUDA 版本（如 CUDA 12.x）。&lt;/p&gt;
&lt;h3&gt;2.1.2 安装CUDA Toolkit&lt;/h3&gt;
&lt;p&gt;下载 &lt;strong&gt;CUDA Toolkit 11.8&lt;/strong&gt;（兼容性好，占用空间小）：  &lt;a href=&quot;https://developer.nvidia.com/cuda-11-8-0-download-archive&quot;&gt;CUDA Toolkit 11.8 Download&lt;/a&gt;&lt;br /&gt;
选择 Windows → x86_64 → exe(local)，安装时选“精简”模式。&lt;/p&gt;
&lt;h3&gt;2.1.3 安装cuDNN&lt;/h3&gt;
&lt;p&gt;下载 cuDNN（需要注册 NVIDIA 账号）：  &lt;a href=&quot;https://developer.nvidia.com/rdp/cudnn-archive&quot;&gt;cuDNN Archive&lt;/a&gt;&lt;br /&gt;
选择和 CUDA 11.8 对应的版本，解压后将 &lt;code&gt;bin&lt;/code&gt;、&lt;code&gt;include&lt;/code&gt;、&lt;code&gt;lib&lt;/code&gt; 文件夹内容复制到 CUDA 安装目录（默认 &lt;code&gt;C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v11.8&lt;/code&gt;）对应文件夹内。&lt;/p&gt;
&lt;h2&gt;2.2 配置 conda 环境&lt;/h2&gt;
&lt;p&gt;下载 &lt;a href=&quot;https://docs.conda.io/en/latest/miniconda.html&quot;&gt;Miniconda Windows 64-bit&lt;/a&gt;，一路下一步安装&lt;br /&gt;
打开 &lt;strong&gt;Anaconda Prompt&lt;/strong&gt;，创建虚拟环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;conda create -n ai-env python=3.10 -y
conda activate ai-env
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2.3 配置 PyTorch&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;安装 PyTorch：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;验证 GPU：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;python -c &quot;import torch; print(torch.cuda.is_available())&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出 &lt;code&gt;True&lt;/code&gt; 即成功。&lt;/p&gt;
&lt;h2&gt;2.4 搭建第一个 Flask API&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;安装 Flask：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;pip install flask
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;新建目录 &lt;code&gt;DEMO_Project\demo-assist\api-server&lt;/code&gt; ， 在里面创建 &lt;code&gt;app.py&lt;/code&gt; ：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;from flask import Flask, request, jsonify
import torch

app = Flask(__name__)

@app.route(&apos;/health&apos;)
def health():
    return jsonify({&quot;status&quot;: &quot;ok&quot;, &quot;cuda&quot;: torch.cuda.is_available()})

@app.route(&apos;/predict&apos;, methods=[&apos;POST&apos;])
def predict():
    # 暂时返回固定结果，后续接模型
    data = request.get_json()
    return jsonify({&quot;result&quot;: &quot;fake_prediction&quot;, &quot;input&quot;: data})

if __name__ == &apos;__main__&apos;:
    app.run(host=&apos;0.0.0.0&apos;, port=5000, debug=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;启动&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;python app.py
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;用浏览器访问 &lt;code&gt;http://localhost:5000/health&lt;/code&gt; ，看到包含&lt;code&gt;&quot;cuda&quot;:true&lt;/code&gt; 的 JSON 即可。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2.5 安装内网穿透客户端 frpc&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;下载 &lt;a href=&quot;https://github.com/fatedier/frp/releases&quot;&gt;frp Windows 版&lt;/a&gt;，解压到本地，放到 &lt;code&gt;DEMO_Project\demo-assist\frp&lt;/code&gt; 文件夹中。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;先不配置，等服务器端 ready。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2.6 安装 Intellij IDEA&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;打开浏览器，访问 &lt;a href=&quot;https://www.jetbrains.com/idea/download/&quot;&gt;JetBrains 官网&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;选择 &lt;strong&gt;Community Edition&lt;/strong&gt;（免费版，够用），点击下载&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;下载完成后双击安装包，一路下一步，注意以下选项：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create Desktop Shortcut&lt;/strong&gt; → 勾选（桌面快捷方式）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Add &quot;Open Folder as Project&quot;&lt;/strong&gt; → 勾选&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;其余默认即可&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;安装完成后启动 IDEA，选择 &lt;strong&gt;&quot;Don&apos;t send&quot;&lt;/strong&gt;（不发送使用数据）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;三、在 Windows 上创建 Spring Boot 项目&lt;/h1&gt;
&lt;h2&gt;3.1 创建 Spring Boot 项目&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;在 IDEA 欢迎界面，点击 &lt;strong&gt;New Project&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;左侧选择 &lt;strong&gt;Spring Boot&lt;/strong&gt;（如果没有，点左侧 &lt;strong&gt;Spring Initializr&lt;/strong&gt;）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;右侧配置以下信息：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;选项&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;demo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[你的项目目录]\DEMO_project\demo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Java&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Maven&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;com.example&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Artifact&lt;/td&gt;
&lt;td&gt;&lt;code&gt;demo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JDK&lt;/td&gt;
&lt;td&gt;选择 &lt;code&gt;JDK 21&lt;/code&gt;（如果没有，点旁边的下载按钮下载）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;21&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packaging&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Jar&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;点击 &lt;strong&gt;Next&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在依赖选择界面，左侧找到 &lt;strong&gt;&lt;code&gt;Spring Web&lt;/code&gt;&lt;/strong&gt;，勾选它&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;点击 &lt;strong&gt;Create&lt;/strong&gt;（或 &lt;strong&gt;Finish&lt;/strong&gt;）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;IDEA 会自动下载依赖，等待右下角进度条走完&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3.2 编写 DemoApplication&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;IDEA 默认已经生成了 &lt;code&gt;DemoApplication.java&lt;/code&gt;，路径在 &lt;code&gt;src/main/java/com/example/demo/DemoApplication.java&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;将其内容替换为以下代码&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @GetMapping(&quot;/hello&quot;)
    public String hello() {
        return &quot;Hello from Server!&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.3 本地启动验证&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;IDEA 右上角找到绿色三角形按钮，点击运行（或快捷键 &lt;code&gt;Shift + F10&lt;/code&gt;）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;底部控制台出现 &lt;code&gt;Started DemoApplication in ... seconds&lt;/code&gt; 表示启动成功&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;打开浏览器访问 &lt;code&gt;http://localhost:8080/hello&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;看到 &lt;code&gt;Hello from Server!&lt;/code&gt; 即成功&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;按红色方块按钮停止（或快捷键 &lt;code&gt;Ctrl + F2&lt;/code&gt;）&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3.4 打包 JAR&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;IDEA 右侧边栏点击 &lt;strong&gt;Maven&lt;/strong&gt;（竖排的 &quot;m&quot; 图标）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;展开 &lt;code&gt;demo&lt;/code&gt; → &lt;code&gt;Lifecycle&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;双击 &lt;strong&gt;package&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;等待控制台出现 &lt;code&gt;BUILD SUCCESS&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;生成的 JAR 文件在项目目录 &lt;code&gt;target/demo-0.0.1-SNAPSHOT.jar&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;四、服务器环境&lt;/h1&gt;
&lt;h2&gt;4.1 安全组放行端口&lt;/h2&gt;
&lt;p&gt;安全组放行端口：8080、5000、7000（入方向）&lt;/p&gt;
&lt;h2&gt;4.2 安装软件包&lt;/h2&gt;
&lt;h3&gt;4.2.1 安装 JDK 21&lt;/h3&gt;
&lt;p&gt;SSH 连接服务器，执行以下命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y
sudo apt install openjdk-21-jdk git curl -y
java -version   # 确认版本为21
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.2.2 安装 frps&lt;/h3&gt;
&lt;p&gt;安装内网穿透服务端 frps：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wget https://github.com/fatedier/frp/releases/download/v0.52.3/frp_0.52.3_linux_amd64.tar.gz
tar -xzf frp_0.52.3_linux_amd64.tar.gz
cd frp_0.52.3_linux_amd64/
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.3 创建 frps 服务&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;创建 &lt;code&gt;/etc/systemd/system/frps.service&lt;/code&gt; ：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;sudo vi /etc/systemd/system/frps.service
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;输入以下内容&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=frps
After=network.target

[Service]
Type=simple
ExecStart=/root/frp_0.52.3_linux_amd64/frps -c /root/frp_0.52.3_linux_amd64/frps.toml
Restart=on-failure
User=root

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;使用 &lt;code&gt;:wq&lt;/code&gt; 保存并退出&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;五、打通网络&lt;/h1&gt;
&lt;h2&gt;5.1 在 Windows端 配置 frpc&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;编辑解压后的 &lt;code&gt;frpc.toml&lt;/code&gt;（或 &lt;code&gt;frpc.ini&lt;/code&gt;）：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;serverAddr = &quot;你的服务器公网IP&quot;
serverPort = 7000

[[proxies]]
name = &quot;ai-api&quot;
type = &quot;tcp&quot;
localIP = &quot;127.0.0.1&quot;
localPort = 5000
remotePort = 5000
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;code&gt;frp&lt;/code&gt; 目录下启动：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;frpc -c frpc.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;出现 &lt;code&gt;success&lt;/code&gt; 等字样即连接成功。&lt;/p&gt;
&lt;h2&gt;5.2 在 服务器端 配置 frps&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;切换到&lt;code&gt;frps.toml&lt;/code&gt;所在目录&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;cd frp_0.52.3_linux_amd64/
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;编辑&lt;code&gt;frps.toml&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;bindPort = 7000
# 如果需要 dashboard 可加下面两行（不要暴露公网）
# webServer.addr = &quot;127.0.0.1&quot;
# webServer.port = 7500
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;在 服务器端 启动刚配置好的服务&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl start frps
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.3 验证打通&lt;/h2&gt;
&lt;p&gt;在服务器上执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl http://localhost:5000/health
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果能返回笔记本上的 JSON（包含 &quot;cuda&quot;:true），说明网络已打通。&lt;/p&gt;
&lt;p&gt;现在，你的 Spring Boot 应用可以配置 &lt;code&gt;http://localhost:5000&lt;/code&gt; 作为模型推理地址，无需公网 IP。&lt;/p&gt;
&lt;h1&gt;六、验收清单&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;笔记本 &lt;code&gt;python -c &quot;import torch; print(torch.cuda.is_available())&quot;&lt;/code&gt; 输出 &lt;code&gt;True&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;笔记本 Flask API 的 &lt;code&gt;/health&lt;/code&gt; 接口能返回 GPU 可用信息&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器 &lt;code&gt;curl http://自己公网IP:8080/hello&lt;/code&gt; 返回 &lt;code&gt;Hello from Server!&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务器 &lt;code&gt;curl http://localhost:5000/health&lt;/code&gt; 能拿到笔记本返回的结果&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item></channel></rss>