通义千问3-VL-Plus - 界面交互(坐标改进)

目录

一、引言

二、代码修改

[1. 先引入依赖](#1. 先引入依赖)

[2. 核心工具类(含 Point 实体 + 映射逻辑)](#2. 核心工具类(含 Point 实体 + 映射逻辑))

[3.OparetionServiceImpl 实现类](#3.OparetionServiceImpl 实现类)

三、运行结果演示


一、引言

在前文 通义千问3-VL-Plus - 界面交互(本地图片改进)-CSDN博客 中我们完成了对GUI模型的接入,但是我发现定位好像不准确,一番查看后发现,原来读取的是压缩图片的定位,想要获取正确的还需要再进一步修改。

二、代码修改

1. 先引入依赖

复制代码
<!-- Maven依赖 -->
<dependencies>
    <!-- OkHttp网络请求 -->
    <dependency>
        <groupId>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>4.12.0</version>
    </dependency>
    <!-- 图像处理(JDK自带,无需额外引入) -->
    <dependency>
        <groupId>javax.imageio</groupId>
        <artifactId>imageio-api</artifactId>
        <version>1.5.1</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

2. 核心工具类(含 Point 实体 + 映射逻辑)

java 复制代码
package gzj.spring.ai.util;

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.FileUtils;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.math.RoundingMode;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.DecimalFormat;

/**
 * 坐标映射工具类(兼容本地图片/网络URL + Base64转换 + 模型坐标→原始坐标)
 */
public class CoordinateMappingUtil {

    // 静态OkHttp客户端(复用连接)
    private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient().newBuilder().build();

    /**
     * 坐标点实体类
     */
    public static class Point {
        private int x;
        private int y;

        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }

        // Getter/Setter
        public int getX() { return x; }
        public void setX(int x) { this.x = x; }
        public int getY() { return y; }
        public void setY(int y) { this.y = y; }

        @Override
        public String toString() {
            return "原始图像坐标(x=" + x + ", y=" + y + ")";
        }
    }

    // ====================== 新增:本地图片转Base64 ======================
    /**
     * 本地图片转Base64(带data:image前缀,兼容GUI-Plus模型输入)
     * @param localImagePath 本地图片绝对路径(如E:\\test.png)
     * @return 带前缀的Base64字符串
     * @throws IOException 文件读取异常
     */
    public static String localImageToBase64(String localImagePath) throws IOException {
        File imageFile = new File(localImagePath);
        // 校验文件存在性
        if (!imageFile.exists()) {
            throw new FileNotFoundException("本地图片不存在:" + localImagePath);
        }
        // 读取文件字节
        byte[] imageBytes = FileUtils.readFileToByteArray(imageFile);
        // Base64编码
        String base64Str = Base64.encodeBase64String(imageBytes);
        // 自动识别图片格式
        String suffix = localImagePath.substring(localImagePath.lastIndexOf(".") + 1).toLowerCase();
        if (!suffix.matches("png|jpg|jpeg|bmp")) {
            suffix = "png"; // 默认PNG
        }
        // 拼接data:image前缀
        return String.format("data:image/%s;base64,%s", suffix, base64Str);
    }

    // ====================== 核心:统一读取图片(本地/网络) ======================
    /**
     * 统一读取图片(自动识别本地路径/网络URL)
     * @param imageSource 本地图片路径(如E:\\test.png)或网络URL(http/https开头)
     * @return 图片的BufferedImage(用于获取原始宽高)
     * @throws IOException 读取异常
     */
    private static BufferedImage getImage(String imageSource) throws IOException {
        // 判定是否为网络URL
        if (imageSource.startsWith("http://") || imageSource.startsWith("https://")) {
            return getImageFromUrl(imageSource);
        } else {
            // 本地图片路径
            File localFile = new File(imageSource);
            if (!localFile.exists()) {
                throw new FileNotFoundException("本地图片不存在:" + imageSource);
            }
            return ImageIO.read(localFile);
        }
    }

    /**
     * 从网络URL读取图片
     */
    private static BufferedImage getImageFromUrl(String imageUrl) throws IOException {
        // 兼容JDK原生URL(兜底OkHttp)
        try {
            URL url = new URL(imageUrl);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setReadTimeout(5000);
            conn.setRequestMethod("GET");
            if (conn.getResponseCode() == 200) {
                return ImageIO.read(conn.getInputStream());
            } else {
                throw new IOException("网络图片读取失败,响应码:" + conn.getResponseCode());
            }
        } catch (Exception e) {
            // 降级使用OkHttp
            Request request = new Request.Builder().url(imageUrl).get().build();
            try (Response response = OK_HTTP_CLIENT.newCall(request).execute()) {
                if (!response.isSuccessful()) {
                    throw new IOException("OkHttp读取图片失败,响应码:" + response.code());
                }
                byte[] imageBytes = response.body().bytes();
                return ImageIO.read(new ByteArrayInputStream(imageBytes));
            }
        }
    }

    // ====================== 核心:坐标映射(兼容本地/网络图片) ======================
    /**
     * 模型坐标映射到原始图像坐标(支持本地图片路径/网络URL)
     * @param imageSource 本地图片路径 或 网络URL
     * @param modelPoint 模型返回的坐标(基于内部缩放图)
     * @param factor 缩放基数(默认28)
     * @param maxPixels 最大像素值(默认1280*28*28)
     * @param minPixels 最小像素值(默认4*28*28)
     * @return 原始图像的绝对坐标
     * @throws IOException 读取图片异常
     */
    public static Point smartSize(String imageSource, Point modelPoint,
                                  Integer factor, Long maxPixels, Long minPixels) throws IOException {
        // 1. 默认参数
        int defaultFactor = factor == null ? 28 : factor;
        long defaultMaxPixels = maxPixels == null ? 1280 * 28 * 28 : maxPixels;
        long defaultMinPixels = minPixels == null ? 4 * 28 * 28 : minPixels;

        // 2. 统一读取图片(本地/网络),获取原始尺寸
        BufferedImage originalImage = getImage(imageSource);
        int originalWidth = originalImage.getWidth();
        int originalHeight = originalImage.getHeight();

        // 3. 初始调整宽高为factor的整数倍(round取整)
        double hBar = Math.round((double) originalHeight / defaultFactor) * defaultFactor;
        double wBar = Math.round((double) originalWidth / defaultFactor) * defaultFactor;

        // 4. 计算缩放因子beta(根据像素阈值调整)
        double totalPixels = hBar * wBar;
        double beta = 1.0;
        if (totalPixels > defaultMaxPixels) {
            beta = Math.sqrt((originalHeight * originalWidth) / (double) defaultMaxPixels);
            hBar = Math.floor(originalHeight / beta / defaultFactor) * defaultFactor;
            wBar = Math.floor(originalWidth / beta / defaultFactor) * defaultFactor;
        } else if (totalPixels < defaultMinPixels) {
            beta = Math.sqrt(defaultMinPixels / (originalHeight * originalWidth));
            hBar = Math.ceil(originalHeight * beta / defaultFactor) * defaultFactor;
            wBar = Math.ceil(originalWidth * beta / defaultFactor) * defaultFactor;
        }

        // 5. 模型坐标 → 原始坐标(四舍五入)
        DecimalFormat df = new DecimalFormat("#");
        df.setRoundingMode(RoundingMode.HALF_UP);
        int originalX = Integer.parseInt(df.format((double) modelPoint.getX() / wBar * originalWidth));
        int originalY = Integer.parseInt(df.format((double) modelPoint.getY() / hBar * originalHeight));

        return new Point(originalX, originalY);
    }

    // ====================== 测试示例 ======================
//    public static void main(String[] args) {
//        try {
//            // 测试1:网络URL场景
//            String netImageUrl = "https://p3-flow-imagex-sign.byteimg.com/tos-cn-i-a9rns2rl98/1e8c83f9e6b94f428e21c754d1265406.png";
//            Point modelPoint1 = new Point(1205, 278);
//            Point netOriginalPoint = smartSize(netImageUrl, modelPoint1, null, null, null);
//            System.out.println("【网络图片】模型坐标:" + modelPoint1.getX() + "," + modelPoint1.getY());
//            System.out.println("【网络图片】原始坐标:" + netOriginalPoint);
//
//            // 测试2:本地图片场景(转Base64 + 坐标映射)
//            String localImagePath = "E:\\screenshot\\desktop.png"; // 替换为你的本地路径
//            // 本地图片转Base64(供模型调用)
//            String base64Str = localImageToBase64(localImagePath);
//            System.out.println("【本地图片】Base64(前50字符):" + base64Str.substring(0, 50) + "...");
//            // 本地图片坐标映射
//            Point modelPoint2 = new Point(1205, 278);
//            Point localOriginalPoint = smartSize(localImagePath, modelPoint2, null, null, null);
//            System.out.println("【本地图片】模型坐标:" + modelPoint2.getX() + "," + modelPoint2.getY());
//            System.out.println("【本地图片】原始坐标:" + localOriginalPoint);
//
//        } catch (IOException e) {
//            e.printStackTrace();
//        }
//    }
}

3.OparetionServiceImpl 实现类

java 复制代码
package gzj.spring.ai.Service.ServiceImpl;


import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult;
import com.alibaba.dashscope.common.MultiModalMessage;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gzj.spring.ai.Request.OparetionRequest;
import gzj.spring.ai.Service.OparetionService;
import gzj.spring.ai.util.CoordinateMappingUtil;
import io.reactivex.Flowable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.awt.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.List;

import static com.alibaba.cloud.ai.graph.utils.TryConsumer.log;
import static gzj.spring.ai.util.CoordinateMappingUtil.smartSize;

/**
 * @author DELL
 */
@Service
public class OparetionServiceImpl implements OparetionService {

    @Value("${spring.ai.dashscope.api-key}")
    private String apiKey;

    @Value("${spring.ai.dashscope.modelV2:gui-plus}")
    private String modelName; // 模型名配置化,便于切换

    /**
     * 工具方法:本地图片转Base64(带data:image前缀,GUI-Plus支持格式)
     */
    private String encodeLocalImageToBase64(String localPath) throws IOException {
        Path imagePath = Paths.get(localPath);
        // 校验文件存在性
        if (!Files.exists(imagePath)) {
            throw new IOException("本地图片不存在:" + localPath);
        }
        // 读取文件并Base64编码(修复原有编码错误)
        byte[] imageBytes = Files.readAllBytes(imagePath);
        String base64Str = Base64.getEncoder().encodeToString(imageBytes);

        // 自动识别图片格式
        String suffix = localPath.substring(localPath.lastIndexOf(".") + 1).toLowerCase();
        if (!Arrays.asList("png", "jpg", "jpeg").contains(suffix)) {
            suffix = "png"; // 默认PNG
        }
        return String.format("data:image/%s;base64,%s", suffix, base64Str);
    }

    /**
     * 工具方法:构建图片内容(优先级:本地图片 > 网络URL)
     */
    private String buildImageContent(OparetionRequest request) throws IOException {
        if (request.getLocalImagePath() != null && !request.getLocalImagePath().isEmpty()) {
            log.info("使用本地图片:{}", request.getLocalImagePath());
            return encodeLocalImageToBase64(request.getLocalImagePath());
        } else if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) {
            log.info("使用网络图片URL:{}", request.getImageUrl());
            return request.getImageUrl();
        } else {
            throw new IllegalArgumentException("必须传入imageUrl(网络图片)或localImagePath(本地图片)");
        }
    }

    /**
     * 构建GUI-Plus核心提示词(优化为Text Blocks,提升可读性)
     */
    private String buildSystemPrompt() {
        return """
                ## 1. 核心角色 (Core Role)
                  你是一个顶级的AI视觉操作代理。你的任务是分析电脑屏幕截图,理解用户的指令,然后将任务分解为单一、精确的GUI原子操作。
                  ## 2. [CRITICAL] JSON Schema & 绝对规则(必须严格遵守)
                  你的输出必须是一个**完整、合法、可直接解析**的JSON对象,任何情况下都不能截断、遗漏字段、缺少闭合符号。
                  ### 强制规则
                  - [R1] 纯JSON输出:回复只能是JSON对象,无任何前缀、后缀、注释、解释性文字。
                  - [R2] 字段必填性:
                    - 所有Action的parameters字段必须包含模板中**所有必填键**(如CLICK必须有x、y整数,缺一不可);
                    - x/y必须是**单个整数**(禁止数组/空值),代表屏幕坐标(像素);
                    - thought字段必须是一句话,描述思考过程,不能为空。
                  - [R3] Action值规范:只能是 CLICK/TYPE/SCROLL/KEY_PRESS/FINISH/FAIL(大写、无空格)。
                  - [R4] JSON格式校验:生成后必须自检------确保大括号闭合、逗号正确、字符串用双引号、数值无引号。
                  ## 3. 工具集 (Available Actions)
                 ### CLICK(必填x、y,可选description)
                   - 功能: 单击屏幕。
                   - 坐标规则: x、y是**当前截图的像素坐标**------以截图的左上角为原点,向右为x轴正方向,向下为y轴正方向,坐标值为截图内的实际像素数值(例如截图宽度是1920像素,则x最大为1919)。
                   - 必须返回如下完整JSON结构(x/y为截图内的实际像素整数):
                   {
                     "thought": "一句话描述思考过程",
                     "action": "CLICK",
                     "parameters": {
                       "x": 1753,
                       "y": 278,
                       "description": "截图中右上角的豆包应用图标"
                     }
                   }
                  ### TYPE(必填text、needs_enter)
                  - 功能: 输入文本。
                  - 必须返回如下完整JSON结构:
                  {
                    "thought": "一句话描述思考过程",
                    "action": "TYPE",
                    "parameters": {
                      "text": "要输入的文本",
                      "needs_enter": true/false
                    }
                  }
                  ### SCROLL(必填direction、amount)
                  - 功能: 滚动窗口。
                  - 必须返回如下完整JSON结构:
                  {
                    "thought": "一句话描述思考过程",
                    "action": "SCROLL",
                    "parameters": {
                      "direction": "up/down",
                      "amount": "small/medium/large"
                    }
                  }
                  ### KEY_PRESS(必填key)
                  - 功能: 按下功能键。
                  - 必须返回如下完整JSON结构:
                  {
                    "thought": "一句话描述思考过程",
                    "action": "KEY_PRESS",
                    "parameters": {
                      "key": "enter/esc/alt+f4等"
                    }
                  }
                  ### FINISH(必填message)
                  - 功能: 任务成功完成。
                  - 必须返回如下完整JSON结构:
                  {
                    "thought": "一句话描述思考过程",
                    "action": "FINISH",
                    "parameters": {
                      "message": "总结任务完成情况"
                    }
                  }
                  ### FAIL(必填reason)
                  - 功能: 任务无法完成。
                  - 必须返回如下完整JSON结构:
                  {
                    "thought": "一句话描述思考过程",
                    "action": "FAIL",
                    "parameters": {
                      "reason": "清晰解释失败原因"
                    }
                  }
                  ## 4. 思维与决策框架
                  1. 目标分析: 用户的最终目标是什么?
                  2. 屏幕观察: 仅基于截图中的视觉证据决策,看不见的元素不交互。
                  3. 行动决策: 选择最合适的Action,确保parameters字段完整。
                  4. 最终校验: 检查JSON是否完整闭合、字段是否必填、格式是否合法,再输出。
                """;
    }

    /**
     * 非流式调用(保留原有逻辑,兼容本地图片)
     */

    @Override
    public String operation(OparetionRequest request) throws ApiException, NoApiKeyException, UploadFileException, IOException {
        // 1. 校验核心参数
        if (request.getText() == null || request.getText().isEmpty()) {
            throw new IllegalArgumentException("用户指令text不能为空");
        }

        // 2. 初始化客户端
        MultiModalConversation conv = new MultiModalConversation();

        // 3. 构建系统提示词
        MultiModalMessage systemMsg = MultiModalMessage.builder()
                .role(Role.SYSTEM.getValue())
                .content(Collections.singletonList(Collections.singletonMap("text", buildSystemPrompt())))
                .build();

        // 4. 构建用户消息(图片+文本)
        String imageContent = buildImageContent(request);
        MultiModalMessage userMessage = MultiModalMessage.builder()
                .role(Role.USER.getValue())
                .content(Arrays.asList(
                        Collections.singletonMap("image", imageContent),
                        Collections.singletonMap("text", request.getText())
                )).build();

        // 5. 构建请求参数(修复API Key使用矛盾)
        MultiModalConversationParam param = MultiModalConversationParam.builder()
                // 统一使用配置文件的API Key
                .apiKey(apiKey)
                .model(modelName)
                .messages(Arrays.asList(systemMsg, userMessage))
                .build();

        // 6. 同步调用+结果解析(增加空指针防护)
        MultiModalConversationResult result = conv.call(param);
        if (result == null || result.getOutput() == null ||
                result.getOutput().getChoices() == null || result.getOutput().getChoices().isEmpty()) {
            log.warn("GUI-Plus返回结果为空");
            // 返回空JSON,避免前端解析异常
            return "{}";
        }

        List<Map<String, Object>> content = result.getOutput().getChoices().get(0).getMessage().getContent();
        String resText = content != null && !content.isEmpty()
                ? content.get(0).get("text").toString()
                : "{}";
        log.info("GUI-Plus非流式调用完成,结果:{}", resText);
        // 坐标映射至实际原始图像
        try {
            // 尝试解析模型返回的JSON文本
            ObjectMapper objectMapper = new ObjectMapper();
            JsonNode jsonNode = objectMapper.readTree(resText);

            int x = jsonNode.path("parameters").path("x").asInt();
            int y = jsonNode.path("parameters").path("y").asInt();

            CoordinateMappingUtil.Point modelPoint = new CoordinateMappingUtil.Point(x, y);
            CoordinateMappingUtil.Point point = CoordinateMappingUtil.smartSize(request.getLocalImagePath(), modelPoint, null, null, null);
            log.info("映射后的坐标:{}", point);
        } catch (Exception e) {
            log.error("解析模型返回结果失败", e);
        }
        return resText;
    }

    /**
     * 新增:SSE流式调用(实时推送结果)
     */
    @Override
    public SseEmitter streamOperation(OparetionRequest request) {
        // 设置SSE超时时间(30秒)
        SseEmitter emitter = new SseEmitter(30000L);
        // 超时回调
        emitter.onTimeout(() -> handleEmitterError(emitter, "SSE连接超时(30秒)"));
        // 客户端关闭回调
        emitter.onCompletion(() -> log.info("SSE连接已关闭"));

        // 异步执行流式调用(避免阻塞主线程)
        new Thread(() -> {
            MultiModalConversation conv = new MultiModalConversation();
            try {
                // 1. 校验参数
                if (request.getText() == null || request.getText().isEmpty()) {
                    throw new IllegalArgumentException("用户指令text不能为空");
                }

                // 2. 构建图片内容+消息
                String imageContent = buildImageContent(request);
                MultiModalMessage systemMsg = MultiModalMessage.builder()
                        .role(Role.SYSTEM.getValue())
                        .content(Collections.singletonList(Collections.singletonMap("text", buildSystemPrompt())))
                        .build();
                MultiModalMessage userMessage = MultiModalMessage.builder()
                        .role(Role.USER.getValue())
                        .content(Arrays.asList(
                                Collections.singletonMap("image", imageContent),
                                Collections.singletonMap("text", request.getText())
                        )).build();

                // 3. 构建流式请求参数
                MultiModalConversationParam param = MultiModalConversationParam.builder()
                        .apiKey(apiKey)
                        .model(modelName)
                        .messages(Arrays.asList(systemMsg, userMessage))
                        .maxTokens(2048)
                        .incrementalOutput(true) // 开启增量输出(流式核心)
                        .build();

                // 4. 流式调用+推送结果
                Flowable<MultiModalConversationResult> resultFlow = conv.streamCall(param);
                resultFlow.blockingForEach(item -> {
                    try {
                        if (item.getOutput() == null || item.getOutput().getChoices() == null || item.getOutput().getChoices().isEmpty()) {
                            return; // 空结果跳过
                        }
                        List<Map<String, Object>> content = item.getOutput().getChoices().get(0).getMessage().getContent();
                        if (content != null && !content.isEmpty()) {
                            String text = content.get(0).get("text").toString();
                            // 推送单条流式数据(event名称:message)
                            emitter.send(SseEmitter.event().name("message").data(text));
                            log.debug("推送流式数据:{}", text);
                        }
                    } catch (Exception e) {
                        log.error("推送单条流式数据失败", e);
                        handleEmitterError(emitter, "数据推送失败:" + e.getMessage());
                    }
                });

                // 流式结束标记
                emitter.send(SseEmitter.event().name("complete").data("流输出完成"));
                emitter.complete();
                log.info("GUI-Plus流式调用完成");

            } catch (IOException e) {
                log.error("读取本地图片失败", e);
                handleEmitterError(emitter, "读取本地图片失败:" + e.getMessage());
            } catch (ApiException | NoApiKeyException | UploadFileException e) {
                log.error("GUI-Plus API调用失败", e);
                handleEmitterError(emitter, "API调用失败:" + e.getMessage());
            } catch (IllegalArgumentException e) {
                log.error("请求参数异常", e);
                handleEmitterError(emitter, "参数错误:" + e.getMessage());
            } catch (Exception e) {
                log.error("流式调用未知异常", e);
                handleEmitterError(emitter, "系统异常:" + e.getMessage());
            }
        }).start();

        return emitter;
    }

    /**
     * 工具方法:统一处理SSE异常
     */
    private void handleEmitterError(SseEmitter emitter, String errorMsg) {
        try {
            emitter.send(SseEmitter.event().name("error").data(errorMsg));
            emitter.completeWithError(new RuntimeException(errorMsg));
        } catch (Exception e) {
            log.error("处理SSE发射器异常失败", e);
        }
    }
}

三、运行结果演示

由于篇幅限制,具体的分析我放在下一篇文章,给大家捋一捋

如果觉得这份修改实用、总结清晰,别忘了动动小手点个赞👍,再关注一下呀~ 后续还会分享更多 AI 接口封装、代码优化的干货技巧,一起解锁更多好用的功能,少踩坑多提效!🥰 你的支持就是我更新的最大动力,咱们下次分享再见呀~🌟

相关推荐
p&f°2 小时前
PostgreSQL 执行计划控制参数详解
数据库·postgresql·oracle
航Hang*2 小时前
第3章:复习篇——第1节:创建和管理数据库
开发语言·数据库·笔记·sql·sqlserver
铉铉这波能秀2 小时前
正则表达式从入门到精通(字符串模式匹配)
java·数据库·python·sql·正则表达式·模式匹配·表格处理
sc.溯琛2 小时前
MySQL 进阶实验:数据库与数据表管理完全指南
数据库·oracle
好记忆不如烂笔头abc2 小时前
oracle迁移到sqlserver的注意点
数据库·oracle·sqlserver
YJlio2 小时前
ZoomIt 学习笔记(11.11):休息计时器与演讲节奏控制——倒计时、番茄钟与现场掌控力
数据库·笔记·学习
sc.溯琛2 小时前
MySQL 性能优化核心:索引创建与管理实战指南
数据库·mysql·性能优化
锋君3 小时前
Orcale数据库在Asp.Net Core环境下使用EF Core 生成实体
数据库·后端·oracle·asp.net
啊吧怪不啊吧3 小时前
SQL之用户管理——权限与用户
大数据·数据库·sql