2024年11月

【引言】

本文将介绍如何使用鸿蒙NEXT框架开发一个简单的光强仪应用,该应用能够实时监测环境光强度,并给出相应的场景描述和活动建议。

【环境准备】

电脑系统:windows 10

开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806

工程版本:API 12

真机:mate60 pro

语言:ArkTS、ArkUI

【功能实现】

1. 项目结构

本项目主要由以下几个部分组成:

  • LightIntensityItem 类
    :用于定义光强度范围及其相关信息,包括光强度的起始值、终止值、类型、描述和建议活动。通过构造函数初始化这些属性,便于后续使用。
  • LightIntensityMeter 组件
    :这是光强仪的核心,包含状态管理、传感器初始化和光强度更新等功能。组件使用
    @State
    装饰器来管理当前光强度值和类型,并在组件即将出现时获取传感器列表。
  • 传感器数据处理
    :通过监听环境光传感器的数据,实时更新当前光强度值,并根据光强度范围更新当前类型。这一过程确保了用户能够获得最新的环境光信息。

2. 界面布局

光强仪的用户界面使用了鸿蒙系统的布局组件,包括
Column

Row
。界面展示了当前光强度值和类型,并通过仪表组件直观地显示光强度。用户可以清晰地看到光强度的变化,并获得相应的场景描述和活动建议。

  • 仪表组件
    :用于显示当前光强度值,采用了动态更新的方式,确保用户能够实时看到光强度的变化。
  • 信息展示
    :通过遍历光强度范围列表,展示每个类型的光强度范围、描述和建议活动。这一部分为用户提供了实用的信息,帮助他们根据环境光条件做出相应的决策。

3. 总结

通过本案例,开发者可以学习到如何在鸿蒙系统中使用传感器服务和组件化开发方式,构建一个功能完整的光强仪应用。该应用不仅能够实时监测光强度,还能根据不同的光强度范围提供实用的建议,提升用户体验。

【完整代码】

import { sensor } from '@kit.SensorServiceKit'; // 导入传感器服务套件
import { BusinessError } from '@kit.BasicServicesKit'; // 导入业务错误类

// 定义一个光强度项类,用于存储不同光强度范围的信息
class LightIntensityItem {
  luxStart: number; // 光感强度范围起点
  luxEnd: number; // 光感强度范围终点
  type: string; // 类型
  description: string; // 场景描述
  recommendation: string; // 建议活动

  // 构造函数,初始化对象属性
  constructor(luxStart: number, luxEnd: number, type: string, description: string, recommendation: string) {
    this.luxStart = luxStart;
    this.luxEnd = luxEnd;
    this.type = type;
    this.description = description;
    this.recommendation = recommendation;
  }
}

// 使用装饰器定义组件,该组件是光强度计
@Entry
@Component
struct LightIntensityMeter {
  @State currentType: string = ""; // 当前光强度类型
  @State currentIntensity: number = 0; // 当前光强度值
  @State lightIntensityList: LightIntensityItem[] = [// 不同光强度范围的列表
    new LightIntensityItem(0, 1, '极暗', '夜晚户外,几乎没有光源。', '不宜进行任何活动,适合完全休息。'),
    new LightIntensityItem(1, 10, '很暗', '夜晚室内,只有微弱的灯光或月光。', '只适合睡觉,避免使用电子设备。'),
    new LightIntensityItem(10, 50, '暗', '清晨或傍晚,自然光较弱。', '轻松休闲,避免长时间阅读,适合放松。'),
    new LightIntensityItem(50, 100, '较暗', '白天阴天,室内光线柔和。', '日常生活,短时间阅读,适合轻度活动。'),
    new LightIntensityItem(100, 300, '适中', '白天多云,室内光线适中。', '工作学习,适度阅读,适合大部分室内活动。'),
    new LightIntensityItem(300, 500, '较亮', '白天晴朗,室内光线充足。', '正常工作学习,长时间阅读,适合大部分活动。'),
    new LightIntensityItem(500, 1000, '亮', '阴天室外,自然光较强。', '户外活动,注意防晒,适合户外休闲。'),
    new LightIntensityItem(1000, 100000, '爆表了', '夏季正午直射阳光,自然光极其强烈。',
      '尽可能避免直视太阳,户外活动需戴太阳镜,注意防晒。'),
  ];

  // 当组件即将出现时调用的方法
  aboutToAppear(): void {
    sensor.getSensorList((error: BusinessError) => { // 获取传感器列表
      if (error) { // 如果有错误
        console.error('获取传感器列表失败', error); // 打印错误信息
        return;
      }
      this.startLightIntensityUpdates(); // 没有错误则开始监听光强度变化
    });
  }

  // 开始监听环境光传感器的数据
  private startLightIntensityUpdates(): void {
    sensor.on(sensor.SensorId.AMBIENT_LIGHT, (data) => { // 监听环境光传感器
      console.info(`data.intensity: ${data.intensity}`); // 打印光强度值
      this.currentIntensity = data.intensity; // 更新当前光强度值
      for (const item of this.lightIntensityList) { // 遍历光强度列表
        if (data.intensity >= item.luxStart && data.intensity <= item.luxEnd) { // 判断当前光强度属于哪个范围
          this.currentType = item.type; // 更新当前光强度类型
          break;
        }
      }
    }, { interval: 10000000 }); // 设置传感器更新间隔,单位为纳秒(10000000纳秒=1秒)
  }

  // 组件构建方法
  build() {
    Column() { // 创建一个垂直布局容器
      Text("光强仪")// 显示标题
        .width('100%')// 设置宽度为100%
        .height(44)// 设置高度为44
        .backgroundColor("#fe9900")// 设置背景颜色
        .textAlign(TextAlign.Center)// 设置文本对齐方式为中心
        .fontColor(Color.White); // 设置字体颜色为白色

      Row() { // 创建一个水平布局容器
        Gauge({
          // 创建一个仪表组件
          value: this.currentIntensity > 1000 ? 1000 : this.currentIntensity, // 设置仪表值
          min: 0, // 最小值
          max: 1000 // 最大值
        }) { // 仪表内部布局
          Column() { // 创建一个垂直布局容器
            Text(`${Math.floor(this.currentIntensity)}`)// 显示当前光强度值
              .fontSize(25)// 设置字体大小
              .fontWeight(FontWeight.Medium)// 设置字体粗细
              .fontColor("#323232")// 设置字体颜色
              .height('30%')// 设置高度为父容器的30%
              .textAlign(TextAlign.Center)// 设置文本对齐方式为中心
              .margin({ top: '22.2%' })// 设置上边距
              .textOverflow({ overflow: TextOverflow.Ellipsis })// 设置文本溢出处理方式
              .maxLines(1); // 设置最大行数为1

            Text(`${this.currentType}`)// 显示当前光强度类型
              .fontSize(16)// 设置字体大小
              .fontColor("#848484")// 设置字体颜色
              .fontWeight(FontWeight.Regular)// 设置字体粗细
              .width('47.4%')// 设置宽度为父容器的47.4%
              .height('15%')// 设置高度为父容器的15%
              .textAlign(TextAlign.Center)// 设置文本对齐方式为中心
              .backgroundColor("#e4e4e4")// 设置背景颜色
              .borderRadius(5); // 设置圆角半径
          }.width('100%'); // 设置列宽度为100%
        }
        .startAngle(225) // 设置仪表起始角度
        .endAngle(135) // 设置仪表结束角度
        .height(250) // 设置仪表高度
        .strokeWidth(18) // 设置仪表边框宽度
        .description(null) // 设置描述为null
        .trackShadow({ radius: 7, offsetX: 7, offsetY: 7 }) // 设置阴影效果
        .padding({ top: 30 }); // 设置内边距
      }.width('100%').justifyContent(FlexAlign.Center); // 设置行宽度为100%并居中对齐

      Column() { // 创建一个垂直布局容器
        ForEach(this.lightIntensityList, (item: LightIntensityItem, index: number) => { // 遍历光强度类型数组
          Row() { // 创建一个水平布局容器
            Text(`${item.luxStart}~${item.luxEnd}Lux `)// 显示每个类型的光强度范围
              .fontSize('25lpx')// 设置字体大小
              .textAlign(TextAlign.Start)// 设置文本对齐方式为左对齐
              .fontColor("#3d3d3d")// 设置字体颜色
              .width('220lpx') // 设置宽度

            Text(`${item.description}\n${item.recommendation}`)// 显示每个类型的描述和建议活动
              .fontSize('23lpx')// 设置字体大小
              .textAlign(TextAlign.Start)// 设置文本对齐方式为左对齐
              .fontColor("#3d3d3d")// 设置字体颜色
              .layoutWeight(1) // 设置布局权重
          }.width('660lpx') // 设置行宽度
          .padding({ bottom: 10, top: 10 }) // 设置上下内边距
          .borderWidth({ bottom: 1 }) // 设置下边框宽度
          .borderColor("#737977"); // 设置下边框颜色
        });
      }.width('100%'); // 设置列宽度为100%
    }
    .height('100%') // 设置容器高度为100%
    .width('100%'); // 设置容器宽度为100%
  }
}

说明

该文章是属于OverallAuth2.0系列文章,每周更新一篇该系列文章(从0到1完成系统开发)。

该系统文章,我会尽量说的非常详细,做到不管新手、老手都能看懂。

说明:OverallAuth2.0 是一个简单、易懂、功能强大的权限+可视化流程管理系统。

友情提醒:本篇文章是属于系列文章,看该文章前,建议先看之前文章,可以更好理解项目结构。

qq群:801913255

有兴趣的朋友,请关注我吧(*^▽^*)。

关注我,学不会你来打我

前言

这篇文章有点长,内容丰富,如果你对该文章感兴趣,请耐心观看。

一、什么是路由守卫,它的作用是什么

什么是路由守卫:
它是控制路由菜单访问的一种机制,当一个用户点击一个路由菜单时,那么路由守卫就会对其进行“保护”,常见的守卫方式有

beforeEach:路由菜单访问前守卫。

afterEach:路由菜单访问后守卫。

路由守卫的作用:
了解什么是路由守卫后,其实我们大致可以得出它大致有以下作用。

1、身份认证:在进入模块之前,验证用户身份是否正确,列如:登录是否过期,用户是否登录等。

2、权限控制:控制用户、角色对应模块的访问权限。

3、日志记录:由于路由守卫能监控到用户对于模块访问前和访问后的动作,那么我们可以用来记录用户的访问日志等。

4、数据预加载:在很多时候,些许数据需要在我们访问页面前,加载完成。

5、路由动画:可以在路由访问前后,加载一个过渡动画,提高用户体验。

二、路由守卫的使用

在使用之前,我们需要安装状态管理库和状态持久化插件以及路由加载进度条。它可以共享程序中的一些状态。

1:安装npm install pinia  状态存储库

2:安装npm install pinia-plugin-persistedstate  状态持久化插件

3:安装 npm install nprogress  进度条插件

4:安装 npm install @types/nprogress

书接上一篇:
Vue3中菜单和路由的结合使用,实现菜单的动态切换

创建一个路由文件index.ts,存放在指定文件夹下,由于我写的是从0到1搭建框架,我放在了以下目录中

内容如下:

import { createRouter, createWebHashHistory, NavigationGuardNext, RouteLocationNormalized } from 'vue-router'import { routes }from './module/base-routes'import NProgressfrom 'nprogress'import'nprogress/nprogress.css'NProgress.configure({ showSpinner:false})const router =createRouter({
history: createWebHashHistory(),
//开发环境 routes
})
/**
路由守卫,访问路由菜单前拦截
* @param to 目标
* @param from 来至
*/router.beforeEach((to: RouteLocationNormalized,from: RouteLocationNormalized, next: NavigationGuardNext) =>{
NProgress.start();
if(to.meta.requireAuth) {
next();
}
else if (to.matched.length == 0) {
next({ path:
'/panel'})
}
else{
next();
}
})

router.afterEach(()
=>{
NProgress.done();
})

export
default router

代码解释:

router.beforeEach:就是每次在访问路由前,都会进入的方法,在该方法中,我添加了一个进度条和路由访问后的拍断。
router.afterEach:就是每次在访问路由后,都会进入的方法,在该方法中,添加了一个进度条结束的方法。

然后我们在main中,全局注册路由和状态管理

做好以上这些,路由守卫就完成。

如果,你是按照我的系列文章所搭建的前端框架,那么你要在以下2个文件中,做出改动(没有,请忽略)。

1、在HelloWorld.vue文件中把import router, { routes } from "../router/module/base-routes";替换成import  { routes } from "../router/module/base-routes"; 并加入import router from "../router/index";

2、在base-routes.ts文件中删除以下代码

//创建路由,并且暴露出去
const router =createRouter({
history: createWebHashHistory(),
//开发环境//history:createWebHistory(),//正式环境 routes
})
export
default router

做好这些,我们启动项目,检查每次点击路由菜单,是否进入路由守护拦截中。

明白的伙伴,请抓紧去填充你的内容吧。

三、请求拦截、响应拦截


我们OverallAuth2.0使用的是Vue3+.net8 WebApi创建的项目,所以我们会使用到后端接口。那么我们前端该如何和后端建立数据交互关系?建立关系会该如何处理返回信息?不要着急,耐心往下看。

首先要安装组合式api请求插件axios

安装命令:npm install axios

然后按照下图,新建文件及文件夹

http.ts文件内容如下

import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios';//声明模型参数
type TAxiosOption ={
timeout: number;
baseURL:
string;
}
//配置赋值 const config: TAxiosOption ={
timeout:
5000,
baseURL:
"https://localhost:44327/", //本地api接口地址 }classHttp {
service;
constructor(config: TAxiosOption) {
this.service =axios.create(config)/*请求拦截*/ this.service.interceptors.request.use((config: InternalAxiosRequestConfig) =>{//可以在这里做请求拦截处理 如:请求接口前,需要传入的token debugger;returnconfig
}, (error: any)
=>{returnPromise.reject(error);
})
/*响应拦截*/ this.service.interceptors.response.use((response: AxiosResponse<any>) =>{
debugger;
switch(response.data.code) {case 200:returnresponse.data;case 500://这里面可以写错误提示,反馈给前端 returnresponse.data;case 99991:returnresponse.data;case 99992:returnresponse.data;case 99998:returnresponse.data;default:break;
}
}, (error: any)
=>{returnPromise.reject(error)
})
}
/*GET 方法*/ get<T>(url: string, params?: object, _object = {}): Promise<any>{return this.service.get(url, { params, ..._object })
}
/*POST 方法*/post<T>(url: string, params?: object, _object = {}): Promise<any>{return this.service.post(url, params, _object)
}
/*PUT 方法*/put<T>(url: string, params?: object, _object = {}): Promise<any>{return this.service.put(url, params, _object)
}
/*DELETE 方法*/delete<T>(url: string, params?: any, _object = {}): Promise<any>{return this.service.delete(url, { params, ..._object })
}
}

export
default new Http(config)

以上代码关键点都有注释说明

user.ts中的内容如下

import Http from '../http';

export
const TestAutofac =function () {return Http.get('/api/SysUser/TestAutofac');
}

这个就是我们之前搭建后端框架,演示示例的接口地址。

做完以上工作,整个响应拦截和请求拦截的基本代码编写完成,接下来就是测试。

四、测试

在用户界面添加以下代码(也可以新建vue页面)

<template>
  <div>用户</div>
</template>

<script lang="ts">import { defineComponent, onMounted }from "vue";
import { TestAutofac }
from "../../api/module/user";
export
defaultdefineComponent({
setup() {
//初始加载 onMounted(() =>{
TestAutofacMsg();
});
//调用接口 const TestAutofacMsg = async () =>{var result = awaitTestAutofac();
console.log(result);
};
return{};
},
components: {},
});
</script>

点击用户菜单,测试请求拦截是否成功

拦截成功,但出现如下错误(跨域问题)

上面问题是跨域问题导致,我们要在接口端配置跨域地址。

打开之前我们的后端框架,创建如下文件CrossDomainPlugIn.cs,路径和jwt鉴权,全局异常捕获插件放在同一个位置。

 /// <summary>
 ///跨域配置插件/// </summary>
 public static classCrossDomainPlugIn
{
/// <summary> ///跨域/// </summary> /// <param name="services"></param> public static void InitCors(thisIServiceCollection services)
{
//允许一个或多个来源可以跨域 services.AddCors(options =>{
options.AddPolicy(
"Access-Control-Allow-Origin", policy =>{var result = AppSettingsPlugIn.GetNode("CustomCorsPolicy:WhiteList").Split(',');//设定允许跨域的来源,有多个可以用','隔开 policy.WithOrigins(result)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
}
}

然后再Program.cs文件中,添加

//跨域配置
builder.Services.InitCors();和app.UseCors("Access-Control-Allow-Origin");代码

appsettings.json配置中添加如下配置,地址是前端访问地址

/*跨越设置*/
"AllowedHosts": "*",
"CustomCorsPolicy": {
"WhiteList": "http://localhost:8080"
},

启动后端接口,在测试下

以上就是本篇文章的全部内容,感谢耐心观看

后端WebApi
预览地址:http://139.155.137.144:8880/swagger/index.html

前端vue 预览地址:http://139.155.137.144:8881

关注公众号:发送【权限】,获取前后端代码

有兴趣的朋友,请关注我微信公众号吧(*^▽^*)。

关注我:一个全栈多端的宝藏博主,定时分享技术文章,不定时分享开源项目。关注我,带你认识不一样的程序世界

Spring AI + ollama 本地搭建聊天 AI

不知道怎么搭建 ollama 的可以查看上一篇
Spring AI 初学

项目可以查看
gitee

前期准备

添加依赖

创建 SpringBoot 项目,添加主要相关依赖(spring-boot-starter-web、spring-ai-ollama-spring-boot-starter)

Spring AI supports Spring Boot 3.2.x and 3.3.x

Spring Boot 3.2.11 requires at least Java 17 and is compatible with versions up to and including Java 23

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    <version>1.0.0-M3</version>
</dependency>

配置文件

application.properties、yml配置文件中添加,也可以在项目中指定模型等参数,具体参数可以参考 OllamaChatProperties

# properties,模型 qwen2.5:14b 根据自己下载的模型而定
spring.ai.ollama.chat.options.model=qwen2.5:14b

#yml
spring:
  ai:
    ollama:
      chat:
        model: qwen2.5:14b

聊天实现

主要使用 org.springframework.ai.chat.memory.ChatMemory 接口保存对话信息。

一、采用 Java 缓存对话信息

支持功能:聊天对话、切换对话、删除对话

controller
import com.yb.chatai.domain.ChatParam;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

/*
 *@title Controller
 *@description 使用内存进行对话
 *@author yb
 *@version 1.0
 *@create 2024/11/12 14:39
 */
@Controller
public class ChatController {

    //注入模型,配置文件中的模型,或者可以在方法中指定模型
    @Resource
    private OllamaChatModel model;

    //聊天 client
    private ChatClient chatClient;

    // 模拟数据库存储会话和消息
    private final ChatMemory chatMemory = new InMemoryChatMemory();

    //首页
    @GetMapping("/index")
    public String index(){
        return "index";
    }

    //开始聊天,生成唯一 sessionId
    @GetMapping("/start")
    public String start(Model model){
        //新建聊天模型
//        OllamaOptions options = OllamaOptions.builder();
//        options.setModel("qwen2.5:14b");
//        OllamaChatModel chatModel = new OllamaChatModel(new OllamaApi(), options);
        //创建随机会话 ID
        String sessionId = UUID.randomUUID().toString();
        model.addAttribute("sessionId", sessionId);
        //创建聊天client
        chatClient = ChatClient.builder(this.model).defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, sessionId, 10)).build();
        return "chatPage";
    }

    //聊天
    @PostMapping("/chat")
    @ResponseBody
    public String chat(@RequestBody ChatParam param){
        //直接返回
        return chatClient.prompt(param.getUserMsg()).call().content();
    }

    //删除聊天
    @DeleteMapping("/clear/{id}")
    @ResponseBody
    public void clear(@PathVariable("id") String sessionId){
        chatMemory.clear(sessionId);
    }

}
效果图

gif

二、采用数据库保存对话信息

支持功能:聊天对话、切换对话、删除对话、撤回消息

实体类
import lombok.Data;

import java.util.Date;

@Data
public class ChatEntity {

    private String id;

    /** 会话id */
    private String sessionId;

    /** 会话内容 */
    private String content;

    /** AI、人 */
    private String type;

    /** 创建时间 */
    private Date time;

    /** 是否删除,Y-是 */
    private String beDeleted;

    /** AI会话时,获取人对话ID */
    private String userChatId;

}
configuration
import com.yb.chatai.domain.ChatEntity;
import com.yb.chatai.service.IChatService;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/*
 *@title DBMemory
 *@description 实现 ChatMemory,注入 spring,方便采用 service 方法
 *@author yb
 *@version 1.0
 *@create 2024/11/12 16:15
 */
@Configuration
public class DBMemory implements ChatMemory {

    @Resource
    private IChatService chatService;

    @Override
    public void add(String conversationId, List<Message> messages) {
        for (Message message : messages) {
            chatService.saveMessage(conversationId, message.getContent(), message.getMessageType().getValue());
        }
    }

    @Override
    public List<Message> get(String conversationId, int lastN) {
        List<ChatEntity> list = chatService.getLastN(conversationId, lastN);
        if(list != null && !list.isEmpty()) {
            return list.stream().map(l -> {
                Message message = null;
                if (MessageType.ASSISTANT.getValue().equals(l.getType())) {
                    message = new AssistantMessage(l.getContent());
                } else if (MessageType.USER.getValue().equals(l.getType())) {
                    message = new UserMessage(l.getContent());
                }
                return message;
            }).collect(Collectors.<Message>toList());
        }else {
            return new ArrayList<>();
        }
    }

    @Override
    public void clear(String conversationId) {
        chatService.clear(conversationId);
    }
}
services实现类
import com.yb.chatai.domain.ChatEntity;
import com.yb.chatai.service.IChatService;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.stereotype.Service;

import java.util.*;

/*
 *@title ChatServiceImpl
 *@description 保存用户会话 service 实现类
 *@author yb
 *@version 1.0
 *@create 2024/11/12 15:50
 */
@Service
public class ChatServiceImpl implements IChatService {

    Map<String, List<ChatEntity>> map = new HashMap<>();

    @Override
    public void saveMessage(String sessionId, String content, String type) {
        ChatEntity entity = new ChatEntity();
        entity.setId(UUID.randomUUID().toString());
        entity.setContent(content);
        entity.setSessionId(sessionId);
        entity.setType(type);
        entity.setTime(new Date());
        //改成常量
        entity.setBeDeleted("N");
        if(MessageType.ASSISTANT.getValue().equals(type)){
            entity.setUserChatId(getLastN(sessionId, 1).get(0).getId());
        }
        //todo 保存数据库
        //模拟保存到数据库
        List<ChatEntity> list = map.getOrDefault(sessionId, new ArrayList<>());
        list.add(entity);
        map.put(sessionId, list);
    }

    @Override
    public List<ChatEntity> getLastN(String sessionId, Integer lastN) {
        //todo 从数据库获取
        //模拟从数据库获取
        List<ChatEntity> list = map.get(sessionId);
        return list != null ? list.stream().skip(Math.max(0, list.size() - lastN)).toList() : List.of();
    }

    @Override
    public void clear(String sessionId) {
        //todo 数据库更新 beDeleted 字段
        map.put(sessionId, new ArrayList<>());
    }

    @Override
    public void deleteById(String id) {
        //todo 数据库直接将该 id 数据 beDeleted 改成 Y
        for (Map.Entry<String, List<ChatEntity>> next : map.entrySet()) {
            List<ChatEntity> list = next.getValue();
            list.removeIf(chat -> id.equals(chat.getId()) || id.equals(chat.getUserChatId()));
        }
    }
}
controller
import com.yb.chatai.configuration.DBMemory;
import com.yb.chatai.domain.ChatEntity;
import com.yb.chatai.domain.ChatParam;
import com.yb.chatai.service.IChatService;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

/*
 *@title ChatController2
 *@description 使用数据库(缓存)进行对话
 *@author yb
 *@version 1.0
 *@create 2024/11/12 16:12
 */
@Controller
public class ChatController2 {

    //注入模型,配置文件中的模型,或者可以在方法中指定模型
    @Resource
    private OllamaChatModel model;

    //聊天 client
    private ChatClient chatClient;

    //操作聊天信息service
    @Resource
    private IChatService chatService;

    //会话存储方式
    @Resource
    private DBMemory dbMemory;

    //开始聊天,生成唯一 sessionId
    @GetMapping("/start2")
    public String start(Model model){
        //新建聊天模型
//        OllamaOptions options = OllamaOptions.builder();
//        options.setModel("qwen2.5:14b");
//        OllamaChatModel chatModel = new OllamaChatModel(new OllamaApi(), options);
        //创建随机会话 ID
        String sessionId = UUID.randomUUID().toString();
        model.addAttribute("sessionId", sessionId);
        //创建聊天 client
        chatClient = ChatClient.builder(this.model).defaultAdvisors(new MessageChatMemoryAdvisor(dbMemory, sessionId, 10)).build();
        return "chatPage2";
    }

    //切换会话,需要传入 sessionId
    @GetMapping("/exchange2/{id}")
    public String exchange(@PathVariable("id")String sessionId){
        //切换聊天 client
        chatClient = ChatClient.builder(this.model).defaultAdvisors(new MessageChatMemoryAdvisor(dbMemory, sessionId, 10)).build();
        return "chatPage2";
    }

    //聊天
    @PostMapping("/chat2")
    @ResponseBody
    public List<ChatEntity> chat(@RequestBody ChatParam param){
        //todo 判断 AI 是否返回会话,从而判断用户是否可以输入
        chatClient.prompt(param.getUserMsg()).call().content();
        //获取返回最新两条,一条用户问题(用户获取用户发送ID),一条 AI 返回结果
        return chatService.getLastN(param.getSessionId(), 2);
    }

    //撤回消息
    @DeleteMapping("/revoke2/{id}")
    @ResponseBody
    public void revoke(@PathVariable("id") String id){
        chatService.deleteById(id);
    }

    //清空消息
    @DeleteMapping("/del2/{id}")
    @ResponseBody
    public void clear(@PathVariable("id") String sessionId){
        dbMemory.clear(sessionId);
    }

}
效果图

db

总结

主要实现 org.springframework.ai.chat.memory.ChatMemory 方法,实际项目过程需要实现该接口重写方法。

我在前面介绍的系统界面功能,包括菜单工具栏、业务表的数据,开始的时候,都是基于模拟的数据进行测试,数据采用JSON格式处理,通过辅助类的方式模拟实现数据的加载及处理,这在开发初期是一个比较好的测试方式,不过实际业务的数据肯定是来自后端,包括本地数据库,SqlServer、Mysql、Oracle、Sqlite、PostgreSQL等,或者后端的WebAPI接口获取,本篇随笔逐步介绍如何对后端的数据接口进行建模以及提供本地WebAPI代理接口类的处理过程。

1、定义Web API接口类并测试API调用基类

我在随笔《
使用wxpython开发跨平台桌面应用,动态工具的创建处理
》中介绍了关于工具栏和菜单栏的数据类,以及模拟方式获得数据进行展示,如下界面所示。

如菜单数据的类信息,如下所示。

classMenuInfo:
id: str
#菜单ID pid: str #父菜单ID label: str #菜单名称 icon: str = None #菜单图标 path: str = None #菜单路径,用来定位视图 tips: str = None #菜单提示 children: list["MenuInfo"] = None

这些数据和后端数据接口的定义一致,那么就很容易切换到动态的接口上。

在系统开发的初期,我们可以先试用模拟方式获得数据集合,如通过一个工具来来获得数据,如下所示。

为了更好的符合实际的业务需求,我们往往需要根据服务端的接口定义来定义调用Web API接口的信息。

我们为了全部采用Python语言进行开发,包括后端的内容,采用
基于SqlAlchemy+Pydantic+FastApi
的后端框架

该后端接口采用统一的接口协议,标准协议如下所示。

{"success": false,"result":  T ,"targetUrl": "string","UnAuthorizedRequest": false,"errorInfo": {"code": 0,"message": "string","details": "string"}
}

其中的result是我们的数据返回,有可能是基本类型(如字符串、数值、布尔型等),也有可能是类集合,对象信息,字典信息等等。

如果是分页查询返回结果集合,其结果如下所示。

展开单条记录明细如下所示。

如果我们基于Pydantic模型定义,我们的Python对象类定义代码如下所示

from pydantic importBaseModelfrom typing importGeneric, Type, TypeVar, Optional
T
= TypeVar("T")#自定义返回模型-统一返回结果 classAjaxResponse(BaseModel, Generic[T]):
success: bool
=False
result: Optional[T]
=None
targetUrl: Optional[str]
=None
UnAuthorizedRequest: Optional[bool]
=False
errorInfo: Optional[ErrorInfo]
= None

也就是结合泛型的方式,这样定义可以很好的抽象不同的业务类接口到基类BaseApi中,这样增删改查等处理的接口都可以抽象到BaseApi里面了。

权限模块我们涉及到的用户管理、机构管理、角色管理、菜单管理、功能管理、操作日志、登录日志等业务类,那么这些类继承BaseApi,就会具有相关的接口了,如下所示继承关系。

2、对异步调用进行测试和接口封装

为了理解客户端Api类的处理,我们先来介绍一些简单的pydantic 入门处理,如下我们先定义一些实体类用来承载数据信息,如下所示。

from typing importList, TypeVar, Optional, Generic, Dict, Anyfrom datetime importdatetimefrom pydantic importBaseModel, Field
T
= TypeVar("T")classAjaxResult(BaseModel, Generic[T]):"""测试统一接口返回格式"""success: bool=True
message: Optional[str]
=None
result: Optional[T]
=NoneclassPagedResult(BaseModel, Generic[T]):"""分页查询结果"""total: int
items: List[T]
classCustomer(BaseModel):"""客户信息类"""name: str
age: int

一般业务的结果是对应的记录列表,或者实体类对象格式,我们先来测试解析下它们的JSON数据,有助于我们理解。

#对返回结果数据格式的处理
json_data = """{
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
"""paged_result=PagedResult.model_validate_json(json_data)print(paged_result.total)print(paged_result.items)

以上正常解析到数据,输出结果如下所示。

100[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]
True

如果我们换为统一返回的结果进行测试,如下所示。

json_data = """{
"success": true,
"message": "success",
"result": {
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
}
"""ajax_result=AjaxResult[PagedResult].model_validate_json(json_data)print(ajax_result.success)print(ajax_result.message)print(ajax_result.result.total)print(ajax_result.result.items)

同样的可以获得正常的输出。

True
success
100[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]

我们通过 model_validate_json 接口可以转换字符串内容为对应的业务类对象,而通过 model_validate 函数可以转换JSON格式为业务类对象。

而对于接口的继承处理,我们采用了泛型的处理,可以极大的减少基类代码的编写,如下基类定义和子类定义,就可以简单很多,所有逻辑放在基类处理即可。

classBaseApi(Generic[T]):def test(self) ->AjaxResult[Dict[str, Any]]:
json_data
= """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}
"""result=AjaxResult[Dict[str, Any]].model_validate_json(json_data)returnresultdef get(self, id: int) ->AjaxResult[T]:
json_data
= """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}
"""result=AjaxResult[T].model_validate_json(json_data)returnresultdef getlist(self) ->AjaxResult[List[T]]:
json_data
= """{
"success": true,
"message": "success",
"result": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
"""result=AjaxResult[List[T]].model_validate_json(json_data)returnresultclass UserApi(BaseApi[Customer]):passuser_api=UserApi()
result
=user_api.getlist()print(result.success)print(result.message)print(result.result)

result
= user_api.get(1)print(result.success)print(result.message)print(result.result)

result
=user_api.test()print(result.success)print(result.message)print(result.result)

可以看到,子类只需要明确好继承关系即可,不需要编写任何多余的代码,但是又有了具体的接口处理。

3、实际HTTTP请求的封装处理

一般对于服务端接口的处理,我们可能需要引入 aiohttp 来处理请求,并结合Pydantic的模型处理,是的数据能够正常的转换,和上面的处理方式一样。

首先我们需要定义一个通用HTTP请求的类来处理常规的HTTP接口数据的返回,如下所示。

classApiClient:
_access_token
= None #类变量,用于全局共享 access_token @classmethoddefset_access_token(cls, token):"""设置全局 access_token"""cls._access_token=token

@classmethod
defget_access_token(cls):"""获取全局 access_token""" returncls._access_tokendef_get_headers(self):
headers
={}ifself.get_access_token():
headers[
"Authorization"] = f"Bearer {self.get_access_token()}" returnheaders

async
def get(self, url, params=None):
async with aiohttp.ClientSession() as session:
async with session.get(
url, headers
=self._get_headers(), params=params
) as response:
returnawait self._handle_response(response)

async
def post(self, url, json_data=None):
async with aiohttp.ClientSession() as session:
async with session.post(
url, headers
=self._get_headers(), json=json_data
) as response:
returnawait self._handle_response(response)

async
def put(self, url, json_data=None):
async with aiohttp.ClientSession() as session:
async with session.put(
url, headers
=self._get_headers(), json=json_data
) as response:
returnawait self._handle_response(response)

async
def delete(self, url, params=None):
async with aiohttp.ClientSession() as session:
async with session.delete(
url, headers
=self._get_headers(), params=params
) as response:
returnawait self._handle_response(response)

async
def_handle_response(self, response):if response.status == 200:returnawait response.json()else:
response.raise_for_status()

这些我来基于通用ApiClient的辅助类,对业务接口的调用进行一个简单基类的封装,命名为BaseApi,接受泛型类型定义,如下所示。

classBaseApi(Generic[T]):
base_url
= "http://jsonplaceholder.typicode.com/"client: ApiClient=ApiClient()

async
def getall(self, endpoint, params=None) ->List[T]:
url
= f"{self.base_url}{endpoint}"json_data= await self.client.get(url, params=params)#print(json_data) returnlist[T](json_data)

async
def get(self, endpoint, id) ->T:
url
= f"{self.base_url}{endpoint}/{id}"json_data=await self.client.get(url)#return parse_obj_as(T,json_data) adapter =TypeAdapter(T)returnadapter.validate_python(json_data)

async
def create(self, endpoint, data) ->bool:
url
= f"{self.base_url}{endpoint}"await self.client.post(url, data)returnTrue

async
def update(self, endpoint, id, data) ->T:
url
= f"{self.base_url}{endpoint}/{id}"json_data=await self.client.put(url, data)

adapter
=TypeAdapter(T)returnadapter.validate_python(json_data)

async
def delete(self, endpoint, id) ->bool:
url
= f"{self.base_url}{endpoint}/{id}"json_data=await self.client.delete(url)#print(json_data) return True

我这里使用了一个 测试API接口很好的网站:
https://jsonplaceholder.typicode.com/
,它提供了很多不同业务对象的接口信息,如下所示。

统一提供GET/POST/PUT/DELETE等常规Restful动作的处理

如我们获取列表数据的接口如下,返回对应的JSON集合。

通过对应的业务对象不同的动作处理,我们可以测试各种接口。

注意,我们上面的接口都是采用了async/awati的对应异步标识来处理异步的HTTP接口请求。

上面我们定义了BaseApi,具有常规的getall/get/create/update/delete的接口,实际开发的时候,这些会根据后端接口请求扩展更多基类接口。

基于基类BaseApi定义,我们创建其子类PostApi,用来获得具体的对象定义接口。

classPostApi(BaseApi[post]):#该业务接口类,具有基类所有的接口

    #并增加一个自定义的接口
    async def test(self) ->Db:
url
= "http://my-json-server.typicode.com/typicode/demo/db"json_data=await self.client.get(url)#print(json_data) return Db.model_validate(json_data)

这里PostApi 具有基类所有的接口:getall/get/create/update/delete的接口, 并可以根据实际情况增加自定义接口,如test 接口定义。

测试代码如下所示。

async defmain():post_api =PostApi()
result
= await post_api.getall("posts")print(len(result))

result
= await post_api.get("posts", 1)print(result)

result
=await post_api.create("posts", {"title": "test", "body": "test body", "userId": 1}
)
print(result)

result
=await post_api.update("posts", 1, {"title": "test2", "body": "test body2", "userId": 1, "id": 1}
)
print(result)

result
= await post_api.delete("posts", 1)print(result)

result
=await post_api.test()print(result)if __name__ == "__main__":
asyncio.run(main())

运行例子,输出如下结果。

之前在这篇文章(
使用 SFTP 服务器备份 VCF 核心组件的配置文件。
)中配置并备份了 VCF 环境中 SDDC Manager 组件的配置文件,这篇文章接着这个主题,看看当 SDDC Manager 组件因意外发生故障时,如何通过备份的配置文件进行还原和恢复。

一、检查 SDDC Manager

执行还原之前,请确保 SDDC Manager 组件具有最新的基于文件的备份。导航到 SDDC Manager UI->管理->备份->SDDC Manager 配置,你应该能够看到最新的备份状态。注,由于此次环境为模拟 SDDC Manager 故障,所以我能够验证备份的状态,如果是真实的环境,SDDC Manager 已经发生故障的情况下,只需要确定备份服务器上具有该组件最新的配置备份文件即可。

登陆 SFTP 备份服务器检查 SDDC Manager 组件的配置备份文件,确保备份的配置文件是最新状态,后面将基于最新备份的配置文件执行还原过程。

SFTP 服务器上安装 tar 以支持解压功能。

yum install -y tar jq

拷贝一份最新的配置备份文件到当前目录,然后使用以下命令解压这个备份文件。注,运行命令后需要输入当时备份 SDDC Manager 配置文件所设置的“加密密码短语”。

OPENSSL_FIPS=1 openssl enc -d -aes-256-cbc -md sha256 -in vcf-backup-vcf-mgmt01-sddc01-mulab-local-2024-11-12-06-05-21.tar.gz | tar -xz

进入到解压的目录,使用以下命令查看配置文件的元数据。注,后面部署 SDDC Manager OVA 模板时所指定的某些参数必须与这里的 metadata.json 信息匹配。

cat metadata.json | jq

使用以下命令查看配置文件中组件的安全密钥,需要记住 BACKUP 用户的密钥,后面部署 SDDC Manager OVA 模板时,此用户的密钥配置需要一致。

cat security_password_vault.json | jq

二、部署 SDDC Manager

根据配置备份文件 metadata.json 中的内容,可以获取到 SDDC Manager OVA 文件的下载链接,当你访问这个链接时,它将跳转到 Broadcom 支持门户(BSP)进行身份验证,验证成功后将自动下载该文件。其实,我们也可以直接登陆 Broadcom 支持门户(BSP),然后在下图的地方进行下载,文件名称为“VCF-SDDC-Manager-Appliance-5.2.0.0-24108943.ova”。

登陆 VCF 管理域 vCenter Server(vSphere Client),将 SDDC Manager 虚拟机关机来模拟该组件发生意外故障,同时将当前 SDDC Manager 虚拟机的名称重命名为其他名字。

导航到集群(vcf-mgmt01-cluster01),右击“部署 OVF 模板”开始部署新的 SDDC Manager 虚拟机。

选择本地上传 SDDC Manager OVA 模板文件,点击下一页。

设置新 SDDC Manager 虚拟机的名称并选择虚拟机文件夹的位置,点击下一页。

选择新 SDDC Manager 虚拟机所使用的计算资源,点击下一页。

验证 SDDC Manager OVA 模板的详细信息,点击下一页。

接受许可协议,点击下一页。

选择新 SDDC Manager 虚拟机的存储位置,点击下一页。

选择新 SDDC Manager 虚拟机的目标网络,点击下一页。

配置新 SDDC Manager 的用户密码、主机名以及网络等信息,点击下一页。

检查所有配置信息,点击完成。

SDDC Manager OVA 模块导入成功后,创建该虚拟机的快照并打开电源。

稍等片刻后,访问 SDDC Manager UI,状态显示为初始化中,可继续进行后续步骤。

三、还原
SDDC Manager

登陆 SFTP 服务器,使用 scp 命令将 SDDC Manager 的配置备份文件拷贝到新部署 SDDC Manager 虚拟机的 tmp 目录。注,运行命令后需输入 vcf 用户的密码进行验证。

scp /backup/vcf/sddc-manager-backup/vcf-backup-vcf-mgmt01-sddc01-mulab-local-2024-11-12-06-05-21.tar.gz vcf@vcf-mgmt01-sddc01.mulab.local:/tmp/

使用以下 API 命令获取新 SDDC Manager 的访问 Token 以执行身份验证,并将获取到的值创建为“TOKEN”的变量,以供后面的 API 命令带入使用。注,admin@local 用户及密码需填入实际环境中的值。

TOKEN=`curl https://vcf-mgmt01-sddc01.mulab.local/v1/tokens -k -X POST -H "Content-Type: application/json" -d '{"username": "admin@local","password": "Vcf520@password"}' | awk -F "\"" '{ print $4}'`

使用以下 API 命令执行 SDDC Manager 基于文件备份的还原过程。注,passphrase 后面的值需要填入备份时所设置的加密密码短语。

curl https://vcf-mgmt01-sddc01.mulab.local/v1/restores/tasks -k -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
    -d '{
  "elements" : [ {
    "resourceType" : "SDDC_MANAGER"
  } ],
  "backupFile" : "/tmp/vcf-backup-vcf-mgmt01-sddc01-mulab-local-2024-11-12-06-05-21.tar.gz",
  "encryption" : {
    "passphrase" : "VCF520@password"
  }
}'

根据上面 API 命令所执行的结果可以得到该任务的 ID,使用以下 API 命令查看这个任务以及子任务的执行状态。

curl https://vcf-mgmt01-sddc01.mulab.local/v1/restores/tasks/6bab2eb0-dd67-4b47-86e8-b6318f5eef66 -k -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" | jq

使用以下 API 命令反复查看任务的状态,当结果显示为“SUCCESSFUL”时,表示还原成功。

curl https://vcf-mgmt01-sddc01.mulab.local/v1/restores/tasks/6bab2eb0-dd67-4b47-86e8-b6318f5eef66 -k -s -X GET -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" | jq -r '.| [.id, .name, .status ]'

四、验证
SDDC Manager

如果一切顺利,应该能成功登录 SDDC Manager UI。导航到 SDDC Manager 仪表盘,在“近期任务”栏中显示了此次还原(Restore)的任务,并且结果为成功状态。

导航到清单->工作负载域/主机,VCF 环境中的所有工作负载域和主机与原来的状态一致。

导航到管理->库设置/备份,SDDC Manager 的配置与之前的状态完全一致。

后续可在 SDDC Manager 上使用 SoS 实用程序并运行 sudo /opt/vmware/sddc-support/sos --health-check 命令检查 VCF 环境的健康状态,有关更多内容和细节请查看这篇文章(
使用 SoS 实用程序检查 VCF 环境的运行状态以及收集相关组件的日志信息。
)。