Compare commits
45 Commits
6e054f7cae
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f47e512d02 | |||
| a5569f70ae | |||
| 911429d591 | |||
| eadbf47c5f | |||
| 9fb8c23518 | |||
| 2be39b4d81 | |||
| b839e361ac | |||
| b40d3f14a8 | |||
| 46366ef73d | |||
| 597159298f | |||
| 21414245d9 | |||
| 9c11e484f3 | |||
| b3c0452621 | |||
| a64f20e84f | |||
| a5976c9783 | |||
| 845a29f476 | |||
| a825ccdadb | |||
| 2777ef8676 | |||
| 6ab2f8a1f2 | |||
| 46545ca0c2 | |||
| efc1f5fbae | |||
| 8c9b3adc57 | |||
| ad9a4d6806 | |||
| e8081aa92a | |||
| 306f8df886 | |||
| 0ea90ccccf | |||
| 94c1157260 | |||
| 906e8ea5d5 | |||
| fa8f0da32a | |||
| 681aff8bd5 | |||
| a0c40be4b6 | |||
| 62d0b0515e | |||
| 24c3fded6c | |||
| e4717ac906 | |||
| 40b5f462fe | |||
| cbe8821376 | |||
| b39d345da9 | |||
| 2549b696e1 | |||
| 4f07faaa64 | |||
| b6669a1731 | |||
| 939cf05db2 | |||
| 66d565d65b | |||
| 3911a00a51 | |||
| c2516e04b5 | |||
| 4fc8d4eecc |
10
.idea/UniappTool.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
|
||||
<option name="generalBasePath" value="$PROJECT_DIR$" />
|
||||
<option name="manifestPath" value="$PROJECT_DIR$/manifest.json" />
|
||||
<option name="pagesPath" value="$PROJECT_DIR$/pages.json" />
|
||||
<option name="scanNum" value="1" />
|
||||
<option name="type" value="store" />
|
||||
</component>
|
||||
</project>
|
||||
39
.idea/dataSources.local.xml
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="dataSourceStorageLocal" created-in="WS-253.29346.143">
|
||||
<data-source name="@localhost" uuid="0a5f547c-9d7e-48c2-8022-49fb896f0b20">
|
||||
<database-info product="MySQL" version="8.4.9" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.4.9" exact-driver-version="9.5">
|
||||
<extra-name-characters>$</extra-name-characters>
|
||||
<identifier-quote-string>`</identifier-quote-string>
|
||||
</database-info>
|
||||
<case-sensitivity plain-identifiers="lower" quoted-identifiers="lower" />
|
||||
<secret-storage>master_key</secret-storage>
|
||||
<user-name>root</user-name>
|
||||
<schema-mapping>
|
||||
<introspection-scope>
|
||||
<node kind="schema" qname="@" />
|
||||
</introspection-scope>
|
||||
</schema-mapping>
|
||||
</data-source>
|
||||
<data-source name="192.168.1.7" uuid="d432891a-8082-4b1f-89b4-c55af2fe67de">
|
||||
<database-info product="MySQL" version="8.0.31" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.0.31" exact-driver-version="9.5">
|
||||
<extra-name-characters>$</extra-name-characters>
|
||||
<identifier-quote-string>`</identifier-quote-string>
|
||||
</database-info>
|
||||
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
|
||||
<secret-storage>master_key</secret-storage>
|
||||
<user-name>root</user-name>
|
||||
<schema-mapping>
|
||||
<introspection-scope>
|
||||
<node kind="schema">
|
||||
<name qname="@" />
|
||||
<name qname="blog" />
|
||||
<name qname="food_hub" />
|
||||
<name qname="sweet_hut" />
|
||||
<name qname="xxl_job" />
|
||||
</node>
|
||||
</introspection-scope>
|
||||
</schema-mapping>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
19
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="@localhost" uuid="0a5f547c-9d7e-48c2-8022-49fb896f0b20">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://localhost:3306</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="192.168.1.7" uuid="d432891a-8082-4b1f-89b4-c55af2fe67de">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://192.168.1.7:3306</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,2 @@
|
||||
#n:information_schema
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
2
.idea/dataSources/0a5f547c-9d7e-48c2-8022-49fb896f0b20/storage_v2/_src_/schema/mysql.osA4Bg.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
#n:mysql
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
@@ -0,0 +1,2 @@
|
||||
#n:performance_schema
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
2
.idea/dataSources/0a5f547c-9d7e-48c2-8022-49fb896f0b20/storage_v2/_src_/schema/sys.zb4BAA.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
#n:sys
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
@@ -0,0 +1,2 @@
|
||||
#n:information_schema
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
2
.idea/dataSources/d432891a-8082-4b1f-89b4-c55af2fe67de/storage_v2/_src_/schema/mysql.osA4Bg.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
#n:mysql
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
@@ -0,0 +1,2 @@
|
||||
#n:performance_schema
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
2
.idea/dataSources/d432891a-8082-4b1f-89b4-c55af2fe67de/storage_v2/_src_/schema/sys.zb4BAA.meta
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
#n:sys
|
||||
!<md> [null, 0, null, null, -2147483648, -2147483648]
|
||||
50
.idea/dataSources/data_sources_history.xml
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
<DataSourcesHistory>
|
||||
<DataSourceFromHistory isRemovedFromProject="false">
|
||||
<data-source source="LOCAL" name="@localhost" uuid="0a5f547c-9d7e-48c2-8022-49fb896f0b20">
|
||||
<database-info product="MySQL" version="8.4.9" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.4.9" exact-driver-version="9.5">
|
||||
<extra-name-characters>$</extra-name-characters>
|
||||
<identifier-quote-string>`</identifier-quote-string>
|
||||
</database-info>
|
||||
<case-sensitivity plain-identifiers="lower" quoted-identifiers="lower" />
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://localhost:3306</jdbc-url>
|
||||
<secret-storage>master_key</secret-storage>
|
||||
<user-name>root</user-name>
|
||||
<schema-mapping>
|
||||
<introspection-scope>
|
||||
<node kind="schema" qname="@" />
|
||||
</introspection-scope>
|
||||
</schema-mapping>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</DataSourceFromHistory>
|
||||
<DataSourceFromHistory isRemovedFromProject="false">
|
||||
<data-source source="LOCAL" name="192.168.1.7" uuid="d432891a-8082-4b1f-89b4-c55af2fe67de">
|
||||
<database-info product="MySQL" version="8.0.31" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-9.5.0 (Revision: a7b3c94f50efbddb9f0dd69b3e0d1aaa25305cd6)" dbms="MYSQL" exact-version="8.0.31" exact-driver-version="9.5">
|
||||
<extra-name-characters>$</extra-name-characters>
|
||||
<identifier-quote-string>`</identifier-quote-string>
|
||||
</database-info>
|
||||
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://192.168.1.7:3306</jdbc-url>
|
||||
<secret-storage>master_key</secret-storage>
|
||||
<user-name>root</user-name>
|
||||
<schema-mapping>
|
||||
<introspection-scope>
|
||||
<node kind="schema">
|
||||
<name qname="@" />
|
||||
<name qname="blog" />
|
||||
<name qname="food_hub" />
|
||||
<name qname="sweet_hut" />
|
||||
<name qname="xxl_job" />
|
||||
</node>
|
||||
</introspection-scope>
|
||||
</schema-mapping>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</DataSourceFromHistory>
|
||||
</DataSourcesHistory>
|
||||
10
.idea/db-forest-config.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-forest-configuration">
|
||||
<data version="2">.
|
||||
----------------------------------------
|
||||
1:0:0a5f547c-9d7e-48c2-8022-49fb896f0b20
|
||||
2:0:d432891a-8082-4b1f-89b4-c55af2fe67de
|
||||
.</data>
|
||||
</component>
|
||||
</project>
|
||||
@@ -30,16 +30,15 @@ export default withMermaid({
|
||||
...routers
|
||||
],
|
||||
sidebar: {
|
||||
'/Web-Front/': [routers[0]],
|
||||
'/Web-Backend/': [routers[1]],
|
||||
'/DevOps/': [routers[2]],
|
||||
'/Flutter/': [routers[3]],
|
||||
'/Others/': [routers[4]]
|
||||
'/Web/': [routers[0]],
|
||||
'/DevOps/': [routers[1]],
|
||||
'/Practice/': [routers[2]],
|
||||
'/Others/': [routers[3]]
|
||||
},
|
||||
|
||||
// socialLinks: [
|
||||
// { icon: 'github', link: 'https://github.com/vuejs/vitepress' }
|
||||
// ]
|
||||
socialLinks: [
|
||||
{ icon: 'gitea', link: 'https://cxxgit.iepose.cn/Cxx0822/blog-press' }
|
||||
],
|
||||
|
||||
footer: {
|
||||
message: '基于 VitePress 构建',
|
||||
|
||||
@@ -34,12 +34,15 @@ import { useData } from 'vitepress'
|
||||
const wordCount = ref(0)
|
||||
const imageCount = ref(0)
|
||||
|
||||
// 获取页面数据
|
||||
const { page } = useData()
|
||||
|
||||
// 文字阅读时间
|
||||
const wordTime = computed(() => {
|
||||
return ((wordCount.value / 275) * 60)
|
||||
})
|
||||
|
||||
// 图片阅读时间
|
||||
const imageTime = computed(() => {
|
||||
const n = imageCount.value
|
||||
if (imageCount.value <= 10) {
|
||||
@@ -54,13 +57,14 @@ const readTime = computed(() => {
|
||||
return Math.ceil((wordTime.value + imageTime.value) / 60)
|
||||
})
|
||||
|
||||
function analyze() {
|
||||
document.querySelectorAll('.meta-des').forEach(v => v.remove())
|
||||
const analyze = () => {
|
||||
// 选择文档内容区域
|
||||
const docDomContainer = window.document.querySelector('#VPContent')
|
||||
const imgs = docDomContainer?.querySelectorAll<HTMLImageElement>(
|
||||
'.content-container .main img'
|
||||
)
|
||||
// 统计图片数量
|
||||
const imgs = docDomContainer?.querySelectorAll<HTMLImageElement>('.content-container .main img')
|
||||
imageCount.value = imgs?.length || 0
|
||||
|
||||
// 统计文字数量(textContent:提取纯文本)
|
||||
const words = docDomContainer?.querySelector('.content-container .main')?.textContent || ''
|
||||
wordCount.value = countWord(words)
|
||||
}
|
||||
|
||||
151
docs/.vitepress/theme/components/Backtotop.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div class="back-top-container" v-show="showBackTop">
|
||||
<svg class="progress-ring" viewBox="0 0 100 100">
|
||||
<circle class="progress-ring-background" cx="50" cy="50" r="42" />
|
||||
<circle
|
||||
class="progress-ring-circle"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="42"
|
||||
:style="{'stroke-dashoffset': circumference - (scrollProgress * circumference)}"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="vitepress-backTop-main"
|
||||
title="返回顶部"
|
||||
@click="scrollToTop()"
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 1024 1024">
|
||||
<path d="M752.736 431.063C757.159 140.575 520.41 8.97 504.518 0.41V0l-0.45 0.205-0.41-0.205v0.41c-15.934 8.56-252.723 140.165-248.259 430.653-48.21 31.457-98.713 87.368-90.685 184.074 8.028 96.666 101.007 160.768 136.601 157.287 35.595-3.482 25.232-30.31 25.232-30.31l12.206-50.095s52.47 80.569 69.304 80.528c15.114-1.23 87-0.123 95.6 0h0.82c8.602-0.123 80.486-1.23 95.6 0 16.794 0 69.305-80.528 69.305-80.528l12.165 50.094s-10.322 26.83 25.272 30.31c35.595 3.482 128.574-60.62 136.602-157.286 8.028-96.665-42.475-152.617-90.685-184.074z m-248.669-4.26c-6.758-0.123-94.781-3.359-102.891-107.192 2.95-98.714 95.97-107.438 102.891-107.93 6.964 0.492 99.943 9.216 102.892 107.93-8.11 103.833-96.174 107.07-102.892 107.192z m-52.019 500.531c0 11.838-9.42 21.382-21.012 21.382a21.217 21.217 0 0 1-21.054-21.34V821.74c0-11.797 9.421-21.382 21.054-21.382 11.591 0 21.012 9.585 21.012 21.382v105.635z m77.333 57.222a21.504 21.504 0 0 1-21.34 21.626 21.504 21.504 0 0 1-21.34-21.626V827.474c0-11.96 9.543-21.668 21.299-21.668 11.796 0 21.38 9.708 21.38 21.668v157.082z m71.147-82.043c0 11.796-9.42 21.34-21.053 21.34a21.217 21.217 0 0 1-21.013-21.34v-75.367c0-11.755 9.421-21.299 21.013-21.299 11.632 0 21.053 9.544 21.053 21.3v75.366z" fill="#FFF"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, computed } from "vue";
|
||||
|
||||
const showBackTop = ref(false); // 初始状态设为false
|
||||
const scrollProgress = ref(0);
|
||||
|
||||
// 圆形进度条计算
|
||||
const radius = 42;
|
||||
const circumference = computed(() => 2 * Math.PI * radius);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
// 使用更高效的节流函数
|
||||
const throttle = (fn, delay = 50) => {
|
||||
let timer = null;
|
||||
return function(...args) {
|
||||
if (!timer) {
|
||||
timer = setTimeout(() => {
|
||||
fn.apply(this, args);
|
||||
timer = null;
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const updateScrollProgress = () => {
|
||||
const { scrollY, innerHeight } = window;
|
||||
const { scrollHeight } = document.documentElement;
|
||||
const totalScroll = scrollHeight - innerHeight;
|
||||
scrollProgress.value = totalScroll > 0 ? Math.min(scrollY / totalScroll, 1) : 0;
|
||||
};
|
||||
|
||||
const handleScroll = throttle(() => {
|
||||
// 当滚动超过100px时显示,否则隐藏
|
||||
const shouldShow = window.scrollY > 100;
|
||||
showBackTop.value = shouldShow;
|
||||
updateScrollProgress();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
updateScrollProgress();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.back-top-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.vitepress-backTop-main {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background-color: #3eaf7c;
|
||||
padding: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.vitepress-backTop-main:hover {
|
||||
background-color: #71cda3;
|
||||
}
|
||||
|
||||
.progress-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.progress-ring-background {
|
||||
fill: none;
|
||||
stroke: rgba(62, 175, 124, 0.15);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.progress-ring-circle {
|
||||
fill: none;
|
||||
stroke: #3eaf7c;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 264; /* 2 * π * 42 */
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.15s ease-out;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
164
docs/.vitepress/theme/components/Loading.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="loading-content">
|
||||
<!-- 动态加载动画 -->
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-circle"></div>
|
||||
<div class="spinner-arc"></div>
|
||||
<div class="brand-dot">
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载文字 -->
|
||||
<div class="loading-text">
|
||||
<h3>正在加载</h3>
|
||||
<p>请稍候...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.spinner-circle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 3px solid rgba(var(--vp-c-brand-rgb, 0, 0, 0), 0.1);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.spinner-arc {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 3px solid transparent;
|
||||
border-top: 3px solid var(--vp-c-brand);
|
||||
border-right: 3px solid var(--vp-c-brand);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
animation: spin 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--vp-c-brand);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 20px rgba(var(--vp-c-brand-rgb, 0, 0, 0), 0.3);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
text-align: center;
|
||||
animation: fadeInOut 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-text h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 0.5rem 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading-text p {
|
||||
font-size: 0.95rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0%, 100% { opacity: 0.9; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 淡入淡出过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 主内容滑入动画 */
|
||||
.slide-up-enter-active {
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.slide-up-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 640px) {
|
||||
.loading-content {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.loading-text h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.loading-text p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,64 @@
|
||||
<template>
|
||||
<div>
|
||||
<Transition name="fade">
|
||||
<div v-if="isLoading" class="elegant-loading">
|
||||
<Loading />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<div v-show="!isLoading">
|
||||
<DefaultTheme.Layout v-bind="$attrs">
|
||||
<template #doc-before>
|
||||
<ArticleMetadat />
|
||||
</template>
|
||||
|
||||
<template #doc-footer-before>
|
||||
<Backtotop />
|
||||
</template>
|
||||
</DefaultTheme.Layout>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import ArticleMetadat from './ArticleMetadata.vue';
|
||||
import Backtotop from './Backtotop.vue';
|
||||
import Loading from './Loading.vue';
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const handleLoad = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// 防止错过load加载完成
|
||||
if (document.readyState === 'complete') {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 加载完成事件
|
||||
window.addEventListener('load', handleLoad, { once: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultTheme.Layout v-bind="$attrs">
|
||||
<template #doc-before>
|
||||
<ArticleMetadat />
|
||||
</template>
|
||||
|
||||
</DefaultTheme.Layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.elegant-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--vp-c-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
</style>
|
||||
@@ -4,36 +4,29 @@ import ArticleMetadata from "./components/ArticleMetadata.vue"
|
||||
import StatsChart from './components/StatsChart.vue'
|
||||
import MenuList from './components/MenuList.vue'
|
||||
import type { EnhanceAppContext } from 'vitepress'
|
||||
import { onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vitepress'
|
||||
import mediumZoom from 'medium-zoom'
|
||||
import imageViewer from "vitepress-plugin-image-viewer";
|
||||
import "viewerjs/dist/viewer.min.css";
|
||||
import '../theme/style.css'
|
||||
import './style/var.css'
|
||||
import './style/vp-code.css'
|
||||
import './style/custom-block.css'
|
||||
import MyLayout from './components/MyLayout.vue';
|
||||
import VPSwiper from '@cssnr/vitepress-swiper'
|
||||
import '@cssnr/vitepress-swiper/style.css'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: MyLayout,
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const initZoom = () => {
|
||||
// 为所有图片增加缩放功能
|
||||
mediumZoom('.main img', { background: 'var(--vp-c-bg)' })
|
||||
}
|
||||
onMounted(() => {
|
||||
initZoom()
|
||||
})
|
||||
watch(
|
||||
() => route.path,
|
||||
() => nextTick(() => initZoom())
|
||||
)
|
||||
const route = useRoute();
|
||||
imageViewer(route);
|
||||
},
|
||||
enhanceApp({ app }: EnhanceAppContext) {
|
||||
app.component("Confetti", Confetti);
|
||||
app.component("StatsChart", StatsChart);
|
||||
app.component("MenuList", MenuList);
|
||||
app.component("ArticleMetadata", ArticleMetadata);
|
||||
app.component("VPSwiper", VPSwiper);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,82 +1,142 @@
|
||||
export const routers = [
|
||||
{
|
||||
text: '🌐 Web前端',
|
||||
text: '🌐 Web平台',
|
||||
items: [
|
||||
{
|
||||
text: '🌿 Vue',
|
||||
items: [
|
||||
{ text: 'Vue3-Common', link: '/Web-Front/Vue/Vue3-Common' },
|
||||
{ text: 'Vue3架构原理', link: '/Web-Front/Vue/Vue3-Architecture' }
|
||||
{ text: 'Vue3架构原理', link: '/Web/Vue/Vue3-Architecture' },
|
||||
{ text: 'Vue3自定义指令', link: '/Web/Vue/Vue3-Directive' },
|
||||
{ text: 'Vue3 defineModel', link: '/Web/Vue/Vue3-DefineModel' },
|
||||
{ text: 'JavaScript知识点整理', link: '/Web/Vue/JavaScript-Guide' },
|
||||
{ text: '前端包管理器', link: '/Web/Vue/Package' },
|
||||
{ text: 'Vite核心原理', link: '/Web/Vue/Vite-Principle' },
|
||||
{ text: 'VueUse简介和使用', link: '/Web/Vue/VueUse' },
|
||||
{ text: '浏览器渲染全流程解析', link: '/Web/Vue/Browser-Process' },
|
||||
{ text: 'Element Plus 上传器', link: '/Web/Vue/ElUpload' },
|
||||
{ text: 'Element Plus 表格组件', link: '/Web/Vue/ElTable' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🔄 其他',
|
||||
items: [
|
||||
{ text: 'JavaScript知识点整理', link: '/Web-Front/Others/JavaScript-Guide' },
|
||||
{ text: '前端包管理器', link: '/Web-Front/Others/Package' },
|
||||
{ text: 'Vite核心原理', link: '/Web-Front/Others/Vite-Principle' },
|
||||
{ text: '浏览器渲染全流程解析', link: '/Web-Front/Others/Browser-Process' }
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🖥️ Web后端',
|
||||
items: [
|
||||
{
|
||||
text: '🍃 SpringBoot',
|
||||
items: [
|
||||
{ text: 'Spring IOC简介', link: '/Web-Backend/SpringBoot/SpringIOC' },
|
||||
{ text: 'SpringBoot启动流程', link: '/Web-Backend/SpringBoot/SpringBoot-Start-Process' },
|
||||
{ text: 'Spring MVC简介', link: '/Web-Backend/SpringBoot/SpringMVC' },
|
||||
{ text: 'SpringBoot Starter原理', link: '/Web-Backend/SpringBoot/SpringBootStarter' },
|
||||
{ text: 'SpringBoot Bean简介', link: '/Web-Backend/SpringBoot/SpringBootBean' },
|
||||
{ text: 'SpringBoot3原生镜像', link: '/Web-Backend/SpringBoot/SpringBoot3-GraalVM' },
|
||||
{ text: 'SpingBoot技巧', link: '/Web-Backend/SpringBoot/SpingBoot-Skills' },
|
||||
{ text: 'SpringBoot Common', link: '/Web-Backend/SpringBoot/SpringBoot-Common' }
|
||||
{ text: 'Spring IOC简介', link: '/Web/SpringBoot/Spring-IOC' },
|
||||
{ text: 'Spring AOP简介', link: '/Web/SpringBoot/Spring-AOP' },
|
||||
{ text: 'Spring MVC简介', link: '/Web/SpringBoot/Spring-MVC' },
|
||||
{ text: '启动流程', link: '/Web/SpringBoot/SpringBoot-Start-Process' },
|
||||
{ text: 'Starter原理', link: '/Web/SpringBoot/SpringBoot-Starter' },
|
||||
{ text: 'Bean简介', link: '/Web/SpringBoot/SpringBoot-Bean' },
|
||||
{ text: 'SpingBoot注解', link: '/Web/SpringBoot/SpringBoot-Annotation' },
|
||||
{ text: 'SpringBoot3原生镜像', link: '/Web/SpringBoot/SpringBoot3-GraalVM' },
|
||||
{ text: 'SpringBoot技巧', link: '/Web/SpringBoot/SpringBoot-Skills' },
|
||||
{ text: 'RestClient简介', link: '/Web/SpringBoot/SpringBoot-RestClient' },
|
||||
{ text: 'Redis简介和使用', link: '/Web/SpringBoot/SpringBoot-Redis' },
|
||||
{ text: 'RocketMQ简介和使用', link: '/Web/SpringBoot/SpringBoot-RocketMQ' },
|
||||
{ text: 'WebSocket简介和使用', link: '/Web/SpringBoot/SpringBoot-WebSocket' },
|
||||
{ text: 'Quarzt简介和使用', link: '/Web/SpringBoot/SpringBoot-Quartz' },
|
||||
{ text: '线程与事件', link: '/Web/SpringBoot/SpringBoot-Thread' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🐍 FastAPI',
|
||||
items: [
|
||||
{ text: 'FastAPI基础教程', link: '/Web-Backend/FastAPI/FastAPI-Guide' },
|
||||
{ text: '基于OAuth2的安全验证', link: '/Web-Backend/FastAPI/OAuth2' }
|
||||
{ text: 'FastAPI基础教程', link: '/Web/FastAPI/FastAPI-Guide' },
|
||||
{ text: '基于OAuth2的安全验证', link: '/Web/FastAPI/OAuth2' },
|
||||
{ text: 'FastAPI Docker部署', link: '/Web/FastAPI/FastAPI-Docker' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🐬 MySQL',
|
||||
items: [
|
||||
{ text: 'MyBatis简介和使用', link: '/Web/MySQL/MyBatis' },
|
||||
{ text: 'Flyway简单使用', link: '/Web/MySQL/Flyway' },
|
||||
{ text: 'MySQL知识点', link: '/Web/MySQL/Summary' },
|
||||
{ text: 'SQL高阶用法', link: '/Web/MySQL/SQL-Advance' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🤖 AI',
|
||||
items: [
|
||||
{ text: 'Agent开发', link: '/Web/AI/Langchain' },
|
||||
{ text: 'Text To SQL开发', link: '/Web/AI/TextToSQL' },
|
||||
{ text: 'RAG开发', link: '/Web/AI/RAG' },
|
||||
{ text: 'Tools开发', link: '/Web/AI/Tools' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '📶 Network',
|
||||
items: [
|
||||
{ text: '网络编程简介', link: '/Web/Network/Summary' },
|
||||
{ text: '.Net Core实现', link: '/Web/Network/NetCore' },
|
||||
{ text: 'Mqtt简介与使用', link: '/Web/Network/Mqtt' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '📱 Flutter',
|
||||
items: [
|
||||
{ text: 'Flutter简介与安装', link: '/Web/Flutter/FlutterInstall' },
|
||||
{ text: 'Dart基础教程', link: '/Web/Flutter/DartGuide' },
|
||||
{ text: 'Flutter基础教程', link: '/Web/Flutter/FlutterGuide' },
|
||||
{ text: '实战技巧', link: '/Web/Flutter/Practice' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🔄 其他',
|
||||
items: [
|
||||
{ text: 'MySQL知识点', link: '/Web-Backend/Others/MySQL-Knowledge' },
|
||||
{ text: 'Flyway简单使用', link: '/Web-Backend/Others/Flyway' },
|
||||
{ text: 'MyBatis简介和使用', link: '/Web-Backend/Others/MyBatis' },
|
||||
{ text: 'RustFS简介和使用', link: '/Web-Backend/Others/RustFS' },
|
||||
{ text: 'AList简介和使用', link: '/Web-Backend/Others/AList' },
|
||||
{ text: '网络编程简介', link: '/Web-Backend/Others/Netword-Program' }
|
||||
{ text: 'RustFS简介和使用', link: '/Web/Others/RustFS' },
|
||||
{ text: 'AList简介和使用', link: '/Web/Others/AList' },
|
||||
{ text: 'InfluxDB简介和使用', link: '/Web/Others/InfluxDB' },
|
||||
{ text: 'Meilisearch简介和使用', link: '/Web/Others/Meilisearch' },
|
||||
{ text: '视频压缩', link: '/Web/Others/VideoCompressor' },
|
||||
{ text: '图片处理服务', link: '/Web/Others/ImageProcessor' },
|
||||
{ text: '秒杀系统设计与实战', link: '/Web/Others/Seckill' }
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🚀 运维部署',
|
||||
items: [
|
||||
{ text: '⭐ OpenObserve部署和使用', link: '/DevOps/OpenObserve' },
|
||||
{ text: 'Jenkins部署和使用', link: '/DevOps/Jenkins' },
|
||||
{ text: 'ElasticSearch部署和使用', link: '/DevOps/ElasticSearch' },
|
||||
{ text: 'Docker简介和安装', link: '/DevOps/Docker' },
|
||||
{ text: 'Linux命令', link: '/DevOps/LinuxCommand' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🚀 DevOps',
|
||||
items: [
|
||||
{ text: 'OpenObserve部署和使用', link: '/DevOps/OpenObserve' },
|
||||
{ text: 'Jenkins部署和使用', link: '/DevOps/Jenkins' },
|
||||
{ text: 'ElasticSearch部署和使用', link: '/DevOps/ElasticSearch' },
|
||||
{ text: 'Docker简介和安装', link: '/DevOps/Docker' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '📱 Flutter',
|
||||
text: '🏗️ 项目实战',
|
||||
items: [
|
||||
{ text: 'Dart基础教程', link: '/Flutter/DartGuide' },
|
||||
{ text: 'Flutter基础教程', link: '/Flutter/FlutterGuide' },
|
||||
{ text: '实战技巧', link: '/Flutter/Practice' }
|
||||
]
|
||||
{
|
||||
text: '🧩 通用模块',
|
||||
items: [
|
||||
{ text: 'Vue3 Common', link: '/Practice/Common/Vue3-Common' },
|
||||
{ text: 'SpringBoot Common', link: '/Practice/Common/SpringBoot-Common' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🏢 公司项目',
|
||||
items: [
|
||||
{ text: '埃学习', link: '/Practice/Company/Learn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '👤 个人项目',
|
||||
items: [
|
||||
{ text: '家庭管理系统', link: '/Practice/Personal/SweetHut' },
|
||||
{ text: '个人博客系统', link: '/Practice/Personal/Blog' }
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🔄 其他',
|
||||
items: [
|
||||
{ text: 'VitePress使用和简介', link: '/Others/VitePress' },
|
||||
{ text: '企业微信工作台开发', link: '/Others/WeCom' },
|
||||
{ text: 'XDRP简介和安装', link: '/Others/XDRP' },
|
||||
{ text: 'RustDesk简介和安装', link: '/Others/RustDesk' },
|
||||
{ text: 'Systemback制作Ubuntu镜像', link: '/Others/Systemback' },
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -36,15 +36,10 @@
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.medium-zoom-overlay {
|
||||
background-color: var(--vp-c-bg) !important;
|
||||
z-index: 100;
|
||||
.vp-swiper-button {
|
||||
color: #bd34fe;
|
||||
}
|
||||
|
||||
.medium-zoom-overlay ~ img {
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.medium-zoom--opened .medium-zoom-overlay {
|
||||
opacity: 0.9 !important;
|
||||
strong {
|
||||
color: #bd34fe;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Docker简介和安装
|
||||
date: 2025-12-15
|
||||
isGreat: true
|
||||
---
|
||||
|
||||
# 一、Docker简介
|
||||
@@ -288,7 +287,7 @@ docker system prune -f
|
||||
# 六、Dockerfile
|
||||
  Dockerfile 是用来构建 Docker 镜像的文本文件,包含了一系列构建指令,可以自动化地创建包含应用及其运行环境的镜像。
|
||||
  基本结构:
|
||||
```
|
||||
```dockerfile
|
||||
# 基础镜像
|
||||
FROM 镜像名:标签
|
||||
|
||||
@@ -344,18 +343,13 @@ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"]
|
||||
```
|
||||
|
||||
```dockerfile [dotnet]
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY publish/ .
|
||||
|
||||
RUN chmod +x EMMessageHubServer
|
||||
|
||||
EXPOSE 5000
|
||||
COPY ./publish .
|
||||
EXPOSE 14040
|
||||
|
||||
ENTRYPOINT ["./EMMessageHubServer"]
|
||||
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||
ENTRYPOINT ["dotnet", "App.dll"]
|
||||
```
|
||||
:::
|
||||
|
||||
|
||||
100
docs/DevOps/LinuxCommand.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Linux命令
|
||||
date: 2026-05-09
|
||||
---
|
||||
|
||||
# 一、系统信息查看
|
||||
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| uname | 查看系统内核信息 | uname | -a:所有信息 |
|
||||
| lsb_release -a | 查看发行版信息 | - | - |
|
||||
| df | 查看磁盘空间使用 | df -h | -h:显示可读的文件大小 |
|
||||
| du | 计算文件/目录大小 | du -sh * | - |
|
||||
| free | 查看内存使用 | free -h | - |
|
||||
| uptime | 查看系统运行时间 | uptime | - |
|
||||
|
||||
::: tip
|
||||
查看指定目录下每个文件/文件夹的大小,并按大小排序:
|
||||
```bash
|
||||
du -sh /root/* | sort -hr
|
||||
```
|
||||
:::
|
||||
|
||||
# 二、文件和目录操作
|
||||
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| ls | 列出目录内容 | ls /home -lah | -a:显示隐藏文件 <br> -l:显示详细信息 <br> -h:显示可读的文件大小 |
|
||||
| pwd | 显示当前路径 | - | - |
|
||||
| cp | 复制文件或目录 | cp -r src/ dst/ | -r:文件夹 |
|
||||
| mv | 移动或重命名 | mv file.txt new.txt | 建议写完整路径 |
|
||||
| find | 查找文件 | find . -name "*.txt" <br> find . -type f -size +10M | - |
|
||||
|
||||
# 三、文件内容查看
|
||||
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| cat | 查看整个文件 | cat /etc/os-release | - |
|
||||
| less | 分页浏览(支持上下翻) | less /var/log/syslog | q:退出 <br> ↑↓:上下一行 <br> b/空格:上下一屏|
|
||||
| more | 分页浏览(仅向下) | more /var/log/syslog | q:退出 <br> 空格:下一屏|
|
||||
| head | 查看文件开头 N 行 | head /var/log/syslog | -n 20:前20行 |
|
||||
| tail | 查看文件末尾 N 行 | tail /var/log/syslog | -n 20:后20行 <br> -f:实时显示|
|
||||
|
||||
# 四、进程管理
|
||||
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| ps | 查看当前进程快照 | ps aux | - |
|
||||
| top | 实时进程监控 | top | 实时刷新,按q退出 <br> P(大写):CPU排序 <br> M:内存排序 |
|
||||
| kill | 终止进程 | kill PID | -9:强制关闭 |
|
||||
| pkill | 按名称终止进程 | pkill PID | -9:强制关闭 |
|
||||
| nohup | 后台运行且忽略挂断 | nohup python app.py & | - |
|
||||
|
||||
::: tip
|
||||
1. 查看某个程序进程:
|
||||
```bash
|
||||
ps aux | grep nginx
|
||||
```
|
||||
|
||||
2. 查看指定 PID 的进程:
|
||||
```bash
|
||||
ps -fp PID号
|
||||
```
|
||||
|
||||
3. 按 CPU 排序
|
||||
```bash
|
||||
ps aux --sort=-%cpu | head -20
|
||||
```
|
||||
|
||||
4. 按内存排序
|
||||
```bash
|
||||
ps aux --sort=-%mem | head -20
|
||||
```
|
||||
|
||||
5. nohup输出日志
|
||||
```bash
|
||||
nohup command > app.log 2>&1 &
|
||||
```
|
||||
|
||||
6. nohup不输出日志
|
||||
```bash
|
||||
nohup command > /dev/null 2>&1 &
|
||||
```
|
||||
:::
|
||||
|
||||
# 五、网络管理
|
||||
| 命令 | 说明 | 示例 | 参数/注释 |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| ip | 查看/配置网络接口 | - | link:查看所有网卡 <br> addr:查看 IP 地址 <br> route:查看路由表 |
|
||||
| ss | 查看网络连接、端口监听、套接字统计 | - | -l:监听 <br> -n:不解析服务名 <br> -t:TCP <br> -p:显示进程 PID / 程序名 |
|
||||
|
||||
::: tip
|
||||
1. 显示进程端口
|
||||
```bash
|
||||
ss -lntp
|
||||
```
|
||||
|
||||
2. 查看某个端口是否启动
|
||||
```bash
|
||||
ss -lntp | grep 8080
|
||||
```
|
||||
:::
|
||||
|
||||
# 六、实战场景
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: OpenObserve部署和使用
|
||||
date: 2025-11-26
|
||||
isGreat: true
|
||||
---
|
||||
|
||||
# 一、基础概念
|
||||
@@ -9,7 +10,7 @@
|
||||
|
||||
### 1.1.1 特点
|
||||
1. 极致的轻量级
|
||||
Fluent Bit 是用 C 语言编写的,运行时内存占用极低(通常只有几百 KB),CPU 使用率也非常低。这使得它成为在资源受限环境(如边缘计算、IoT 设备、容器侧车)中运行的理想选择。
|
||||
Fluent Bit 是用 C 语言编写的,运行时内存占用极低(通常只有几百 KB),CPU 使用率也非常低。这使得它成为在资源受限环境(如边缘计算、IoT 设备、容器)中运行的理想选择。
|
||||
2. 高性能
|
||||
其核心架构经过高度优化,处理数据的速度极快,能够轻松应对高吞吐量的日志流。
|
||||
3. 可插拔架构
|
||||
@@ -23,7 +24,17 @@ Fluent Bit 的功能通过“插件”来扩展。
|
||||
3. Output(输出)
|
||||
定义数据目的地,例如 Elasticsearch、Splunk、Kafka、Datadog、Loki、S3、另一个 Fluentd/Fluent Bit 实例等。
|
||||
|
||||
### 1.1.3 核心架构与数据流
|
||||
::: tip
|
||||
常用Input插件:
|
||||
1. Forward
|
||||
  适用于Fluent Bit / Fluentd 节点,传输协议为私有二进制协议。支持压缩和重连,传输效率极高,但客户端必须使用特定的库。
|
||||
2. TCP
|
||||
  适用于能建立 Socket 连接的程序,传输协议为TCP协议,只负责接收字节流,通常需要配合解析器(Parser)来处理格式。
|
||||
3. HTTP
|
||||
  通过标准的 HTTP POST 请求接收 JSON 数据,传输协议为HTTP协议,兼容性最好,任何编程语言都能轻松发送 HTTP 请求。
|
||||
:::
|
||||
|
||||
### 1.1.3 核心架构
|
||||
1. Input(输入)
|
||||
一个或多个输入插件持续地从数据源收集数据。每条数据被读入后,会带上时间戳和标签等信息,被包装成一个内部的事件记录。
|
||||
2. Parser(解析器)
|
||||
@@ -37,6 +48,20 @@ Fluent Bit 的功能通过“插件”来扩展。
|
||||
6. Output(输出)
|
||||
最终,输出插件将处理好的数据发送到配置的目的地。
|
||||
|
||||
### 1.1.4 安装
|
||||
```bash
|
||||
sudo sh -c 'curl https://packages.fluentbit.io/fluentbit.key | gpg --dearmor > /usr/share/keyrings/fluentbit-keyring.gpg'
|
||||
CODENAME=$(lsb_release -cs) && echo "deb [signed-by=/usr/share/keyrings/fluentbit-keyring.gpg] https://packages.fluentbit.io/ubuntu/$CODENAME $CODENAME main" | sudo tee /etc/apt/sources.list.d/fluent-bit.list
|
||||
apt-get update
|
||||
apt-get install fluent-bit
|
||||
systemctl start fluent-bit.service
|
||||
systemctl enable fluent-bit.service
|
||||
```
|
||||
|
||||
::: tip
|
||||
默认安装位置为:/opt/fluent-bit/bin/fluent-bit,默认配置文件位置为:/etc/fluent-bit。
|
||||
:::
|
||||
|
||||
## 1.2 OpenObserve
|
||||
  [OpenObserve](https://openobserve.ai/) 是一个开源的、云原生的可观测性数据平台,旨在处理日志、指标、追踪和性能数据。它的目标是成为 Elasticsearch/Logstash/Kibana (ELK)、Grafana Loki 和 Datadog 等流行解决方案的更简单、更经济高效、更高性能的替代品。
|
||||
  OpenObserve 是一个“一体化”的平台,你可以将所有的可观测性数据(日志、指标、追踪)都发送到它这里,然后通过统一的界面进行搜索、分析和可视化。
|
||||
@@ -65,7 +90,7 @@ services:
|
||||
- ./openobserve-data:/data
|
||||
environment:
|
||||
- ZO_ROOT_USER_EMAIL=1556464090@qq.com
|
||||
- ZO_ROOT_USER_PASSWORD=19940822Cxx
|
||||
- ZO_ROOT_USER_PASSWORD=yourpassword
|
||||
- TZ=Asia/Shanghai
|
||||
ports:
|
||||
- "5080:5080"
|
||||
@@ -81,7 +106,7 @@ volumes:
|
||||
driver: local
|
||||
```
|
||||
|
||||
  fluent bit配置文件:
|
||||
  fluent bit配置文件fluent-bit.conf:
|
||||
```conf
|
||||
[SERVICE]
|
||||
Flush 5
|
||||
@@ -102,16 +127,466 @@ volumes:
|
||||
Port 5080
|
||||
URI /api/default/default/_json
|
||||
http_user 1556464090@qq.com
|
||||
http_passwd 19940822Cxx
|
||||
http_passwd yourpassword
|
||||
Format json
|
||||
json_date_key timestamp
|
||||
json_date_format iso8601
|
||||
```
|
||||
|
||||
  INPUT为日志输入源,监听24422端口。
|
||||
  OUTPUT为日志输出源,将日志发送到openobserve中(这里使用的是容器名称),URI表示openobserve接收数据的api路径,default/default表示组织名称/流名称,可以更改为其他的名称。_json表示以json的格式。
|
||||
  INPUT为日志输入源,使用Forward插件,监听24422端口。
|
||||
  OUTPUT为日志输出源,将日志发送到openobserve中(这里使用的是容器名称),URI表示openobserve接收数据的api路径,default/default表示组织名称/流名称,可以更改为其他的名称。_json表示以json的格式。
|
||||
  Match *表示接收所有的INPUT,也可以根据INPUT的tag来区分分发到哪个OUTPUT。
|
||||
|
||||
# 三、简单使用
|
||||
# 三、数据接入
|
||||
## 3.1 SpringBoot
|
||||
### 3.1.1 安装依赖
|
||||
```xml
|
||||
<properties>
|
||||
<logback-more-appenders.version>1.8.8</logback-more-appenders.version>
|
||||
<fluency-fluentd.version>2.7.0</fluency-fluentd.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- SpringBoot 日志启动 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-logging</artifactId>
|
||||
</dependency>
|
||||
<!-- 扩展 logback appender -->
|
||||
<dependency>
|
||||
<groupId>com.sndyuk</groupId>
|
||||
<artifactId>logback-more-appenders</artifactId>
|
||||
<version>${logback-more-appenders.version}</version>
|
||||
</dependency>
|
||||
<!-- Fluentd 日志搜集和转发 -->
|
||||
<dependency>
|
||||
<groupId>org.komamitsu</groupId>
|
||||
<artifactId>fluency-fluentd</artifactId>
|
||||
<version>${fluency-fluentd.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
### 3.1.2 日志配置
|
||||
```yml
|
||||
logging:
|
||||
config: classpath:logback-spring.xml
|
||||
fluentd:
|
||||
host: host.docker.internal
|
||||
port: 24224
|
||||
level:
|
||||
root: info
|
||||
```
|
||||
|
||||
### 3.1.3 配置文件
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration scan="true" scanPeriod="10 seconds">
|
||||
|
||||
<!-- 自定义属性 -->
|
||||
<springProperty scope="context" name="fluentdHost" source="logging.fluentd.host" defaultValue="localhost"/>
|
||||
<springProperty scope="context" name="fluentdPort" source="logging.fluentd.port" defaultValue="24224"/>
|
||||
<springProperty scope="context" name="topic" source="spring.application.name" defaultValue="default"/>
|
||||
|
||||
<!-- log路径 -->
|
||||
<property name="log.path" value="logs/${topic}"/>
|
||||
|
||||
<!--控制台日志格式:彩色日志-->
|
||||
<!-- magenta:洋红 -->
|
||||
<!-- boldMagenta:粗红-->
|
||||
<!-- cyan:青色 -->
|
||||
<!-- white:白色 -->
|
||||
<!-- magenta:洋红 -->
|
||||
<property name="CONSOLE_LOG_PATTERN"
|
||||
value="%yellow(%date{yyyy-MM-dd HH:mm:ss}) |%highlight(%-5level) |%blue(%thread) |%blue(%file:%line) |%green(%logger) |%cyan(%msg%n)"/>
|
||||
|
||||
<!--文件日志格式-->
|
||||
<property name="FILE_LOG_PATTERN"
|
||||
value="%date{yyyy-MM-dd HH:mm:ss} |%-5level |%thread |%file:%line |%logger |%msg%n"/>
|
||||
|
||||
<!--编码-->
|
||||
<property name="ENCODING"
|
||||
value="UTF-8"/>
|
||||
|
||||
<!--输出到控制台-->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<!--日志级别-->
|
||||
<level>DEBUG</level>
|
||||
</filter>
|
||||
<encoder>
|
||||
<!--日志格式-->
|
||||
<Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
|
||||
<!--日志字符集-->
|
||||
<charset>${ENCODING}</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!--输出到文件-->
|
||||
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<!--日志过滤器:此日志文件只记录INFO级别的-->
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>INFO</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||
<file>${log.path}/log_info.log</file>
|
||||
<encoder>
|
||||
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||
<charset>${ENCODING}</charset>
|
||||
</encoder>
|
||||
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!-- 每天日志归档路径以及格式 -->
|
||||
<fileNamePattern>${log.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<!-- 日志文件的最大大小 -->
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<!--日志文件保留天数-->
|
||||
<maxHistory>15</maxHistory>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<!-- 日志过滤器:此日志文件只记录WARN级别的 -->
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>WARN</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||
<file>${log.path}/log_warn.log</file>
|
||||
<encoder>
|
||||
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||
<charset>${ENCODING}</charset> <!-- 此处设置字符集 -->
|
||||
</encoder>
|
||||
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${log.path}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<!-- 日志文件的最大大小 -->
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<!--日志文件保留天数-->
|
||||
<maxHistory>15</maxHistory>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<!-- 日志过滤器:此日志文件只记录ERROR级别的 -->
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>ERROR</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||
<file>${log.path}/log_error.log</file>
|
||||
<encoder>
|
||||
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||
<charset>${ENCODING}</charset> <!-- 此处设置字符集 -->
|
||||
</encoder>
|
||||
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${log.path}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<!-- 日志文件的最大大小 -->
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<!--日志文件保留天数-->
|
||||
<maxHistory>15</maxHistory>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<!--输出到FLUENCY-->
|
||||
<appender name="FLUENCY" class="ch.qos.logback.more.appenders.FluencyLogbackAppender">
|
||||
<remoteHost>${fluentdHost}</remoteHost>
|
||||
<port>${fluentdPort}</port>
|
||||
<additionalField>
|
||||
<key>topic</key>
|
||||
<value>${topic}</value>
|
||||
</additionalField>
|
||||
<ignoredField>caller</ignoredField>
|
||||
<useEventTime>true</useEventTime>
|
||||
</appender>
|
||||
|
||||
<!-- 开发环境 -->
|
||||
<springProfile name="dev">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<!-- 生产环境 -->
|
||||
<springProfile name="prod">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="INFO_FILE"/>
|
||||
<appender-ref ref="WARN_FILE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<!-- 容器环境 -->
|
||||
<springProfile name="docker">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FLUENCY"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
::: tip
|
||||
这里是用docker部署的,如果是非docker部署,host为服务器ip地址。
|
||||
:::
|
||||
|
||||
## 3.2 .NET Core
|
||||
### 3.2.1 安装Nuget包
|
||||
  安装NLog.Extensions.Hosting和NLog.Targets.Network包。
|
||||
|
||||
::: tip
|
||||
NLog.Targets.Fluentd这个包也可以,但是无法定制FluentBit的消息格式。
|
||||
:::
|
||||
|
||||
### 3.2.2 使用NLog
|
||||
```cs
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.UseNLog()
|
||||
.Build();
|
||||
```
|
||||
|
||||
### 3.2.3 配置NLog
|
||||
  在项目目录创建`NLog.config`并设置复制到输出目录为始终复制。
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
autoReload="true">
|
||||
|
||||
<extensions>
|
||||
<add assembly="NLog.Targets.Network" />
|
||||
</extensions>
|
||||
|
||||
<targets async="true">
|
||||
<target name="fluentbit" xsi:type="Network"
|
||||
newLine="true" lineEnding="LF"
|
||||
address="tcp://ip:5170">
|
||||
<layout xsi:type="JsonLayout">
|
||||
<attribute name="topic" layout="message-hub"/>
|
||||
<attribute name="timestamp" layout="${longdate:universalTime=true}"/>
|
||||
<attribute name="level" layout="${level:uppercase=true}"/>
|
||||
<attribute name="logger" layout="${logger}"/>
|
||||
<attribute name="message" layout="${message}"/>
|
||||
<attribute name="throwable" layout="${exception:format=ToString}"/>
|
||||
</layout>
|
||||
</target>
|
||||
|
||||
<target name="stdout"
|
||||
xsi:type="Console"
|
||||
layout="${longdate} ${level:uppercase=true}: ${logger} - ${message}${onexception:${newline}${exception:format=ToString}}" />
|
||||
</targets>
|
||||
|
||||
<rules>
|
||||
<logger name="*" minlevel="Info" writeTo="stdout, fluentbit"/>
|
||||
</rules>
|
||||
</nlog>
|
||||
```
|
||||
|
||||
::: tip
|
||||
这里的topic为项目名称。
|
||||
:::
|
||||
|
||||
### 3.2.3 配置插件
|
||||
  在FluentBit中增加TCP插件:
|
||||
```conf
|
||||
[INPUT]
|
||||
Name tcp
|
||||
Listen 0.0.0.0
|
||||
Port 5170
|
||||
Tag tcp
|
||||
```
|
||||
|
||||
::: tip
|
||||
docker-compose文件中也要增加5170端口映射。
|
||||
:::
|
||||
|
||||
## 3.3 Python
|
||||
### 3.3.1 安装依赖
|
||||
```bash
|
||||
pip install fluent-logger
|
||||
```
|
||||
|
||||
### 3.3.2 配置文件
|
||||
```.env
|
||||
FLUENT_HOST=ip
|
||||
FLUENT_PORT=24224
|
||||
FLUENT_TAG=test-server
|
||||
```
|
||||
|
||||
### 3.3.3 日志配置
|
||||
```py
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fluent import sender, event
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Fluent-bit配置
|
||||
FLUENT_HOST = os.getenv("FLUENT_HOST", "localhost")
|
||||
FLUENT_PORT = int(os.getenv("FLUENT_PORT", 24224))
|
||||
FLUENT_TAG = os.getenv("FLUENT_TAG", "default")
|
||||
|
||||
|
||||
class FluentHandler(logging.Handler):
|
||||
"""Fluent-bit日志处理器"""
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
log_data = {
|
||||
"level": record.levelname,
|
||||
"message": record.message,
|
||||
"module": record.module,
|
||||
"line": record.lineno,
|
||||
"topic": FLUENT_TAG,
|
||||
}
|
||||
event.Event(record.levelname.lower(), log_data)
|
||||
except Exception as e:
|
||||
print(f"Fluent日志发送失败: {str(e)}")
|
||||
|
||||
|
||||
def setup_logger():
|
||||
logger = logging.getLogger("app_logger")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False # 防止日志重复输出
|
||||
|
||||
# 日志格式
|
||||
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s")
|
||||
|
||||
# 控制台处理器(所有环境启用)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 输出到fluent-bit
|
||||
sender.setup(FLUENT_TAG, host=FLUENT_HOST, port=FLUENT_PORT)
|
||||
fluent_handler = FluentHandler()
|
||||
fluent_handler.setFormatter(formatter)
|
||||
fluent_handler.setLevel(logging.INFO)
|
||||
logger.addHandler(fluent_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 全局日志实例
|
||||
logger = setup_logger()
|
||||
```
|
||||
|
||||
## 3.4 Unity
|
||||
### 3.4.1 日志配置
|
||||
```cs
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using System.Collections;
|
||||
using System;
|
||||
|
||||
public class FluentBitLogger : MonoBehaviour
|
||||
{
|
||||
private string url = "http://ip:9880";
|
||||
|
||||
void OnEnable() => Application.logMessageReceived += HandleLog;
|
||||
void OnDisable() => Application.logMessageReceived -= HandleLog;
|
||||
|
||||
void HandleLog(string logString, string stackTrace, LogType type)
|
||||
{
|
||||
// 1. 准备数据
|
||||
var logData = new LogData
|
||||
{
|
||||
topic = "default",
|
||||
message = logString,
|
||||
logType = type.ToString(),
|
||||
timestamp = DateTime.Now.ToString("o")
|
||||
};
|
||||
|
||||
// 2. 转 JSON 并发送
|
||||
string json = JsonUtility.ToJson(logData);
|
||||
StartCoroutine(Send(json));
|
||||
}
|
||||
|
||||
IEnumerator Send(string json)
|
||||
{
|
||||
// 快速判断设备是否连接到互联网
|
||||
if (Application.internetReachability == NetworkReachability.NotReachable)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
using (UnityWebRequest req = UnityWebRequest.Put(url, json))
|
||||
{
|
||||
req.method = "POST";
|
||||
req.timeout = 1;
|
||||
req.SetRequestHeader("Content-Type", "application/json");
|
||||
yield return req.SendWebRequest();
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LogData
|
||||
{
|
||||
public string topic;
|
||||
public string message;
|
||||
public string logType;
|
||||
public string timestamp;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4.2 配置插件
|
||||
  在FluentBit中增加http插件:
|
||||
```conf
|
||||
[INPUT]
|
||||
Name http
|
||||
Listen 0.0.0.0
|
||||
Port 9880
|
||||
Tag unity.logs
|
||||
```
|
||||
|
||||
::: tip
|
||||
docker-compose文件中也要增加9880端口映射。
|
||||
:::
|
||||
|
||||
## 3.5 Vue
|
||||
  发送HTTP数据到FluentBit中:
|
||||
```ts
|
||||
export const sendError = (err, instance, info) => {
|
||||
try {
|
||||
const log = {
|
||||
top: 'vue-ui',
|
||||
level: 'ERROR',
|
||||
info,
|
||||
component: instance?.$options?.name || 'Anonymous',
|
||||
message: err.message,
|
||||
url: location.href
|
||||
}
|
||||
|
||||
fetch('http://ip:9880', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(log)
|
||||
})
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  利用app.config.errorHandler监听异常:
|
||||
```ts
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
sendError(err, instance, info)
|
||||
}
|
||||
```
|
||||
|
||||
# 四、简单使用
|
||||
## 3.1 配置数据流
|
||||
  打开控制台,选择数据流->添加数据流,输入fluent bit配置文件中的流名称,选择数据流类型为Logs。
|
||||
  在新添加的数据流中,点击查看,跳转到日志页面即可看到上次的日志信息。
|
||||
@@ -130,7 +605,7 @@ openobserve:
|
||||
- ./openobserve-data:/data
|
||||
environment:
|
||||
- ZO_ROOT_USER_EMAIL=njcxx0822@163.com
|
||||
- ZO_ROOT_USER_PASSWORD=19940822Cxx
|
||||
- ZO_ROOT_USER_PASSWORD=yourpassword
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
- ZO_SMTP_ENABLED=true
|
||||
@@ -221,7 +696,7 @@ curl -X 'POST' \
|
||||
  注意这里要加上Basic auth认证,并提供username和password。
|
||||

|
||||
|
||||
# 四、自定义日志告警服务
|
||||
# 五、自定义日志告警服务
|
||||

|
||||
  基本原理为通过提供一个HTTP的API接口,当Openobserver触发告警时,调用该接口,并传入当前的alert_name和timestamp,然后根据timestamp和Openobserver提供的API接口去查询最近一段时间内的所有日志信息,并存储到SqLite数据库中。同时也可以选择是否同步推送到企业微信和邮箱。
|
||||
  最后也需要提供一个查询历史日志告警信息的接口,并可视化显示,方便运维和开发查看。
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
layout: doc
|
||||
title: DevOps
|
||||
title: 运维部署
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { routers } from '../.vitepress/theme/router'
|
||||
</script>
|
||||
|
||||
<MenuList :routers=routers[2] />
|
||||
<MenuList :routers=routers[1] />
|
||||
108
docs/Others/RustDesk.md
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: RustDesk简介和安装
|
||||
date: 2026-03-24
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  RustDesk是一款开源的远程桌面控制软件,它允许用户通过网络从一台计算机远程控制另一台计算机。
|
||||
  特点:
|
||||
1. 跨平台:支持 Windows、macOS、Linux、Android、iOS 等主流操作系统。
|
||||
2. 自托管:你可以搭建自己的中继服务器,将数据完全掌握在自己手中,保障安全和隐私。
|
||||
3. 高性能:使用 Rust 语言编写,并采用了先进的视频编解码技术(如 H.264),力求在低带宽下也能实现流畅的远程控制体验。
|
||||
4. 功能全面:支持文件传输、远程终端、语音通话、多显示器切换、远程重启等常用功能。
|
||||
|
||||
# 二、安装
|
||||
## 2.1 自建服务器安装
|
||||
  [RustDesk文档-Docker](https://rustdesk.com/docs/zh-cn/self-host/rustdesk-server-oss/docker/)
|
||||
```yml
|
||||
services:
|
||||
hbbs:
|
||||
container_name: hbbs
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbs
|
||||
volumes:
|
||||
- ./data:/root
|
||||
network_mode: "host"
|
||||
|
||||
depends_on:
|
||||
- hbbr
|
||||
restart: unless-stopped
|
||||
|
||||
hbbr:
|
||||
container_name: hbbr
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbr
|
||||
volumes:
|
||||
- ./data:/root
|
||||
network_mode: "host"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
::: tip
|
||||
需要开放以下端口:
|
||||
hbbs:
|
||||
  21114 (TCP): 用于网页控制台,仅在 Pro 版本中可用。
|
||||
  21115 (TCP): 用于 NAT 类型测试。
|
||||
  21116 (TCP/UDP): 请注意 21116 应该同时为 TCP 和 UDP 启用。 21116/UDP 用于 ID 注册和心跳服务。21116/TCP 用于 TCP 打洞和连接服务。
|
||||
  21118 (TCP): 用于支持网页客户端。
|
||||
hbbr:
|
||||
  21117 (TCP): 用于中继服务。
|
||||
  21119 (TCP): 用于支持网页客户端。
|
||||
:::
|
||||
|
||||
## 2.2 客户端安装
|
||||
  进入[RustDesk官网](https://rustdesk.com/zh-cn/),根据系统类型选择相应的安装包下载并安装。
|
||||
|
||||
## 2.3 客户端配置
|
||||
  例如,在Windows系统中,打开exe文件(可以直接免安装打开,也可以安装到本地),点击右上角设置,选择网络->ID/中继服务器,配置ID服务器、中继服务器和Key值。
|
||||
  其中ID服务器和中继服务器为自建服务器的IP地址,Key值可以通过docker日志查看。
|
||||
|
||||
# 三、RustDeskApi
|
||||
  官方RustDesk自建服务的Web Api和管理后台是需要收费的,可以使用免费的[RustDeskApi](https://github.com/lejianwen/rustdesk-api)项目。
|
||||
|
||||
## 3.1 自建服务器安装
|
||||
```yml
|
||||
networks:
|
||||
rustdesk-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
rustdesk_server_data:
|
||||
rustdesk_api_data:
|
||||
|
||||
services:
|
||||
rustdesk-full:
|
||||
container_name: rustdesk-full
|
||||
image: lejianwen/rustdesk-api:full-s6
|
||||
ports:
|
||||
- "21114:21114" # API 服务端口
|
||||
- "21115:21115" # hbbs 端口
|
||||
- "21116:21116" # hbbs 端口
|
||||
- "21116:21116/udp" # hbbs UDP 端口
|
||||
- "21117:21117" # hbbr 中继端口
|
||||
- "21118:21118" # WebSocket 端口(Web客户端)
|
||||
- "21119:21119" # 额外中继端口
|
||||
environment:
|
||||
- RELAY=<ip>:21117
|
||||
- ENCRYPTED_ONLY=1
|
||||
- MUST_LOGIN=N
|
||||
- TZ=Asia/Shanghai
|
||||
- RUSTDESK_API_RUSTDESK_ID_SERVER=<ip>:21116
|
||||
- RUSTDESK_API_RUSTDESK_RELAY_SERVER=<ip>:21117
|
||||
- RUSTDESK_API_RUSTDESK_API_SERVER=http://<ip>:21114
|
||||
- RUSTDESK_API_KEY_FILE=/app/conf/data/id_ed25519.pub
|
||||
volumes:
|
||||
- ./data/server:/data
|
||||
- ./data/server:/app/conf/data
|
||||
- ./data/api:/app/data
|
||||
networks:
|
||||
- rustdesk-net
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
::: tip
|
||||
这里采用官方S6镜像,[Wiki](https://github.com/lejianwen/rustdesk-api/wiki),其中ip为自建服务器ip。
|
||||
:::
|
||||
|
||||
  输入`http://ip:21114/_admin/`进入管理员后台。
|
||||
  或者输入`http://ip:21114/webclient/`直接进入RustDesk Web客户端。
|
||||
37
docs/Others/Systemback.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Systemback制作Ubuntu镜像
|
||||
date: 2026-03-12
|
||||
---
|
||||
|
||||
# 一、Systemback简介
|
||||
  Systemback 是一款用于 Ubuntu 等基于 Ubuntu 的 Linux 发行版的系统管理工具。它主要功能是创建系统备份和还原系统,类似于 Windows 系统中的 “系统还原” 功能。
|
||||
|
||||
# 二、安装
|
||||
```bash
|
||||
sudo sh -c 'echo "deb [arch=amd64] http://mirrors.bwbot.org/ stable main" > /etc/apt/sources.list.d/systemback.list'
|
||||
sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key 50B2C005A67B264F
|
||||
sudo apt-get update
|
||||
sudo apt-get install systemback
|
||||
```
|
||||
|
||||
# 三、制作镜像
|
||||
1. 打开Systemback工具
|
||||
```bash
|
||||
sudo systemback
|
||||
```
|
||||
2. 选择 **Live system create**
|
||||
3. 勾选include the user data files
|
||||
4. 点击 **Create new**
|
||||
5. 等待制作
|
||||
6. 插入U盘,在Write target中选择U盘,点击 **Write to target**
|
||||
7. 等待制作完成
|
||||
|
||||
# 四、还原镜像
|
||||
1. 插入U盘,进入安装界面,选择 **Boot System installer**
|
||||
2. 设置用户名和密码
|
||||
3. 设置分区,**注意一定要设置/boot/efi分区**
|
||||
4. 等待还原
|
||||
|
||||
# 五、参考资料
|
||||
1. [使用systemback备份和还原Ubuntu](https://blog.csdn.net/weixin_51526597/article/details/135712523)
|
||||
2. [Systemback Ubuntu18.04 制作系统ISO镜像并物理机还原](https://www.zhihu.com/tardis/zm/art/576420693?source_id=1005)
|
||||
@@ -3,3 +3,554 @@
|
||||
date: 2025-12-18
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  [VitePress](https://vitepress.dev/zh/)是由 Vue 团队开发的静态网站生成器 (SSG),基于 Vite(前端构建工具)和 Vue 3 构建,专门用于快速搭建文档网站、博客或个人主页。它继承了 Vite 的极速开发体验,同时具备 Vue 的组件化能力,语法上兼容 Markdown 并支持扩展 Vue 组件,是替代 VuePress 的新一代工具。
|
||||
|
||||
# 二、快速开始
|
||||
## 2.1 安装
|
||||
```sh
|
||||
pnpm add -D vitepress@next
|
||||
```
|
||||
|
||||
## 2.2 初始化
|
||||
```sh
|
||||
pnpm vitepress init
|
||||
```
|
||||
|
||||
::: tip
|
||||
建议按照官网配置,文档目录设置为`./docs`。
|
||||
:::
|
||||
|
||||
## 2.3 运行
|
||||
```sh
|
||||
pnpm run docs:dev
|
||||
```
|
||||
|
||||
## 2.4 使用
|
||||
  在`docs`文件夹新建`md`文档,并在`.vitepress/config.mts`中配置`nav`和`sidebar`路径。
|
||||
|
||||
# 三、优化
|
||||
## 3.1 路由组件
|
||||
  每次新建文档时,都需要在`.vitepress/config.mts`重复`nav`和`sidebar`路径,有时还需要在文档目录的`index.md`配置导航链接,每次都要重复写3次,很麻烦,因此可以构造一个路由配置清单和路由组件,每次只需更新一次路由配置清单即可。
|
||||
  在`.vitepress/theme/router`文件夹下新建路由清单文件:
|
||||
```ts
|
||||
export const routers = [
|
||||
{
|
||||
text: '🌐 Web前端',
|
||||
items: [
|
||||
{
|
||||
text: '🌿 Vue',
|
||||
items: [
|
||||
{ text: 'Test', link: '/Web-Front/Vue/Test' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🔄 其他',
|
||||
items: [
|
||||
{ text: 'Test', link: '/Web-Front/Others/Test' }
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🖥️ Web后端',
|
||||
items: [
|
||||
{
|
||||
text: '🍃 SpringBoot',
|
||||
items: [
|
||||
{ text: 'Test', link: '/Web-Backend/SpringBoot/Test' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🐍 FastAPI',
|
||||
items: [
|
||||
{ text: 'Test', link: '/Web-Backend/FastAPI/Test' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🔄 其他',
|
||||
items: [
|
||||
{ text: 'Test', link: '/Web-Backend/Others/Test' }
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '🚀 DevOps',
|
||||
items: [
|
||||
{ text: 'Test', link: '/DevOps/Test' }
|
||||
]
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
  这里列举了有二级菜单和只有一级菜单的两种情况。
|
||||
  在`.vitepress/theme/components`文件夹下新建路由组件`MenuList.vue`:
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="isNested">
|
||||
<div v-for="category in props.routers.items" :key="category.text">
|
||||
<h3>{{ category.text }}</h3>
|
||||
<ul>
|
||||
<li v-for="item in category.items" :key="item.text">
|
||||
<a :href="withBase(item.link)">{{ item.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ul>
|
||||
<li v-for="item in props.routers.items" :key="item.text">
|
||||
<a :href="withBase(item.link)">{{ item.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress'
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
routers: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
isNested: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
  这里需要使用`withBase`拼接路由,防止存在设置了`base`根路径的情况。通过`isNested`来控制是否存在二级菜单。
|
||||
::: warning
|
||||
这里只考虑了最多存在二级菜单的情况。
|
||||
:::
|
||||
|
||||
  在`.vitepress/theme/index.ts`注册组件:
|
||||
```ts
|
||||
import MenuList from './components/MenuList.vue'
|
||||
import type { EnhanceAppContext } from 'vitepress'
|
||||
|
||||
export default {
|
||||
enhanceApp({ app }: EnhanceAppContext) {
|
||||
app.component("MenuList", MenuList);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
  在`.vitepress/config.mts`中配置`nav`和`sidebar`:
|
||||
```ts
|
||||
export default defineConfig({
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: '🏠 Home', link: '/' },
|
||||
...routers
|
||||
],
|
||||
sidebar: {
|
||||
'/Web-Front/': [routers[0]],
|
||||
'/Web-Backend/': [routers[1]],
|
||||
'/DevOps/': [routers[2]]
|
||||
},
|
||||
},
|
||||
|
||||
lastUpdated: true,
|
||||
})
|
||||
```
|
||||
|
||||
  在文档所在目录的`index.md`中配置:
|
||||
```md
|
||||
---
|
||||
layout: doc
|
||||
title: Web前端
|
||||
description: Web前端开发技术文档
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { routers } from '../.vitepress/theme/router'
|
||||
</script>
|
||||
|
||||
<MenuList :routers=routers[0] :isNested=true />
|
||||
```
|
||||
|
||||
  这样后续只需要维护路由配置清单文件即可自动生成路由菜单。
|
||||
|
||||
## 3.2 博客元数据
|
||||
  可以通过在每个博客中添加`frontmatter`元数据,在最顶部添加博客属性,例如创建日期、字数、时长和是否精品等信息。
|
||||
### 3.2.1 定义元数据
|
||||
  在每个博客最上面添加元数据:
|
||||
```md
|
||||
---
|
||||
title: Docker简介和安装
|
||||
date: 2025-12-15
|
||||
isGreat: true
|
||||
---
|
||||
```
|
||||
|
||||
  这里可以自定义添加任何`key: value`形式的字段信息。
|
||||
::: tip
|
||||
建议手动加上每个博客的创建日期
|
||||
:::
|
||||
|
||||
### 3.2.2 博客信息组件
|
||||
  在`.vitepress/theme/components`文件夹下新建博客信息组件`ArticleMetadata.vue`
|
||||
```vue
|
||||
<template>
|
||||
<div class="title">
|
||||
<span>{{ page.frontmatter.title }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!page.filePath.includes('index.md')" class="blog-stats">
|
||||
<div v-if="page.frontmatter.isGreat" class="item">
|
||||
</svg>
|
||||
精品
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
</svg>
|
||||
日期: {{ page.frontmatter.date.split('T')[0] }}
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
</svg>
|
||||
字数: {{ formatNumberUnit(wordCount) }} 字
|
||||
</div>
|
||||
|
||||
<div>
|
||||
</svg>
|
||||
时长: {{ readTime }} 分钟
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { countWord, formatNumberUnit } from '../utils'
|
||||
import { useData } from 'vitepress'
|
||||
|
||||
const wordCount = ref(0)
|
||||
const imageCount = ref(0)
|
||||
|
||||
// 获取页面数据
|
||||
const { page } = useData()
|
||||
|
||||
// 文字阅读时间
|
||||
const wordTime = computed(() => {
|
||||
return ((wordCount.value / 275) * 60)
|
||||
})
|
||||
|
||||
// 图片阅读时间
|
||||
const imageTime = computed(() => {
|
||||
const n = imageCount.value
|
||||
if (imageCount.value <= 10) {
|
||||
// 等差数列求和
|
||||
return n * 13 + (n * (n - 1)) / 2
|
||||
}
|
||||
return 175 + (n - 10) * 3
|
||||
})
|
||||
|
||||
// 阅读时间
|
||||
const readTime = computed(() => {
|
||||
return Math.ceil((wordTime.value + imageTime.value) / 60)
|
||||
})
|
||||
|
||||
const analyze = () => {
|
||||
// 选择文档内容区域
|
||||
const docDomContainer = window.document.querySelector('#VPContent')
|
||||
// 统计图片数量
|
||||
const imgs = docDomContainer?.querySelectorAll<HTMLImageElement>('.content-container .main img')
|
||||
imageCount.value = imgs?.length || 0
|
||||
|
||||
// 统计文字数量(textContent:提取纯文本)
|
||||
const words = docDomContainer?.querySelector('.content-container .main')?.textContent || ''
|
||||
wordCount.value = countWord(words)
|
||||
}
|
||||
|
||||
watch(() => page.value.title, () => {
|
||||
// 路径变化时执行
|
||||
analyze()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化时执行一次
|
||||
analyze()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.blog-stats {
|
||||
margin-bottom: 10px;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 5px;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
background: -webkit-linear-gradient(10deg, #3DC8F0 5%, #8036FA 15%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
transform: translate(0px , 2px);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
  这里统计了正文部分的文字和图片数量,并通过相应规则计算阅读时间。
|
||||
  通过`page.frontmatter`获取自定义的元数据信息并在博客最上面显示。
|
||||
|
||||
### 3.2.3 注册组件
|
||||
  在`.vitepress/theme/index.ts`注册组件(参考3.1节内容)。
|
||||
|
||||
### 3.2.4 自定义布局组件
|
||||
  需要将博客信息组件放在自定义布局组件中。
|
||||
  在`.vitepress/theme/components`文件夹下新建自定义布局组件`MyLayout.vue`。
|
||||
```vue
|
||||
<template>
|
||||
<DefaultTheme.Layout v-bind="$attrs">
|
||||
<template #doc-before>
|
||||
<ArticleMetadat />
|
||||
</template>
|
||||
</DefaultTheme.Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import ArticleMetadat from './ArticleMetadata.vue';
|
||||
</script>
|
||||
```
|
||||
|
||||
  更多插槽可以参考[Github源码](https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/Layout.vue)。
|
||||
  同样需要注册组件。
|
||||
|
||||
### 3.2.5 配置布局组件
|
||||
  在`.vitepress/config.mts`中配置布局:
|
||||
```ts
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import MyLayout from './components/MyLayout.vue';
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: MyLayout,
|
||||
};
|
||||
```
|
||||
|
||||
## 3.3 热力图
|
||||
  使用`vue3-calendar-heatmap`组件在首页绘制博客热力图。
|
||||
### 3.3.1 安装
|
||||
```sh
|
||||
pnpm install vue3-calendar-heatmap
|
||||
```
|
||||
|
||||
### 3.3.2 获取数据
|
||||
  使用`VitePress`提供的[createContentLoader](https://vitepress.dev/zh/guide/data-loading)来获取所有的`md`博客数据。
|
||||
  在`.vitepress/data`新建`posts.data.js`:
|
||||
```js
|
||||
import { createContentLoader } from 'vitepress'
|
||||
|
||||
export default createContentLoader('./**/*.md', {
|
||||
includeSrc: true, // 包含原始 markdown 源
|
||||
render: true, // 包含渲染的整页 HTML
|
||||
transform(rawData) {
|
||||
return rawData.filter(page => {
|
||||
return page.url.includes('.html') && page.frontmatter.date
|
||||
}).map((page) => {
|
||||
// 对每个页面进行处理
|
||||
return {
|
||||
// 返回你需要的页面数据
|
||||
title: page.frontmatter.title,
|
||||
date: page.frontmatter.date,
|
||||
url: page.url
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
  通过判断路径是否包含`html`来确认是博客文档,并且筛选出元数据中包含日期的博客,然后返回相应内容。
|
||||
|
||||
### 3.3.3 博客统计组件
|
||||
  在`.vitepress/theme/components`文件夹下新建博客信息组件`StatsChart.vue`:
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="!isMobile()">
|
||||
<calendar-heatmap
|
||||
:values="heatmapData"
|
||||
:end-date="new Date()"
|
||||
no-data-text="暂无记录"
|
||||
tooltip-unit="篇"
|
||||
:round="2"
|
||||
:locale="{
|
||||
less: '少于',
|
||||
more: '多于',
|
||||
months: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
|
||||
days: ['日', '一', '二', '三', '四', '五', '六'],
|
||||
}"
|
||||
class="blog-heatmap"
|
||||
/>
|
||||
<div class="heatmap-footer">
|
||||
⏱️ 已坚持<span class="highlight">{{ getDaysDifference(new Date(), startBlogDate) }}</span> 天
|
||||
| 📝 已创作<span class="highlight">{{ data.length }}</span> 篇
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CalendarHeatmap } from 'vue3-calendar-heatmap';
|
||||
import 'vue3-calendar-heatmap/dist/style.css';
|
||||
import { data } from '../../data/posts.data.js'
|
||||
import { isMobile, getDaysDifference, startBlogDate } from "../utils";
|
||||
|
||||
const dateCountMap = new Map<string, number>();
|
||||
|
||||
// 按日期分组统计
|
||||
data.forEach(item => {
|
||||
const date = item.date
|
||||
|
||||
if (dateCountMap.has(date)) {
|
||||
dateCountMap.set(date, dateCountMap.get(date)! + 1);
|
||||
} else {
|
||||
dateCountMap.set(date, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为目标格式
|
||||
const heatmapData = Array.from(dateCountMap.entries()).map(([date, count]) => ({
|
||||
date,
|
||||
count
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.blog-heatmap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.blog-heatmap :deep(.vch__wrapper) {
|
||||
font-family: 'CustomFont', sans-serif !important;
|
||||
}
|
||||
|
||||
.blog-heatmap :deep(.vch__month__label) {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.blog-heatmap :deep(.vch__day__label) {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.heatmap-footer {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
margin: 0 2px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
  根据`posts.data.js`提供的博客数据进行日期分组,并统计已坚持天数和已创作博客数。
|
||||
|
||||
### 3.3.4 注册组件
|
||||
  在`.vitepress/theme/index.ts`注册组件(参考3.1节内容)。
|
||||
|
||||
### 3.3.5 使用组件
|
||||
  在`docs/index.md`最后添加组件。
|
||||
|
||||
::: tip
|
||||
如果在打包时遇到`Named export 'CalendarHeatmap' not found. The requested module 'vue3-calendar-heatmap' is a CommonJS module, which may not support all module.exports as named exports.`错误提示,说明`vue3-calendar-heatmap`是`CommonJS`模块,而你在代码中使用了`ES`模块的命名导入方式,需要在`config.mts`中添加以下配置:
|
||||
```ts
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
ssr: {
|
||||
noExternal: ['vue3-calendar-heatmap']
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
:::
|
||||
|
||||
## 3.4 Mermaid 图表
|
||||
### 3.4.1 安装
|
||||
```sh
|
||||
pnpm i vitepress-plugin-mermaid mermaid -D
|
||||
```
|
||||
|
||||
### 3.4.2 配置
|
||||
  在`.vitepress/config.mts`中配置:
|
||||
```ts
|
||||
import { withMermaid } from 'vitepress-plugin-mermaid'
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default withMermaid({
|
||||
title: "拾光记"
|
||||
})
|
||||
```
|
||||
|
||||
### 3.4.3 使用
|
||||
  在`Markdown`中使用:
|
||||
```md
|
||||
'''mermaid
|
||||
graph TD
|
||||
A[Vue 3] --> B[Compiler]
|
||||
A --> C[Runtime]
|
||||
A --> D[Reactivity]
|
||||
|
||||
B --> B1[Parser]
|
||||
B --> B2[Transformer]
|
||||
B --> B3[Codegen]
|
||||
|
||||
C --> C1[Virtual DOM]
|
||||
C --> C2[Renderer]
|
||||
C --> C3[Component]
|
||||
|
||||
D --> D1[Proxy]
|
||||
D --> D2[Effect]
|
||||
D --> D3[Dependency Graph]
|
||||
|
||||
B3 -->|生成| C1
|
||||
C1 -->|Diff/Patch| C2
|
||||
D3 -->|驱动更新| C1
|
||||
'''
|
||||
```
|
||||
|
||||
## 3.5 图片预览
|
||||
### 3.5.1 安装
|
||||
```sh
|
||||
pnpm i vitepress-plugin-image-viewer viewerjs
|
||||
```
|
||||
|
||||
### 3.5.2 配置
|
||||
  在`.vitepress/theme/index.ts`中配置:
|
||||
```ts
|
||||
import imageViewer from "vitepress-plugin-image-viewer";
|
||||
import "viewerjs/dist/viewer.min.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
imageViewer(route);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
509
docs/Others/WeCom.md
Normal file
@@ -0,0 +1,509 @@
|
||||
---
|
||||
title: 企业微信工作台开发
|
||||
date: 2026-01-23
|
||||
---
|
||||
|
||||
# 一、建立工作台
|
||||
1. 进入企业微信后台管理系统,选择应用管理->应用管理。
|
||||
2. 选择自建->创建应用。
|
||||
3. 输入名称、图标等信息,分配可见范围,完成应用创建.
|
||||
4. 点击应用,选择开发者接口->网页授权,配置可信域名和企业可信IP。
|
||||
5. 查看并保存AgentId和Secret。
|
||||
|
||||
::: tip
|
||||
可信域名不能是IP形式,必须是和企业微信备案主体一致的服务器。
|
||||
:::
|
||||
|
||||
# 二、JS-SDK开发
|
||||
## 2.1 接口鉴权
|
||||
  [官方文档](https://developer.work.weixin.qq.com/document/path/90514)
|
||||
  参考第三节企业微信鉴权接口开发
|
||||
|
||||
## 2.2 打开默认浏览器
|
||||
  使用系统浏览器打开指定 URL,支持传入 oauth2 链接,从而实现在系统浏览器内免登录的效果。
|
||||
|
||||
# 三、鉴权接口开发
|
||||
  本教程以node.js为例。
|
||||
## 3.1 通用企业微信服务端API
|
||||
```js
|
||||
const axios = require('axios');
|
||||
|
||||
const TOKEN_URL = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken'
|
||||
const JSAPI_TICKET_URL = 'https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket'
|
||||
const MESSAGE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/message/send'
|
||||
|
||||
// 获取access_token
|
||||
async function getAccessTokenApi(corpId, corpSecret) {
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const url = `${TOKEN_URL}?corpid=${corpId}&corpsecret=${corpSecret}`
|
||||
const response = await axios.get(url);
|
||||
|
||||
if (response.data.errcode === 0) {
|
||||
return {
|
||||
accessToken: response.data.access_token,
|
||||
expires: now + (response.data.expires_in - 300) * 1000 // 提前5分钟过期
|
||||
};
|
||||
} else {
|
||||
throw new Error(`获取access_token失败: ${response.data.errmsg}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取access_token错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取jsapi_ticket
|
||||
async function getJsapiTicketApi(accessToken) {
|
||||
try {
|
||||
const url = `${JSAPI_TICKET_URL}?access_token=${accessToken}`
|
||||
const response = await axios.get(url);
|
||||
|
||||
if (response.data.errcode === 0) {
|
||||
return response.data.ticket;
|
||||
} else {
|
||||
throw new Error(`获取jsapi_ticket失败: ${response.data.errmsg}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取jsapi_ticket错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 发送应用消息
|
||||
async function sendMessageApi(agentid, accessToken, touser, content) {
|
||||
try {
|
||||
const url = `${MESSAGE_URL}?access_token=${accessToken}`
|
||||
const response = await axios.post(url, {
|
||||
touser: touser,
|
||||
msgtype: 'text',
|
||||
agentid: agentid,
|
||||
text: {
|
||||
content: content
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.errcode === 0) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(`发送应用消息失败: ${response.data.errmsg}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送应用消息错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccessTokenApi,
|
||||
getJsapiTicketApi,
|
||||
sendMessageApi
|
||||
};
|
||||
```
|
||||
|
||||
## 3.2 自建应用鉴权接口
|
||||
```js
|
||||
const {getAccessTokenApi, getJsapiTicketApi, sendMessageApi} = require("./wecom");
|
||||
|
||||
const config = {
|
||||
corpId: '', // 企业id
|
||||
corpSecret: '', // 应用密码
|
||||
agentId: '', // 应用id
|
||||
domain: '', // 跳转域名
|
||||
password: '' // 登录默认密码
|
||||
};
|
||||
|
||||
// 存储access_token和jsapi_ticket
|
||||
let tokenCache = {
|
||||
accessToken: null,
|
||||
jsapiTicket: null,
|
||||
expires: 0
|
||||
};
|
||||
|
||||
// 获取access_token
|
||||
async function getRepairAccessToken() {
|
||||
const now = Date.now();
|
||||
|
||||
if (tokenCache.accessToken && tokenCache.expires > now) {
|
||||
return tokenCache.accessToken;
|
||||
}
|
||||
|
||||
const result = await getAccessTokenApi(config.corpId, config.corpSecret)
|
||||
tokenCache.accessToken = result.accessToken;
|
||||
tokenCache.expires = result.expires
|
||||
}
|
||||
|
||||
// 获取jsapi_ticket
|
||||
async function getRepairJsapiTicket() {
|
||||
const now = Date.now();
|
||||
|
||||
if (tokenCache.jsapiTicket && tokenCache.expires > now) {
|
||||
return tokenCache.jsapiTicket;
|
||||
}
|
||||
|
||||
await getRepairAccessToken();
|
||||
tokenCache.jsapiTicket = await getJsapiTicketApi(tokenCache.accessToken);
|
||||
}
|
||||
|
||||
// 发送应用消息
|
||||
async function sendRepairMessage(touser, content) {
|
||||
await getRepairAccessToken();
|
||||
await sendMessageApi(config.agentId, tokenCache.accessToken, touser, content);
|
||||
}
|
||||
|
||||
function getRepairDomain() {
|
||||
return config.domain
|
||||
}
|
||||
|
||||
function getRepairPassword() {
|
||||
return config.password
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRepairAccessToken,
|
||||
getRepairJsapiTicket,
|
||||
sendRepairMessage,
|
||||
getRepairDomain,
|
||||
getRepairPassword
|
||||
};
|
||||
```
|
||||
|
||||
## 3.3 NodeJS API
|
||||
```js
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const cors = require('cors');
|
||||
const {generateNonceStr, generateSignature} = require("./utils");
|
||||
const {sendRepairMessage, getRepairJsapiTicket, getRepairAccessToken, getRepairDomain, getRepairPassword} = require("./repair");
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const corpId = 'ww48932728388c9e3a'
|
||||
const USER_INFO_URL = 'https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo'
|
||||
|
||||
// API接口:生成JS-SDK配置
|
||||
app.post('/auth/config/repair', async (req, res) => {
|
||||
await configService(req, res, 'repair')
|
||||
});
|
||||
|
||||
async function configService(req, res, appName) {
|
||||
try {
|
||||
const {url} = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({errcode: -1, errmsg: '缺少url参数'});
|
||||
}
|
||||
|
||||
let jsapiTicket = ''
|
||||
|
||||
if (appName === 'repair') {
|
||||
jsapiTicket = await getRepairJsapiTicket();
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const noncestr = generateNonceStr();
|
||||
const signature = generateSignature(jsapiTicket, noncestr, timestamp, url);
|
||||
|
||||
res.json({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
appId: corpId,
|
||||
timestamp,
|
||||
nonceStr: noncestr,
|
||||
signature
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('生成JS-SDK配置错误:', error);
|
||||
res.status(500).json({errcode: -1, errmsg: '服务器内部错误'});
|
||||
}
|
||||
}
|
||||
|
||||
// API接口:OAuth回调处理
|
||||
app.get('/auth/callback/repair', async (req, res) => {
|
||||
await callback(req, res, 'repair')
|
||||
});
|
||||
|
||||
async function callback(req, res, appName) {
|
||||
try {
|
||||
const {code, state} = req.query;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).send('缺少code参数');
|
||||
}
|
||||
|
||||
// 通过code获取用户信息
|
||||
let accessToken = ''
|
||||
if (appName === 'repair') {
|
||||
accessToken = await getRepairAccessToken();
|
||||
}
|
||||
|
||||
const url = `${USER_INFO_URL}?access_token=${accessToken}&code=${code}`
|
||||
const userResponse = await axios.get(url);
|
||||
|
||||
if (userResponse.data.errcode !== 0) {
|
||||
throw new Error(`获取用户信息失败: ${userResponse.data.errmsg}`);
|
||||
}
|
||||
|
||||
const userInfo = userResponse.data;
|
||||
|
||||
// 重定向到前端页面
|
||||
let redirectUrl = ''
|
||||
if (appName === 'repair') {
|
||||
redirectUrl = `${getRepairDomain()}?username=${userInfo.UserId}&password=${getRepairPassword()}&type=repair`;
|
||||
}
|
||||
|
||||
res.redirect(redirectUrl);
|
||||
|
||||
} catch (error) {
|
||||
console.error('OAuth回调处理错误:', error);
|
||||
res.status(500).send(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>登录失败</h1>
|
||||
<p>${error.message}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// API接口:发现应用消息
|
||||
app.post('/message/repair', async (req, res) => {
|
||||
const {touser, content} = req.body;
|
||||
await sendRepairMessage(touser, content)
|
||||
res.json({
|
||||
errcode: 0,
|
||||
errmsg: 'ok'
|
||||
});
|
||||
});
|
||||
|
||||
// 启动服务
|
||||
app.listen(PORT, () => {
|
||||
console.log(`企业微信登录服务运行在端口 ${PORT}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
```
|
||||
|
||||
## 3.4 工具类
|
||||
```js
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 工具函数:生成随机字符串
|
||||
function generateNonceStr(length = 16) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 工具函数:生成签名
|
||||
function generateSignature(jsapiTicket, noncestr, timestamp, url) {
|
||||
const string = `jsapi_ticket=${jsapiTicket}&noncestr=${noncestr}×tamp=${timestamp}&url=${url}`;
|
||||
return crypto.createHash('sha1').update(string).digest('hex');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateNonceStr,
|
||||
generateSignature
|
||||
};
|
||||
```
|
||||
|
||||
# 四、应用主页
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>企业微信登录跳转</title>
|
||||
<!-- 引入新版企业微信JS-SDK -->
|
||||
<script src="https://wwcdn.weixin.qq.com/node/open/js/wecom-jssdk-2.3.3.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">正在跳转,请稍候...</div>
|
||||
|
||||
<script>
|
||||
// 配置参数
|
||||
const serverUrl = ''
|
||||
|
||||
const config = {
|
||||
corpId: '', // 企业ID
|
||||
agentId: '', // 应用ID
|
||||
redirectUrl: encodeURIComponent(`${serverUrl}auth/callback/repair`), // OAuth回调地址
|
||||
authUrl: `${serverUrl}auth/config/repair` // 后端授权入口
|
||||
};
|
||||
|
||||
// 主函数
|
||||
async function init() {
|
||||
try {
|
||||
// 获取当前页面URL(用于签名)
|
||||
const currentUrl = window.location.href.split('#')[0];
|
||||
|
||||
// 从后端获取签名信息
|
||||
const signatureData = await getSignatureData(currentUrl);
|
||||
|
||||
const getConfigSignature = function(url) {
|
||||
return {
|
||||
timestamp: signatureData.timestamp,
|
||||
nonceStr: signatureData.nonceStr,
|
||||
signature: signatureData.signature
|
||||
};
|
||||
};
|
||||
|
||||
// 使用ww.register 进行身份注册
|
||||
await ww.register({
|
||||
corpId: config.corpId,
|
||||
agentId: config.agentId,
|
||||
jsApiList: ['openDefaultBrowser', 'closeWindow'],
|
||||
getConfigSignature: getConfigSignature
|
||||
});
|
||||
|
||||
// 生成授权URL
|
||||
const authUrl = await generateAuthUrl();
|
||||
|
||||
const isMobile = /(phone|pad|pod|iphone|ipod|ios|ipad|android|mobile|blackberry|iemobile|mqqbrowser|fennec|wosbrowser|browserng|webos|symbian|windows phone)/i.test(navigator.userAgent);
|
||||
|
||||
if (isMobile) {
|
||||
// 在移动端企业微信中直接打开链接
|
||||
window.location.href = authUrl;
|
||||
} else {
|
||||
// 使用新版API调用系统浏览器
|
||||
ww.openDefaultBrowser({
|
||||
url: authUrl,
|
||||
success: (res) => {
|
||||
// console.log('成功调用系统浏览器:', res);
|
||||
ww.closeWindow({})
|
||||
},
|
||||
fail: (err) => {
|
||||
// console.error('打开浏览器失败:', err);
|
||||
document.getElementById('loading').innerHTML = `打开失败: ${err.errMsg || '未知错误'},请点击右上角菜单选择"在浏览器中打开"`;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
document.getElementById('loading').innerHTML = '跳转失败,请手动在浏览器中打开: ' + error.errMsg;
|
||||
// window.alert(error.errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取签名数据
|
||||
async function getSignatureData(url) {
|
||||
const response = await fetch(`${config.authUrl}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取签名配置失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 确保返回的数据包含必要的字段
|
||||
if (!result.timestamp || !result.nonceStr || !result.signature) {
|
||||
throw new Error('签名数据不完整');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 生成授权URL
|
||||
async function generateAuthUrl() {
|
||||
// 使用URLSearchParams构建参数
|
||||
const authParams = new URLSearchParams({
|
||||
appid: config.corpId,
|
||||
redirect_uri: config.redirectUrl,
|
||||
response_type: 'code',
|
||||
scope: 'snsapi_base', // 静默授权
|
||||
state: 'wecom_redirect',
|
||||
agentid: config.agentId
|
||||
});
|
||||
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?${authParams.toString()}#wechat_redirect`;
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
  将该应用主页的URL放在工作台的应用主页中。
|
||||
::: tip
|
||||
需要在前端工程中配置路由参数访问,即通过ip:port/#/login?username=''&password=''的形式访问。
|
||||
:::
|
||||
::: tip
|
||||
如果需要在发送应用消息时,可以点击消息访问应用,绑定的URL链接也是该应用主页的链接。
|
||||
:::
|
||||
|
||||
# 五、使用
|
||||
  本教程以SpringBoot为例。
|
||||
## 5.1 配置RestClient
|
||||
```java
|
||||
@Configuration
|
||||
public class RestClientConfig {
|
||||
final String weiComBaseUrl = "";
|
||||
|
||||
@Bean
|
||||
public RestClient weiComClient() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(5));
|
||||
factory.setReadTimeout(Duration.ofSeconds(10));
|
||||
|
||||
return RestClient.builder()
|
||||
.requestFactory(factory)
|
||||
.baseUrl(weiComBaseUrl)
|
||||
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5.2 调用发送消息接口
|
||||
```java
|
||||
@Service
|
||||
@Slf4j
|
||||
public class NoticeService {
|
||||
@Resource
|
||||
private RestClient weiComClient;
|
||||
|
||||
@Async
|
||||
public void sendRepairMessage(String touser, String content) {
|
||||
String link = "";
|
||||
|
||||
WeComNoticeRequest request = new WeComNoticeRequest();
|
||||
request.setTouser(touser);
|
||||
request.setContent(content + "\n点击打开工作台应用:" + "<a href=\"" + link + "\">打开应用</a>");
|
||||
|
||||
WeComResponse response = weiComClient.post()
|
||||
.uri("/message/repair")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(WeComResponse.class);
|
||||
|
||||
if (response == null || response.getErrcode() != 0) {
|
||||
throw new CustomException("企业微信发送消息失败 请联系管理员");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 附录
|
||||
企业微信官方文档:[接口](https://developer.work.weixin.qq.com/document/path/90664)
|
||||
33
docs/Others/XDRP.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: XDRP简介和安装
|
||||
date: 2026-02-25
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  XDRP 是一个基于 RDP(Remote Desktop Protocol 远程桌面协议)的开源服务器软件,允许用户通过远程桌面客户端连接到 Linux 服务器,提供图形化远程访问功能。
|
||||
  特点:
|
||||
1. 支持 Windows 远程桌面客户端
|
||||
2. 支持多种桌面环境:GNOME、KDE、XFCE 等
|
||||
3. 使用标准的 3389 端口
|
||||
4. 轻量级,资源占用较少
|
||||
5. 不需要连接显示器
|
||||
|
||||
# 二、安装
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install xrdp -y
|
||||
sudo systemctl enable xrdp
|
||||
```
|
||||
|
||||
# 三、连接
|
||||
  打开Windows自带的远程桌面连接工具,输入ip、用户名和密码登录。
|
||||
|
||||
::: tip
|
||||
如果遇到黑屏的情况,打开/etc/xrdp/startwm.sh脚本,在`if test -r /etc/default/locale; then fi`这段结束后,插入以下内容:
|
||||
```bash
|
||||
unset DBUS_SESSION_BUS_ADDRESS
|
||||
unset XDG_RUNTIME_DIR
|
||||
. $HOME/.profile
|
||||
```
|
||||
然后sudo systemctl restart xrdp重启服务。
|
||||
:::
|
||||
@@ -7,4 +7,4 @@ title: 其他
|
||||
import { routers } from '../.vitepress/theme/router'
|
||||
</script>
|
||||
|
||||
<MenuList :routers=routers[4] />
|
||||
<MenuList :routers=routers[3] />
|
||||
@@ -365,7 +365,7 @@ public abstract class AbstractException extends RuntimeException {
|
||||
```
|
||||
:::
|
||||
|
||||
### 3.4 starters模块
|
||||
## 3.4 starters模块
|
||||
  该模块为整个项目的核心模块,在实际项目中,通过引入相应的starter模块,并配合autoconfigure模块中的功能,即可实现快速自动装配。
|
||||
  该模块的pom.xml声明的依赖会注入到实际的项目中。
|
||||
::: danger
|
||||
@@ -373,7 +373,7 @@ public abstract class AbstractException extends RuntimeException {
|
||||
之前的SringBoot2,是在**main->resources->META-INF**目录下新建spring.factories文件。
|
||||
:::
|
||||
|
||||
#### 3.4.1 stater-jdbc
|
||||
### 3.4.1 stater-jdbc
|
||||
  该模块主要提供数据库相关的配置功能。
|
||||
  目前实现的功能有:
|
||||
1. 配置MybatisPlus拦截器,添加乐观锁和分页插件。
|
||||
@@ -450,7 +450,7 @@ public class MybatisMetaObjectHandler implements MetaObjectHandler {
|
||||
  `@ConditionalOnClass(SqlSessionFactory.class)`表示只有存在SqlSessionFactory类时,这个配置类才会生效。即如果没有 MyBatis 相关依赖,这个配置类会被 Spring 完全忽略。
|
||||
  `@Import`用于导入其他配置类或组件到当前配置类中。
|
||||
|
||||
#### 3.4.2 starter-web
|
||||
### 3.4.2 starter-web
|
||||
  该模块主要提供web相关的配置功能。
|
||||
  目前实现的功能有:
|
||||
1. 注册审计拦截器
|
||||
@@ -748,6 +748,162 @@ HandlerInterceptor 拦截器执行顺序:
|
||||
4. afterCompletion():视图渲染后 按注册顺序逆序执行。
|
||||
:::
|
||||
|
||||
### 3.4.3 starter-logging
|
||||
  在`src->main->resources`中添加`logback-spring.xml`文件:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration scan="true" scanPeriod="10 seconds">
|
||||
|
||||
<!-- 自定义属性 -->
|
||||
<springProperty scope="context" name="fluentdHost" source="logging.fluentd.host" defaultValue="localhost"/>
|
||||
<springProperty scope="context" name="fluentdPort" source="logging.fluentd.port" defaultValue="24224"/>
|
||||
<springProperty scope="context" name="topic" source="spring.application.name" defaultValue="default"/>
|
||||
|
||||
<!-- log路径 -->
|
||||
<property name="log.path" value="logs/${topic}"/>
|
||||
|
||||
<!--控制台日志格式:彩色日志-->
|
||||
<!-- magenta:洋红 -->
|
||||
<!-- boldMagenta:粗红-->
|
||||
<!-- cyan:青色 -->
|
||||
<!-- white:白色 -->
|
||||
<!-- magenta:洋红 -->
|
||||
<property name="CONSOLE_LOG_PATTERN"
|
||||
value="%yellow(%date{yyyy-MM-dd HH:mm:ss}) |%highlight(%-5level) |%blue(%thread) |%blue(%file:%line) |%green(%logger) |%cyan(%msg%n)"/>
|
||||
|
||||
<!--文件日志格式-->
|
||||
<property name="FILE_LOG_PATTERN"
|
||||
value="%date{yyyy-MM-dd HH:mm:ss} |%-5level |%thread |%file:%line |%logger |%msg%n"/>
|
||||
|
||||
<!--编码-->
|
||||
<property name="ENCODING"
|
||||
value="UTF-8"/>
|
||||
|
||||
<!--输出到控制台-->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<!--日志级别-->
|
||||
<level>DEBUG</level>
|
||||
</filter>
|
||||
<encoder>
|
||||
<!--日志格式-->
|
||||
<Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
|
||||
<!--日志字符集-->
|
||||
<charset>${ENCODING}</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!--输出到文件-->
|
||||
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<!--日志过滤器:此日志文件只记录INFO级别的-->
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>INFO</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||
<file>${log.path}/log_info.log</file>
|
||||
<encoder>
|
||||
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||
<charset>${ENCODING}</charset>
|
||||
</encoder>
|
||||
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!-- 每天日志归档路径以及格式 -->
|
||||
<fileNamePattern>${log.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<!-- 日志文件的最大大小 -->
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<!--日志文件保留天数-->
|
||||
<maxHistory>15</maxHistory>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<!-- 日志过滤器:此日志文件只记录WARN级别的 -->
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>WARN</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||
<file>${log.path}/log_warn.log</file>
|
||||
<encoder>
|
||||
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||
<charset>${ENCODING}</charset> <!-- 此处设置字符集 -->
|
||||
</encoder>
|
||||
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${log.path}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<!-- 日志文件的最大大小 -->
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<!--日志文件保留天数-->
|
||||
<maxHistory>15</maxHistory>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<!-- 日志过滤器:此日志文件只记录ERROR级别的 -->
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>ERROR</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<!-- 正在记录的日志文件的路径及文件名 -->
|
||||
<file>${log.path}/log_error.log</file>
|
||||
<encoder>
|
||||
<pattern>${FILE_LOG_PATTERN}</pattern>
|
||||
<charset>${ENCODING}</charset> <!-- 此处设置字符集 -->
|
||||
</encoder>
|
||||
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${log.path}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<!-- 日志文件的最大大小 -->
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<!--日志文件保留天数-->
|
||||
<maxHistory>15</maxHistory>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<!--输出到FLUENCY-->
|
||||
<appender name="FLUENCY" class="ch.qos.logback.more.appenders.FluencyLogbackAppender">
|
||||
<remoteHost>${fluentdHost}</remoteHost>
|
||||
<port>${fluentdPort}</port>
|
||||
<additionalField>
|
||||
<key>topic</key>
|
||||
<value>${topic}</value>
|
||||
</additionalField>
|
||||
<ignoredField>caller</ignoredField>
|
||||
<useEventTime>true</useEventTime>
|
||||
</appender>
|
||||
|
||||
<!-- 开发环境 -->
|
||||
<springProfile name="dev">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<!-- 生产环境 -->
|
||||
<springProfile name="prod">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="INFO_FILE"/>
|
||||
<appender-ref ref="WARN_FILE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<!-- 容器环境 -->
|
||||
<springProfile name="docker">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FLUENCY"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
  该文件会根据当前环境自动添加日志输出节点。
|
||||
|
||||
# 四、项目发布
|
||||
|
||||
## 4.1 发布到Git中
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Vue3-Common
|
||||
title: Vue3 Common
|
||||
date: 2025-12-15 23:25
|
||||
---
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
:::
|
||||
|
||||
# 二、vite.config.ts配置
|
||||
## 2.1 生成Typescript类型文件
|
||||
## 2.1 生成ts类型文件
|
||||
  安装vite-plugin-dts插件
|
||||
|
||||
```cmd
|
||||
@@ -377,6 +377,167 @@ const app = createApp(App)
|
||||
app.use(router)
|
||||
```
|
||||
|
||||
8. 在layout中使用:
|
||||
```vue
|
||||
<template>
|
||||
<aside class="layout-sidebar">
|
||||
<menu-list
|
||||
:route="useRoute()"
|
||||
:routers="menuRoutes"
|
||||
:collapse="isCollapse"
|
||||
class="layout-menu"/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MenuList } from 'vue3-common'
|
||||
import menuRoutes from '@/router/menu'
|
||||
import { useRoute } from 'vue-router'
|
||||
</script>
|
||||
```
|
||||
|
||||
  如果需要对路由菜单进行权限处理,可以使用计算属性过滤一遍:
|
||||
```ts
|
||||
const routers = computed(() => {
|
||||
const menuPathList = menuRoutes.map((item) => item.path)
|
||||
const removePathList = menuPathList.filter((item) => !authStore.currentUser.menu.includes(item))
|
||||
|
||||
removePathList.forEach((item) => {
|
||||
router.removeRoute(capitalizeStr(item.replace('/', '')))
|
||||
})
|
||||
|
||||
return menuRoutes.filter((route) => !removePathList.includes(route.path))
|
||||
})
|
||||
```
|
||||
|
||||
::: tip
|
||||
实现原理:
|
||||
```ts
|
||||
import type { RouteComponent, RouteRecordRaw, RouteMeta } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 任意对象类型
|
||||
*/
|
||||
interface ICommonObj {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由meta配置
|
||||
*/
|
||||
interface IRouteMetaConfig {
|
||||
[key: string]: RouteMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由路径转为名称 /admin/dashboard -> AdminDashboard
|
||||
* @param path
|
||||
*/
|
||||
export const convertPath2Name = (path: string): string => {
|
||||
// 移除路径开头和结尾的斜杠(如果存在)
|
||||
const newPath = path.replace(/^\/|\/$/g, '')
|
||||
|
||||
// 将路径按斜杠分割成多个部分
|
||||
const parts = newPath.split('/')
|
||||
|
||||
// 处理每个部分:将连字符后的字母大写,并移除连字符;处理动态参数(如 :id)
|
||||
return parts.map((part) =>
|
||||
part.split('-').map((subPart) => {
|
||||
// 处理动态参数(如 :id)
|
||||
if (subPart.startsWith(':')) {
|
||||
return subPart.slice(1).charAt(0).toUpperCase() + subPart.slice(2)
|
||||
}
|
||||
// 普通部分
|
||||
return subPart.charAt(0).toUpperCase() + subPart.slice(1)
|
||||
}).join('')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据meta的order排序
|
||||
* @param routes 路由列表
|
||||
*/
|
||||
export const sortRoutesByOrder = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
|
||||
// 排序当前路由的子路由
|
||||
routes.forEach((route) => {
|
||||
if (route.children && route.children.length > 0) {
|
||||
// 如果有子路由,递归排序子路由
|
||||
route.children = sortRoutesByOrder(route.children)
|
||||
}
|
||||
})
|
||||
|
||||
// 对当前路由按 meta.order 排序
|
||||
return routes.sort((a, b) => (a.meta?.order as number || 0) - (b.meta?.order as number || 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由
|
||||
* @param modules 通过import.meta.glob导入的模块
|
||||
* @param layout 布局组件
|
||||
* @param metaConfig 路由meta配置
|
||||
*/
|
||||
export const getRoutersByModules = (modules: ICommonObj,
|
||||
layout: RouteComponent, metaConfig?: IRouteMetaConfig): RouteRecordRaw[] => {
|
||||
// const modules = import.meta.glob('@/views/admin/**/*.vue')
|
||||
const routes: RouteRecordRaw[] = []
|
||||
|
||||
Object.keys(modules).forEach((filePath) => {
|
||||
// 获取文件的模块路径和文件名
|
||||
const parentPath = filePath.substring(0, filePath.lastIndexOf('/')).replace('/src/views', '')
|
||||
const path = filePath.replace('.vue', '')
|
||||
.replace('/src/views', '')
|
||||
.replace(/\[([^\]]+)]/g, '/:$1')
|
||||
|
||||
// 为每个文件生成对应的路由
|
||||
const route: RouteRecordRaw = {
|
||||
path,
|
||||
name: convertPath2Name(path),
|
||||
component: modules[filePath]
|
||||
}
|
||||
|
||||
// 如果存在meta配置
|
||||
if (metaConfig) {
|
||||
route.meta = metaConfig[path]
|
||||
|
||||
// 设置重定向属性
|
||||
if (route.meta?.redirect) {
|
||||
route.redirect = route.meta?.redirect as any
|
||||
}
|
||||
}
|
||||
|
||||
// 如果该路径已经存在(嵌套路由),则将其作为子路由
|
||||
let parentRoute = routes.find((item) => item.path === parentPath)
|
||||
if (!parentRoute) {
|
||||
parentRoute = {
|
||||
path: parentPath,
|
||||
name: convertPath2Name(parentPath),
|
||||
component: layout,
|
||||
children: []
|
||||
}
|
||||
|
||||
// 如果存在meta配置
|
||||
if (metaConfig) {
|
||||
parentRoute.meta = metaConfig[parentPath]
|
||||
|
||||
// 设置重定向属性
|
||||
if (parentRoute.meta?.redirect) {
|
||||
parentRoute.redirect = parentRoute.meta?.redirect
|
||||
}
|
||||
}
|
||||
|
||||
routes.push(parentRoute)
|
||||
}
|
||||
|
||||
// 如果是子路由,加入到父路由的 `children` 数组
|
||||
parentRoute.children?.push(route)
|
||||
})
|
||||
|
||||
return routes
|
||||
}
|
||||
```
|
||||
|
||||
  核心原理就是利用`import.meta.glob`创建路径->组件的映射,然后结合路由配置表,转为路由对象并添加到`vue router`中。
|
||||
:::
|
||||
|
||||
## 5.2 图标组件
|
||||
1. 安装vite-plugin-svg-icons依赖
|
||||
```cmd
|
||||
62
docs/Practice/Company/Learn.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 埃学习
|
||||
date: 2026-05-27
|
||||
---
|
||||
|
||||
# 一、项目背景
|
||||
  为解决企业内部线下培训成本高、进度难追踪的问题,开发了一套企业在线学习平台,用于员工课程学习、在线考试、培训计划管理与数据统计分析,已全面接入企业微信工作台。
|
||||
|
||||
# 二、系统架构
|
||||

|
||||
|
||||
# 三、核心模块
|
||||
## 3.1 员工端
|
||||
1. 课程学习(视频播放、进度记录、课程评论)
|
||||
2. 在线考试(随机试卷、自动阅卷)
|
||||
3. 学习记录和统计
|
||||
|
||||
## 3.2 管理员端
|
||||
1. 课程管理(视频上传、章节管理)
|
||||
2. 培训计划管理(按部门/岗位下发)
|
||||
3. 考试与题库管理
|
||||
4. 人员与组织管理
|
||||
5. 员工学习数据统计
|
||||
|
||||
# 四、技术栈
|
||||
| 层级 | 技术 |
|
||||
|---|---|
|
||||
| **前端** | Vue 3、Element Plus、Vue Router、Pinia、DPlayer (视频播放)、@vue-office (文档预览)、ECharts (数据可视化)|
|
||||
| **后端** | Spring Boot 3、Spring MVC、Sa-Token (鉴权)、MyBatis-Plus |
|
||||
| **中间件** | Redis、企业微信 SDK |
|
||||
| **视频处理** | Java JAVE2 (FFmpeg 封装,视频压缩转码) |
|
||||
| **存储** | MySQL 8.0 (业务数据)、AList (视频/文件资源存储) |
|
||||
| **运维** | Docker、Nginx、Fluent Bit (日志采集)、OpenObserver (日志可视化) |
|
||||
|
||||
# 五、核心设计
|
||||
- 基于 **Spring Boot 3 + MyBatis‑Plus** 构建稳定的后端架构,通过 **Sa‑Token** 实现接口级鉴权,保障系统安全与权限可控。
|
||||
- 集成 **企业微信 SDK**,实现企业微信工作台免登录认证;通过**企业微信消息通知机制**,自动推送学习任务与提醒。
|
||||
- 设计 **课程资源、学习计划与在线考试** 等核心业务模型,支持批量下发培训任务;实现随机组卷、自动阅卷与成绩统计,并自动提醒员工完成学习任务。
|
||||
- 基于 **JAVE2(FFmpeg)** 实现视频自动压缩与转码,适配多终端播放;采用 AList 作为统一文件存储平台,集中管理音视频及课件资源。
|
||||
- 基于 **Vue 3 + Element Plus + Pinia** 构建管理员端与员工端页面,集成 **vue‑office** 实现多格式文档在线预览,集合**ECharts**实现多维度数据分析。
|
||||
- 使用 **Docker + Nginx** 部署,结合 **Fluent Bit + OpenObserver** 构建统一日志监控体系,提升系统可观测性与运维效率。
|
||||
|
||||
# 六、难点/亮点
|
||||
## 5.1 公网视频播放卡顿
|
||||
- **问题**:服务器公网带宽小,高清视频并发播放严重卡顿。
|
||||
- **方案**:上传视频后,使用JAVE2调用FFmpeg将视频统一压缩为720p的清晰度视频。
|
||||
- **效果**:视频体积减少60%+,公网环境播放流畅。
|
||||
|
||||
## 5.2 学习进度防作弊
|
||||
- **问题**:员工学习视频课程时,可通过拖动进度条快速完成课程,无法真实反映学习行为,存在“刷课”风险。
|
||||
- **方案**:根据管理员设定的课程学习时长,在员工学习时,启动定时器;每分钟会保存学习时长至数据库中,下次学习时,直接跳转至上次学习进度;课程是否完成已定时器结束为准。
|
||||
- **效果**:有效防止通过拖拽进度条刷课,保证学习时长真实可信;同时支持断点续学,避免重复学习,满足企业培训考核与合规审计要求。
|
||||
|
||||
## 5.3 学习计划通知
|
||||
- **问题**:培训计划、考试通知无法及时触达员工,导致培训参与率低、管理成本高。
|
||||
- **方案**:在发布培训计划时,调用企业微信消息推送接口,自动向指定员工发送应用消息通知;结合定时任务,对未完成培训的人员进行周期性提醒。
|
||||
- **效果**:培训通知实时触达,未读提醒显著提高培训完成率;管理员无需手动维护人员信息,降低了系统运维成本。
|
||||
|
||||
# 七、成果
|
||||
1. 平台全面替代线下培训,年度培训计划完成率由 60% 提升至 95% 以上,满足了企业合规培训要求。
|
||||
2. 过视频压缩方案解决了公网带宽瓶颈,结合企业微信自动化通知,大幅降低人工催办成本,整体培训运营成本下降约 40%。
|
||||
3. 实现了学习进度防作弊与学习记录全跟踪,为公司提供精准的数据统计报表,支撑人才盘点与绩效评估。
|
||||
88
docs/Practice/Personal/Blog.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: 个人博客系统
|
||||
date: 2026-06-08
|
||||
---
|
||||
|
||||
# 一、项目背景
|
||||
  为满足个人技术积累与内容分享需求,独立开发了一套多端博客系统,支持博客管理、发布与多端阅读。
|
||||
  系统包含博客网站、博客管理后台与移动端 App,覆盖 Web 端与移动端用户场景,实现了从内容创作、存储、检索到多端展示的完整闭环。
|
||||
|
||||
# 二、系统架构
|
||||
|
||||
# 三、核心模块
|
||||
## 3.1 博客网站
|
||||
1. 首页博客列表展示(分页、置顶)
|
||||
2. 博客分类与标签筛选
|
||||
3. 博客归档(按时间维度)
|
||||
4. 博客全文搜索(Elasticsearch)
|
||||
5. Markdown 文章渲染与代码高亮
|
||||
6. 博客阅读时长和阅读人数统计
|
||||
7. 评论发布与互动(匿名)
|
||||
|
||||
<VPSwiper
|
||||
:slides="[
|
||||
'/blog-press/images/blog/home.png',
|
||||
'/blog-press/images/blog/category.png',
|
||||
'/blog-press/images/blog/archive.png',
|
||||
'/blog-press/images/blog/search.png',
|
||||
'/blog-press/images/blog/content.png',
|
||||
'/blog-press/images/blog/commit.png'
|
||||
]"
|
||||
button-text="点击全屏"
|
||||
:navigation="false"
|
||||
effect="coverflow"
|
||||
/>
|
||||
|
||||
## 3.2 博客管理后台
|
||||
1. 博客新增、编辑、删除(权限控制)
|
||||
2. Markdown 编辑器(md-editor-v3)
|
||||
3. 博客图片上传(RustFS)
|
||||
4. 分类 / 标签管理
|
||||
5. 博客状态管理(草稿 / 发布)
|
||||
6. 博客访问数据查看
|
||||
7. 统计数据仪表盘(ECharts)
|
||||
|
||||
<VPSwiper
|
||||
:slides="[
|
||||
'/blog-press/images/blog/login.png',
|
||||
'/blog-press/images/blog/stats.png',
|
||||
'/blog-press/images/blog/article.png',
|
||||
'/blog-press/images/blog/edit.png'
|
||||
]"
|
||||
button-text="点击全屏"
|
||||
:navigation="false"
|
||||
effect="coverflow"
|
||||
/>
|
||||
|
||||
## 3.3 移动端APP
|
||||
1. 博客列表与详情浏览
|
||||
2. Markdown 内容原生渲染(markdown_widget)
|
||||
3. 分类、归档、搜索功能
|
||||
4. 移动端自适应布局
|
||||
|
||||
# 四、技术栈
|
||||
| 层级 | 技术 |
|
||||
|---|---|
|
||||
| **前端** | Vue 3、Element Plus、Vue Router、Pinia、md-editor-v3、Axios |
|
||||
| **后端** | FastAPI、Pydantic、SQLAlchemy、Uvicorn |
|
||||
| **移动端** | Flutter、markdown_widget、provider、dio |
|
||||
| **中间件** | Elasticsearch(全文检索、分词索引) |
|
||||
| **存储** | MySQL 8.0(业务数据)、RustFS(图片存储) |
|
||||
| **运维** | Docker、Nginx、Fluent Bit (日志采集)、OpenObserver (日志可视化) |
|
||||
|
||||
# 五、核心设计
|
||||
- 基于 FastAPI 构建高性能后端 API,采用分层结构设计,保证业务逻辑清晰、接口响应高效。
|
||||
- 博客内容统一采用 Markdown 格式,Web 端与移动端分别基于 md-editor-v3与 markdown_widget实现一致的内容渲染效果。
|
||||
- 使用 Elasticsearch 构建博客全文检索引擎,支持标题、内容、标签多维搜索,显著提升内容查找效率。
|
||||
- 图片资源统一上传至 RustFS 对象存储,通过 URL 访问,降低应用服务器存储压力,提升静态资源加载速度。
|
||||
- 管理后台与博客前端共享同一后端服务,通过接口级权限控制,实现“管理操作需鉴权、公开访问无需登录”的安全设计。
|
||||
- Web 端采用 Vue 3 + Pinia 实现状态管理与组件化开发;移动端基于 Flutter 实现跨平台一致体验。
|
||||
|
||||
# 六、难点 / 亮点
|
||||
## 6.1 博客搜索性能优化
|
||||
- **问题**:随着博客数量增长,MySQL LIKE 查询性能下降明显。
|
||||
- **方案**:引入 Elasticsearch,构建博客索引,支持中文分词与多字段检索。
|
||||
|
||||
## 6.2 图片存储与访问优化
|
||||
- **问题**:本地存储图片不利于扩展,且增加服务器带宽压力。
|
||||
- **方案**:集成 RustFS 对象存储,所有博客图片统一上传并返回访问 URL。
|
||||
65
docs/Practice/Personal/SweetHut.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 家庭管理系统
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# 一、项目背景
|
||||
  针对家庭信息分散、难以统一管理的问题,设计并实现了一套移动端家庭管理平台,将账单、旅行、健康、纪念日等数据进行集中管理,并通过私有化部署与数据加密保障安全,实现家庭生活的数字化沉淀。
|
||||
|
||||
# 二、系统架构
|
||||
|
||||
# 三、核心模块
|
||||
- **账单记录**
|
||||

|
||||
|
||||
- **美食记录**
|
||||
|
||||
- **旅行记录**
|
||||
|
||||
- **物品管理**
|
||||
|
||||
- **运动健康**
|
||||
|
||||
- **纪念日管理**
|
||||
|
||||
- **家庭资产**
|
||||
|
||||
- **密码管家**
|
||||
|
||||
- **奖状证书**
|
||||
|
||||
- **时光手账**
|
||||
|
||||
# 四、技术栈
|
||||
| 层级 | 技术 |
|
||||
|---|---|
|
||||
| **前端** | Vue 3、Vant、Vue Router、Pinia、Axios |
|
||||
| **后端** | Spring Boot 3、Spring MVC、Satoken、MyBatis‑Plus |
|
||||
| **中间件** | Express +Sharp(图片缩略图)、Quartz(定时任务) |
|
||||
| **存储** | MySQL 8.0(业务数据)、AList(图片/视频对象存储) |
|
||||
| **视频处理** | Java JAVE2 (FFmpeg 封装,视频压缩转码) |
|
||||
| **安全** | AES 数据加密 |
|
||||
| **移动端** | Flutter(WebView 封装为 Android APK) |
|
||||
| **运维** | Docker、Nginx、Fluent Bit (日志采集)、OpenObserver (日志可视化) |
|
||||
|
||||
# 五、核心设计
|
||||
- 基于 **Spring Boot 3 + MyBatis‑Plus** 构建稳定的后端架构,通过 **Sa‑Token** 实现接口级鉴权,保障系统安全与权限可控。
|
||||
- 所有敏感信息(密码、资产等)均使用 **AES 对称加密** 存储,防止数据库泄露导致隐私暴露。
|
||||
- 使用 **Node.js + Sharp** 搭建图片服务,移动端优先加载缩略图,点击后按需加载原图,显著优化首屏性能。
|
||||
- 视频资源上传后通过 **JAVE2 + FFmpeg** 压缩并转码,在保证清晰度的前提下减小体积。
|
||||
- 基于 **Quartz** 实现定时任务,用于纪念日提醒等场景。
|
||||
- 前端基于 **Vue 3 + Element Plus + Vant + Pinia** 开发移动端页面,并使用 **Flutter WebView** 将 Web 应用封装为 Android APK,实现一套代码多端使用。
|
||||
- 使用 **Docker + Nginx** 部署,结合 **Fluent Bit + OpenObserver** 构建统一日志监控体系,提升系统可观测性与运维效率。
|
||||
|
||||
# 六、难点 / 亮点
|
||||
## 6.1 图片资源加载性能优化
|
||||
- **问题**:手机拍摄的照片体积较大,移动端直接加载原图导致页面卡顿、流量消耗大。
|
||||
- **方案**:使用 **Node.js + Sharp** 构建图片实时处理服务,通过路径参数实时获取压缩图片,默认加载缩略图,点击后加载原图,并配合lazy懒加载优化首屏加载时间。
|
||||
|
||||
## 6.2 视频资源压缩与存储
|
||||
- **问题**:手机视频多为高清录制,体积大、加载慢,直接存储与播放影响用户体验。
|
||||
- **方案**:使用 **JAVE2 + FFmpeg** 在上传时将视频统一压缩并转码,减少视频空间。
|
||||
|
||||
## 6.3 敏感数据加密存储
|
||||
- **问题**:密码、资产等数据高度敏感,若数据库被攻破将造成严重隐私泄露。
|
||||
- **方案**:采用 AES 对敏感字段进行加密存储,密钥与应用配置隔离管理,服务端统一加解密。
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
layout: doc
|
||||
title: Flutter
|
||||
title: 项目实战
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { routers } from '../.vitepress/theme/router'
|
||||
</script>
|
||||
|
||||
<MenuList :routers=routers[3] />
|
||||
<MenuList :routers=routers[2] :isNested=true />
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
layout: doc
|
||||
title: Web后端
|
||||
description: Web后端开发技术文档,包含数据库、框架、中间件等
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { routers } from '../.vitepress/theme/router'
|
||||
</script>
|
||||
|
||||
<MenuList :routers=routers[1] :isNested=true />
|
||||
242
docs/Web/AI/Langchain.md
Normal file
@@ -0,0 +1,242 @@
|
||||
---
|
||||
title: 基于Langchain的Agent开发
|
||||
date: 2026-05-28
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  LangChain是一整个智能体开发平台,包含一系列开源的智能体(Agent)开发框架,而且兼容Python和TypeScript两种语言。
|
||||
  Agent(通常翻译为智能体或代理)是指一种能够感知环境、进行推理、自主决策并采取行动以实现特定目标的智能系统。
|
||||
|
||||
# 二、模型
|
||||
## 2.1 模型定义
|
||||
  通用自定义模型`init_chat_model`:
|
||||
```python
|
||||
# 初始化模型
|
||||
model = init_chat_model(
|
||||
model="qwen-max", # 模型名称,这里可以自定义,我们用的是阿里的qwen-max
|
||||
model_provider="openai",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
temperature=1.5,
|
||||
)
|
||||
```
|
||||
|
||||
  如果是Langchain不支持的模型,需要指定模型提供者(虽然我们用的是阿里,但是阿里兼容openai,所以这里用openai,就是默认采用openai的API规范)
|
||||
|
||||
::: tip
|
||||
init_chat_model还可以调整模型参数:
|
||||
- temperature: 控制生成文本的随机性,值越小越确定,值越大越随机
|
||||
- max_tokens: 控制生成文本的最大长度
|
||||
- top_p: 控制生成文本的多样性,值越小越多样,值越大越确定
|
||||
- timeout: 控制生成文本的超时时间
|
||||
- max_retries: 控制生成文本的最大重试次数
|
||||
:::
|
||||
|
||||
## 2.2 访问模型
|
||||
### 2.2.1 阻塞调用
|
||||
```python
|
||||
# 调用invoke方法
|
||||
response = model.invoke("中国的首都是哪里?")
|
||||
|
||||
# 查看响应结果
|
||||
print(response)
|
||||
```
|
||||
|
||||
### 2.2.2 流式调用
|
||||
```python
|
||||
# 通过.stream方法实现流式访问
|
||||
stream = model.stream("中国的首都是哪里?")
|
||||
|
||||
# 遍历stream结果,实时打印AI的回复
|
||||
for chunk in stream:
|
||||
print(chunk.content, end="", flush=True)
|
||||
```
|
||||
|
||||
# 三、智能体
|
||||
## 3.1 创建智能体
|
||||
```python
|
||||
# 1.使用init_chat_model初始化模型
|
||||
model = init_chat_model(
|
||||
model="qwen-max",
|
||||
model_provider="openai",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
temperature=1.5,
|
||||
)
|
||||
|
||||
# 2.使用初始化好的model创建智能体
|
||||
agent = create_agent(model=model)
|
||||
```
|
||||
|
||||
## 3.2 消息类型
|
||||
- SystemMessage:role是system,代表系统消息,用于设定模型角色和交互背景
|
||||
- HumanMessage:role是user,代表用户输入的消息
|
||||
- AIMessage:role是assistant,代表LLM生成的响应,包含:文本、工具调用、元数据
|
||||
- ToolMessage:role是tool,代表工具调用时产生的结果
|
||||
|
||||
## 3.3 调用模型
|
||||
```python
|
||||
# 调用Agent,发送消息
|
||||
response = agent.invoke({
|
||||
"messages": [
|
||||
HumanMessage(content="你好,我是虎哥"),
|
||||
AIMessage(content="你好,虎哥,很高兴认识你。"),
|
||||
HumanMessage(content="我的名字是什么?")
|
||||
]
|
||||
})
|
||||
|
||||
for message in response['messages']:
|
||||
message.pretty_print()
|
||||
```
|
||||
|
||||
# 四、提示词
|
||||
  发送给大模型的所有消息都可以称为提示词(Prompt),它直接影响模型的输出结果。
|
||||
  其中,SystemMessage尤为重要,我们把SystemMessage称为系统提示词(System Prompt),它可以给模型设定角色和本次聊天的背景,对模型生成的内容有很大的影响。
|
||||
  从内容来说,提示词通常包含以下几个部分,通常按此顺序排列:
|
||||
- 身份(Identity):描述AI的职责、沟通风格和总体目标。
|
||||
- 说明(Instructions):请指导模型如何生成所需的响应。它应该遵循哪些规则?模型应该做什么,以及模型绝对不能做什么?
|
||||
- 示例(Examples):提供可能的输入示例,以及模型期望的输出。
|
||||
- 背景信息(Context):向模型提供生成响应所需的任何额外信息,例如RAG的额外知识库数据,或您认为特别相关的任何其他数据。
|
||||
|
||||
  例如写一个智能生成菜谱的提示词:
|
||||
```python
|
||||
system_prompt = """
|
||||
你是一名私人厨师。收到用户提供的食材照片或清单后,请按以下流程操作:
|
||||
1.识别和评估食材:若用户提供照片,首先辨识所有可见食材。基于食材的外观状态,评估其新鲜度与可用量,整理出一份“当前可用食材清单”。
|
||||
2.智能食谱检索:优先调用 web_search 工具,以“可用食材清单”为核心关键词,查找可行菜谱。
|
||||
3.多维度评估与排序:从营养价值和制作难度两个维度对检索到的候选食谱进行量化打分,并根据得分排序,制作简单且营养丰富的排名靠前。
|
||||
4.结构化方案输出:把排序后的食谱整理为一份结构清晰的建议报告,要包含食谱信息、得分、推荐理由、食谱的参考图片,帮助用户快速做出决策。
|
||||
|
||||
请严格按照流程,优先调用 web_search 工具搜索食谱,搜索不到的情况下才能自己发挥。
|
||||
"""
|
||||
```
|
||||
|
||||
  提示词作为agent的参数传递:
|
||||
```python
|
||||
agent = create_agent(model=model, system_prompt=system_prompt)
|
||||
```
|
||||
|
||||
# 五、工具
|
||||
  一个完整的Agent至少要包含两个关键的部分:
|
||||
- 模型:是Agent的大脑,负责推理、分析,规划任务步骤
|
||||
- 工具:是Agent的手脚,负责执行任务,与外界交互
|
||||
|
||||
  例如使用专门用于给Agent提供Web搜索的工具:Tavily
|
||||
```python
|
||||
# 初始化工具,并设置参数,具体参数设置参考官网
|
||||
tool = TavilySearch(
|
||||
tavily_api_key=key,
|
||||
max_results=5,
|
||||
topic="general"
|
||||
)
|
||||
|
||||
# 创建智能体,使用预定义工具tavily
|
||||
agent = create_agent(
|
||||
model=model,
|
||||
tools=[tool],
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
```
|
||||
|
||||
::: tip
|
||||
tavily_api_key可以通过访问官网免费获取。
|
||||
:::
|
||||
|
||||
# 六、记忆
|
||||
  LangChain也提供了很多持久化存储的checkpointer,例如:
|
||||
- SqlLiteSaver :基于sqlite存储
|
||||
- PostgresSaver :基于Postgres存储
|
||||
- CosmosDBSaver :使用Azure Cosmos DB的实现
|
||||
|
||||
## 6.1 SqlLiteSaver
|
||||
### 6.1.1 初始化
|
||||
```python
|
||||
# 初始化checkpointer
|
||||
checkpointer = SqliteSaver(sqlite3.connect("checkpoint.db", check_same_thread=False))
|
||||
# 自动建表
|
||||
checkpointer.setup()
|
||||
```
|
||||
|
||||
### 6.1.2 配置
|
||||
```python
|
||||
# 创建agent
|
||||
agent = create_agent(
|
||||
model=model,
|
||||
checkpointer=checkpointer,
|
||||
)
|
||||
|
||||
agent.stream({"messages": [message]},
|
||||
{"configurable": {"thread_id": thread_id}},
|
||||
stream_mode="messages"
|
||||
):
|
||||
```
|
||||
|
||||
  调用模型的时候需要传递`thread_id`。
|
||||
::: tip
|
||||
通常还需要构建一个`用户-问题标题`的数据库,方便查询用户的所有历史回答。
|
||||
问题标题可以再调一次模型总结。
|
||||
:::
|
||||
|
||||
# 七、实战
|
||||
## 7.1 后端
|
||||
```python
|
||||
async def query_agent(request: ChatRequest):
|
||||
try:
|
||||
message = HumanMessage(content=request.message)
|
||||
|
||||
# 流式调用Agent
|
||||
for chunk, metadata in agent.stream(
|
||||
{"messages": [message]},
|
||||
{"configurable": {"thread_id": request.thread_id}},
|
||||
stream_mode="messages"
|
||||
):
|
||||
if isinstance(chunk, AIMessageChunk) and chunk.content:
|
||||
yield chunk.content
|
||||
|
||||
# 总结对话标题并保存
|
||||
if not exists_session(request.thread_id, request.username):
|
||||
title = generate_title(get_messages(request.thread_id))
|
||||
save_session(request.thread_id, request.username, title)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[错误]: {str(e)}")
|
||||
yield "信息检索失败,请重新输入问题提问"
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def chat_endpoint(request: ChatRequest):
|
||||
"""流式对话"""
|
||||
return StreamingResponse(
|
||||
query_agent(request),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
```
|
||||
|
||||
## 7.2 前端
|
||||
```typescript
|
||||
const sendMessage = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/chief-agent-api/chat/stream",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(message)
|
||||
}
|
||||
);
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let aiText = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
aiText += decoder.decode(value, { stream: true });
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
```
|
||||
211
docs/Web/AI/RAG.md
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
title: RAG开发
|
||||
date: 2026-06-24
|
||||
---
|
||||
|
||||
# 一、工作流程
|
||||
```text
|
||||
原始文档 / 数据
|
||||
↓
|
||||
【文档收集与清洗】
|
||||
↓
|
||||
【文档切分(Chunking)】
|
||||
↓
|
||||
【向量化(Embedding)】
|
||||
↓
|
||||
【向量数据库存储】
|
||||
↓
|
||||
用户自然语言问题
|
||||
↓
|
||||
【检索系统】召回相关知识片段
|
||||
↓
|
||||
【上下文组装】构建增强 Prompt
|
||||
↓
|
||||
【大模型】基于知识生成回答
|
||||
↓
|
||||
【引用 / 校验】
|
||||
↓
|
||||
最终返回给用户(答案 + 来源)
|
||||
```
|
||||
|
||||
# 二、实战
|
||||
## 2.1 资料处理
|
||||
### 2.1.1 pdf处理
|
||||
  [PaddleOCR](https://aistudio.baidu.com/paddleocr)是百度飞桨(PaddlePaddle)团队开源的产业级 OCR(光学字符识别)与文档智能开发套件。
|
||||
```python
|
||||
JOB_URL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
|
||||
TOKEN = ""
|
||||
MODEL = "PP-OCRv5"
|
||||
|
||||
FILE_URL = ""
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"bearer {TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ========== 提交 OCR 任务 ==========
|
||||
payload = {
|
||||
"fileUrl": FILE_URL,
|
||||
"model": MODEL,
|
||||
"optionalPayload": {
|
||||
"useDocOrientationClassify": False,
|
||||
"useDocUnwarping": False,
|
||||
"useChartRecognition": False,
|
||||
},
|
||||
}
|
||||
|
||||
print("🚀 Submitting OCR job...")
|
||||
resp = requests.post(JOB_URL, json=payload, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
|
||||
job_id = resp.json()["data"]["jobId"]
|
||||
print(f"✅ Job submitted, jobId={job_id}")
|
||||
|
||||
# ========== 轮询任务状态 ==========
|
||||
while True:
|
||||
r = requests.get(f"{JOB_URL}/{job_id}", headers=HEADERS)
|
||||
r.raise_for_status()
|
||||
|
||||
data = r.json()["data"]
|
||||
state = data["state"]
|
||||
|
||||
if state == "pending":
|
||||
print("⏳ Job status: pending")
|
||||
elif state == "running":
|
||||
prog = data.get("extractProgress", {})
|
||||
total = prog.get("totalPages")
|
||||
done = prog.get("extractedPages")
|
||||
if total and done:
|
||||
print(f"📄 Processing: {done}/{total} pages")
|
||||
else:
|
||||
print("📄 Processing...")
|
||||
elif state == "done":
|
||||
prog = data["extractProgress"]
|
||||
print(
|
||||
f"✅ Job finished | Pages: {prog['extractedPages']} | "
|
||||
f"Start: {prog['startTime']} | End: {prog['endTime']}"
|
||||
)
|
||||
break
|
||||
elif state == "failed":
|
||||
print("❌ Job failed:", data.get("errorMsg", "Unknown error"))
|
||||
sys.exit(1)
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
# ========== 获取 OCR 结果 ==========
|
||||
jsonl_url = data["resultUrl"]["jsonUrl"]
|
||||
print(f"📥 Fetching result: {jsonl_url}")
|
||||
|
||||
resp = requests.get(jsonl_url)
|
||||
resp.raise_for_status()
|
||||
```
|
||||
|
||||
  这里只处理了url形式的文档,实际开发中,可以搭建一个管理页面集中处理文档资料。
|
||||
  获取到的结果是按照区域划分的,还需要进一步的处理:
|
||||
```python
|
||||
pages = []
|
||||
|
||||
for line in resp.text.strip().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
obj = json.loads(line)
|
||||
|
||||
for page in obj.get("result", {}).get("ocrResults", []):
|
||||
pruned = page.get("prunedResult", {})
|
||||
texts = pruned.get("rec_texts", [])
|
||||
|
||||
# 一页一段
|
||||
page_text = "\n".join(texts)
|
||||
pages.append(page_text)
|
||||
|
||||
# ✅ 最终文档
|
||||
full_doc = "\n\n".join(pages)
|
||||
```
|
||||
|
||||
## 2.2 向量化
|
||||
  使用在线或者离线向量化模型处理:
|
||||
```python
|
||||
text_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=500, # 每个 chunk 最大长度
|
||||
chunk_overlap=50, # 重叠,防止切坏语义
|
||||
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
|
||||
)
|
||||
|
||||
chunks = text_splitter.split_text(full_doc)
|
||||
print(f"✅ 切分成 {len(chunks)} 个 chunk")
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
print(f"\n===== Chunk {i} =====")
|
||||
print(chunk)
|
||||
|
||||
model = SentenceTransformer("BAAI/bge-base-zh", cache_folder="./models")
|
||||
|
||||
embeddings = model.encode(
|
||||
chunks,
|
||||
normalize_embeddings=True
|
||||
)
|
||||
|
||||
print(f"✅ 向量维度: {embeddings.shape}")
|
||||
|
||||
client = chromadb.PersistentClient(path="./vector_db/train_docs")
|
||||
collection = client.get_or_create_collection(name="train_docs")
|
||||
|
||||
for i, (chunk, emb) in enumerate(zip(chunks, embeddings)):
|
||||
collection.add(
|
||||
ids=[str(i)],
|
||||
documents=[chunk],
|
||||
embeddings=[emb.tolist()]
|
||||
)
|
||||
|
||||
print("✅ 向量库写入完成")
|
||||
```
|
||||
|
||||
  这里使用的是`BAAI/bge-base-zh`模型,第一次会将模型下载到本地。
|
||||
  将处理结果存储到chroma向量数据库中。
|
||||
|
||||
## 2.3 检索系统
|
||||
```python
|
||||
model_path = "./models/models--BAAI--bge-base-zh/snapshots/0e5f83d4895db7955e4cb9ed37ab73f7ded339b6"
|
||||
model = SentenceTransformer(model_path, local_files_only=True)
|
||||
|
||||
client = chromadb.PersistentClient(path="./vector_db/train_docs")
|
||||
collection = client.get_or_create_collection(name="train_docs")
|
||||
|
||||
q = "培训计划怎么制定?"
|
||||
q_emb = model.encode([q], normalize_embeddings=True)
|
||||
|
||||
res = collection.query(
|
||||
query_embeddings=q_emb.tolist(),
|
||||
n_results=3
|
||||
)
|
||||
|
||||
print("\n\n".join(res["documents"][0]))
|
||||
```
|
||||
|
||||
::: tip
|
||||
注意这里使用离线模型时的路径
|
||||
:::
|
||||
|
||||
## 2.4 结合大模型
|
||||
  常用提示词:
|
||||
```python
|
||||
你是专业的知识问答助手。
|
||||
请严格基于下方提供的参考资料回答问题。
|
||||
如果参考资料中不包含答案,请明确说明“当前资料无法回答该问题”。
|
||||
不要编造、推测或引入外部知识。
|
||||
|
||||
参考资料:
|
||||
"""
|
||||
{{context}}
|
||||
"""
|
||||
|
||||
用户问题:
|
||||
{{question}}
|
||||
|
||||
请按以下要求回答:
|
||||
1. 回答简洁、准确、有条理
|
||||
2. 必要时使用列表或分点说明
|
||||
3. 避免冗余描述
|
||||
```
|
||||
103
docs/Web/AI/TextToSql.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Text To SQL开发
|
||||
date: 2026-06-09
|
||||
---
|
||||
|
||||
# 一、工作流程
|
||||
```text
|
||||
用户自然语言问题
|
||||
↓
|
||||
【大模型】生成 SQL
|
||||
↓
|
||||
【数据库】执行 SQL → 得到结构化结果
|
||||
↓
|
||||
【大模型】分析 / 解读 / 总结 / 可视化建议
|
||||
↓
|
||||
最终返回给用户(文字、结论、图表说明)
|
||||
```
|
||||
|
||||
# 二、实战
|
||||
## 2.1 获取数据库结构
|
||||
```python
|
||||
db = SQLDatabase.from_uri(os.getenv("DB_URL"), include_tables=['table_1', 'table_2'])
|
||||
table_info = db.get_table_info()
|
||||
```
|
||||
|
||||
::: warning
|
||||
这里的数据库账号只能为只读权限。
|
||||
:::
|
||||
|
||||
## 2.2 SQL提示词
|
||||
```python
|
||||
from langchain_core.prompts import PromptTemplate
|
||||
|
||||
SQL_PROMPT = PromptTemplate.from_template(
|
||||
"""
|
||||
你是一个 MySQL 专家。
|
||||
请根据用户问题生成一条可执行的 MySQL 查询语句。
|
||||
只返回 SQL,不要解释,不要加 ```。
|
||||
|
||||
数据库结构:
|
||||
{schema}
|
||||
|
||||
数据库补充:
|
||||
{option}
|
||||
|
||||
用户问题:
|
||||
{question}
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
  数据库结构就是刚才生成的`table_info`,数据库补充就是对一些关系表的说明和字段含义的解释等。
|
||||
|
||||
## 2.3 生成SQL
|
||||
```python
|
||||
sql = chat_model.invoke(
|
||||
SQL_PROMPT.format(schema=table_info, option=option, question=request.message)
|
||||
).content.strip()
|
||||
```
|
||||
|
||||
  这里的`chat_model`为`init_chat_model`构建的大模型。
|
||||
|
||||
## 2.4 执行SQL
|
||||
```python
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
engine = create_engine(
|
||||
os.getenv("DB_URL"),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
conn = engine.connect()
|
||||
|
||||
|
||||
def is_select(sql: str) -> bool:
|
||||
return sql.lower().lstrip().startswith("select")
|
||||
|
||||
def execute_sql(sql: str):
|
||||
if not is_select(sql):
|
||||
raise ValueError("只允许 SELECT 查询")
|
||||
|
||||
result = conn.execute(text(sql))
|
||||
rows = result.fetchall()
|
||||
columns = result.keys()
|
||||
return {
|
||||
"columns": list(columns),
|
||||
"rows": rows
|
||||
}
|
||||
```
|
||||
  使用sqlalchemy执行SQL,并加入了安全校验。
|
||||
|
||||
## 2.5 分析数据
|
||||
```python
|
||||
agent = create_agent(
|
||||
model=chat_model,
|
||||
system_prompt=SYSTEM_PROMPT.format(question=request.message, result=result),
|
||||
)
|
||||
|
||||
response = agent.invoke({"message": HumanMessage(content=request.message)})
|
||||
```
|
||||
|
||||
  这里的系统提示词根据情况编写。
|
||||
77
docs/Web/AI/Tools.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Tools开发
|
||||
date: 2026-06-29
|
||||
---
|
||||
|
||||
# 一、工作流程
|
||||
```text
|
||||
【定义工具能力】
|
||||
↓
|
||||
【约定输入输出】
|
||||
↓
|
||||
【实现工具逻辑】
|
||||
↓
|
||||
【注册到模型】
|
||||
↓
|
||||
用户自然语言请求
|
||||
↓
|
||||
【模型选择工具】
|
||||
↓
|
||||
【执行工具】
|
||||
↓
|
||||
【返回结果】
|
||||
```
|
||||
|
||||
# 二、实战
|
||||
## 2.1 工具定义
|
||||
  实现一个根据接口查询数据的工具:
|
||||
```python
|
||||
@tool
|
||||
def repair_search(date: list[str]) -> str:
|
||||
"""
|
||||
查询维修工单列表。
|
||||
|
||||
参数说明:
|
||||
- date (list[str], 必填):
|
||||
- 日期范围,格式:["YYYY-MM-DD", "YYYY-MM-DD"]
|
||||
- 示例:["2026-07-01", "2026-08-01"]
|
||||
"""
|
||||
|
||||
if date is None:
|
||||
date = []
|
||||
|
||||
params = {
|
||||
"currentPage": 0,
|
||||
"pageSize": 0,
|
||||
"customer": "",
|
||||
"deviceName": "",
|
||||
"deviceModel": "",
|
||||
"date": date
|
||||
}
|
||||
|
||||
token = get_token()
|
||||
|
||||
headers = {
|
||||
"satoken": token
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}repair", params=params, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
return f"查询维修工单失败: {e}"
|
||||
```
|
||||
|
||||
## 2.2 注册到模型
|
||||
```python
|
||||
integrate_agent = create_agent(
|
||||
model=model,
|
||||
tools=[repair_search],
|
||||
system_prompt=integrate_prompt
|
||||
)
|
||||
```
|
||||
|
||||
::: tip
|
||||
工具里面的注释大模型是无法查看的,需要把工具的使用说明,例如接口返回值的含义,写在提示词里面。
|
||||
:::
|
||||
33
docs/Web/FastAPI/FastAPI-Docker.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: FastAPI Docker离线部署
|
||||
date: 2026-01-06
|
||||
---
|
||||
|
||||
# 一、安装依赖
|
||||
  通过pip将requirements.txt中的依赖包下载到本地:
|
||||
```sh
|
||||
pip download -r requirements.txt -d ./packages --only-binary=:all: --platform manylinux2014_x86_64 -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
```
|
||||
|
||||
::: tip
|
||||
`--only-binary=:all:`表示只下载预编译的二进制包
|
||||
`--platform manylinux2014_x86_64`指定目标平台为 Linux
|
||||
`-i`指定pip镜像源源
|
||||
还可以加上`--python-version 310`指定python版本
|
||||
:::
|
||||
|
||||
  执行命令后,会在./packages中下载相应的二进制包。
|
||||
|
||||
# 二、Dockfile
|
||||
```dockerfile
|
||||
FROM python:3.10-slim
|
||||
WORKDIR /app
|
||||
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||
RUN echo 'Asia/Shanghai' > /etc/timezone
|
||||
COPY ./packages /app/packages
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir --no-index --find-links=/app/packages -r requirements.txt
|
||||
COPY . /app/
|
||||
EXPOSE 8095
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8095"]
|
||||
```
|
||||
29
docs/Web/Flutter/FlutterInstall.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Flutter简介与安装
|
||||
date: 2026-05-02
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  Flutter 是 Google 推出的跨平台 UI 开发框架,使用 Dart 语言,通过自带的渲染引擎直接绘制界面,可一套代码同时构建 iOS、Android、Web 及 Windows/macOS/Linux 桌面应用,具备原生级性能、热重载和高 UI 一致性,适合追求多端统一与体验效率的项目开发。
|
||||
|
||||
# 二、安装
|
||||
## 2.1 下载SDK
|
||||
  打开[官网](https://docs.flutter.dev/install/archive),选择SDK版本下载,然后解压到相应目录。
|
||||
|
||||
## 2.2 环境变量配置
|
||||
1. 在系统变量->path中添加:'/path/to/flutter/bin'
|
||||
2. 在系统变量中添加: 'PUB_HOSTED_URL'->'https://pub.flutter-io.cn'
|
||||
3. 在系统变量中添加: 'FLUTTER_STORAGE_BASE_URL'->'https://storage.flutter-io.cn'
|
||||
4. 在系统变量中添加: 'FLUTTER_GIT_URL'->'https://gitee.com/mirrors/Flutter.git'
|
||||
|
||||
## 2.3 校验安装
|
||||
  输入`flutter --version`和`flutter doctor`查看是否有错误输出。
|
||||
::: tip
|
||||
Windows开发需要安装Visual Studio的'C++的桌面开发'。
|
||||
Android开发需要安装Android SDK。
|
||||
Web开发需要安装Chrome浏览器。
|
||||
:::
|
||||
|
||||
## 三、Android Studio
|
||||
1. 安装Flutter和Dart插件
|
||||
2. 在Settings->Languages->Dart中选择Dart路径:`/path/to/flutter/bin/cache/dart-sdk`
|
||||
@@ -262,10 +262,47 @@ public interface UserMapper {
|
||||
</resultMap>
|
||||
```
|
||||
|
||||
  其中`<collection>`也可以使用嵌套查询:
|
||||
  对应的数据结构为:
|
||||
```java
|
||||
public class Blog {
|
||||
private Integer id;
|
||||
private String title;
|
||||
private Author author; // 一对一
|
||||
private List<Post> posts; // 一对多
|
||||
}
|
||||
|
||||
public class Author {
|
||||
private Integer id;
|
||||
private String username;
|
||||
private String password;
|
||||
private String email;
|
||||
private String bio;
|
||||
private String favouriteSection;
|
||||
}
|
||||
|
||||
public class Post {
|
||||
private Integer id;
|
||||
private String subject;
|
||||
private Author author; // 一对一
|
||||
private List<Comment> comments; // 一对多
|
||||
private List<Tag> tags; // 一对多
|
||||
}
|
||||
|
||||
public class Comment {
|
||||
private Integer id;
|
||||
}
|
||||
|
||||
public class Tag {
|
||||
private Integer id;
|
||||
}
|
||||
```
|
||||
|
||||
  其中`<collection>`也可以使用嵌套查询,例如`<collection property="posts" ofType="Post" select="queryPost"/>`:
|
||||
|
||||
```xml
|
||||
<collection property="posts" ofType="Post" select="queryPost"/>
|
||||
<select id="queryPost" resultMap="postResultMap">
|
||||
|
||||
</select>
|
||||
|
||||
<resultMap id="postResultMap" type="Post">
|
||||
<id property="id" column="post_id"/>
|
||||
@@ -281,11 +318,6 @@ public interface UserMapper {
|
||||
<case value="1" resultType="DraftPost"/>
|
||||
</discriminator>
|
||||
</resultMap>
|
||||
|
||||
<select id="queryPost" resultMap="postResultMap">
|
||||
|
||||
</select>
|
||||
|
||||
```
|
||||
|
||||
  如果需要传递参数,可以在`<collection>`添加`column`属性:
|
||||
@@ -304,7 +336,12 @@ public interface UserMapper {
|
||||
|
||||
+ 映射语句文件中的所有 select 语句的结果将会被缓存。
|
||||
+ 映射语句文件中的所有 insert、update 和 delete 语句会刷新缓存。
|
||||
+ 一级缓存和二级缓存区别在于一级缓存只针对一次SqlSession,二级缓存针对全局范围。
|
||||
+ 一级缓存和二级缓存区别在于一级缓存只针对一次SqlSession,二级缓存针对全局范围。
|
||||
|
||||
::: tip
|
||||
一级缓存作用于service里面的同一个方法(一个方法就是一个SqlSession),多次查询时,直接返回缓存数据,如果是同一个service里面的不同方法,就是多个SqlSession,一级缓存会失效。
|
||||
二级缓存作用于同一个mapper,即使是不同的service的不同方法,只要是同一个mapper,都可以使用二级缓存。
|
||||
:::
|
||||
|
||||
## 4.6 动态SQL
|
||||
+ if :是/否
|
||||
@@ -456,3 +493,70 @@ public class BlobToBase64TypeHandler extends BaseTypeHandler<String> {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  例如自定义一个处理敏感字段(如密码、手机号)的自动加解密:
|
||||
```java
|
||||
public class EncryptTypeHandler extends BaseTypeHandler<String> {
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
|
||||
ps.setString(i, AESUtil.encrypt(parameter)); // 自定义加密方法
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||
String encrypted = rs.getString(columnName);
|
||||
return AESUtil.decrypt(encrypted); // 自定义解密方法
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  然后在实体类字段上加上 @TableField(typeHandler = EncryptTypeHandler.class):
|
||||
```java
|
||||
@TableName("user")
|
||||
public class User {
|
||||
private Long id;
|
||||
private String name;
|
||||
|
||||
// 这个字段在数据库里存的是加密后的字符串
|
||||
@TableField(typeHandler = EncryptTypeHandler.class)
|
||||
private String phone; // 例如 "13800138000" → 存为 "U2FsdGVkX1+oO1W..."
|
||||
|
||||
@TableField(typeHandler = EncryptTypeHandler.class)
|
||||
private String idCard; // 身份证号
|
||||
|
||||
// 普通字段,不加密
|
||||
private String email;
|
||||
}
|
||||
```
|
||||
|
||||
  如果是在XML文件中查询,需要指定TypeHandler:
|
||||
```xml
|
||||
<resultMap id="UserResultMap" type="com.example.entity.User">
|
||||
<id column="id" property="id" />
|
||||
<result column="name" property="name" />
|
||||
<!-- 关键:phone 字段使用 EncryptTypeHandler -->
|
||||
<result column="phone" property="phone" typeHandler="com.example.handler.EncryptTypeHandler"/>
|
||||
<!-- idCard 字段也用同一个处理器 -->
|
||||
<result column="id_card" property="idCard" typeHandler="com.example.handler.EncryptTypeHandler"/>
|
||||
<result column="email" property="email" />
|
||||
</resultMap>
|
||||
```
|
||||
|
||||
  MyBatisPlus中定义了一些常用的类型处理器,例如:JacksonTypeHandler:
|
||||
```java
|
||||
@TableField(value = "fault_type", typeHandler = JacksonTypeHandler.class)
|
||||
```
|
||||
|
||||
  在XML文件中,将typeHandler设置为`com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler`。
|
||||
|
||||
# 六、MyBatis/MyBatis Plus常见问题
|
||||
1. 更新值为null的字段时会失效
|
||||
|
||||
  需要更改字段的更新策略:
|
||||
```java
|
||||
@TableField(value = "file_name", updateStrategy = FieldStrategy.ALWAYS)
|
||||
```
|
||||
|
||||
2. 自动填充出现失效
|
||||
|
||||
  strictInsert/UpdateFill的默认填充策略是:如果实体类中需要自动填充的字段已经有值了,那么当前值就不会进行填充,或者你想给一个字段填充null值也是不可以的。可以替换为setFieldValByName,将其直接覆盖。(一般是更新的时候需要替换,新增默认就是没有值的)
|
||||
41
docs/Web/MySQL/SQL-Advance.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: SQL高阶用法
|
||||
date: 2025-12-21
|
||||
---
|
||||
|
||||
# 一、WITH
|
||||
## 1.1 定义
|
||||
  SQL 中的WITH子句也被称为**公用表表达式**(CTE,Common Table Expression),它的作用是**在执行主查询之前,先定义一个临时的结果集,这个结果集可以在后续的查询中被多次引用**,就像一个临时表一样。它能让复杂的 SQL 查询变得更清晰、更易读,还能简化嵌套查询的逻辑。
|
||||
|
||||
## 1.2 使用
|
||||
```sql
|
||||
-- 定义第一个CTE:数学高分学生
|
||||
WITH math_high_score AS (
|
||||
SELECT name, score FROM student_score WHERE subject = '数学' AND score > 85
|
||||
),
|
||||
-- 定义第二个CTE:语文高分学生
|
||||
chinese_high_score AS (
|
||||
SELECT name, score FROM student_score WHERE subject = '语文' AND score > 85
|
||||
)
|
||||
-- 主查询:查询既在数学高分又在语文高分的学生
|
||||
SELECT m.name
|
||||
FROM math_high_score m
|
||||
JOIN chinese_high_score c ON m.name = c.name;
|
||||
```
|
||||
|
||||
  递归 CTE:
|
||||
```sql
|
||||
WITH recursive dept_hierarchy AS (
|
||||
-- 锚点成员:查询顶级部门(parent_id为NULL)
|
||||
SELECT dept_id, dept_name, parent_id, 1 AS level
|
||||
FROM department
|
||||
WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
-- 递归成员:查询子部门,关联自身的dept_id和parent_id
|
||||
SELECT d.dept_id, d.dept_name, d.parent_id, dh.level + 1 AS level
|
||||
FROM department d
|
||||
JOIN dept_hierarchy dh ON d.parent_id = dh.dept_id
|
||||
)
|
||||
-- 主查询:获取所有部门的层级
|
||||
SELECT * FROM dept_hierarchy;
|
||||
```
|
||||
@@ -3,10 +3,9 @@
|
||||
date: 2025-11-26
|
||||
---
|
||||
|
||||
# MySQL知识点
|
||||
## 一、基础知识
|
||||
### 1.1 数据类型
|
||||
#### 1.1.1 汇总
|
||||
# 一、基础知识
|
||||
## 1.1 数据类型
|
||||
### 1.1.1 汇总
|
||||
|类型|存储空间|范围|适用场景|
|
||||
| :-------------------------------------------------------------------------------------------------: | :-----------------: | :--------------------------: | :-----------------------: |
|
||||
|<span data-type="text" style="background-color: var(--b3-card-error-background); color: var(--b3-card-error-color);">TINYINT</span>|1字节|-128 \~ 127|状态码、年龄、布尔值|
|
||||
@@ -34,15 +33,15 @@
|
||||
|MEDIUMBLOB|16MB||中等二进制对象|
|
||||
|LONGBLOB|4GB||超大二进制对象|
|
||||
|
||||
#### 1.1.2 法则
|
||||
### 1.1.2 法则
|
||||
  **整数选择**:优先INT,大数量用BIGINT,布尔值用TINYINT(1)或BIT(1)
|
||||
  **小数选择**:金融金额必须用DECIMAL,非精确测量可用FLOAT/DOUBLE
|
||||
  **字符串选择**:定长编码用CHAR,变长文本用VARCHAR,大文本用TEXT系列
|
||||
  **时间选择**:日期用DATE,精确时间用DATETIME,自动更新用TIMESTAMP
|
||||
  **特殊场景**:多选项用SET,结构化数据用JSON
|
||||
|
||||
### 1.2 常用函数
|
||||
#### 1.2.1 字符串函数
|
||||
## 1.2 常用函数
|
||||
### 1.2.1 字符串函数
|
||||
1. CONCAT(str1, str2, ...):连接字符串
|
||||
```sql
|
||||
SELECT CONCAT('Hello', ' ', 'World');
|
||||
@@ -56,7 +55,7 @@ SELECT LENGTH('Hello');
|
||||
SELECT SUBSTRING('Hello World', 7, 5); -- 返回 'World'
|
||||
```
|
||||
|
||||
#### 1.2.2 日期/时间函数
|
||||
### 1.2.2 日期/时间函数
|
||||
1. NOW():返回当前日期和时间(yyyy-MM-DD HH:mm:ss)。
|
||||
2. CURDATE():返回当前日期,不带时间部分(yyyy-MM-DD)。
|
||||
3. DATE_FORMAT(date, format):按照指定的格式返回日期值。
|
||||
@@ -76,7 +75,7 @@ SELECT DATEDIFF('2024-12-31', '2024-01-01'); -- 返回364
|
||||
SELECT STR_TO_DATE('01-09-2024', '%d-%m-%Y'); -- 返回 '2024-09-01'
|
||||
```
|
||||
|
||||
#### 1.2.3 数值函数
|
||||
### 1.2.3 数值函数
|
||||
1. ROUND(x, d):将数值 x 四舍五入到 d 位小数。
|
||||
```sql
|
||||
SELECT ROUND(123.4567, 2); -- 返回 123.46
|
||||
@@ -87,13 +86,13 @@ SELECT FLOOR(2.9); -- 返回 2
|
||||
SELECT CEIL(2.1); -- 返回 3
|
||||
```
|
||||
|
||||
#### 1.2.4 聚合函数
|
||||
### 1.2.4 聚合函数
|
||||
1. COUNT(expression):返回某列中的记录数。
|
||||
2. SUM(expression):返回某列中数值的总和。
|
||||
3. AVG(expression):返回某列中数值的平均值。
|
||||
4. MAX(expression) / MIN(expression):返回某列的最大值或最小值。
|
||||
|
||||
#### 1.2.5 控制流函数
|
||||
### 1.2.5 控制流函数
|
||||
1. IF(condition, true_value, false_value):如果 condition 为真,返回 true_value,否则返回 false_value。
|
||||
```sql
|
||||
SELECT IF(1 > 0, 'Yes', 'No'); -- 返回 'Yes'
|
||||
@@ -109,11 +108,11 @@ SELECT
|
||||
FROM employees;
|
||||
```
|
||||
|
||||
## 二、索引
|
||||
# 二、索引
|
||||
  索引是对数据库表中的一列或多列值进行**排序**的一种结构,使用索引可以快速访问数据库表中的特定信息。
|
||||
  **索引相当于图书上的目录,可以根据目录上的页码快速找到所需的内容,提高性能(查询速度)**
|
||||
  **索引相当于图书上的目录,可以根据目录上的页码快速找到所需的内容,提高性能(查询速度),相当于用空间换时间。**
|
||||
|
||||
### 2.1 优缺点
|
||||
## 2.1 优缺点
|
||||
优点:
|
||||
* **查询速度起飞 (主要目的)** :通过索引,数据库可以**大幅减少需要扫描的数据量**,直接定位到符合条件的记录,从而显著加快数据检索速度,减少磁盘 I/O 次数。
|
||||
* **保证数据唯一性**:通过创建**唯一索引 (Unique Index)** ,可以确保表中的某一列(或几列组合)的值是独一无二的,比如用户ID、邮箱等。**主键本身就是一种唯一索引**。
|
||||
@@ -124,7 +123,7 @@ FROM employees;
|
||||
* **占用存储空间**:索引本质上也是一种数据结构,需要以物理文件(或内存结构)的形式存储,因此会**额外占用一定的磁盘空间**。索引越多、越大,占用的空间也就越多。
|
||||
* **可能被误用或失效**:如果索引设计不当,或者查询语句写得不好,数据库优化器可能不会选择使用索引(或者选错索引),反而导致性能下降。
|
||||
|
||||
### 2.2 适用场景
|
||||
## 2.2 适用场景
|
||||
适用场景
|
||||
* **频繁作为查询条件的字段**应该创建索引
|
||||
* **查询中排序的字段**创建索引将大大提高排序的速度(索引就是排序加快速查找)
|
||||
@@ -135,7 +134,7 @@ FROM employees;
|
||||
* **表记录太少,不需要创建索引**;
|
||||
* **数据重复且分布平均的字段**,因此为经常查询的和经常排序的字段建立索引。注意某些数据包含大量重复数据,因此他建立索引就没有太大的效果,例如性别字段,只有男女,不适合建立索引。
|
||||
|
||||
### 2.3 数据结构
|
||||
## 2.3 数据结构
|
||||
  在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构。
|
||||

|
||||
  例如要查找id=75的用户:`SELECT * FROM users WHERE id = 75`
|
||||
@@ -152,7 +151,7 @@ FROM employees;
|
||||
|叶子节点连接|支持范围查询|ORDER BY、分页查询快|
|
||||
|只在叶子存数据|内部节点小|更多索引数据放入内存|
|
||||
|
||||
### 2.4 索引类型
|
||||
## 2.4 索引类型
|
||||
1. 主键索引
|
||||
主键索引是最特殊的索引。
|
||||
```sql
|
||||
@@ -178,7 +177,7 @@ CREATE INDEX idx_name ON users(name);
|
||||
SELECT * FROM users WHERE name = "张三";
|
||||
```
|
||||
|
||||
4. 复合索引
|
||||
4. 联合索引
|
||||
多个字段组合的索引
|
||||
```sql
|
||||
CREATE INDEX idx_name_age_city ON users(name, age, city);
|
||||
@@ -189,10 +188,22 @@ SELECT * FROM users WHERE name = '张三' AND age = 25 AND city = '北京';
|
||||
SELECT * FROM users WHERE age = 25; -- 无法使用索引
|
||||
SELECT * FROM users WHERE city = '北京'; -- 无法使用索引
|
||||
```
|
||||
  复合索引的使用最左前缀原则。
|
||||
  联合索引的使用最左前缀原则。最左前缀原则指的是查询从索引的最左列开始,不能跳过索引中的列。比如有A、B、C这3列作为索引,则查询语句中必须是A、AB、AC、ABC,不能是BC这样的跳过了A,SQL语句中字段的位置可以任意,但一定要包含,比如CBA顺序也可以。
|
||||
  联合索引中,出现范围查询(<, >),范围查询右侧的列索引失效。可以用>=或者<=来规避索引失效问题。
|
||||
  `CREATE INDEX idx_name_age_city ON users(name, age, city);`相当于创建了3个索引:`users(name),users(name, age),users(name, age, city)`。
|
||||
|
||||
### 2.5 索引设计
|
||||
::: tip
|
||||
在 InnoDB 存储引擎中,根据索引的存储形式,又可以分为以下两种:
|
||||
| 分类 | 含义 | 特点 |
|
||||
|---|---|---|
|
||||
| 聚集索引 | 将数据存储与索引放在一起,索引结构的叶子节点保存了行数据 | 必须有,而且只有一个 |
|
||||
| 二级索引 | 将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键值 | 可以存在多个 |
|
||||
|
||||
  聚集索引就是主键索引,其他的都是二级索引,不能存放所有的数据。
|
||||
  比如查询`select * from user where name = 'Arm';`时,虽然使用了索引,但是只能查到`Arm`对应的`user_id`主键值,还需要根据主键值去查找完整的行记录。
|
||||
:::
|
||||
|
||||
## 2.5 索引设计
|
||||
* 为WHERE条件添加索引
|
||||
```sql
|
||||
SELECT * FROM orders WHERE user_id = 123;
|
||||
@@ -223,7 +234,7 @@ CREATE INDEX idx_city_age_create_time ON users(city, age, create_time);
|
||||
* 被频繁更新的字段应该慎重建立索引,虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。
|
||||
* 尽可能的考虑建立联合索引而不是单列索引。因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+ 树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。
|
||||
|
||||
### 2.6 索引优化
|
||||
## 2.6 索引优化
|
||||
1. 监控慢查询
|
||||
```sql
|
||||
-- 开启慢查询日志
|
||||
@@ -243,7 +254,7 @@ CREATE INDEX idx_title_prefix ON articles(title(20)); -- 只索引前20个字
|
||||
## 三、事务
|
||||
  事务是数据库操作的基本单位,它是一组原子性的 SQL 语句,或者说是一个独立的工作单元。事务内的所有操作**要么全部成功,要么全部失败**。
|
||||
|
||||
### 3.1 特性
|
||||
## 3.1 特性
|
||||
* **原子性**(`Atomicity`):原子性确保事务中的所有操作要么全部完成,要么全部不完成。如果事务执行过程中发生错误,所有已执行的操作都会回滚。
|
||||
```sql
|
||||
START TRANSACTION;
|
||||
@@ -286,13 +297,13 @@ COMMIT;
|
||||
-- 提交后,数据已经持久化到磁盘
|
||||
```
|
||||
|
||||
### 3.2 隔离级别
|
||||
#### 3.2.1 概念
|
||||
## 3.2 隔离级别
|
||||
### 3.2.1 概念
|
||||
* **脏读 (Dirty Read):** 事务A读取了事务B**尚未提交**的修改数据。如果事务B最终回滚,事务A读到的就是无效的"脏"数据。
|
||||
* **不可重复读 (Non-Repeatable Read):** 在同一个事务A中,**两次读取同一行数据**,得到的结果不同。这是因为在两次读取之间,该行数据被另一个提交了的事务B**修改**了。
|
||||
* **幻读 (Phantom Read):** 在同一个事务A中,**两次执行相同的查询**(通常是范围查询 `SELECT ... WHERE ...`),得到的结果集行数不同(出现了新的"幻影"行或原有行消失了)。这是因为在两次查询之间,另一个提交了的事务B**插入**了满足查询条件的新行或**删除**了原有的行。
|
||||
|
||||
#### 3.2.2 类型
|
||||
### 3.2.2 类型
|
||||
1. 读未提交 (READ UNCOMMITTED)
|
||||
* 特点: 这是最低的隔离级别,允许读取尚未提交的数据变更。
|
||||
* 允许的问题:
|
||||
@@ -333,7 +344,7 @@ COMMIT;
|
||||
* 数据一致性: **最好**。完全保证事务的隔离性,不会出现任何并发问题。
|
||||
* 使用场景: 仅在对数据一致性要求极高,且完全不能接受任何并发副作用(如金融核心系统的某些极端操作),并且能承受极低并发性能的情况下使用。**实践中很少使用。**
|
||||
|
||||
### 3.3 MySQL锁
|
||||
## 3.3 MySQL锁
|
||||
* **表级锁:** MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。不过,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁。
|
||||
* **行级锁:** MySQL 中锁定粒度最小的一种锁,是 **针对索引字段加的锁** ,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的。
|
||||
* **共享锁(S 锁)** :又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
|
||||
@@ -348,35 +359,36 @@ COMMIT;
|
||||
|UPDATE|排他锁(X)|自动获取|
|
||||
|DELETE|排他锁(X)|自动获取|
|
||||
|
||||
## 四、日志
|
||||
# 四、日志
|
||||
  MySQL 的三大核心日志系统是保证数据一致性、实现故障恢复和提供复制功能的关键组件。这三大日志分别是:二进制日志(binlog)、错误日志(error log) 和 重做日志(redo log)。
|
||||
### 4.1 二进制日志(Binary Log,简称 binlog)
|
||||
#### 4.1.1 概念
|
||||
## 4.1 二进制日志(Binary Log,简称 binlog)
|
||||
### 4.1.1 概念
|
||||
  二进制日志是 MySQL 记录所有修改数据或可能修改数据的语句(或数据变更)的日志文件。它记录了数据库执行的更改操作(如 INSERT、UPDATE、DELETE 等 DML 操作,以及 CREATE、ALTER、DROP 等 DDL 操作),但**不记录 SELECT 这类不修改数据的查询操作**。
|
||||
|
||||
#### 4.1.2 作用
|
||||
### 4.1.2 作用
|
||||
1. 主从复制(Replication):在主从架构中,主库将 binlog 发送给从库,从库通过读取并重放 binlog 来保持与主库的数据同步。
|
||||
2. 数据恢复(Point-in-Time Recovery):通过备份 + binlog 可以恢复到某个具体时间点。
|
||||
3. 审计:可以追踪数据库的所有变更操作。
|
||||
|
||||
### 4.2 重做日志(Redo Log)
|
||||
#### 4.2.1 概念
|
||||
## 4.2 重做日志(Redo Log)
|
||||
### 4.2.1 概念
|
||||
  重做日志是 InnoDB 存储引擎特有的日志,它记录的是 “物理级别” 上的页修改信息,主要用于 崩溃恢复(Crash Recovery)。
|
||||
  InnoDB 使用 WAL(Write-Ahead Logging,预写日志)技术,即 事务提交时,先写 redo log,再写磁盘数据页,从而保证即使系统崩溃,也能通过 redo log 恢复数据。
|
||||
|
||||
#### 4.2.2 作用
|
||||
### 4.2.2 作用
|
||||
1. 实现事务的持久性(Durability):确保事务提交后,即使发生宕机,数据也不会丢失。
|
||||
2. 提高写入性能:数据不是每次修改都直接写磁盘,而是先写 redo log(顺序写,速度快),后续再异步刷盘。
|
||||
3. 支持 crash-safe:MySQL 宕机重启后,可通过 redo log 恢复未刷盘的数据。
|
||||
|
||||
### 4.3 回滚日志(Undo Log)
|
||||
#### 4.3.1 概念
|
||||
## 4.3 回滚日志(Undo Log)
|
||||
### 4.3.1 概念
|
||||
  回滚日志也是 InnoDB 引擎特有 的日志,它记录的是 数据被修改前的原始值
|
||||
|
||||
#### 4.3.2 作用
|
||||
### 4.3.2 作用
|
||||
1. 支持事务回滚:如果事务执行失败或调用了 ROLLBACK,可以通过 undo log 将数据恢复到修改之前的状态。
|
||||
2. 实现 MVCC(多版本并发控制):在读已提交(RC)、可重复读(RR)隔离级别下,undo log 用于提供历史版本数据,使得不同事务能看到不同的数据快照,而不需要加锁。
|
||||
### 4.4 和事务、锁之间的关系
|
||||
|
||||
## 4.4 和事务、锁之间的关系
|
||||
  例如,当执行一条SQL语句时,事务、锁、日志等之间的关系:
|
||||
1. 发起一个事务
|
||||
2. 执行一系列的DML操作(如 INSERT/UPDATE/DELETE)
|
||||
@@ -389,7 +401,7 @@ COMMIT;
|
||||
  **undo log:用于实现事务回滚、MVCC。**
|
||||
4. **背后有日志系统默默记录一切,锁系统保障并发安全,隔离级别定义了“你能看到啥”。**
|
||||
|
||||
#### 4.4.1 相互关系
|
||||
### 4.4.1 相互关系
|
||||
1. 事务是锁的使用者,锁是事务实现隔离性的手段。当一个事务对某行数据执行 UPDATE/DELETE 操作时,为了防止其他事务同时修改相同数据,InnoDB 会自动对该行或索引加 排他锁(X锁)。如果事务只是读取数据,根据隔离级别,可能会加 共享锁(S锁) 或使用 MVCC(不加锁)。
|
||||
2. 隔离级别定义了事务之间的可见性规则,是事务“隔离性”的具体体现。事务的隔离性是通过锁 + MVCC(多版本并发控制,依赖 undo log)+ 隔离级别共同实现的。
|
||||
3. 日志是事务实现 持久性、崩溃恢复、回滚 等能力的基石。redo log(重做日志)保障事务的持久性(Durability),事务提交时,先将数据页的变更记录到 redo log(顺序写,高性能),随后再异步刷盘到磁盘数据页。即使系统崩溃,重启后也能通过 redo log 恢复已提交但尚未刷盘的数据。undo log(回滚日志)支持事务回滚 和 MVCC。事务修改数据前,会先把原始数据保存到 undo log,如果事务回滚,可以用它恢复旧值。同时,undo log 也是 MVCC(多版本控制)实现的基础,用于提供历史版本数据。binlog(二进制日志)用于主从复制、时间点恢复。虽然 binlog 是 Server 层的日志,不属于 InnoDB 事务引擎的一部分,但在事务提交时,binlog 与 redo log 通过两阶段提交(2PC)保证一致性。
|
||||
@@ -397,14 +409,42 @@ COMMIT;
|
||||
5. 锁保障并发安全,日志保障操作可恢复。两者从不同维度确保数据库的正确性。锁:是在运行时控制谁可以访问哪些数据,是 并发控制 的手段。日志:是在磁盘上记录操作过程,是 故障恢复 & 事务一致性 的手段。
|
||||
6. 隔离级别定义了事务间数据的可见性,而日志(尤其是 undo log 和 binlog)为这种“可见性”提供了实现基础。undo log 是实现 MVCC(多版本并发控制) 的基础,而 MVCC 是 REPEATABLE READ 等隔离级别的关键。binlog 虽不直接影响隔离性,但它记录了事务的变更历史,是构建主从环境、实现数据恢复的基础。
|
||||
|
||||
## 五、优化
|
||||
### 5.1 Explain 执行计划
|
||||
  `EXPLAIN`是 MySQL 自带的一个诊断工具,它可以模拟 MySQL 查询优化器的执行过程,对`SELECT`语句(在 MySQL 8.0 及以上版本,也支持对`UPDATE`、`DELETE`等语句使用)进行分析,并输出该语句的执行计划。
|
||||
#### 5.1.1 基本用法
|
||||
  在select语句前面加上EXPLAIN关键字即可,例如:`EXPLAIN SELECT * FROM users WHERE age > 25;
|
||||
`
|
||||
# 五、优化
|
||||
## 5.1 开启慢查询
|
||||
  查看慢查询是否开启:
|
||||
```bash
|
||||
SHOW VARIABLES LIKE 'slow_query_log';
|
||||
SHOW VARIABLES LIKE 'slow_query_log_file';
|
||||
```
|
||||
|
||||
#### 5.1.2 输出列说明
|
||||
  临时开启慢查询,重启服务后无效:
|
||||
```bash
|
||||
SET GLOBAL slow_query_log = ON;
|
||||
SET GLOBAL long_query_time = 1;
|
||||
```
|
||||
|
||||
  永久开启慢查询:
|
||||
```bash
|
||||
[mysqld]
|
||||
slow_query_log = ON
|
||||
long_query_time = 1
|
||||
```
|
||||
|
||||
  开启后可以通过查看`SHOW VARIABLES LIKE 'slow_query_log_file'`查看记录的文件路径。
|
||||
|
||||
## 5.2 查看SQL运行时间
|
||||
```bash
|
||||
SET profiling = 1;
|
||||
SELECT name, age FROM users WHERE name = "user235909051";
|
||||
SHOW PROFILES;
|
||||
```
|
||||
|
||||
## 5.3 Explain 执行计划
|
||||
  `EXPLAIN`是 MySQL 自带的一个诊断工具,它可以模拟 MySQL 查询优化器的执行过程,对`SELECT`语句(在 MySQL 8.0 及以上版本,也支持对`UPDATE`、`DELETE`等语句使用)进行分析,并输出该语句的执行计划。
|
||||
### 5.3.1 基本用法
|
||||
  在select语句前面加上EXPLAIN关键字即可,例如:`EXPLAIN SELECT * FROM users WHERE age > 25;`
|
||||
|
||||
### 5.3.2 输出列说明
|
||||
| 列名 | 说明 | 示例 |
|
||||
| :-: | :-: | :-: |
|
||||
| id | 查询标识符 | 相同 id 表示同组查询,执行顺序从上到下;不同 id 值越大优先级越高 |
|
||||
@@ -420,7 +460,7 @@ COMMIT;
|
||||
| filtered | 存储引擎返回数据后在 server 层过滤的比例 | |
|
||||
| **Extra** | 额外执行信息 | |
|
||||
|
||||
#### 5.1.3 重要指标:Type
|
||||
### 5.3.3 重要指标:Type
|
||||
| 类型 | 描述 | 性能 | 示例 |
|
||||
| - | - | - | - |
|
||||
| system | 系统表,仅一行 | 最优 | MyISAM 引擎的空表 |
|
||||
@@ -436,7 +476,7 @@ COMMIT;
|
||||
| index | 全索引扫描 | 差 | SELECT indexed_col FROM table |
|
||||
| ALL | 全表扫描 | 最差 | 无索引查询|
|
||||
|
||||
#### 5.1.4 重要指标:Extra
|
||||
### 5.3.4 重要指标:Extra
|
||||
| 值 | 含义 | 优化建议 |
|
||||
| - | - | - |
|
||||
| Using index | 覆盖索引(无需回表) | 优,保持 |
|
||||
@@ -449,13 +489,72 @@ COMMIT;
|
||||
| Impossible WHERE | WHERE 条件永不成立 | 查询逻辑错误 |
|
||||
| Distinct | 优化 DISTINCT 操作 | |
|
||||
|
||||
## 六、执行过程
|
||||
# 六、执行过程
|
||||

|
||||
  MySQL架构分为两层,Service层和存储引擎层。
|
||||
  MySQL架构分为两层,Service层和存储引擎层。
|
||||
  Server 层负责建立连接、分析和执行 SQL。存储引擎层负责数据的存储和提取。
|
||||
1. 连接器:建立连接、管理连接、校验用户身份。
|
||||
2. 查询缓存(MySQL8.0已删除)
|
||||
3. 解析SQL,通过解析器对SQL查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段和语句类型等。
|
||||
4. 执行SQL:分为预处理阶段、优化阶段和执行阶段。
|
||||
|
||||
# 七、视图
|
||||
## 7.1 定义
|
||||
  视图(View)是 MySQL 中的一种虚拟表,它的数据来源于一个或多个实际的表(基表),**其结构和数据是通过 SQL 查询语句定义的**。简单来说,视图就像是一个 “查询窗口”,你看到的是基表的数据,但**视图本身并不存储实际数据,每次访问视图时都会执行对应的查询语句**。
|
||||
|
||||
## 7.2 作用
|
||||
1. 简化复杂查询:将多表关联、聚合等复杂的 SQL 逻辑封装到视图中,后续使用时只需查询视图即可。
|
||||
2. 数据安全:可以只暴露基表中的部分列或部分行给用户,隐藏敏感数据。
|
||||
3. 数据一致性:如果业务逻辑发生变化,只需修改视图的定义,而无需修改所有使用该逻辑的查询。
|
||||
|
||||
## 7.3 使用
|
||||
  创建视图
|
||||
```sql
|
||||
-- 创建视图v_student_basic
|
||||
CREATE VIEW v_student_basic
|
||||
AS
|
||||
SELECT name, age, gender FROM student;
|
||||
```
|
||||
|
||||
  使用视图和正常使用数据表一致:
|
||||
```sql
|
||||
-- 查询视图(和查询普通表的方式完全一样)
|
||||
SELECT * FROM v_student_basic;
|
||||
```
|
||||
|
||||
# 八、事件
|
||||
## 8.1 定义
|
||||
  MySQL 事件(Event)也被称为事件调度器(Event Scheduler),是 `MySQL 中一种定时执行的数据库对象`,可以理解为数据库层面的 “定时任务” 或 “计划任务”。它能根据你设定的时间规则(一次性执行、周期性执行),自动触发并执行指定的 SQL 逻辑(如数据清理、统计报表生成、数据同步等)。
|
||||
|
||||
## 8.2 特点
|
||||
1. 定时执行:支持一次性执行(如某个具体时间点)和周期性执行(如每天凌晨 3 点、每小时执行一次)。
|
||||
2. 自动运行:依赖 MySQL 的事件调度器线程,只要调度器开启,事件就会按规则自动触发。
|
||||
3. 与存储过程结合:事件的执行逻辑可以是单条 SQL,也可以是复杂的存储过程(推荐用存储过程封装复杂逻辑)。
|
||||
|
||||
## 8.3 作用
|
||||
- 数据清理:定期删除过期数据(如删除 30 天前的日志表数据)。
|
||||
- 数据统计:定时生成业务统计报表(如每天凌晨统计前一天的订单数据)。
|
||||
- 数据同步:定期将 A 表的数据同步到 B 表。
|
||||
- 定时备份:定期执行数据库备份脚本(配合存储过程)。
|
||||
|
||||
## 8.4 使用
|
||||
  开启事件调度器:
|
||||
```sql
|
||||
-- 查看事件调度器状态(ON表示开启,OFF表示关闭)
|
||||
SHOW VARIABLES LIKE 'event_scheduler';
|
||||
|
||||
-- 临时开启(MySQL重启后会恢复为默认状态)
|
||||
SET GLOBAL event_scheduler = ON;
|
||||
-- 永久开启(需要修改my.cnf/my.ini配置文件,添加以下内容,然后重启MySQL)
|
||||
event_scheduler = ON # 放在[mysqld]节点下
|
||||
```
|
||||
|
||||
  每天凌晨 1 点执行的事件,删除日志表中 7 天前的过期数据:
|
||||
```sql
|
||||
-- 创建周期性事件,每天凌晨1点执行,无结束时间
|
||||
CREATE EVENT IF NOT EXISTS event_clear_log
|
||||
ON SCHEDULE EVERY 1 DAY STARTS '2025-12-22 01:00:00'
|
||||
COMMENT '每天凌晨1点删除7天前的日志'
|
||||
DO
|
||||
DELETE FROM log WHERE create_time < DATE_SUB(NOW(), INTERVAL 7 DAY);
|
||||
```
|
||||
56
docs/Web/Network/Mqtt.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Mqtt简介与使用
|
||||
date: 2026-05-27
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  MQTT(Message Queuing Telemetry Transport) 是一种 轻量级物联网通信协议,基于 发布 / 订阅(Pub/Sub) 模式。
|
||||
|
||||
## 1.1 核心概念
|
||||
1. Broker:消息服务器
|
||||
2. Client:发布或订阅消息的设备 / 程序
|
||||
3. Topic: 消息主题(路径形式)
|
||||
4. Publish:发布消息
|
||||
5. Subscribe:订阅消息
|
||||
6. QoS:服务质量等级(0 / 1 / 2)
|
||||
|
||||
## 1.2 工作方式
|
||||
```text
|
||||
[设备A] --发布--> TopicA --> [Broker] --> TopicA --> [设备B]
|
||||
```
|
||||
|
||||
# 二、安装
|
||||
  [EMQX](https://www.emqx.com/zh) Enterprise 是基于开源 EMQX 扩展的企业级 MQTT 物联网消息接入平台,完全兼容 MQTT v3.1.1 / v5.0。
|
||||
|
||||
## 2.1 docker安装
|
||||
```yml
|
||||
services:
|
||||
emqx:
|
||||
image: emqx/emqx-enterprise:5.10.0
|
||||
container_name: emqx
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "8083:8083"
|
||||
- "8084:8084"
|
||||
- "8883:8883"
|
||||
- "18083:18083"
|
||||
restart: always
|
||||
```
|
||||
|
||||
  启动容器后,输入ip:18083,默认账号密码为admin/public。
|
||||
|
||||
# 三、消息规范
|
||||
## 3.1 Topic
|
||||
```text
|
||||
<domain>/<product>/<device_id>/<msg_type>/<resource>
|
||||
```
|
||||
|
||||
  例如`iot/thermostat/dev001/status/online`、`iot/thermostat/dev001/cmd/set_mode`。
|
||||
|
||||
## 通配符
|
||||
1. +:单层通配符,例如:iot/+/dev001/telemetry/+
|
||||
2. #:多层通配符,只能放在topic末尾,例如:iot/thermostat/dev001/#
|
||||
|
||||
::: tip
|
||||
使用+通配符时,必须严格满足层级要求,例如,如果topic是5层,那么iot/+将无法匹配到消息
|
||||
:::
|
||||
392
docs/Web/Network/NetCore.md
Normal file
@@ -0,0 +1,392 @@
|
||||
---
|
||||
title: .Net Core实现
|
||||
date: 2026-05-27
|
||||
---
|
||||
|
||||
# 一、.Net Core实现
|
||||
## 1.1 SuperSocket
|
||||
  [SuperSocket](https://www.supersocket.net/)是一个轻量级, 跨平台而且可扩展的 .Net/Mono Socket 服务器程序框架。可以轻松构建TCP、UDP、WebSocket服务器。
|
||||
|
||||
## 1.2 安装依赖
|
||||
  NuGut安装SuperSocket、SuperSocket.WebSocket和SuperSocket.WebSocket.Server 2.0及以上版本。
|
||||
|
||||
## 1.3 配置文件
|
||||
  appsettings.json
|
||||
```json
|
||||
{
|
||||
"serverOptions": {
|
||||
"TcpServer": {
|
||||
"name": "TcpServer",
|
||||
"listeners": [
|
||||
{
|
||||
"ip": "Any",
|
||||
"port": 4040
|
||||
}
|
||||
]
|
||||
},
|
||||
"WebSocketServer": {
|
||||
"name": "WebSocket",
|
||||
"listeners": [
|
||||
{
|
||||
"ip": "Any",
|
||||
"port": 5050
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Mqtt": {
|
||||
"Host": "127.0.0.1",
|
||||
"Port": 1883,
|
||||
"ClientId": "MqttClient",
|
||||
"Topics": [
|
||||
"test/topic1",
|
||||
"test/topic2"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 1.4 主程序
|
||||
  program.cs
|
||||
```cs
|
||||
var host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.AddSingleton<IPackageHandler<ProtocolFrame>, TcpPackageHandler>();
|
||||
services.AddSingleton<WebSocketMessageHandler>();
|
||||
services.AddSingleton<WebSocketConnectionManager>();
|
||||
|
||||
// 1. 注入 MQTT 配置
|
||||
services.Configure<MqttSettings>(context.Configuration.GetSection("Mqtt"));
|
||||
// 2. 注册 MQTT 客户端
|
||||
services.AddSingleton<IMqttClient>(serviceProvider => new MqttClientFactory().CreateMqttClient());
|
||||
// 3. 注册 MQTT 后台服务
|
||||
services.AddHostedService<MqttService>();
|
||||
})
|
||||
.AsMultipleServerHostBuilder()
|
||||
.AddServer<TcpService, ProtocolFrame, BinaryPipelineFilter>(builder =>
|
||||
{
|
||||
builder.ConfigureServerOptions((ctx, config) => config.GetSection("TcpServer"));
|
||||
})
|
||||
.AddWebSocketServer(builder =>
|
||||
{
|
||||
builder
|
||||
.UseSessionHandler(
|
||||
session =>
|
||||
{
|
||||
if (session is WebSocketSession wsSession)
|
||||
{
|
||||
var connectionManager = session.Server.ServiceProvider.GetRequiredService<WebSocketConnectionManager>();
|
||||
connectionManager.Add(wsSession);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
},
|
||||
(session, reason) =>
|
||||
{
|
||||
if (session is WebSocketSession wsSession)
|
||||
{
|
||||
var connectionManager = session.Server.ServiceProvider.GetRequiredService<WebSocketConnectionManager>();
|
||||
connectionManager.Remove(wsSession);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
)
|
||||
.UseWebSocketMessageHandler(async (session, package) =>
|
||||
{
|
||||
using var scope = session.Server.ServiceProvider.CreateScope();
|
||||
var handler = scope.ServiceProvider.GetRequiredService<WebSocketMessageHandler>();
|
||||
|
||||
await handler.HandleAsync(session, package);
|
||||
})
|
||||
.ConfigureServerOptions((ctx, config) => config.GetSection("WebSocketServer"));
|
||||
})
|
||||
.ConfigureLogging(logging => logging.ClearProviders())
|
||||
.UseNLog()
|
||||
.Build();
|
||||
|
||||
await host.RunAsync();
|
||||
```
|
||||
|
||||
  通过.AsMultipleServerHostBuilder()可以构造多服务器实例。
|
||||
|
||||
# 二、 TCP服务器
|
||||
## 2.1 协议数据
|
||||
```cs
|
||||
/// <summary>
|
||||
/// 协议数据结构 示例:65 6D 00 05 00 01 68 65 6C 6C 6F
|
||||
/// </summary>
|
||||
/// <param name="Magic">帧头标识</param>
|
||||
/// <param name="Length">数据长度</param>
|
||||
/// <param name="Type">数据类型</param>
|
||||
/// <param name="Payload">数据内容</param>
|
||||
///
|
||||
record ProtocolFrame(
|
||||
ushort Magic,
|
||||
ushort Length,
|
||||
ushort Type,
|
||||
byte[] Payload
|
||||
);
|
||||
```
|
||||
|
||||
  可以根据实际情况自定义消息格式。
|
||||
|
||||
## 2.2 协议解析
|
||||
```cs
|
||||
class BinaryPipelineFilter : FixedHeaderPipelineFilter<ProtocolFrame>
|
||||
{
|
||||
/// <summary>
|
||||
/// 固定头长度
|
||||
/// </summary>
|
||||
public BinaryPipelineFilter() : base(6)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从包头中解析出 Body 长度
|
||||
/// </summary>
|
||||
/// <param name="buffer">字节流</param>
|
||||
/// <returns>Body 长度</returns>
|
||||
protected override int GetBodyLengthFromHeader(ref ReadOnlySequence<byte> buffer)
|
||||
{
|
||||
var reader = new SequenceReader<byte>(buffer);
|
||||
|
||||
// 读取前2字节 → magic
|
||||
reader.TryReadBigEndian(out ushort magic);
|
||||
|
||||
// 再读2字节 → Length
|
||||
reader.TryReadBigEndian(out ushort length);
|
||||
|
||||
// 再读2字节 → Type
|
||||
reader.TryReadBigEndian(out ushort type);
|
||||
|
||||
// 校验帧头
|
||||
if (magic != 0x656D)
|
||||
{
|
||||
throw new Exception("非法帧头");
|
||||
}
|
||||
|
||||
// 限制长度
|
||||
if (length == 0 || length > 8192)
|
||||
{
|
||||
throw new Exception("非法长度");
|
||||
}
|
||||
|
||||
// 返回 Payload 长度
|
||||
return length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把完整字节包 → 转换成 ProtocolFrame
|
||||
/// </summary>
|
||||
/// <param name="buffer">字节流</param>
|
||||
/// <returns>ProtocolFrame</returns>
|
||||
protected override ProtocolFrame DecodePackage(ref ReadOnlySequence<byte> buffer)
|
||||
{
|
||||
var reader = new SequenceReader<byte>(buffer);
|
||||
|
||||
// 读取前2字节 → magic
|
||||
reader.TryReadBigEndian(out ushort magic);
|
||||
|
||||
// 再读2字节 → Length
|
||||
reader.TryReadBigEndian(out ushort length);
|
||||
|
||||
// 再读2字节 → Type
|
||||
reader.TryReadBigEndian(out ushort type);
|
||||
|
||||
var payload = buffer.Slice(6, length).ToArray();
|
||||
|
||||
// 构造ProtocolFrame
|
||||
return new ProtocolFrame(magic, length, type, payload);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  根据协议数据ProtocolFrame来解包。
|
||||
|
||||
## 2.3 消息处理
|
||||
```cs
|
||||
class TcpPackageHandler(ILogger<TcpPackageHandler> logger) : IPackageHandler<ProtocolFrame>
|
||||
{
|
||||
private readonly ILogger<TcpPackageHandler> _logger = logger;
|
||||
|
||||
public async ValueTask Handle(IAppSession session, ProtocolFrame package, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation($"Magic={package.Magic:X4}, Type={package.Type}, Len={package.Length}");
|
||||
await session.SendAsync(package.Payload, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2.4 TCPService
|
||||
```cs
|
||||
class TcpService(IServiceProvider serviceProvider, IOptions<ServerOptions> serverOptions, ILogger<TcpService> logger) : SuperSocketService<ProtocolFrame>(serviceProvider, serverOptions)
|
||||
{
|
||||
private readonly ILogger<TcpService> _logger = logger;
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _sessionDevices = new();
|
||||
|
||||
public void BindDevice(string sessionId, string deviceNum)
|
||||
{
|
||||
_sessionDevices.AddOrUpdate(sessionId, deviceNum, (k, v) => deviceNum);
|
||||
}
|
||||
|
||||
protected override async ValueTask OnSessionConnectedAsync(IAppSession session)
|
||||
{
|
||||
_logger.LogInformation("TCP Session 连上: {SessionID}", session.SessionID);
|
||||
await base.OnSessionConnectedAsync(session);
|
||||
}
|
||||
|
||||
protected override async ValueTask OnSessionClosedAsync(IAppSession session, CloseEventArgs e)
|
||||
{
|
||||
_logger.LogInformation("TCP Session 断开: {SessionID}, Reason={Reason}", session.SessionID, e.Reason);
|
||||
|
||||
if (_sessionDevices.TryRemove(session.SessionID, out var deviceNum))
|
||||
{
|
||||
_logger.LogInformation("设备 {DeviceNum} 标记离线", deviceNum);
|
||||
}
|
||||
|
||||
await base.OnSessionClosedAsync(session, e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 三、Websocket服务器
|
||||
## 3.1 消息处理
|
||||
```cs
|
||||
class WebSocketMessageHandler(ILogger<WebSocketMessageHandler> logger)
|
||||
{
|
||||
private readonly ILogger<WebSocketMessageHandler> _logger = logger;
|
||||
|
||||
public async ValueTask HandleAsync(WebSocketSession session, WebSocketPackage package)
|
||||
{
|
||||
_logger.LogInformation($"[WebSocket] {package.Message}");
|
||||
await session.SendAsync("ok");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.2 连接管理
|
||||
```cs
|
||||
public class WebSocketConnectionManager(ILogger<WebSocketConnectionManager> logger)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, WebSocketSession> _sessions = new();
|
||||
private readonly ILogger<WebSocketConnectionManager> _logger = logger;
|
||||
|
||||
public void Add(WebSocketSession session) => _sessions.TryAdd(session.SessionID, session);
|
||||
|
||||
public void Remove(WebSocketSession session) => _sessions.TryRemove(session.SessionID, out _);
|
||||
|
||||
public async Task BroadcastAsync(string json)
|
||||
{
|
||||
var payload = new ReadOnlyMemory<byte>(System.Text.Encoding.UTF8.GetBytes(json));
|
||||
|
||||
var tasks = _sessions.Values
|
||||
.Where(s => s.State == SessionState.Connected)
|
||||
.Select(async s =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await s.SendAsync(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Remove(s);
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 四、 MQTT服务器
|
||||
## 4.1 MqttService
|
||||
```cs
|
||||
class MqttService(IMqttClient mqttClient, IOptions<MqttSettings> settings, ILogger<TcpPackageHandler> logger) : BackgroundService
|
||||
{
|
||||
private readonly IMqttClient _mqttClient = mqttClient;
|
||||
private readonly MqttSettings _settings = settings.Value;
|
||||
private readonly ILogger<TcpPackageHandler> _logger = logger;
|
||||
private MqttClientOptions? _mqttOptions;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 构建连接配置
|
||||
_mqttOptions = new MqttClientOptionsBuilder()
|
||||
.WithClientId($"{_settings.ClientId}_{Guid.NewGuid():N}")
|
||||
.WithTcpServer(_settings.Host, _settings.Port)
|
||||
.WithCleanSession()
|
||||
.Build();
|
||||
|
||||
_mqttClient.ConnectedAsync += OnConnectedAsync;
|
||||
_mqttClient.DisconnectedAsync += OnDisconnectedAsync;
|
||||
_mqttClient.ApplicationMessageReceivedAsync += HandleMessage;
|
||||
|
||||
_logger.LogInformation("[MQTT] 正在连接到服务器 {Host}:{Port}...", _settings.Host, _settings.Port);
|
||||
|
||||
await _mqttClient.ConnectAsync(_mqttOptions, stoppingToken);
|
||||
|
||||
// 等待程序停止
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
public async Task OnConnectedAsync(MqttClientConnectedEventArgs arg)
|
||||
{
|
||||
_logger.LogInformation("[MQTT] 已成功连接到服务器");
|
||||
|
||||
// 连接成功后订阅主题
|
||||
foreach (var topic in _settings.Topics)
|
||||
{
|
||||
await _mqttClient.SubscribeAsync(topic, MqttQualityOfServiceLevel.AtLeastOnce);
|
||||
_logger.LogInformation($"[MQTT] 已订阅主题:{topic}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task OnDisconnectedAsync(MqttClientDisconnectedEventArgs arg)
|
||||
{
|
||||
_logger.LogError("[MQTT] 连接失败,原因:{Reason}", arg.Reason);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task HandleMessage(MqttApplicationMessageReceivedEventArgs arg)
|
||||
{
|
||||
var topic = arg.ApplicationMessage.Topic;
|
||||
var payload = Encoding.UTF8.GetString(arg.ApplicationMessage.Payload);
|
||||
|
||||
_logger.LogInformation($"\n[MQTT] 收到消息\n主题:{topic}\n内容:{payload}\n");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<bool> PublishAsync(string topic, string payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = new MqttApplicationMessageBuilder()
|
||||
.WithTopic(topic)
|
||||
.WithPayload(payload)
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
|
||||
.Build();
|
||||
|
||||
await _mqttClient.PublishAsync(message, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[MQTT] 发布消息到主题 {Topic} 失败", topic);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _mqttClient.DisconnectAsync();
|
||||
await base.StopAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  需要实现后台服务接口一直运行。
|
||||
@@ -433,45 +433,45 @@ class TcpMessageProcessor:
|
||||
:::
|
||||
|
||||
|
||||
# HTTP
|
||||
## 定义
|
||||
# 二、HTTP
|
||||
## 2.1 定义
|
||||
  HTTP(HyperText Transfer Protocol,超文本传输协议)是互联网的核心协议之一,用于**客户端(如浏览器、App)与服务器之间的通信**,是万维网(WWW)数据交换的基础。它定义了请求 / 响应的格式、传输规则和状态码等核心机制,支持从简单文本到复杂多媒体(图片、视频、文件)的传输,也是现代 Web 应用的底层通信标准。
|
||||
|
||||
## 特性
|
||||
### 请求 - 响应模式
|
||||
## 2.2 特性
|
||||
### 2.2.1 请求 - 响应模式
|
||||
  通信由客户端主动发起请求,服务器接收后处理并返回响应,不存在服务器主动向客户端推送数据的情况(HTTP/2 引入 Server Push 扩展,可主动推送关联资源)。
|
||||
  一次完整通信流程:**客户端建立连接 → 发送请求 → 服务器处理 → 返回响应 → 连接关闭**(HTTP/1.1 默认开启长连接 Keep-Alive)。
|
||||
|
||||
### 无状态
|
||||
### 2.2.2 无状态
|
||||
  **服务器不会保存客户端的会话状态**(如登录状态、浏览记录),每次请求都是独立的,服务器无法通过协议本身识别连续请求是否来自同一客户端。通过 Cookie、Session、Token(如 JWT)等机制补充状态管理。
|
||||
|
||||
## 版本
|
||||
### HTTP/1.0(1996 年)
|
||||
## 2.3 版本
|
||||
### 2.3.1 HTTP/1.0(1996 年)
|
||||
- 基础版本,支持 GET、POST、HEAD 三种请求方法。
|
||||
- 每次请求都需要建立新的 TCP 连接(短连接),连接建立和关闭的开销大,性能较低。
|
||||
- 不支持长连接、管线化请求,仅支持简单的文本传输。
|
||||
|
||||
### HTTP/1.1(1999 年)
|
||||
### 2.3.2 HTTP/1.1(1999 年)
|
||||
- 默认开启 长连接(Keep-Alive):同一 TCP 连接可处理多个请求,减少连接开销。
|
||||
- 支持 管线化请求:客户端可连续发送多个请求,无需等待前一个响应返回(部分浏览器未完全支持)。
|
||||
- 新增请求方法:PUT、DELETE、OPTIONS、TRACE、CONNECT。
|
||||
- 支持 chunked 编码(分块传输)、缓存控制(Cache-Control)、内容协商等核心功能。
|
||||
|
||||
### HTTP/2(2015 年)
|
||||
### 2.3.3 HTTP/2(2015 年)
|
||||
  基于 SPDY 协议优化,核心目标是提升性能:
|
||||
- 二进制帧传输:将请求 / 响应数据拆分为二进制帧,而非 HTTP/1.x 的文本格式,解析效率更高。
|
||||
- 多路复用:同一 TCP 连接中可并发处理多个请求(通过帧的 Stream ID 区分),解决 HTTP/1.1 的 “队头阻塞” 问题。
|
||||
- 服务器推送(Server Push):服务器可主动向客户端推送关联资源(如 HTML 引用的 CSS/JS),减少客户端请求次数。
|
||||
- 头部压缩(HPACK):对请求头和响应头进行压缩,减少传输体积(HTTP/1.x 头部重复传输开销大)。
|
||||
|
||||
### HTTP/3(2022 年)
|
||||
### 2.3.4 HTTP/3(2022 年)
|
||||
  基于 SPDY 协议优化,核心目标是提升性能:
|
||||
- 解决 TCP 队头阻塞:UDP 无连接特性,单个流的阻塞不影响其他流。
|
||||
- 更快的连接建立:QUIC 集成 TLS 1.3,减少握手次数(1-RTT 甚至 0-RTT 建立连接)。
|
||||
- 更好的移动网络支持:支持连接迁移(如手机切换 WiFi/4G 时,连接不中断)。
|
||||
|
||||
## 组成
|
||||
### 请求消息(Request)
|
||||
## 2.4 组成
|
||||
### 2.4.1 请求消息(Request)
|
||||
  客户端向服务器发送的请求格式,由 请求行、请求头、空行、请求体 四部分组成:
|
||||
```http
|
||||
GET /api/courses/1 HTTP/1.1 # 请求行
|
||||
@@ -506,7 +506,7 @@ Content-Type: application/json
|
||||
- 文件上传:multipart/form-data(如 LMS 系统的作业文件上传)。
|
||||
- 纯文本:text/plain。
|
||||
|
||||
### 响应消息(Response)
|
||||
### 2.4.2 响应消息(Response)
|
||||
  服务器向客户端返回的响应格式,由 状态行、响应头、空行、响应体 四部分组成:
|
||||
```http
|
||||
HTTP/1.1 200 OK # 状态行
|
||||
@@ -549,8 +549,8 @@ Set-Cookie: sessionId=abc123; Path=/
|
||||
- 图片 / 视频:二进制流(如 image/jpeg、video/mp4)。
|
||||
- 纯文本:text/plain。
|
||||
|
||||
# WebSocket
|
||||
## 定义
|
||||
# 三、WebSocket
|
||||
## 3.1 定义
|
||||
  WebSocket 是一种**全双工、双向、持久化的网络通信协议**(属于应用层协议),由 HTML5 规范定义,专门解决 HTTP 协议无法实现服务器主动向客户端推送数据的问题。
|
||||
  它的核心特点是:**一旦客户端与服务器建立连接,双方就可以在这个连接上实时、双向地发送数据**,无需像 HTTP 那样每次通信都由客户端发起请求,非常适合实时通信场景(如聊天、直播弹幕、实时数据监控、在线协作等)。
|
||||
|
||||
@@ -562,8 +562,8 @@ Set-Cookie: sessionId=abc123; Path=/
|
||||
| 服务器主动推送 | 不支持(HTTP/2 的 Server Push 仅能推送资源,非实时数据) | 原生支持,可主动向客户端发数据 |
|
||||
| 协议标识 | http:// / https:// | ws:// / wss://(加密版)|
|
||||
|
||||
## 特性
|
||||
### 握手过程
|
||||
## 3.2 特性
|
||||
### 3.2.1 握手过程
|
||||
1. 客户端发送 HTTP 请求,请求头包含 Upgrade: websocket 和 Connection: Upgrade(表示要升级为 WebSocket 协议);
|
||||
2. 服务器响应 101 Switching Protocols,握手成功,连接转为 WebSocket 持久连接;
|
||||
3. 后续通信不再使用 HTTP 格式,而是 WebSocket 帧格式(二进制 / 文本)。
|
||||
@@ -587,50 +587,50 @@ Connection: Upgrade
|
||||
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= # 服务器加密后的密钥,客户端验证
|
||||
```
|
||||
|
||||
### 全双工通信
|
||||
### 3.2.2 全双工通信
|
||||
  此时 HTTP 连接已升级为 WebSocket 连接,双方可以通过这个连接实时、双向地发送数据。数据传输采用帧(Frame) 格式,支持文本数据和二进制数据(如图片、视频流)。
|
||||
|
||||
### 帧格式
|
||||
### 3.2.3 帧格式
|
||||
  WebSocket 数据以帧为单位传输,帧头包含操作码(文本帧 0x01、二进制帧 0x02、关闭帧 0x08 等)、掩码(客户端发送数据需掩码,服务器无需);
|
||||
|
||||
### 无同源限制
|
||||
### 3.2.4 无同源限制
|
||||
  WebSocket 不遵循同源策略(但服务器可通过 Origin 头限制跨域);
|
||||
|
||||
### 心跳机制
|
||||
### 3.2.5 心跳机制
|
||||
  通过 Ping/Pong 帧维持连接(避免网络设备断开空闲连接,如 LMS 系统需定期发送 Ping 帧,服务器回复 Pong 帧)。
|
||||
|
||||
# MQTT
|
||||
## 定义
|
||||
# 四、MQTT
|
||||
## 4.1 定义
|
||||
  MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是一种**轻量级、低带宽、低功耗的发布 / 订阅(Publish/Subscribe)模式物联网(IoT)通信协议**,由 IBM 于 1999 年设计,核心目标是解决受限设备(如传感器、嵌入式设备)和低带宽、不稳定网络环境下的高效数据传输问题。
|
||||
|
||||
## 架构
|
||||
## 4.2 架构
|
||||
- 发布者(Publisher):发送消息的设备 / 服务;
|
||||
- 订阅者(Subscriber):接收消息的设备 / 服务;
|
||||
- broker(代理服务器):核心中间件,接收发布者的消息,根据「主题(Topic)」转发给订阅者(如 EMQ X、Mosquitto、RabbitMQ 支持 MQTT 插件);
|
||||
- 主题(Topic):消息的分类标识。
|
||||
|
||||
## 特性
|
||||
### QoS(服务质量)等级
|
||||
## 4.3 特性
|
||||
### 4.3.1 QoS(服务质量)等级
|
||||
- QoS 0(最多一次):消息发送一次,不保证送达;
|
||||
- QoS 1(至少一次):消息至少送达一次,可能重复;
|
||||
- QoS 2(恰好一次):消息仅送达一次,最可靠。
|
||||
|
||||
### 轻量级
|
||||
### 4.3.2 轻量级
|
||||
  头部开销极小(固定头部仅 2 字节),消息体支持二进制 / 文本,适合低带宽场景
|
||||
|
||||
### 保留消息(Retained Message)
|
||||
### 4.3.3 保留消息(Retained Message)
|
||||
  broker 保存某个主题的最后一条消息,新订阅者订阅后立即收到该消息
|
||||
|
||||
### 遗嘱消息(Will Message)
|
||||
### 4.3.4 遗嘱消息(Will Message)
|
||||
  客户端异常断开时,broker 自动发送预设消息
|
||||
|
||||
### 清洁会话(Clean Session)
|
||||
### 4.3.5 清洁会话(Clean Session)
|
||||
  客户端断开连接后,broker 是否保留订阅信息和未送达消息
|
||||
|
||||
## 组成
|
||||
## 4.4 组成
|
||||
  MQTT 消息由 固定头部(Fixed Header)、可变头部(Variable Header)、负载(Payload) 三部分组成:
|
||||
| 部分 | 作用 |
|
||||
|--------------|----------------------------------------------------------------------|
|
||||
| 固定头部 | 必选,2 字节起,包含消息类型(如发布、订阅)、QoS 等级、是否保留消息等标识。 |
|
||||
| 可变头部 | 可选,仅部分消息类型(如发布、订阅)需要,包含主题名、消息 ID 等信息。 |
|
||||
| 负载 | 可选,消息的实际内容(如 JSON 字符串、二进制数据),例如 {"temperature": 25}。 |
|
||||
| 负载 | 可选,消息的实际内容(如 JSON 字符串、二进制数据),例如 {"temperature": 25}。 |
|
||||
@@ -28,8 +28,8 @@ services:
|
||||
|
||||
# 三、挂载
|
||||
## 3.1 挂载本地存储
|
||||
  打开控制台,选择存储->添加->本地存储,设置挂载路径(自定义,如`/local`),选择根文件夹路径为`/srv`,点击添加。
|
||||
  这样就可以将`/local`路径映射到宿主机的`/srv`目录了。
|
||||
  打开控制台,选择存储->添加->本地存储,设置挂载路径(自定义,如`/local`),选择根文件夹路径为`/srv`,点击添加。
|
||||
  这里的`/srv`文件夹为`docker`里面的文件夹路径,一般只需要更改`docker compose`文件里面的`/srv:/srv`前面的`srv`路径为宿主机路径即可,后面的`/srv`路径不要更改。
|
||||
  可以在宿主机的`/srv`目录中添加文件,查看AList的主页是否可以正确显示。
|
||||
|
||||
# 四、API
|
||||
@@ -71,4 +71,9 @@ await axios({
|
||||
  在AList的文件路径中加上前缀`/p/`。例如文件路径为`/local/Test/test.mp4`,则下载路径为`/p/local/Test/test.mp4`。
|
||||
::: tip
|
||||
以上为相对路径,使用时加上相应的ip和端口号。
|
||||
:::
|
||||
|
||||
::: warning
|
||||
如果出现无法访问的情况,需要在设置->全局里面,将`签名所有`关闭。
|
||||
签名所有:Alist是否将为这个存储下的所有文件请求生成带签名的临时链接,如果开启则需要带上校验码签名,否则不需要。
|
||||
:::
|
||||
154
docs/Web/Others/ImageProcessor.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
title: 图片处理服务
|
||||
date: 2026-06-04
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  基于 Node.js + Sharp 实现的轻量级图片处理服务。
|
||||
  [sharp](https://sharp.pixelplumbing.com/)是 Node.js 下非常常用的高性能图像处理库,主要用来缩放、裁剪、旋转、格式转换、压缩图片等,速度快、内存占用低。
|
||||
  支持路径+参数的形式实时压缩图片,例如`ip:port/a.png?q=80&w=1024&h=768`
|
||||
|
||||
# 二、核心方法
|
||||
## 2.1 根据参数调整图片
|
||||
```JavaScript
|
||||
const sharp = require('sharp');
|
||||
|
||||
// 压缩配置
|
||||
const compressOptions = {
|
||||
quality: 0.7,
|
||||
maxHeight: 800,
|
||||
maxWidth: 600
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理图片压缩和调整大小
|
||||
* @param {string} imagePath 图片路径
|
||||
* @param {Object} query 查询参数
|
||||
* @returns {Promise<Buffer>} 处理后的图片Buffer
|
||||
*/
|
||||
async function processImage(imagePath, query) {
|
||||
let sharpInstance = sharp(imagePath).rotate();
|
||||
|
||||
// 处理宽高参数
|
||||
let targetWidth = parseInt(query.w) || parseInt(query.width);
|
||||
let targetHeight = parseInt(query.h) || parseInt(query.height);
|
||||
|
||||
// 如果没有指定宽高,使用配置的默认最大宽高
|
||||
if (!targetWidth && !targetHeight) {
|
||||
targetWidth = compressOptions.maxWidth;
|
||||
targetHeight = compressOptions.maxHeight;
|
||||
}
|
||||
|
||||
// 如果指定了宽高,进行调整大小处理
|
||||
if (targetWidth || targetHeight) {
|
||||
const resizeOptions = {
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
fit: sharp.fit.inside, // 保持宽高比,图片会完整显示在指定区域内
|
||||
withoutEnlargement: true // 不放大比原始尺寸小的图片
|
||||
};
|
||||
sharpInstance = sharpInstance.resize(resizeOptions);
|
||||
}
|
||||
|
||||
// 处理质量参数(优先使用查询参数,否则使用配置的默认值)
|
||||
const quality = query.q ? Math.max(1, Math.min(100, parseInt(query.q))) :
|
||||
Math.round(compressOptions.quality * 100);
|
||||
const finalQuality = Math.max(1, Math.min(100, quality));
|
||||
|
||||
// 转换为JPEG格式并压缩
|
||||
return sharpInstance.jpeg({quality: finalQuality, mozjpeg: true}).toBuffer();
|
||||
}
|
||||
```
|
||||
|
||||
  核心方法:[resize](https://sharp.nodejs.cn/api-resize/)、[jpeg](https://sharp.nodejs.cn/api-output/#jpeg)、[toBuffer](https://sharp.nodejs.cn/api-output/#tobuffer)。
|
||||
|
||||
## 2.2 返回图片数据
|
||||
```JavaScript
|
||||
/**
|
||||
* 发送响应
|
||||
* @param {ServerResponse} res 响应对象
|
||||
* @param {number} statusCode 状态码
|
||||
* @param {string} message 信息
|
||||
*/
|
||||
function sendResponse(res, statusCode, message) {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end(message);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
return sendResponse(res, 405, '仅支持GET请求');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
const requestPath = parsedUrl.pathname;
|
||||
const query = parsedUrl.query;
|
||||
|
||||
// 过滤根路径请求(避免直接访问服务根目录)
|
||||
if (requestPath === '/') {
|
||||
return sendResponse(res, 200, '图片处理服务已启动!');
|
||||
}
|
||||
|
||||
const requestedFilePath = requestPath.slice(1);
|
||||
const fullImagePath = path.join(IMAGE_BASE_DIR, requestedFilePath);
|
||||
|
||||
// 安全检查:防止路径遍历
|
||||
if (!isSafePath(fullImagePath, IMAGE_BASE_DIR)) {
|
||||
return sendResponse(res, 403, '访问拒绝:非法路径(路径遍历攻击)');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullImagePath)) {
|
||||
return sendResponse(res, 404, `图片不存在:${requestedFilePath}`);
|
||||
}
|
||||
|
||||
// 如果没有压缩参数,直接返回原图
|
||||
if (!query.q && !query.w && !query.width && !query.h && !query.height) {
|
||||
const fileStream = fs.createReadStream(fullImagePath);
|
||||
const ext = path.extname(fullImagePath).slice(1);
|
||||
res.writeHead(200, {'Content-Type': `image/${ext}`});
|
||||
// 流式传输文件(适合大文件)
|
||||
fileStream.pipe(res);
|
||||
fileStream.on('error', (err) => {
|
||||
sendResponse(res, 500, `读取文件失败:${err.message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理图片压缩和调整大小
|
||||
const imageBuffer = await processImage(fullImagePath, query);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': imageBuffer.length,
|
||||
'Cache-Control': 'public, max-age=86400' // 缓存1天
|
||||
});
|
||||
res.end(imageBuffer);
|
||||
|
||||
} catch (error) {
|
||||
console.error('服务器异常:', error);
|
||||
sendResponse(res, 500, `服务器内部错误:${error.message}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
  原生Node.js中res用法:
|
||||
```js
|
||||
res.writeHead(statusCode, [statusMessage], [headers]);
|
||||
res.end(message)
|
||||
```
|
||||
|
||||
::: tip
|
||||
Express中req(请求对象)常用用法:
|
||||
1. 获取请求路径参数:`/user/:id`->`req.params.id`
|
||||
2. 获取查询参数(?key=value):`/search?name=tom&age=18`->`req.query.name`
|
||||
3. 获取请求体:`req.body.username`
|
||||
4. 获取请求头:`req.headers['user-agent']`
|
||||
|
||||
Express中res(响应对象)常用用法:
|
||||
1. 返回普通文本:`res.send('Hello Express');`
|
||||
2. 返回JSON:`res.json({ success: true, data: {} });`
|
||||
3. 设置状态码:`res.status(404).json({ msg: 'Not Found' });`
|
||||
4. 设置响应头:`res.set('Content-Type', 'text/plain');`
|
||||
5. 结束响应:`res.end()`
|
||||
:::
|
||||
82
docs/Web/Others/InfluxDB.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: InfluxDB简介和使用
|
||||
date: 2026-05-11
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  InfluxDB 是一个开源的时序数据库(Time Series Database, TSDB),由 InfluxData 公司开发,专门用于存储、查询和处理时间序列数据(按时间顺序记录的数据点)。它在监控、物联网(IoT)、实时分析等场景中广泛应用,是时序数据领域的流行解决方案之一。
|
||||
  InfluxDB 3.0 是一次底层的彻底重构(内核代号 IOx),核心代码从 Go 改写为 Rust,并从自研的 TSM 引擎转向基于 Apache Arrow 生态的开放架构。
|
||||
|
||||
# 二、特点
|
||||
1. Rust 全新重写:更快、更省内存、更稳
|
||||
2. 支持无限标签基数:不再怕设备 ID、用户 ID 这类高基数场景
|
||||
3. 原生支持标准 SQL:不用只学 InfluxQL,普通 SQL 直接查
|
||||
4. 底层基于 Arrow + Parquet:列式存储、压缩率高、查询快
|
||||
5. 原生对接对象存储:支持 S3/OSS,冷热数据自动分层,可存海量历史数据
|
||||
|
||||
# 三、概念
|
||||
1. 库 Database:相当于 MySQL 的库,隔离业务数据
|
||||
2. 测量 Measurement:相当于 MySQL 的表(比如 cpu、温度传感器)
|
||||
3. 标签 Tag:维度、索引字段(字符串,如设备 id、地区,用来筛选分组)
|
||||
4. 字段 Field:实际指标值(数值,如温度、CPU 使用率,不做索引)
|
||||
5. 时间 Time:自带时间戳,时序数据唯一主键、按时间排序
|
||||
|
||||
# 四、安装
|
||||
## 4.1 Docker安装
|
||||
```yml
|
||||
services:
|
||||
influxdb3-core:
|
||||
image: influxdb:3.9.1-core
|
||||
container_name: influxdb3-core
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- influx-network
|
||||
ports:
|
||||
- "8181:8181"
|
||||
volumes:
|
||||
- ./influxdb3/data:/var/lib/influxdb3/data
|
||||
- ./influxdb3/plugins:/var/lib/influxdb3/plugins
|
||||
user: root
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
command: >
|
||||
influxdb3 serve
|
||||
--node-id=node0
|
||||
--object-store=file
|
||||
--data-dir=/var/lib/influxdb3/data
|
||||
--plugin-dir=/var/lib/influxdb3/plugins
|
||||
|
||||
influxdb3-explorer:
|
||||
image: influxdata/influxdb3-ui:1.6.3
|
||||
container_name: influxdb3-explorer
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- influx-network
|
||||
ports:
|
||||
- "8600:80"
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
- ./config:/app-root/config:ro
|
||||
- ./db:/db:rw
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
command: --mode=admin
|
||||
|
||||
networks:
|
||||
influx-network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
## 4.2 token生成
|
||||
  进入influxdb3-core容器,输入命令获取token:
|
||||
```bash
|
||||
influxdb3 create token --admin
|
||||
```
|
||||
|
||||
## 4.3 可视化页面
|
||||
  打开ip:8600(具体端口看docker的配置),选择`Configure->Servers`,点击`Connect Your First Server`,输入InfluxDB服务器信息进行配置。
|
||||
  在`Query Data->Data Explorer`中查看数据。
|
||||
|
||||
# 五、使用
|
||||
## 5.1 SpringBoot使用
|
||||
181
docs/Web/Others/Meilisearch.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: Meilisearch简介和使用
|
||||
date: 2026-08-15
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  [Meilisearch](https://meilisearch.com.cn/docs/home) 是一个基于 Rust 构建的开源全文搜索引擎,面向“用户侧搜索体验”设计。通过 REST API 接收 JSON 文档并建立倒排索引,提供 sub‑50ms 的搜索响应、默认拼写容错、前缀搜索、相关度排序与过滤分面能力,适用于站内搜索、商品检索、文档/知识库搜索、Cmd+K、RAG 检索层等场景。
|
||||
|
||||
# 二、核心概念
|
||||
| 概念 | 说明 |
|
||||
|---|---|
|
||||
| **Index(索引)** | 一组共享设置的文档集合,等价于“表”,如 `products`、`articles` |
|
||||
| **Document(文档)** | JSON 对象,是搜索基本单位,必须包含主键 |
|
||||
| **Primary Key(主键)** | 文档唯一标识,默认推断 `id`,相同主键写入即覆盖(upsert) |
|
||||
| **Task(任务)** | 所有写操作异步执行,返回 `taskUid`,状态:enqueued → processing → succeeded / failed |
|
||||
| **Settings** | Index 级配置:searchable / filterable / sortable / displayed attributes、ranking rules、synonyms、stop words、tokenizer |
|
||||
| **Ranking Rules** | 默认相关度规则:`words → typo → proximity → attribute → sort → exactness`,可定制 |
|
||||
| **Master Key** | 实例级最高密钥(≥16 bytes),用于生成其他 Key,**禁止下发前端** |
|
||||
| **API Key** | Search / Admin / Read-Only Admin / Chat,按 action + index 粒度授权 |
|
||||
| **Tenant Token** | 由 API Key 派生,搜索时注入 filter,实现多租户行级隔离 |
|
||||
| **Embedder** | 内建向量化配置,支持 OpenAI / HuggingFace / Ollama / Cohere / 自定义 REST,用于 Hybrid Search |
|
||||
|
||||
# 三、与Elasticsearch比较
|
||||
| 维度 | Meilisearch | Elasticsearch |
|
||||
|---|---|---|
|
||||
| 核心引擎 | Rust + LMDB(mmap,单机 ACID) | Java + Apache Lucene(distributed) |
|
||||
| 部署形态 | 单进程 / 单容器 | 多节点集群(shard + replica + 选主) |
|
||||
| 查询延迟 | **sub-50ms 默认**(前缀/typo 开箱) | sub-100ms~200ms,需调优达到极低 P99 |
|
||||
| 索引吞吐 | 中(异步 task 队列,单机受限) | 高(bulk + 多 shard 并行) |
|
||||
| 近实时性 | 亚秒级 task 完成即可见 | refresh_interval 默认 1s |
|
||||
| 内存占用 | ~200MB 空闲,无 GC | 1–2GB+ 起,JVM heap 调优关键 |
|
||||
| 横向扩展 | 社区版单节点;Cloud/企业版支持 shard+replica | 原生分片、副本、跨集群复制 |
|
||||
| 规模上限 | 单机百万~千万文档(索引通常 < 100GB 级) | PB 级、百亿~千亿文档 |
|
||||
| 聚合分析 | facet 计数级 | 完整 aggregation / pipeline / SIEM |
|
||||
| 分词/相关度 | 内置 Charabia + 默认 ranking | 显式 analyzer / DSL / fuzziness 调优 |
|
||||
| 运维复杂度 | 低(启容器+key+dump) | 高(heap/shard/ILM/TLS/RBAC)|
|
||||
| 成本模型 | 单机 $10–20/月级 | 托管最小集群 $150+/月级 |
|
||||
| 许可证 | MIT CE / BUSL-1.1 | AGPL / SSPL / ELv2 |
|
||||
|
||||
# 四、Docker安装
|
||||
```yml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.16
|
||||
container_name: meilisearch
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7700:7700"
|
||||
environment:
|
||||
MEILI_MASTER_KEY: key
|
||||
volumes:
|
||||
- ./meili_data:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- meili-net
|
||||
|
||||
meilisearch-ui:
|
||||
image: eyeix/meilisearch-ui:v0.15.1-lite
|
||||
container_name: meilisearch-ui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "24900:24900"
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
depends_on:
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- meili-net
|
||||
|
||||
networks:
|
||||
meili-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
  启动后在浏览器中打开ip:24900,新增实例和索引。
|
||||
|
||||
# 五、SpringBoot集成
|
||||
## 5.1 安装依赖
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.meilisearch.sdk</groupId>
|
||||
<artifactId>meilisearch-java</artifactId>
|
||||
<version>0.20.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
::: warning
|
||||
该版本需要okhttp 4.12版本,如有冲突,需要指定okhttp版本
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>4.12.0</version>
|
||||
</dependency>
|
||||
```
|
||||
:::
|
||||
|
||||
## 5.2 配置属性
|
||||
```yml
|
||||
meilisearch:
|
||||
host: http:/ip:7700
|
||||
api-key: key
|
||||
```
|
||||
|
||||
## 5.3 配置类
|
||||
```java
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "meilisearch")
|
||||
@Data
|
||||
public class MeiliSearchConfig {
|
||||
private String host;
|
||||
private String apiKey;
|
||||
|
||||
@Bean
|
||||
public Client meiliClient() {
|
||||
return new Client(new Config(host, apiKey));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5.4 操作实例
|
||||
```java
|
||||
@Service
|
||||
public class MeiliSearchServiceImpl implements MeiliSearchService {
|
||||
@Resource
|
||||
private Client meiliClient;
|
||||
|
||||
@Override
|
||||
public <T> int addDocuments(String indexUid, List<T> documents) {
|
||||
String jsonArray = JSONUtil.toJsonStr(documents);
|
||||
TaskInfo taskInfo = meiliClient.index(indexUid).addDocuments(jsonArray);
|
||||
meiliClient.waitForTask(taskInfo.getTaskUid());
|
||||
return taskInfo.getTaskUid();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDocument(String indexUid, Object document) {
|
||||
TaskInfo task = meiliClient.index(indexUid).updateDocuments(JSONUtil.toJsonStr(document));
|
||||
meiliClient.waitForTask(task.getTaskUid());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDocument(String indexUid, String documentId) {
|
||||
TaskInfo task = meiliClient.index(indexUid).deleteDocument(documentId);
|
||||
meiliClient.waitForTask(task.getTaskUid());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(String indexUid, String query, List<String> highlightFields) {
|
||||
SearchRequest request = new SearchRequest(query)
|
||||
.setShowMatchesPosition(true)
|
||||
.setAttributesToHighlight(highlightFields.toArray(new String[0]));
|
||||
return (SearchResult) meiliClient.index(indexUid).search(request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
这里如果开启高亮搜索,会有个bug:`Meilisearch ApiException: {Error=APIError: {message='Invalid value type at .attributesToHighlight: expected an array, but found a string: "**[Ljava.lang.String;@10fc5e2f**"', code='invalid_search_attributes_to_highlight', type='invalid_request',`
|
||||
解决方案:
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.vaadin.external.google</groupId>
|
||||
<artifactId>android-json</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
```
|
||||
参考:[issue](https://github.com/meilisearch/meilisearch-java/issues/687)
|
||||
:::
|
||||
130
docs/Web/Others/Seckill.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: 秒杀系统设计与实战
|
||||
date: 2026-06-10
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  一个典型的秒杀系统通常采用 **限流 + 缓存 + 异步** 的架构:
|
||||
```plain
|
||||
客户端
|
||||
↓
|
||||
Nginx / 网关(限流)
|
||||
↓
|
||||
秒杀接口(校验、Redis 预扣库存)
|
||||
↓
|
||||
RocketMQ(下单消息)
|
||||
↓
|
||||
订单服务(创建订单、真正扣库存)
|
||||
↓
|
||||
MySQL
|
||||
```
|
||||
  **限流挡人、缓存决策、异步解耦**。
|
||||
|
||||
# 二、实战设计
|
||||
## 2.1 限流
|
||||
### 2.1.1 Nginx限流
|
||||
```conf
|
||||
# 定义规则
|
||||
limit_req_zone $binary_remote_addr zone=seckill:10m rate=5r/s;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
location /seckill-api/ {
|
||||
# 启用限流
|
||||
limit_req zone=seckill burst=10 nodelay;
|
||||
|
||||
# 返回 429
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  `limit_req_zone`用来制定规则,`limit_req`表示启用规则,如果某个`location`没写,则不会启动规则。
|
||||
  `zone=seckill:10m`表示开辟一块`10MB`的内存,取名叫`seckill`,专门存`IP`访问记录,`rate=5r/s`表示每个`IP`每秒最多`5`个请求。
|
||||
  `limit_req zone=seckill`表示启动`seckill`规则,`burst=10`表示突发请求缓冲,`nodelay`表示超出后直接失败。
|
||||
|
||||
|
||||
::: tip
|
||||
`limit_req_zone`一定要写在`http`模块中,如果是`Linux`部署的,直接写在最外面。
|
||||
:::
|
||||
|
||||
::: warning
|
||||
Nginx限流只能防止单ip高刷,无法挡住大量不同IP的分布式攻击。
|
||||
:::
|
||||
|
||||
### 2.1.2 Semaphore限流
|
||||
  `Semaphore`是针对应用层限流,保护`JVM`,防止`Tomcat`线程池被打爆。
|
||||
```java
|
||||
private final Semaphore semaphore = new Semaphore(100);
|
||||
|
||||
if (!semaphore.tryAcquire()) {
|
||||
return ResponseEntity.status(429).body("系统繁忙");
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
```
|
||||
|
||||
  `new Semaphore(100)`表示最多`100`个线程同时进来,超过则直接返回失败,不会进入排队,也不会卡住线程。
|
||||
::: tip
|
||||
Semaphore不是全局限流,不具备分布式能力。
|
||||
:::
|
||||
|
||||
### 2.1.3 Redis限流
|
||||
```java
|
||||
String userKey = "seckill:user:" + userId;
|
||||
Boolean first = redis.opsForValue().setIfAbsent(userKey, "1", 60, TimeUnit.SECONDS);
|
||||
if (Boolean.FALSE.equals(first)) {
|
||||
return ResponseEntity.status(429).body("请勿重复尝试");
|
||||
}
|
||||
```
|
||||
  如果是限制每个用户只能限购一次,则可以将过期时间设置为永久。
|
||||
|
||||
## 2.2 缓存
|
||||
  先在`Redis`里面判断逻辑,然后再执行数据库。
|
||||
```java
|
||||
String stockKey = "seckill:stock:1";
|
||||
Long remain = redis.opsForValue().decrement(stockKey);
|
||||
if (remain == null || remain < 0) {
|
||||
redis.opsForValue().increment(stockKey);
|
||||
return ResponseEntity.status(200).body("已售罄");
|
||||
}
|
||||
```
|
||||
  要先减再判断,如果小于0,则表示已售罄,再回滚数据。
|
||||
|
||||
## 2.3 异步
|
||||
```java
|
||||
rocketMQTemplate.convertAndSend("seckill-order", Map.of("userId", userId, "goodsId", 1));
|
||||
return ResponseEntity.status(200).body("秒杀成功");
|
||||
```
|
||||
  Redis成功后则通过发消息通知消费者操作数据库。
|
||||
  为了防止重复创建订单,还需要进行一次幂等校验,即根据订单号的唯一性判断是否有重复的。
|
||||
|
||||
# 三、压测
|
||||
  采用JMeter工具进行压测。
|
||||
## 3.1 创建测试数据
|
||||
  新建users.csv,输入以下内容:
|
||||
```csv
|
||||
1
|
||||
2
|
||||
3
|
||||
...
|
||||
```
|
||||
|
||||
  然后在`线程组->添加->配置元件->CSV Data Set Config`,设置变量名称为userId。
|
||||
|
||||
::: tip
|
||||
不需要输入表头。
|
||||
:::
|
||||
|
||||
## 3.2 HTTP请求
|
||||
  设置请求路径为`/seckill/${userId}`,设置线程组参数,例如线程数1000,Ramp-Up时间5秒,循环次数为1000,创建聚合报告,执行线程,查看结果。
|
||||
|
||||

|
||||
243
docs/Web/Others/VideoCompressor.md
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
title: 视频压缩
|
||||
date: 2026-01-29
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  视频码率(Bit Rate),指的是视频文件在单位时间内(通常是每秒)所包含的数据量。单位通常是 Mbps(兆比特每秒)或 Kbps(千比特每秒)。
|
||||
  码率越高:视频中包含的细节信息越多,画面越清晰、色彩越丰富、动态画面(如快速运动、爆炸等)越流畅,不容易出现马赛克或模糊。但代价是文件体积越大,传输所需的网络带宽也越高。码率越低:视频文件更小,传输更快,但在复杂画面中容易产生压缩瑕疵(如马赛克、模糊、色带等)。
|
||||
  如果不压缩,原始视频数据量极其庞大,一部2小时的未压缩1080p电影可能占用数TB的存储空间,普通硬盘无法承受,家庭宽带或移动网络无法实时传输如此巨大的数据量,视频播放会不断卡顿。
|
||||
  如果服务器的带宽为10M,适合 720p,勉强支持低码率 1080p。
|
||||
|
||||
# 二、引入依赖
|
||||
```xml
|
||||
<!-- Jave 2 视频处理库 -->
|
||||
<dependency>
|
||||
<groupId>ws.schild</groupId>
|
||||
<artifactId>jave-all-deps</artifactId>
|
||||
<version>3.5.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
# 三、工具类
|
||||
```java
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import ws.schild.jave.Encoder;
|
||||
import ws.schild.jave.MultimediaObject;
|
||||
import ws.schild.jave.encode.AudioAttributes;
|
||||
import ws.schild.jave.encode.EncodingAttributes;
|
||||
import ws.schild.jave.encode.VideoAttributes;
|
||||
import ws.schild.jave.info.VideoSize;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* 视频压缩工具类
|
||||
* 提供将视频压缩到720p的静态方法
|
||||
*/
|
||||
@Slf4j
|
||||
public class VideoCompressor {
|
||||
|
||||
// 支持的视频格式
|
||||
private static final String[] SUPPORTED_FORMATS = {
|
||||
".mp4", ".avi", ".mov", ".mkv", ".flv",
|
||||
".wmv", ".webm", ".mpeg", ".mpg", ".3gp"
|
||||
};
|
||||
|
||||
// 压缩参数常量
|
||||
private static final int TARGET_WIDTH = 1280;
|
||||
private static final int TARGET_HEIGHT = 720;
|
||||
private static final int VIDEO_BITRATE = 1500000; // 1.5 Mbps
|
||||
private static final int AUDIO_BITRATE = 128000; // 128 kbps
|
||||
private static final String VIDEO_CODEC = "libx264";
|
||||
private static final String AUDIO_CODEC = "aac";
|
||||
private static final String OUTPUT_FORMAT = "mp4";
|
||||
private static final String OUTPUT_SUFFIX = "_720p.mp4";
|
||||
|
||||
private static final int MAX_BITRATE = 2500; // 2.5 Mbps
|
||||
private static final int MIN_BITRATE = 1000; // 1 Mbps
|
||||
|
||||
/**
|
||||
* 压缩视频到720p,统一输出为MP4格式
|
||||
*
|
||||
* @param inputFile 输入视频文件
|
||||
* @return 压缩后的视频文件,如果不需要压缩则返回原文件
|
||||
* @throws Exception 压缩过程中可能出现的异常
|
||||
*/
|
||||
public static File compressTo720p(File inputFile) throws Exception {
|
||||
if (!isSupportedVideoFile(inputFile)) {
|
||||
log.info("文件格式不支持或文件不存在,直接返回源文件");
|
||||
return inputFile;
|
||||
}
|
||||
|
||||
// 判断是否需要压缩
|
||||
if (!needCompression(inputFile)) {
|
||||
return inputFile;
|
||||
}
|
||||
|
||||
File outputFile = createOutputFile(inputFile);
|
||||
Encoder encoder = new Encoder();
|
||||
|
||||
// 配置编码参数
|
||||
EncodingAttributes encodingAttributes = createEncodingAttributes();
|
||||
|
||||
log.info("开始压缩视频: {} -> {}", inputFile.getName(), outputFile.getName());
|
||||
encoder.encode(new MultimediaObject(inputFile), outputFile, encodingAttributes);
|
||||
log.info("视频压缩完成: {}", outputFile.getName());
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能判断视频是否需要压缩
|
||||
* 基于分辨率、码率、数据密度等多维度综合判断
|
||||
*/
|
||||
public static boolean needCompression(File videoFile) throws Exception {
|
||||
MultimediaObject media = new MultimediaObject(videoFile);
|
||||
|
||||
// 获取视频基本信息
|
||||
double fileSizeMB = videoFile.length() / 1048576.0; // 转换为MB
|
||||
double duration = media.getInfo().getDuration() / 1000.0;
|
||||
int bitRateKbps = media.getInfo().getVideo().getBitRate() / 1000; // 转换为kbps
|
||||
|
||||
// 获取视频分辨率
|
||||
int width = media.getInfo().getVideo().getSize().getWidth();
|
||||
int height = media.getInfo().getVideo().getSize().getHeight();
|
||||
|
||||
log.info("视频分析: 分辨率 {}x{}, 大小 {}MB, 时长 {}秒, 码率约 {}kbps", width, height, fileSizeMB, duration, bitRateKbps);
|
||||
|
||||
// 判断逻辑:基于分辨率和码率的智能判断
|
||||
boolean needsCompression = false;
|
||||
|
||||
// 1. 如果分辨率已经<=720p,检查码率是否过高
|
||||
if (height <= TARGET_HEIGHT && width <= TARGET_WIDTH) {
|
||||
if (bitRateKbps > MAX_BITRATE) {
|
||||
log.info("视频分辨率达标但码率({}kbps)过高,需要压缩", bitRateKbps);
|
||||
needsCompression = true;
|
||||
} else {
|
||||
log.info("视频分辨率已达720p或以下,且码率合理,无需压缩");
|
||||
}
|
||||
}
|
||||
// 2. 如果分辨率>720p,但码率很低,说明已是优化过的视频,无需压缩
|
||||
else if (height > TARGET_HEIGHT && bitRateKbps < MIN_BITRATE) {
|
||||
log.info("视频虽为{}p高分辨率,但码率({}kbps)已优化,无需压缩", height, bitRateKbps);
|
||||
}
|
||||
// 3. 高分辨率+高码率,需要压缩
|
||||
else if (height > TARGET_HEIGHT && bitRateKbps >= MAX_BITRATE) {
|
||||
log.info("视频为{}p高分辨率且码率较高({}kbps),需要压缩至720p", height, bitRateKbps);
|
||||
needsCompression = true;
|
||||
}
|
||||
// 4. 其他情况(如异常数据)默认不压缩
|
||||
else {
|
||||
log.info("视频参数异常,保守处理:不进行压缩");
|
||||
}
|
||||
|
||||
return needsCompression;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否为支持的视频格式
|
||||
*/
|
||||
private static boolean isSupportedVideoFile(File file) {
|
||||
if (file == null || !file.exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String filename = file.getName().toLowerCase();
|
||||
for (String format : SUPPORTED_FORMATS) {
|
||||
if (filename.endsWith(format)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建输出文件对象
|
||||
*/
|
||||
private static File createOutputFile(File inputFile) {
|
||||
String baseName = getFileBaseName(inputFile.getName());
|
||||
String outputFilename = baseName + OUTPUT_SUFFIX;
|
||||
return new File(inputFile.getParent(), outputFilename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(不含扩展名)
|
||||
*/
|
||||
private static String getFileBaseName(String filename) {
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
return (dotIndex == -1) ? filename : filename.substring(0, dotIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建编码配置参数
|
||||
*/
|
||||
private static EncodingAttributes createEncodingAttributes() {
|
||||
// 音频配置
|
||||
AudioAttributes audio = new AudioAttributes();
|
||||
audio.setCodec(AUDIO_CODEC);
|
||||
audio.setBitRate(AUDIO_BITRATE);
|
||||
|
||||
// 视频配置
|
||||
VideoAttributes video = new VideoAttributes();
|
||||
video.setCodec(VIDEO_CODEC);
|
||||
video.setBitRate(VIDEO_BITRATE);
|
||||
video.setSize(new VideoSize(TARGET_WIDTH, TARGET_HEIGHT));
|
||||
|
||||
// 编码设置
|
||||
EncodingAttributes attributes = new EncodingAttributes();
|
||||
attributes.setOutputFormat(OUTPUT_FORMAT);
|
||||
attributes.setAudioAttributes(audio);
|
||||
attributes.setVideoAttributes(video);
|
||||
|
||||
return attributes;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  通过分辨率和码率的双重判断,确定是否需要压缩。
|
||||
|
||||
# 四、使用
|
||||
```java
|
||||
public void uploadFile(MultipartFile file, String filePath) {
|
||||
try {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
|
||||
File tempFile = convertMultipartFileToFile(file);
|
||||
log.info("临时文件地址:{}", tempFile.toPath());
|
||||
|
||||
log.info("开始压缩文件:{}", tempFile.getName());
|
||||
File compressFile = VideoCompressor.compressTo720p(tempFile);
|
||||
log.info("完成压缩文件:{}", tempFile.getName());
|
||||
|
||||
log.info("压缩文件地址:{}", tempFile.toPath());
|
||||
|
||||
body.add("file", new FileSystemResource(compressFile));
|
||||
|
||||
aListClient.put()
|
||||
.uri("api/fs/form")
|
||||
.header(HttpHeaders.AUTHORIZATION, token)
|
||||
.header("File-Path", filePath)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
|
||||
Files.deleteIfExists(compressFile.toPath());
|
||||
Files.deleteIfExists(tempFile.toPath());
|
||||
} catch (Exception e) {
|
||||
log.error("文件上传失败{}", e.getMessage());
|
||||
throw new RuntimeException("文件上传失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private File convertMultipartFileToFile(MultipartFile file) throws IOException {
|
||||
String suffix = (file.getOriginalFilename() != null && file.getOriginalFilename().contains("."))
|
||||
? file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."))
|
||||
: ".tmp";
|
||||
|
||||
Path tempFile = Files.createTempFile(System.currentTimeMillis() + "", suffix);
|
||||
file.transferTo(tempFile);
|
||||
return tempFile.toFile();
|
||||
}
|
||||
```
|
||||
222
docs/Web/SpringBoot/Spring-AOP.md
Normal file
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: Spring AOP简介
|
||||
date: 2025-12-25
|
||||
---
|
||||
|
||||
# 一、基本概念
|
||||
  AOP(Aspect-Oriented Programming,面向切面编程)是一种编程范式,核心思想是**将与业务核心逻辑无关但又被多个模块共享的功能(如日志、事务、权限校验、性能监控)抽取出来,形成独立的 “切面”**,在不修改原有业务代码的前提下,通过 “织入” 的方式动态添加到目标方法的执行流程中。
|
||||
  Spring AOP 是 Spring 框架对 AOP 思想的实现,基于**动态代理(JDK 动态代理 + CGLIB 动态代理)**,专注于解决企业级开发中的横切关注点问题。
|
||||
| 术语 | 说明 |
|
||||
|------|------|
|
||||
| 切面(Aspect) | 封装横切逻辑的类(如日志切面、事务切面),包含通知和切点 |
|
||||
| 连接点(Joinpoint) | 程序执行过程中的某个特定点(如方法调用、异常抛出),Spring AOP 中仅支持方法级连接点 |
|
||||
| 切点(Pointcut) | 匹配连接点的规则,用于指定切面要织入到哪些方法上 |
|
||||
| 通知(Advice) | 切面的具体逻辑(如日志打印、权限校验),按执行时机分为 5 种 |
|
||||
| 目标对象(Target) | 被切面织入的目标类(业务类) |
|
||||
| 代理对象(Proxy) | Spring AOP 为目标对象创建的代理类,横切逻辑通过代理执行 |
|
||||
| 织入(Weaving) | 将切面逻辑添加到目标对象的过程,Spring AOP 是运行时织入(通过动态代理) |
|
||||
|
||||
| 通知类型 | 执行时机 |
|
||||
|----------|----------|
|
||||
| 前置通知(Before) | 目标方法执行前执行 |
|
||||
| 后置通知(After) | 目标方法执行后执行(无论是否抛出异常) |
|
||||
| 返回通知(AfterReturning) | 目标方法正常返回后执行 |
|
||||
| 异常通知(AfterThrowing) | 目标方法抛出异常后执行 |
|
||||
| 环绕通知(Around) | 包裹目标方法执行,可自定义目标方法执行前后的逻辑(功能最强) |
|
||||
  [AOP API](https://docs.springjava.cn/spring-framework/reference/core/aop-api.html)
|
||||
|
||||
# 二、原理
|
||||
  Spring AOP 基于动态代理实现,分为两种方式:
|
||||
1. JDK 动态代理
|
||||
通过 java.lang.reflect.Proxy 生成代理类,代理类实现目标接口,调用 InvocationHandler 处理横切逻辑;基于接口,无侵入。
|
||||
|
||||
2. CGLIB 动态代理
|
||||
通过继承目标类生成子类作为代理类,重写目标方法,织入横切逻辑;基于继承,目标类不能是 final,方法不能是 final。
|
||||
|
||||
  Spring AOP 自动选择规则:如果目标类实现了接口,使用 JDK 动态代理;否则使用 CGLIB。
|
||||
|
||||
# 三、实战
|
||||
  例如存在以下业务模块:
|
||||
```java
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
// 模拟创建订单
|
||||
public String createOrder(String orderNo) {
|
||||
System.out.println("创建订单:" + orderNo);
|
||||
return "订单创建成功:" + orderNo;
|
||||
}
|
||||
|
||||
// 模拟删除订单(故意抛出异常)
|
||||
public void deleteOrder(String orderNo) {
|
||||
System.out.println("删除订单:" + orderNo);
|
||||
throw new RuntimeException("删除订单失败:订单不存在");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  AOP实现:
|
||||
```java
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
// 1. 标记为切面类
|
||||
@Aspect
|
||||
// 2. 交给Spring容器管理
|
||||
@Component
|
||||
public class LogAspect {
|
||||
|
||||
// 3. 定义切点:匹配OrderService类的所有方法
|
||||
@Pointcut("execution(* com.example.aopdemo.service.OrderService.*(..))")
|
||||
public void orderServicePointcut() {}
|
||||
|
||||
// 4. 前置通知:目标方法执行前执行
|
||||
@Before("orderServicePointcut()")
|
||||
public void beforeAdvice(JoinPoint joinPoint) {
|
||||
// 获取方法名和参数
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
Object[] args = joinPoint.getArgs();
|
||||
System.out.println("[前置通知] 方法:" + methodName + ",参数:" + Arrays.toString(args));
|
||||
}
|
||||
|
||||
// 5. 后置通知:目标方法执行后执行(无论是否异常)
|
||||
@After("orderServicePointcut()")
|
||||
public void afterAdvice(JoinPoint joinPoint) {
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
System.out.println("[后置通知] 方法:" + methodName + " 执行完毕");
|
||||
}
|
||||
|
||||
// 6. 返回通知:目标方法正常返回后执行
|
||||
@AfterReturning(value = "orderServicePointcut()", returning = "result")
|
||||
public void afterReturningAdvice(JoinPoint joinPoint, Object result) {
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
System.out.println("[返回通知] 方法:" + methodName + ",返回值:" + result);
|
||||
}
|
||||
|
||||
// 7. 异常通知:目标方法抛出异常后执行
|
||||
@AfterThrowing(value = "orderServicePointcut()", throwing = "ex")
|
||||
public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) {
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
System.out.println("[异常通知] 方法:" + methodName + ",异常:" + ex.getMessage());
|
||||
}
|
||||
|
||||
// 8. 环绕通知:包裹目标方法执行(功能最强)
|
||||
@Around("orderServicePointcut()")
|
||||
public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
|
||||
String methodName = proceedingJoinPoint.getSignature().getName();
|
||||
Object[] args = proceedingJoinPoint.getArgs();
|
||||
|
||||
// 前置逻辑
|
||||
System.out.println("[环绕通知-前置] 方法:" + methodName + ",参数:" + Arrays.toString(args));
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
// 执行目标方法
|
||||
result = proceedingJoinPoint.proceed();
|
||||
// 返回后逻辑
|
||||
System.out.println("[环绕通知-返回] 方法:" + methodName + ",返回值:" + result);
|
||||
} catch (Throwable e) {
|
||||
// 异常逻辑
|
||||
System.out.println("[环绕通知-异常] 方法:" + methodName + ",异常:" + e.getMessage());
|
||||
throw e; // 必须抛出异常,否则上层无法捕获
|
||||
} finally {
|
||||
// 后置逻辑
|
||||
System.out.println("[环绕通知-后置] 方法:" + methodName + " 执行完毕");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  测试:
|
||||
```java
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
public class OrderServiceTest {
|
||||
|
||||
@Autowired
|
||||
private OrderService orderService;
|
||||
|
||||
// 测试正常方法(createOrder)
|
||||
@Test
|
||||
public void testCreateOrder() {
|
||||
orderService.createOrder("ORDER_20251225");
|
||||
}
|
||||
|
||||
// 测试异常方法(deleteOrder)
|
||||
@Test
|
||||
public void testDeleteOrder() {
|
||||
try {
|
||||
orderService.deleteOrder("ORDER_9999");
|
||||
} catch (Exception e) {
|
||||
// 捕获异常,避免测试中断
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  测试结果:
|
||||
```plaintext
|
||||
[环绕通知-前置] 方法:createOrder,参数:[ORDER_20251225]
|
||||
[前置通知] 方法:createOrder,参数:[ORDER_20251225]
|
||||
创建订单:ORDER_20251225
|
||||
[环绕通知-返回] 方法:createOrder,返回值:订单创建成功:ORDER_20251225
|
||||
[返回通知] 方法:createOrder,返回值:订单创建成功:ORDER_20251225
|
||||
[后置通知] 方法:createOrder 执行完毕
|
||||
[环绕通知-后置] 方法:createOrder 执行完毕
|
||||
|
||||
|
||||
[环绕通知-前置] 方法:deleteOrder,参数:[ORDER_9999]
|
||||
[前置通知] 方法:deleteOrder,参数:[ORDER_9999]
|
||||
删除订单:ORDER_9999
|
||||
[环绕通知-异常] 方法:deleteOrder,异常:删除订单失败:订单不存在
|
||||
[异常通知] 方法:deleteOrder,异常:删除订单失败:订单不存在
|
||||
[后置通知] 方法:deleteOrder 执行完毕
|
||||
[环绕通知-后置] 方法:deleteOrder 执行完毕
|
||||
```
|
||||
|
||||
# 四、切点表达式
|
||||
| 切点表达式 | 说明 |
|
||||
|------------|------|
|
||||
| `execution(* com.example.service.*.*(..))` | 匹配 com.example.service 包下所有类的所有方法 |
|
||||
| `execution(public String com.example.service.OrderService.create*(String))` | 匹配 OrderService 类中公共、返回值为 String、方法名以 create 开头、参数为 String 的方法 |
|
||||
| `execution(* com.example..*Service.*(..))` | 匹配 com.example 包及其子包下所有以 Service 结尾的类的所有方法 |
|
||||
| `@annotation(com.example.annotation.Log)` | 匹配标注了 @Log 自定义注解的方法 |
|
||||
|
||||
  组合切点:
|
||||
```java
|
||||
// 匹配OrderService的方法 且 方法名包含"Order"
|
||||
@Pointcut("execution(* com.example.service.OrderService.*(..)) && execution(* *Order*(..))")
|
||||
public void combinePointcut() {}
|
||||
```
|
||||
|
||||
# 五、总结
|
||||
  通俗的解释,AOP本质是:找目标(通过切点规则,精准找到要 “加戏” 的方法 / 类)→ 造代理(Spring 自动给这些目标生成一个 “中间商”(代理对象))→ 加戏(在目标方法真正执行前 / 后 / 出问题时,让这个 “中间商” 帮你干额外的活)。
|
||||
|
||||
::: tip
|
||||
基于 Spring AOP 的知名框架/组件汇总
|
||||
| 框架/组件 | 核心作用 | Spring AOP 应用说明 |
|
||||
|-----------|----------|---------------------|
|
||||
| AspectJ | 提供切点表达式语法、注解解析(@Aspect/@Pointcut 等) | Spring AOP 仅复用其语法规则,底层仍为自身动态代理(未使用 AspectJ 编译期织入) |
|
||||
| Spring Core | 容器管理、动态代理创建(JDK/CGLIB) | 负责切面/目标类的生命周期管理,生成代理对象并完成织入逻辑 |
|
||||
| Spring Transaction | 声明式事务管理 | 切点:匹配 @Transactional 注解方法;<br>通知:方法执行前开启事务、执行后提交、异常时回滚 |
|
||||
| Spring Cache | 声明式缓存管理 | 切点:匹配 @Cacheable/@CacheEvict 等注解方法;<br>通知:方法执行前查缓存、执行后更新缓存 |
|
||||
| spring-boot-starter-aop | AOP 基础依赖封装 | 自动引入 AspectJ + Spring AOP 核心,开箱即用 |
|
||||
| Spring Security | 安全权限管理 | 切点:匹配 @PreAuthorize 等权限注解方法;<br>通知:方法执行前校验用户权限,无权限抛异常 |
|
||||
| MyBatis-Plus | 增强 MyBatis CRUD 操作 | 切点:匹配 Mapper 接口方法/标注注解的实体类;<br>通知:织入分页逻辑、注解(@TableName)解析等 |
|
||||
| Shiro | 权限管理框架 | 切点:匹配 @RequiresPermissions 注解方法;<br>通知:方法执行前校验用户权限 |
|
||||
| Seata | 分布式事务框架 | 切点:匹配 @GlobalTransactional 注解方法;<br>通知:织入全局事务的开启、提交、回滚逻辑 |
|
||||
| Sentinel | 流量控制/熔断降级框架 | 切点:匹配 @SentinelResource 注解方法;<br>通知:方法执行前校验流量阈值、熔断规则 |
|
||||
| Dynamic-TP | 动态线程池框架 | 切点:匹配线程池执行方法;<br>通知:织入线程池耗时、队列长度等监控逻辑 |
|
||||
| p6spy | 数据库 SQL 监控 | 切点:拦截 JDBC 操作方法;<br>通知:织入 SQL 打印、执行耗时统计逻辑 |
|
||||
| dynamic-datasource-spring-boot-starter | 动态数据源切换 | 切点:匹配 @DS 注解方法;<br>通知:方法执行前切换数据源、执行后恢复默认数据源 |
|
||||
| xxl-job | 分布式任务调度 | 切点:匹配任务执行方法;<br>通知:织入任务执行日志、监控指标采集逻辑 |
|
||||
| Spring Cloud Sleuth | 微服务链路追踪 | 切点:拦截 HTTP/RPC 调用方法;<br>通知:织入 traceId/spanId 传递、链路信息采集逻辑 |
|
||||
:::
|
||||
@@ -4,7 +4,8 @@
|
||||
---
|
||||
|
||||
# 一、IOC
|
||||
  Spring框架的核心是IOC(控制反转)容器,它负责管理应用程序中的对象(称为Bean)的创建、配置和组装。
|
||||
  Spring框架的核心是IOC(控制反转)容器,它负责管理应用程序中的对象(称为Bean)的创建、配置和组装。
|
||||
  [IoC 容器](https://docs.springjava.cn/spring-framework/reference/core/beans.html)
|
||||
|
||||
# 二、Bean
|
||||
## 2.1 定义
|
||||
@@ -38,6 +39,12 @@ public class AppConfig {
|
||||
<bean id="userService" class="com.example.UserService"/>
|
||||
```
|
||||
|
||||
::: tip
|
||||
@Service、@Controller这些注解是@Component的衍生注解。
|
||||
@Component注解可以通过@Autowired自动注入,一般用于自己编写的业务模块,需要配合@ComponentScan启用组件扫描(实际开发中@SpringBootApplication注解已经包含了@ComponentScan注解)。
|
||||
@Bean注解一般用于注册第三方库的类。
|
||||
:::
|
||||
|
||||
## 2.4 作用域
|
||||
  控制 Bean 在容器中的实例数量和生命周期。Spring 提供多种作用域,默认是 singleton。通过 @Scope 注解指定,如 @Scope("prototype")。
|
||||
- singleton(单例):容器中只有一个实例,所有请求共享该实例(默认值)。
|
||||
@@ -46,25 +53,32 @@ public class AppConfig {
|
||||
- session(Web):每个会话创建一个实例,在会话有效期内有效。
|
||||
|
||||
## 2.5 生命周期
|
||||
1. 实例化(Instantiation):容器通过构造器创建 Bean 实例(分配内存)。
|
||||
2. 属性注入(Population):容器将依赖的 Bean 注入到当前 Bean 的字段或方法(如 @Autowired 标注的依赖)。
|
||||
3. 初始化前(Post-processing before initialization):
|
||||
执行 BeanPostProcessor 的 postProcessBeforeInitialization 方法(AOP 代理生成在此阶段)。
|
||||
4. 初始化(Initialization):
|
||||
调用 @PostConstruct 注解的方法(JSR-250 标准,推荐)。
|
||||
若实现 InitializingBean 接口,调用 afterPropertiesSet() 方法。
|
||||
执行自定义初始化方法(如 @Bean(initMethod = "init") 中指定的 init 方法)。
|
||||
5. 初始化后(Post-processing after initialization):
|
||||
执行 BeanPostProcessor 的 postProcessAfterInitialization 方法。
|
||||
6. 使用(In Use):Bean 被应用程序调用。
|
||||
7. 销毁(Destruction):
|
||||
调用 @PreDestroy 注解的方法(JSR-250 标准,推荐)。
|
||||
若实现 DisposableBean 接口,调用 destroy() 方法。
|
||||
执行自定义销毁方法(如 @Bean(destroyMethod = "destroy") 中指定的 destroy 方法)。
|
||||
1. 实例化(Instantiation)
|
||||
容器通过构造器创建 Bean 实例(分配内存)。
|
||||
|
||||
2. 属性注入(Population)
|
||||
容器将依赖的 Bean 注入到当前 Bean 的字段或方法(如 @Autowired 标注的依赖)。
|
||||
|
||||
3. 初始化前(Post-processing before initialization)
|
||||
执行 BeanPostProcessor 的 postProcessBeforeInitialization 方法(AOP 代理生成在此阶段)。
|
||||
|
||||
4. 初始化(Initialization)
|
||||
调用 @PostConstruct 注解的方法(JSR-250 标准,推荐)。
|
||||
若实现 InitializingBean 接口,调用 afterPropertiesSet() 方法。
|
||||
执行自定义初始化方法(如 @Bean(initMethod = "init") 中指定的 init 方法)。
|
||||
|
||||
5. 初始化后(Post-processing after initialization)
|
||||
执行 BeanPostProcessor 的 postProcessAfterInitialization 方法。
|
||||
|
||||
6. 使用(In Use)
|
||||
Bean 被应用程序调用。
|
||||
|
||||
7. 销毁(Destruction)
|
||||
调用 @PreDestroy 注解的方法(JSR-250 标准,推荐)。
|
||||
若实现 DisposableBean 接口,调用 destroy() 方法。
|
||||
执行自定义销毁方法(如 @Bean(destroyMethod = "destroy") 中指定的 destroy 方法)。
|
||||

|
||||
|
||||
|
||||
# 三、BeanDefinition
|
||||
## 3.1 定义
|
||||
  BeanDefinition是Spring框架中描述Bean的元数据对象,它包含了创建Bean实例所需的所有信息。
|
||||
@@ -172,7 +186,7 @@ public interface BeanFactory {
|
||||
}
|
||||
```
|
||||
|
||||
## 4.4 实现示例
|
||||
## 4.4 使用示例
|
||||
```java
|
||||
// 创建BeanFactory实例
|
||||
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
|
||||
@@ -193,7 +207,7 @@ UserService userService = beanFactory.getBean("userService", UserService.class);
|
||||
- 延迟加载:默认在第一次请求时才创建Bean实例
|
||||
- 基础功能:提供最基本的IOC功能
|
||||
|
||||
# 五、ApplicationContext
|
||||
# 五、ApplicationContext ★★★
|
||||
## 5.1 定义
|
||||
  ApplicationContext是BeanFactory的子接口,在BeanFactory的基础上提供了更多企业级功能,是Spring容器的完整实现。
|
||||
|
||||
@@ -544,5 +558,5 @@ protected void finishRefresh() {
|
||||
- 标记整个容器启动流程结束,容器进入就绪状态
|
||||
|
||||
### 5.4.13 总结
|
||||
  refresh()方法按固定顺序执行12个步骤,核心可归纳为:准备容器环境 → 解析并注册Bean定义→ 初始化事件、消息等基础设施 → 实例化所有非懒加载的单例Bean→ 完成启动并发布事件。整个过程通过模板方法模式定义,确保了扩展性和一致性。
|
||||
  核心在于两个关键扩展机制:BeanFactoryPostProcessor在Bean定义阶段介入,负责修改和注册Bean的定义信息;BeanPostProcessor在Bean实例化阶段介入,负责对创建好的Bean实例进行增强和包装。
|
||||
  refresh()方法按固定顺序执行12个步骤,核心可归纳为:**准备容器环境 → 解析并注册Bean定义→ 初始化事件、消息等基础设施 → 实例化所有非懒加载的单例Bean→ 完成启动并发布事件**。整个过程通过模板方法模式定义,确保了扩展性和一致性。
|
||||
  核心在于两个关键扩展机制:**BeanFactoryPostProcessor在Bean定义阶段介入,负责修改和注册Bean的定义信息;BeanPostProcessor在Bean实例化阶段介入,负责对创建好的Bean实例进行增强和包装。**
|
||||
@@ -4,37 +4,38 @@
|
||||
---
|
||||
|
||||
# 一、基础概念
|
||||
  Spring MVC(Spring Model-View-Controller)是 Spring Framework 中的一部分,它是一个基于 **<font style="color:#DF2A3F;">请求驱动</font>** 的 Web 框架,主要用于构建 Web 应用程序,并且遵循 MVC(模型-视图-控制器) 设计模式。它将 Web 应用的业务逻辑、用户界面和请求处理分离,使得代码更加模块化和可维护。
|
||||
  Spring MVC(Spring Model-View-Controller)是 Spring Framework 中的一部分,它是一个基于 **请求驱动** 的 Web 框架,主要用于构建 Web 应用程序,并且遵循 MVC(模型-视图-控制器) 设计模式。它将 Web 应用的业务逻辑、用户界面和请求处理分离,使得代码更加模块化和可维护。
|
||||
  [Spring Web MVC](https://docs.springjava.cn/spring-framework/reference/web/webmvc.html)
|
||||
|
||||
# 二、具体流程
|
||||

|
||||
|
||||
1. **客户端发起HTTP请求**
|
||||
1. 客户端发起HTTP请求
|
||||
|
||||
  客户端(浏览器、API 客户端等)向服务器发送 HTTP 请求。请求包含了目标 URL、HTTP 方法(GET、POST 等)以及可能的请求参数。
|
||||
前端通过发送 HTTP 请求(通常是 AJAX 请求)到后端的 API 端点,并期望获得响应(通常是 JSON 数据)。
|
||||
|
||||
2. **请求到达 DispatcherServlet**
|
||||
2. 请求到达 DispatcherServlet
|
||||
|
||||
  所有 HTTP 请求都会先到达 Spring MVC 的核心组件:**<font style="color:#DF2A3F;">DispatcherServlet</font>**。它是前端控制器,负责将请求分发给正确的处理器。DispatcherServlet 是所有请求的入口,Spring Boot 会自动配置它。
|
||||
  所有 HTTP 请求都会先到达 Spring MVC 的核心组件:**DispatcherServlet**。它是前端控制器,负责将请求分发给正确的处理器。DispatcherServlet 是所有请求的入口,Spring Boot 会自动配置它。
|
||||
  DispatcherServlet 接受所有的 HTTP 请求,根据请求 URL 和 HTTP 方法,定位匹配的控制器方法,将请求交给相应的 Handler Mapping 来匹配控制器。在请求处理前后,执行 拦截器(可选)。最终通过 View Resolver 返回一个合适的视图(对于 RESTful API,通常返回 JSON 格式的数据)。
|
||||
|
||||
3. **查找处理器(Handler Mapping)**
|
||||
3. 查找处理器(Handler Mapping)
|
||||
|
||||
  DispatcherServlet 根据请求的 URL 和 HTTP 方法,查找与之匹配的处理器方法(通常是一个控制器中的方法)。这是由 HandlerMapping 完成的。Spring MVC 提供了多种方式来映射 URL 到控制器方法,例如 @RequestMapping、@GetMapping 等注解。
|
||||
  @RestController:该注解标记的类会返回 JSON 数据。
|
||||
  @RequestMapping:用于映射 URL 路径和 HTTP 方法。
|
||||
  @GetMapping、@PostMapping 等:简化版本的 @RequestMapping。
|
||||
|
||||
4. **执行拦截器(Interceptor)**
|
||||
4. 执行拦截器(Interceptor)
|
||||
|
||||
  在请求到达控制器之前,DispatcherServlet 会依次执行配置的拦截器。拦截器主要用于日志记录、安全检查、请求参数的预处理等。
|
||||
  preHandle:请求进入控制器方法之前。
|
||||
  postHandle:请求完成后,视图渲染之前。
|
||||
  afterCompletion:视图渲染完成后。
|
||||
  **<font style="color:#DF2A3F;">如果拦截器的 preHandle 方法返回 false,请求会被拦截,后续的处理不会继续进行。</font>**
|
||||
  **如果拦截器的 preHandle 方法返回 false,请求会被拦截,后续的处理不会继续进行。**
|
||||
|
||||
5. **控制器方法处理请求(Handler Execution)**
|
||||
5. 控制器方法处理请求(Handler Execution)
|
||||
|
||||
  DispatcherServlet 调用合适的控制器方法来处理请求。控制器方法负责处理请求的业务逻辑,并返回一个 Java 对象,这个对象会被自动转换成 JSON 格式返回给客户端。
|
||||
  Spring 通过 @ResponseBody 注解(或 @RestController)实现将 Java 对象自动转换为 JSON 格式。默认情况下,Spring 使用 Jackson 来将 Java 对象序列化成 JSON 数据。
|
||||
@@ -43,7 +44,7 @@
|
||||
  @RequestParam:用于获取单个请求参数。
|
||||
  @PathVariable:用于从 URL 中提取变量(如 /users/{id} 中的 {id})。
|
||||
|
||||
6. **响应返回客户端**
|
||||
6. 响应返回客户端
|
||||
|
||||
  DispatcherServlet 将处理完的结果(JSON 数据)返回给客户端。响应数据通常会包含:
|
||||
  **状态码**:如 200 OK(成功)、201 Created(资源创建成功)、404 Not Found(资源未找到)、500 Internal Server Error(服务器错误)等。
|
||||
72
docs/Web/SpringBoot/SpringBoot-Annotation.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: SpingBoot注解
|
||||
date: 2025-12-30
|
||||
---
|
||||
|
||||
# 一、注解
|
||||
## 1.1 定义
|
||||
  注解(Annotation)是 JDK5.0 引入的特性,可以理解为:给代码添加的 “元数据”(描述数据的数据),就像给代码贴标签,**本身不直接影响代码执行,但可以被编译器、框架(如 SpringBoot)读取并做相应处理**。
|
||||
|
||||
## 1.2 分类
|
||||
| 类型 | 核心作用 | 典型示例 |
|
||||
|----------------|--------------------|------------------|
|
||||
| 源码注解 | 仅在源码编译阶段生效,编译后注解消失 | `@Override` |
|
||||
| 编译时注解 | 编译期生效,注解信息保留到class文件,但JVM运行时不加载 | Lombok的`@Data` |
|
||||
| 运行时注解 | 整个生命周期都存在(源码→编译→运行),可通过反射动态获取注解信息 | SpringBoot的`@RestController`、`@Service`、`@Transactional` |
|
||||
|
||||
## 1.3 示例
|
||||
```java
|
||||
import java.lang.annotation.*;
|
||||
|
||||
// 1. 注解的元注解(描述注解的注解)
|
||||
@Target(ElementType.METHOD) // 注解作用在方法上
|
||||
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,可通过反射获取
|
||||
@Documented // 生成Javadoc时包含该注解
|
||||
public @interface MyAnnotation {
|
||||
// 注解的属性(类似方法,可设置默认值)
|
||||
String value() default "默认描述";
|
||||
int num() default 0;
|
||||
}
|
||||
|
||||
// 2. 使用自定义注解
|
||||
public class AnnotationTest {
|
||||
@MyAnnotation(value = "测试方法", num = 10)
|
||||
public void test() {
|
||||
System.out.println("执行测试方法");
|
||||
}
|
||||
|
||||
// 3. 通过反射读取注解
|
||||
public static void main(String[] args) throws NoSuchMethodException {
|
||||
// 获取方法对象
|
||||
java.lang.reflect.Method method = AnnotationTest.class.getMethod("test");
|
||||
// 判断方法是否有该注解
|
||||
if (method.isAnnotationPresent(MyAnnotation.class)) {
|
||||
// 获取注解实例
|
||||
MyAnnotation annotation = method.getAnnotation(MyAnnotation.class);
|
||||
// 读取注解属性
|
||||
System.out.println("注解value:" + annotation.value()); // 输出:测试方法
|
||||
System.out.println("注解num:" + annotation.num()); // 输出:10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  @Target:指定注解能作用的位置(如ElementType.METHOD= 方法、ElementType.TYPE= 类 / 接口、ElementType.FIELD= 字段)。
|
||||
  @Retention:指定注解的保留阶段(RUNTIME是 SpringBoot 注解最常用的)
|
||||
  首先定义注解的@Target和@Retention信息,然后设置注解的属性,类似于方法的参数,比如这里的value和num,需要在使用的时候通过命名参数的形式传递过来。如果是RUNTIME类型的注解,可以通过反射来获取方法和注解的参数,实现自定义逻辑功能。
|
||||
|
||||
# 二、SpringBoot核心注解
|
||||
## 2.1 启动类注解
|
||||
  @SpringBootApplication为SpringBoot启动类注解,由以下三个注解组成:
|
||||
1. @Configuration:标记类为配置类(替代 XML 配置)
|
||||
2. @EnableAutoConfiguration:开启自动配置(SpringBoot 核心,自动配置 Tomcat、数据库连接等)
|
||||
3. @ComponentScan:扫描当前包及子包下的 @Component、@Service 等注解的类,纳入 Spring 容器管理
|
||||
|
||||
## 2.2 组件注册注解
|
||||
| 注解 | 作用 | 使用场景 |
|
||||
|----------------|--------------------------|------------------------------------------------------------------|
|
||||
| @Component | 通用组件注解 | 通用工具类、非业务层类 |
|
||||
| @Controller | 控制器注解 | MVC 的控制层(返回页面) |
|
||||
| @RestController| REST 控制器 | API 接口层(返回 JSON/XML,= @Controller + @ResponseBody) |
|
||||
| @Service | 服务层注解 | 业务逻辑层 |
|
||||
| @Repository | 数据访问层注解 | DAO 层 / 持久层(如 MyBatis 的 Mapper 接口) |
|
||||
120
docs/Web/SpringBoot/SpringBoot-Quartz.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: SpringBoot Quarzt简介和使用
|
||||
date: 2026-06-08
|
||||
---
|
||||
|
||||
# 一、基本概念
|
||||
  Quartz 是 Java 生态最主流的企业级作业调度框架,支持简单间隔触发、Cron 表达式、持久化到数据库、集群部署。
|
||||
- Job(任务) — 实现 execute()方法,写你要执行的业务逻辑
|
||||
- Trigger(触发器) — 定义"什么时候执行",常用 SimpleTrigger(固定间隔)和 CronTrigger(Cron 表达式)
|
||||
- Scheduler(调度器) — 把 Job + Trigger 绑定,负责启动和停止调度
|
||||
|
||||
# 二、原理
|
||||
  Job相当于员工,只知道干活;Trigger 相当于排班表,决定什么时候干活;Scheduler 相当于老板,统一管理 Job 和 Trigger。
|
||||
  Quartz内部有一个核心线程 QuartzSchedulerThread,会不停的检查哪个员工需要在啥时候干活,但并不是按照固定间隔轮询的方式,而是计算出最近一次 Trigger 的触发时间,通过 wait()精确等待,当等待结束后,调度线程会唤醒线程池中的工作线程,执行到点的 Job。如果当前没有任何 Trigger了,默认最多等待 30 秒,然后再重新扫描。如果此时有新增/修改/删除任务了,调度线程会被 notifyAll()立即唤醒,重新计算最近的触发时间。
|
||||
|
||||
# 三、安装
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-quartz</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
  持久化:
|
||||
```yml
|
||||
spring:
|
||||
quartz:
|
||||
job-store-type: jdbc
|
||||
```
|
||||
|
||||
  启用后会在数据库自动建表。
|
||||
|
||||
# 四、使用
|
||||
## 4.1 定义Job
|
||||
```java
|
||||
@Component
|
||||
@DisallowConcurrentExecution // 防止同一 Job 并发执行
|
||||
public class QuartzJob extends QuartzJobBean {
|
||||
@Override
|
||||
protected void executeInternal(JobExecutionContext context) {
|
||||
// 获取任务内容
|
||||
JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
|
||||
|
||||
// TODO 根据任务内容执行任务
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4.2 定义Trigger
|
||||
```java
|
||||
@Component
|
||||
public class QuartzUtils {
|
||||
public static JobDetail buildJobDetail(Task task, JobKey jobKey) {
|
||||
JobDataMap jobDataMap = new JobDataMap();
|
||||
jobDataMap.put("id", task.getId());
|
||||
jobDataMap.put("type", task.getType());
|
||||
jobDataMap.put("content", task.getContent());
|
||||
|
||||
return JobBuilder.newJob(QuartzJob.class)
|
||||
.withIdentity(jobKey) // 使用 id 作为唯一标识
|
||||
.usingJobData(jobDataMap)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static Trigger buildTrigger(Task task, JobKey jobKey) {
|
||||
Trigger trigger;
|
||||
// 判断任务类型:Cron 任务或者一次性任务
|
||||
if (task.getCronExpression() != null && !task.getCronExpression().isEmpty()) {
|
||||
// 定时任务:使用 Cron 表达式
|
||||
trigger = TriggerBuilder.newTrigger()
|
||||
.withIdentity(jobKey.getName() + "Trigger")
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(task.getCronExpression()))
|
||||
.forJob(jobKey)
|
||||
.build();
|
||||
} else if (task.getExecuteTime() != null) {
|
||||
// 一次性任务:使用 executeTime
|
||||
trigger = TriggerBuilder.newTrigger()
|
||||
.withIdentity(jobKey.getName() + "Trigger")
|
||||
.startAt(task.getExecuteTime())
|
||||
.forJob(jobKey)
|
||||
.build();
|
||||
} else {
|
||||
// 如果没有 Cron 表达式和 executeTime,任务不能调度,抛出异常
|
||||
throw new CustomException("任务没有有效的 Cron 表达式或执行时间");
|
||||
}
|
||||
|
||||
return trigger;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
这里的Task可以根据实际情况定义:
|
||||
```java
|
||||
@Data
|
||||
public class Task {
|
||||
private Long id;
|
||||
private String type;
|
||||
private String content;
|
||||
private Date executeTime;
|
||||
private String cronExpression;
|
||||
}
|
||||
```
|
||||
:::
|
||||
|
||||
## 4.3 执行Scheduler
|
||||
```java
|
||||
@Resource
|
||||
private Scheduler scheduler;
|
||||
|
||||
// 新增/更新
|
||||
JobKey jobKey = new JobKey(task.getId().toString());
|
||||
scheduler.scheduleJob(QuartzUtils.buildJobDetail(task, jobKey), QuartzUtils.buildTrigger(task, jobKey));
|
||||
|
||||
// 更新
|
||||
scheduler.rescheduleJob(QuartzUtils.buildJobDetail(task, jobKey), QuartzUtils.buildTrigger(task, jobKey));
|
||||
|
||||
// 删除
|
||||
scheduler.deleteJob(jobKey);
|
||||
```
|
||||
170
docs/Web/SpringBoot/SpringBoot-Redis.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: SpringBoot Redis简介和使用
|
||||
date: 2026-03-22
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  Redis是一个高性能的开源内存数据库,用作缓存、数据库和消息中间件。它以极快的读写速度(基于内存存储)支持字符串、列表、哈希等多种数据结构。Redis提供数据持久化、主从复制、哨兵模式等高可用特性,广泛应用于缓存、会话存储、排行榜、消息队列等场景。
|
||||
|
||||
# 二、安装
|
||||
## 2.1 apt安装
|
||||
1. 通过apt包管理器安装:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install redis-server
|
||||
sudo systemctl enable redis-server
|
||||
```
|
||||
|
||||
2. 配置远程和密码访问(可选)
|
||||
|
||||
  打开`/etc/redis/redis.conf`文件,更改配置:
|
||||
```conf
|
||||
bind 0.0.0.0 ::1
|
||||
protected-mode yes
|
||||
requirepass password
|
||||
```
|
||||
|
||||
  `systemctl restart redis-server`重启服务。
|
||||
|
||||
## 2.2 docker安装
|
||||
```yml
|
||||
services:
|
||||
redis:
|
||||
image: redis:8.6.4
|
||||
container_name: redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
||||
- ./data:/data
|
||||
command: redis-server /usr/local/etc/redis/redis.conf
|
||||
```
|
||||
|
||||
  redis.conf:
|
||||
```conf
|
||||
bind 0.0.0.0
|
||||
protected-mode yes
|
||||
port 6379
|
||||
|
||||
requirepass password
|
||||
```
|
||||
|
||||
## 2.3 客户端
|
||||
[Tiny RDM](https://redis.tinycraft.cc/zh/)
|
||||
|
||||
# 三、SpringBoot使用
|
||||
## 3.1 引入依赖
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## 3.2 配置文件
|
||||
```yml
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1
|
||||
port: 6379
|
||||
password: 123456
|
||||
```
|
||||
|
||||
::: tip
|
||||
SpringBoot2中没有data层级
|
||||
:::
|
||||
|
||||
## 3.3 简单使用
|
||||
```java
|
||||
@Component
|
||||
public class RedisUtils {
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate redis;
|
||||
|
||||
public void set(String key, String value, long seconds) {
|
||||
redis.opsForValue().set(key, value, seconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public String get(String key) {
|
||||
return redis.opsForValue().get(key);
|
||||
}
|
||||
|
||||
public void delete(String key) {
|
||||
redis.delete(key);
|
||||
}
|
||||
|
||||
public Boolean hasKey(String key) {
|
||||
return redis.hasKey(key);
|
||||
}
|
||||
|
||||
// 匹配查询
|
||||
public Set<String> scanKeys(String pattern) {
|
||||
return redis.execute((RedisCallback<Set<String>>) connection -> {
|
||||
Set<String> keys = new HashSet<>();
|
||||
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
|
||||
|
||||
try (Cursor<byte[]> cursor = connection.keyCommands().scan(options)) {
|
||||
while (cursor.hasNext()) {
|
||||
keys.add(new String(cursor.next(), StandardCharsets.UTF_8));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Redis SCAN 执行失败,pattern=" + pattern, e);
|
||||
}
|
||||
|
||||
return keys;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
生产环境中使用`scan`命令批量查询`key`值,`keys`命令会阻塞`Redis`线程。
|
||||
:::
|
||||
|
||||
# 四、实战
|
||||
## 4.1 设备在线功能
|
||||
  实现一个统计设备是否实时在线的功能。
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/device")
|
||||
public class DeviceController {
|
||||
|
||||
@Resource
|
||||
private RedisUtils redisUtils;
|
||||
|
||||
private static final String HEARTBEAT_KEY = "device:heartbeat:";
|
||||
private static final long EXPIRE_SECONDS = 40;
|
||||
|
||||
/**
|
||||
* 设备心跳接口
|
||||
* 设备每20-30秒调用一次
|
||||
* 心跳key 40秒后过期,过期即认为设备离线
|
||||
*/
|
||||
@PostMapping("/heartbeat")
|
||||
public Boolean heartbeat(@RequestParam String deviceId) {
|
||||
redisUtils.set(HEARTBEAT_KEY + deviceId, String.valueOf(System.currentTimeMillis()), EXPIRE_SECONDS);
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前在线设备列表
|
||||
*/
|
||||
@GetMapping("/online")
|
||||
public Set<String> getOnlineDevices() {
|
||||
Set<String> keys = redisUtils.scanKeys(HEARTBEAT_KEY + "*");
|
||||
Set<String> deviceIds = new HashSet<>();
|
||||
|
||||
for (String key : keys) {
|
||||
String deviceId = key.substring(HEARTBEAT_KEY.length());
|
||||
deviceIds.add(deviceId);
|
||||
}
|
||||
|
||||
return deviceIds;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  客户端每30秒调用一次`/device/heartbeat`接口。
|
||||
134
docs/Web/SpringBoot/SpringBoot-RestClient.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: SpringBoot RestClient简介
|
||||
date: 2026-01-06
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  RestClient 是 Spring Framework 6.1(及对应的 Spring Boot 3.2+)推出的新一代同步 HTTP 客户端,设计目标是替代传统的 RestTemplate(Spring 已标记 RestTemplate 为维护模式,不再新增功能),同时结合了 WebClient 的流畅 API 设计,又保持了 RestTemplate 的同步、简单易用的特点。
|
||||
|
||||
# 二、使用
|
||||
## 2.1 创建实例
|
||||
```java
|
||||
@Configuration
|
||||
public class RestClientConfig {
|
||||
final String baseUrl = "https://demo.com";
|
||||
|
||||
@Bean
|
||||
public RestClient restClient() {
|
||||
|
||||
return RestClient.builder()
|
||||
// 基础RURL
|
||||
.baseUrl(baseUrl)
|
||||
// 默认请求头 请求体为JSON格式
|
||||
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
// 默认请求头 响应体为JSON格式
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2.2 HTTP请求
|
||||
```java
|
||||
@Service
|
||||
public class RestClientService {
|
||||
|
||||
@Resource
|
||||
private RestClient restClient;
|
||||
|
||||
// 1. GET 请求:获取单个资源
|
||||
public Post getPostById(Long id) {
|
||||
return restClient.get() // 指定 GET 方法
|
||||
.uri("/posts/{id}", id) // 请求路径(支持路径参数)
|
||||
.retrieve() // 执行请求并获取响应
|
||||
.body(Post.class); // 解析响应体为Post实体
|
||||
}
|
||||
|
||||
// 2. GET 请求:获取列表并解析为 Map
|
||||
public Map<String, Object>[] getAllPosts() {
|
||||
return restClient.get()
|
||||
.uri("/posts")
|
||||
.retrieve()
|
||||
.body(Map[].class); // 解析为 Map 数组(适合未知结构的 JSON)
|
||||
}
|
||||
|
||||
// 3. POST 请求:提交数据并获取响应
|
||||
public Boolean createPost(Post post) {
|
||||
return restClient.post() // 指定 POST 方法
|
||||
.uri("/posts")
|
||||
.body(post) // 设置请求体
|
||||
.retrieve()
|
||||
.body(Boolean.class);
|
||||
}
|
||||
|
||||
// 4. PUT 请求:更新资源
|
||||
public Boolean updatePost(Long id, Post post) {
|
||||
return restClient.put() // 指定 PUT 方法
|
||||
.uri("/posts/{id}", id)
|
||||
.body(post)
|
||||
.retrieve()
|
||||
.body(Boolean.class);
|
||||
}
|
||||
|
||||
// 5. DELETE 请求:删除资源
|
||||
public void deletePost(Long id) {
|
||||
restClient.delete() // 指定 DELETE 方法
|
||||
.uri("/posts/{id}", id)
|
||||
.retrieve(); // DELETE 请求通常无响应体
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
在.retrieve()之前的是请求阶段,在此之后是响应阶段。
|
||||
因此在此之前的.body()是请求体,在此之后的.body()是响应体。
|
||||
:::
|
||||
|
||||
## 2.3 进阶用法
|
||||
### 2.3.1 设置超时时间
|
||||
```java
|
||||
@Bean
|
||||
public RestClient restClient() {
|
||||
// 创建请求工厂,设置超时
|
||||
ClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory() {{
|
||||
setConnectTimeout(3000); // 连接超时 3 秒
|
||||
setReadTimeout(5000); // 读取超时 5 秒
|
||||
}};
|
||||
|
||||
return RestClient.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.requestFactory(factory) // 设置请求工厂
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3.2 添加请求拦截器
|
||||
```java
|
||||
@Bean
|
||||
public RestClient restClient() {
|
||||
return RestClient.builder()
|
||||
.baseUrl(baseUrl)
|
||||
// 添加拦截器
|
||||
.requestInterceptor(request -> {
|
||||
request.getHeaders().add("Authorization", "Bearer your-token-here");
|
||||
})
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3.3 自定义响应处理
|
||||
```java
|
||||
public String getPostWithErrorHandling(Long id) {
|
||||
return restClient.get()
|
||||
.uri("/posts/{id}", id)
|
||||
.retrieve()
|
||||
// 自定义状态码处理
|
||||
.onStatus(status -> status.is4xxClientError(), (request, response) -> {
|
||||
throw new RuntimeException("客户端错误:" + response.getStatusCode() + ",路径:" + request.getURI());
|
||||
})
|
||||
.onStatus(status -> status.is5xxServerError(), (request, response) -> {
|
||||
throw new RuntimeException("服务端错误:" + response.getStatusCode());
|
||||
})
|
||||
.body(String.class);
|
||||
}
|
||||
```
|
||||
100
docs/Web/SpringBoot/SpringBoot-RocketMQ.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: SpringBoot RocketMQ简介和使用
|
||||
date: 2026-06-10
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  [RocketMQ](https://rocketmq.apache.org/zh/)Apache RocketMQ 是一款面向万亿级消息规模的 AI 原生异步通信引擎。诞生于阿里巴巴高并发电商场景,经过数千家企业的生产验证,RocketMQ 已从高性能消息队列演进为统一消息平台,横跨传统业务消息、事件流处理和新兴的 AI 原生通信三大范式。
|
||||
|
||||
# 二、安装
|
||||
## 2.1 docker安装
|
||||
```yml
|
||||
services:
|
||||
namesrv:
|
||||
image: apache/rocketmq:5.3.2
|
||||
container_name: rmqnamesrv
|
||||
ports:
|
||||
- 9876:9876
|
||||
networks:
|
||||
- rocketmq
|
||||
command: sh mqnamesrv
|
||||
broker:
|
||||
image: apache/rocketmq:5.3.2
|
||||
container_name: rmqbroker
|
||||
ports:
|
||||
- 10909:10909
|
||||
- 10911:10911
|
||||
- 10912:10912
|
||||
volumes:
|
||||
- ./broker.conf:/home/rocketmq/rocketmq-5.3.2/conf/broker.conf
|
||||
environment:
|
||||
- NAMESRV_ADDR=rmqnamesrv:9876
|
||||
depends_on:
|
||||
- namesrv
|
||||
networks:
|
||||
- rocketmq
|
||||
command: sh mqbroker -c /home/rocketmq/rocketmq-5.3.2/conf/broker.conf
|
||||
proxy:
|
||||
image: apache/rocketmq:5.3.2
|
||||
container_name: rmqproxy
|
||||
networks:
|
||||
- rocketmq
|
||||
depends_on:
|
||||
- broker
|
||||
- namesrv
|
||||
ports:
|
||||
- 8380:8080
|
||||
- 8381:8081
|
||||
restart: on-failure
|
||||
environment:
|
||||
- NAMESRV_ADDR=rmqnamesrv:9876
|
||||
command: sh mqproxy
|
||||
rocketmq-dashboard:
|
||||
image: apacherocketmq/rocketmq-dashboard:2.1.0
|
||||
container_name: rocketmq-dashboard
|
||||
environment:
|
||||
- JAVA_OPTS=-Drocketmq.namesrv.addr=rmqnamesrv:9876
|
||||
ports:
|
||||
- 8382:8082
|
||||
networks:
|
||||
- rocketmq
|
||||
depends_on:
|
||||
- namesrv
|
||||
restart: always
|
||||
networks:
|
||||
rocketmq:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
  broker.conf:
|
||||
```conf
|
||||
brokerIP1=192.168.1.100
|
||||
autoCreateTopicEnable=true
|
||||
```
|
||||
|
||||
::: tip
|
||||
这里一定要配置brokerIP1,否则Broker的地址是docker的内网地址,具体可以在控制台中查看Broker的地址。
|
||||
:::
|
||||
|
||||
# 三、SpringBoot使用
|
||||
## 3.1 引入依赖
|
||||
```xml
|
||||
<!-- RocketMQ -->
|
||||
<dependency>
|
||||
<groupId>org.apache.rocketmq</groupId>
|
||||
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||
<version>2.3.6</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
::: tip
|
||||
需要加上版本号,SpringBoot3使用2.3以上的版本。
|
||||
:::
|
||||
|
||||
## 3.2 配置文件
|
||||
```yml
|
||||
rocketmq:
|
||||
name-server: 192.168.1.100:9876
|
||||
producer:
|
||||
group: seckill-producer-group
|
||||
```
|
||||
@@ -304,4 +304,56 @@ public class UserDTO {
|
||||
@PhoneNumber
|
||||
private String mobile;
|
||||
}
|
||||
```
|
||||
|
||||
# 四、ConfigurationProperties注解
|
||||
  @ConfigurationProperties 是 Spring Boot 提供的核心注解,用于将配置文件(如 application.yml/application.properties)中的属性批量绑定到 Java 类的字段上,相比 @Value 注解,它更适合管理一组有层级、有前缀的配置,代码更整洁、可维护性更高。
|
||||
|
||||
## 4.1 配置文件
|
||||
```yml
|
||||
app:
|
||||
name: SpringBootDemo
|
||||
version: 1.0.0
|
||||
author:
|
||||
name: 张三
|
||||
age: 25
|
||||
servers:
|
||||
- 192.168.1.100
|
||||
- 192.168.1.101
|
||||
- 192.168.1.102
|
||||
```
|
||||
|
||||
## 4.2 配置属性类
|
||||
```java
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
@Getter
|
||||
@Setter
|
||||
public class AppProperties {
|
||||
// 对应 app.name
|
||||
private String name;
|
||||
// 对应 app.version
|
||||
private String version;
|
||||
// 嵌套属性 - 对应 app.author
|
||||
private Author author;
|
||||
// 集合属性 - 对应 app.servers
|
||||
private List<String> servers;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Author {
|
||||
private String name;
|
||||
|
||||
private Integer age;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  属性类支持嵌套和集合属性。
|
||||
|
||||
## 4.3 使用
|
||||
  在Service或相应地方注入即可:
|
||||
```java
|
||||
@Autowired
|
||||
private AppProperties appProperties;
|
||||
```
|
||||
333
docs/Web/SpringBoot/SpringBoot-Thread.md
Normal file
@@ -0,0 +1,333 @@
|
||||
---
|
||||
title: SpringBoot 线程与事件
|
||||
date: 2026-06-02
|
||||
---
|
||||
|
||||
# 一、基本概念
|
||||
  **进程**:进程是操作系统资源分配的最小单位,每个进程都有独立的内存空间(代码段、数据段、堆、栈),比如打开一个应用程序,就是一个进程。
|
||||
  **线程**:线程是 CPU 调度的最小单位,线程共享所属进程的内存空间。 比如一个HTTP请求就是一个线程。
|
||||
  **协程**:协程是 用户态的轻量级“线程”,由程序自己调度,不依赖操作系统,切换成本极低。
|
||||
  创建线程方法:
|
||||
```java
|
||||
// 继承 Thread 不推荐
|
||||
class MyThread extends Thread {
|
||||
@Override
|
||||
public void run() {
|
||||
System.out.println("线程运行中:" + Thread.currentThread().getName());
|
||||
}
|
||||
}
|
||||
|
||||
public class Test {
|
||||
public static void main(String[] args) {
|
||||
MyThread t = new MyThread();
|
||||
t.start(); // ✅ 启动线程
|
||||
}
|
||||
}
|
||||
|
||||
// 实现 Runnable
|
||||
class MyRunnable implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
System.out.println("线程运行中:" + Thread.currentThread().getName());
|
||||
}
|
||||
}
|
||||
|
||||
public class Test2 {
|
||||
public static void main(String[] args) {
|
||||
Thread t = new Thread(new MyRunnable());
|
||||
t.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Lambda 写法
|
||||
Thread t = new Thread(() -> {
|
||||
System.out.println("Hello Thread");
|
||||
});
|
||||
t.start();
|
||||
|
||||
// 线程池
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||
2, // 核心线程数
|
||||
4, // 最大线程数
|
||||
60,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(100),
|
||||
Executors.defaultThreadFactory(),
|
||||
new ThreadPoolExecutor.AbortPolicy()
|
||||
);
|
||||
```
|
||||
|
||||
  **SpringBoot线程池**:
|
||||
```java
|
||||
// 1. 启动类加上 @EnableAsync 注解
|
||||
@EnableAsync
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
|
||||
}
|
||||
|
||||
// 2. 线程池配置
|
||||
@Bean
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(5);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(20);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
// 3. 调用方法上加上 @Async 注解
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
@Async
|
||||
public void createOrder() {
|
||||
System.out.println(Thread.currentThread().getName());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 二、案例
|
||||
  案例:电商秒杀系统。
|
||||
- 资源竞争:多个用户抢同一件商品(多线程抢锁)。
|
||||
- 异步解耦:扣减库存后,需要发短信、发优惠券(多线程并行)。
|
||||
- 系统解耦:订单系统不应依赖短信系统(事件驱动)。
|
||||
|
||||
# 二、JDK原生线程通信
|
||||
- synchronized:锁住库存,防止超卖。
|
||||
- wait/notify:通知消息线程“库存已扣,可以发通知了”。
|
||||
- Thread+ ExecutorService:实现多线程并行发通知。
|
||||
|
||||
## 2.1 共享资源
|
||||
```java
|
||||
/**
|
||||
* 商品库存(共享资源)
|
||||
*/
|
||||
public class Stock {
|
||||
// 假设只有 1 件库存
|
||||
public int count = 1;
|
||||
// 信号:是否有人下单成功
|
||||
public volatile boolean ordered = false;
|
||||
}
|
||||
```
|
||||
|
||||
## 2.2 下单线程
|
||||
```java
|
||||
/**
|
||||
* 用户下单线程
|
||||
*/
|
||||
public class OrderWorker extends Thread {
|
||||
|
||||
private final Stock stock;
|
||||
|
||||
public OrderWorker(Stock stock) {
|
||||
this.stock = stock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
// 1. 锁住库存,防止超卖
|
||||
synchronized (stock) {
|
||||
if (stock.count > 0) {
|
||||
System.out.println(Thread.currentThread().getName() + " 抢到锁,准备扣库存");
|
||||
stock.count--;
|
||||
stock.ordered = true;
|
||||
System.out.println("库存扣减成功,剩余:" + stock.count);
|
||||
stock.notifyAll(); // 通知消息线程
|
||||
} else {
|
||||
System.out.println(Thread.currentThread().getName() + " 没抢到,库存不足");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2.3 消息分发线程
|
||||
```java
|
||||
/**
|
||||
* 消息分发线程
|
||||
* 负责监听下单成功信号,并启动线程池发通知
|
||||
*/
|
||||
public class MessageDispatcher extends Thread {
|
||||
|
||||
private final Stock stock;
|
||||
private final ExecutorService pool;
|
||||
|
||||
public MessageDispatcher(Stock stock, ExecutorService pool) {
|
||||
this.stock = stock;
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
synchronized (stock) {
|
||||
try {
|
||||
while (!stock.ordered) {
|
||||
System.out.println("消息线程:等待下单信号...");
|
||||
stock.wait(); // 没下单就死等
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
System.out.println("消息线程:收到下单成功信号,开始并行发通知!");
|
||||
|
||||
// 2. 并行执行通知任务
|
||||
pool.execute(() -> System.out.println("线程池任务:发送短信"));
|
||||
pool.execute(() -> System.out.println("线程池任务:发放优惠券"));
|
||||
pool.execute(() -> System.out.println("线程池任务:更新用户积分"));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2.4 主程序
|
||||
```java
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class TraditionalDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
Stock stock = new Stock();
|
||||
ExecutorService pool = Executors.newFixedThreadPool(3);
|
||||
|
||||
// 消息线程先启动(等待)
|
||||
new MessageDispatcher(stock, pool).start();
|
||||
|
||||
// 模拟 3 个用户同时下单(竞争)
|
||||
new OrderWorker(stock).start();
|
||||
new OrderWorker(stock).start();
|
||||
new OrderWorker(stock).start();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 三、SpringBoot 异步方法
|
||||
- @Transactional:保证库存扣减的事务性。
|
||||
- @Async:将发短信、发券等耗时操作丢给线程池,立即释放 Tomcat 线程。
|
||||
|
||||
## 3.1 配置线程池
|
||||
```java
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
@Bean("orderExecutor")
|
||||
public Executor orderExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(5);
|
||||
executor.setThreadNamePrefix("Order-Async-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.2 订单服务
|
||||
```java
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
@Autowired
|
||||
private StockService stockService;
|
||||
@Autowired
|
||||
private NotifyService notifyService;
|
||||
|
||||
/**
|
||||
* 下单主流程
|
||||
*/
|
||||
public void createOrder() {
|
||||
// 1. 扣库存(核心,同步)
|
||||
boolean success = stockService.decreaseStock();
|
||||
if (!success) {
|
||||
throw new RuntimeException("库存不足");
|
||||
}
|
||||
|
||||
// 2. 异步通知(非核心,并行)
|
||||
notifyService.sendSms();
|
||||
notifyService.sendCoupon();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.3 通知服务
|
||||
```java
|
||||
@Service
|
||||
public class NotifyService {
|
||||
|
||||
// 指定使用哪个线程池
|
||||
@Async("orderExecutor")
|
||||
public void sendSms() {
|
||||
System.out.println(Thread.currentThread().getName() + " 发送短信");
|
||||
}
|
||||
|
||||
@Async("orderExecutor")
|
||||
public void sendCoupon() {
|
||||
System.out.println(Thread.currentThread().getName() + " 发放优惠券");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 四、SpringBoot 事件机制
|
||||
- 事件(Event):下单成功这个“事实”。
|
||||
- 发布者(Publisher):只负责发布事件,不关心后续。
|
||||
- 监听者(Listener):谁关心下单成功,谁就干活(且可以异步)。
|
||||
|
||||
## 4.1 定义事件
|
||||
```java
|
||||
public class OrderCreatedEvent {
|
||||
private final String orderId;
|
||||
|
||||
public OrderCreatedEvent(String orderId) {
|
||||
this.orderId = orderId;
|
||||
}
|
||||
|
||||
public String getOrderId() {
|
||||
return orderId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4.2 发布事件
|
||||
```java
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
@Autowired
|
||||
private StockService stockService;
|
||||
|
||||
@Autowired
|
||||
private ApplicationEventPublisher publisher;
|
||||
|
||||
public void createOrder() {
|
||||
// 1. 扣库存
|
||||
boolean success = stockService.decreaseStock();
|
||||
if (success) {
|
||||
// 2. 发布事件(我不关心谁处理)
|
||||
publisher.publishEvent(new OrderCreatedEvent("ORDER_123"));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4.3 监听事件
|
||||
```java
|
||||
@Component
|
||||
public class OrderListener {
|
||||
|
||||
// 监听下单事件,并异步执行
|
||||
@EventListener
|
||||
@Async("orderExecutor")
|
||||
public void handleSendSms(OrderCreatedEvent event) {
|
||||
System.out.println(Thread.currentThread().getName() + " 监听到事件,发送短信");
|
||||
}
|
||||
|
||||
@EventListener
|
||||
@Async("orderExecutor")
|
||||
public void handleSendCoupon(OrderCreatedEvent event) {
|
||||
System.out.println(Thread.currentThread().getName() + " 监听到事件,发放优惠券");
|
||||
}
|
||||
}
|
||||
```
|
||||
154
docs/Web/SpringBoot/SpringBoot-WebSocket.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
title: SpringBoot WebSocket简介和使用
|
||||
date: 2026-06-08
|
||||
---
|
||||
|
||||
# 一、简介
|
||||
  [WebSocket API](https://docs.springjava.cn/spring-framework/reference/web/websocket/server.html)
|
||||
|
||||
# 二、安装
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
# 三、使用
|
||||
## 3.1 定义WebSocketHandler
|
||||
```java
|
||||
@Component
|
||||
public class GenericWebSocketHandler extends TextWebSocketHandler {
|
||||
private static final Map<String, WebSocketSession> SESSION_MAP =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) {
|
||||
String userId = getUserId(session);
|
||||
SESSION_MAP.put(userId, session);
|
||||
log("用户连接成功:" + userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||
String userId = getUserId(session);
|
||||
SESSION_MAP.remove(userId);
|
||||
log("用户断开连接:" + userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
|
||||
String payload = message.getPayload();
|
||||
|
||||
// 心跳处理
|
||||
if ("PING".equalsIgnoreCase(payload)) {
|
||||
session.sendMessage(new TextMessage("PONG"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 普通消息(可按 JSON 协议扩展)
|
||||
log("收到消息:" + payload);
|
||||
session.sendMessage(new TextMessage("服务端已收到:" + payload));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
||||
log("传输异常:" + exception.getMessage());
|
||||
}
|
||||
|
||||
/** 从 session 中获取用户标识(握手阶段放入) */
|
||||
private String getUserId(WebSocketSession session) {
|
||||
return (String) session.getAttributes().getOrDefault("userId", session.getId());
|
||||
}
|
||||
|
||||
/** 单发 */
|
||||
public void sendToUser(String userId, String msg) throws IOException {
|
||||
WebSocketSession session = SESSION_MAP.get(userId);
|
||||
if (session != null && session.isOpen()) {
|
||||
session.sendMessage(new TextMessage(msg));
|
||||
}
|
||||
}
|
||||
|
||||
/** 广播 */
|
||||
public void broadcast(String msg) throws IOException {
|
||||
for (WebSocketSession session : SESSION_MAP.values()) {
|
||||
if (session.isOpen()) {
|
||||
session.sendMessage(new TextMessage(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 在线人数 */
|
||||
public int onlineCount() {
|
||||
return SESSION_MAP.size();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.2 定义HandshakeInterceptor
|
||||
```java
|
||||
@Component
|
||||
public class GenericHandshakeInterceptor implements HandshakeInterceptor {
|
||||
|
||||
/**
|
||||
* 握手前执行
|
||||
*/
|
||||
@Override
|
||||
public boolean beforeHandshake(
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler,
|
||||
Map<String, Object> attributes) {
|
||||
// 从请求中URL中获取id
|
||||
// 例如:ws://localhost:8080/ws?userId=123,多个参数用&连接
|
||||
String userId = servletRequest.getServletRequest().getParameter("userId");
|
||||
|
||||
if (userId == null) {
|
||||
// 返回 false → 握手失败 → 连接被拒绝
|
||||
return false;
|
||||
}
|
||||
|
||||
// 放入 attributes 中,方便下次获取
|
||||
attributes.put("userId", userId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 握手完成后执行(一般不用写)
|
||||
*/
|
||||
@Override
|
||||
public void afterHandshake(
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler,
|
||||
Exception exception) {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.3 配置WebSocketConfigurer
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
@Resource
|
||||
private GenericWebSocketHandler genericWebSocketHandler;
|
||||
|
||||
@Resource
|
||||
private GenericHandshakeInterceptor genericHandshakeInterceptor;
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(genericWebSocketHandler, "/ws")
|
||||
.addInterceptors(genericHandshakeInterceptor)
|
||||
.setAllowedOrigins("*");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  WebSocket连接地址:ip:port/ws?userId=123。
|
||||
|
||||
# 四、前端使用
|
||||
  参考:[useWebSocket](../Vue/VueUse.md)
|
||||
202
docs/Web/Vue/ElTable.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: Element Plus 表格组件
|
||||
date: 2026-05-27
|
||||
---
|
||||
|
||||
# 一、加载中
|
||||
```vue
|
||||
<el-table
|
||||
v-loading="dispatchStore.isLoading"
|
||||
element-loading-text="加载中..."
|
||||
:data="dispatchStore.contractList"
|
||||
border
|
||||
stripeempty-text="暂无记录"
|
||||
class="common-table">
|
||||
|
||||
</el-table>
|
||||
```
|
||||
|
||||
::: tip
|
||||
要配合pinia存储使用,获取数据前将loading置为true,完成后置为false。
|
||||
:::
|
||||
|
||||
# 二、展开行
|
||||
```vue
|
||||
<template>
|
||||
<el-table :data="dispatchStore.contractList"
|
||||
:row-key="getRowKey"
|
||||
:expand-row-keys="expandedRows"
|
||||
@expand-change="handleExpandChange">
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<el-table :data="props.row.dispatchList"
|
||||
border stripeempty-text="暂无记录"
|
||||
class="dispatch-content"
|
||||
style="width: 100%">
|
||||
<el-table-column fixed type="index" align="center" width="60" />
|
||||
<el-table-column prop="customer" label="终端客户" align="center" width="180"/>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const expandedRows = ref<number[]>([])
|
||||
|
||||
// 获取行的唯一标识
|
||||
const getRowKey = (row: IDispatch) => {
|
||||
return row.id
|
||||
}
|
||||
|
||||
// 处理展开行变化
|
||||
const handleExpandChange = (row: IDispatch, expandedRowsList: IDispatch[]) => {
|
||||
if (expandedRowsList.length > 0) {
|
||||
// 只保留最新展开的行
|
||||
expandedRows.value = [getRowKey(row)]
|
||||
} else {
|
||||
expandedRows.value = []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
  通过类型expand和插槽props实现,可以展示任意格式数据。
|
||||
  增加表格的`row-key`、`expand-row-key`属性可以实现只展开某一行功能。
|
||||
|
||||
# 三、排序和筛选
|
||||
```vue
|
||||
<template>
|
||||
<el-table :data="dispatchStore.contractList">
|
||||
<el-table-column prop="contractNum" label="合同编号" align="center" min-width="10%"
|
||||
:filters=contractNumFilters :filter-method="filterColumnMethod" sortable>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDispatchStore } from '@/stores/dispatch.ts'
|
||||
import { computed } from 'vue'
|
||||
import type { IContract } from '@/types/dispatch.ts'
|
||||
|
||||
const dispatchStore = useDispatchStore()
|
||||
|
||||
type ContractField = keyof IContract
|
||||
|
||||
const filterColumnMethod = (value, row, column) => {
|
||||
return row[column.property] === value
|
||||
}
|
||||
|
||||
const getFiltersByKey = (key: ContractField) => {
|
||||
const result = [...new Set(dispatchStore.contractList.map((item) => item[key]).filter((value) => value != null && value !==''))]
|
||||
|
||||
return result.map((item) => ({
|
||||
text: item,
|
||||
value: item
|
||||
}))
|
||||
}
|
||||
|
||||
const contractNumFilters = computed(() => getFiltersByKey('contractNum'))
|
||||
</script>
|
||||
```
|
||||
|
||||
  在`el-table-column`中添加`sortable`即可实现排序功能。
|
||||
  在`el-table-column`中添加`filters`和`filter-method`实现筛选功能。
|
||||
  `filters`为原始数据中该字段下的所有值,并转为指定格式,也可以自定义筛选字段。
|
||||
|
||||
# 四、表头搜索
|
||||
```vue
|
||||
<template>
|
||||
<el-table :data="dispatchStore.contractList" >
|
||||
<el-table-column prop="contractNum" label="合同编号" align="center" min-width="10%">
|
||||
<template #header>
|
||||
<div class="custom-header">
|
||||
<span :class="{ 'active': contractNumSearch }">合同编号</span>
|
||||
<el-popover ref="contractNumPopoverRef" placement="bottom" width="230" trigger="click">
|
||||
<template #default>
|
||||
<el-select v-model="contractNumSearch" filterable clearable
|
||||
placeholder="请选择或输入合同编号" @change="applySearch('contractNum')">
|
||||
<el-option v-for="(item, index) in contractNumValue"
|
||||
:key="index" :label="item" :value="item"/>
|
||||
</el-select>
|
||||
<div style="margin-top: 8px;display: flex;justify-content: flex-end;">
|
||||
<el-button type="primary" size="small" @click="applySearch('contractNum')">搜索</el-button>
|
||||
<el-button size="small" @click="resetSearch('contractNum')">重置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #reference>
|
||||
<el-icon class="search-icon"><Search/></el-icon>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDispatchStore } from '@/stores/dispatch.ts'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import type { IContract } from '@/types/dispatch.ts'
|
||||
import type { PopoverInstance } from 'element-plus'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const dispatchStore = useDispatchStore()
|
||||
|
||||
type ContractField = keyof IContract
|
||||
|
||||
const getFiltersByKey = (key: ContractField) => {
|
||||
const result = [...new Set(dispatchStore.contractList.map((item) => item[key]).filter((value) => value != null && value !== ''))]
|
||||
|
||||
return result.map((item) => ({
|
||||
text: item,
|
||||
value: item
|
||||
}))
|
||||
}
|
||||
|
||||
const contractNumFilters = computed(() => getFiltersByKey('contractNum'))
|
||||
|
||||
const contractNumValue = computed(() => contractNumFilters.value.map((item) => item.value))
|
||||
|
||||
const contractNumPopoverRef = ref<PopoverInstance>()
|
||||
const contractNumSearch = ref('')
|
||||
|
||||
const popoverMap = new Map<string, Ref<PopoverInstance | undefined>>([
|
||||
['contractNum', contractNumPopoverRef]
|
||||
])
|
||||
const searchMap = new Map<string, Ref<string>>([
|
||||
['contractNum', contractNumSearch]
|
||||
])
|
||||
|
||||
const applySearch = async (field: ContractField) => {
|
||||
const searchRef = searchMap.get(field)
|
||||
|
||||
if (!searchRef) return
|
||||
|
||||
if (!searchRef.value) {
|
||||
await dispatchStore.queryContractList()
|
||||
return
|
||||
}
|
||||
if (!searchRef.value.trim()) return
|
||||
|
||||
dispatchStore.contractList = dispatchStore.contractList.filter((item) => {
|
||||
const value = item[field]
|
||||
return typeof value === 'string' && value.includes(searchRef.value)
|
||||
})
|
||||
popoverMap.get(field)?.value?.hide()
|
||||
}
|
||||
|
||||
const resetSearch = async (field: string) => {
|
||||
const searchRef = searchMap.get(field)
|
||||
|
||||
if (searchRef) {
|
||||
searchRef.value = ''
|
||||
}
|
||||
popoverMap.get(field)?.value?.hide()
|
||||
await dispatchStore.queryContractList()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
  表头搜索主要利用`header`插槽和`el-popover`组件实现。
|
||||
313
docs/Web/Vue/ElUpload.md
Normal file
@@ -0,0 +1,313 @@
|
||||
---
|
||||
title: Element Plus 上传器
|
||||
date: 2026-01-29
|
||||
---
|
||||
|
||||
# 一、上传图片
|
||||
```vue
|
||||
<template>
|
||||
<el-upload
|
||||
v-model:file-list="imageFileList"
|
||||
list-type="picture-card"
|
||||
:on-preview="handlePreview"
|
||||
:limit="3"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="httpRequest"
|
||||
:on-exceed="handleExceed"
|
||||
:before-remove="beforeRemove"
|
||||
:on-remove="handleRemove"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
请上传小于10M的图片
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="dialogVisible">
|
||||
<el-image :src="dialogImageUrl" alt="Preview Image" />
|
||||
</el-dialog>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { UploadRequestOptions } from 'element-plus'
|
||||
import type { UploadProps, UploadUserFile, UploadRawFile } from 'element-plus'
|
||||
import { getFileExtension, getFileHashName, validateImage } from '@/utils/file.ts'
|
||||
import { uploadFileApi } from '@/apis/file.ts'
|
||||
|
||||
const imageFileList = ref<UploadUserFile[]>([])
|
||||
const dialogImageUrl = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const baseUrl = ''
|
||||
const apiImageList = [] as string[]
|
||||
|
||||
onMounted(async () => {
|
||||
imageFileList.value = []
|
||||
|
||||
// 根据后端传来的List数据将信息添加文件列表中
|
||||
apiImageList.forEach((item, index) => {
|
||||
imageFileList.value.push({
|
||||
name: index.toString(),
|
||||
url: `${baseUrl}${item}`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => {
|
||||
const maxFileMax = 1024 * 1024 * 10
|
||||
const fileType = rawFile.type
|
||||
const fileName = rawFile.name
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
|
||||
|
||||
if (!validateImage(fileType, fileExtension)) {
|
||||
ElMessage.error('请上传图片')
|
||||
return false
|
||||
}
|
||||
|
||||
if (rawFile.size > maxFileMax) {
|
||||
ElMessage.error('请上传10M以内的文件!')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const httpRequest: UploadProps['httpRequest'] = async (options: UploadRequestOptions): Promise<any> => {
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '文件上传中 请等待',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
|
||||
try {
|
||||
// 重命名文件
|
||||
const filePath = `${baseUrl}/${getFileHashName(options.file.name)}.${getFileExtension(options.file.name)}`
|
||||
|
||||
const data = new FormData()
|
||||
data.append('file', options.file)
|
||||
data.append('filePath', filePath)
|
||||
|
||||
// 调用后端接口上传文件
|
||||
await uploadFileApi(data)
|
||||
apiImageList.push(filePath)
|
||||
ElMessage.success('上传成功')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
ElMessage.error('上传失败 请联系管理员')
|
||||
} finally {
|
||||
loading.close()
|
||||
}
|
||||
}
|
||||
|
||||
const handleExceed: UploadProps['onExceed'] = () => {
|
||||
ElMessage.warning('最多上传3张图片')
|
||||
}
|
||||
|
||||
const handleRemove: UploadProps['onRemove'] = (uploadFile, uploadFiles) => {
|
||||
const index = imageFileList.value.findIndex((item) => item.uid === uploadFile.uid)
|
||||
apiImageList.splice(index, 1)
|
||||
}
|
||||
|
||||
const handlePreview: UploadProps['onPreview'] = (uploadFile) => {
|
||||
dialogImageUrl.value = uploadFile.url!
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const beforeRemove: UploadProps['beforeRemove'] = () => {
|
||||
return ElMessageBox.confirm('确认删除该文件?').then(() => true, () => false)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
# 二、上传视频
|
||||
```vue
|
||||
<template>
|
||||
<el-upload
|
||||
v-model:file-list="videoFileList"
|
||||
:limit="1"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="httpRequest"
|
||||
:on-exceed="handleExceed"
|
||||
:before-remove="beforeRemove"
|
||||
:on-remove="handleRemove"
|
||||
>
|
||||
<el-button type="primary">点击上传</el-button>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
请上传小于200M的视频
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { UploadRequestOptions } from 'element-plus'
|
||||
import type { UploadProps, UploadUserFile, UploadRawFile } from 'element-plus'
|
||||
import { getFileExtension, getFileHashName, validateVideo } from '@/utils/file.ts'
|
||||
import { uploadFileApi } from '@/apis/file.ts'
|
||||
|
||||
const videoFileList = ref<UploadUserFile[]>([])
|
||||
|
||||
let baseUrl = ''
|
||||
const apiVideoUrl = ''
|
||||
|
||||
onMounted(async () => {
|
||||
videoFileList.value = []
|
||||
if (apiVideoUrl) {
|
||||
videoFileList.value.push({
|
||||
name: 'video',
|
||||
url: `${baseUrl}p/${apiVideoUrl}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => {
|
||||
const maxFileMax = 1024 * 1024 * 200
|
||||
const fileType = rawFile.type
|
||||
const fileName = rawFile.name
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
|
||||
|
||||
if (!validateVideo(fileType, fileExtension)) {
|
||||
ElMessage.error('请上传视频')
|
||||
return false
|
||||
}
|
||||
|
||||
if (rawFile.size > maxFileMax) {
|
||||
ElMessage.error('请上传200M以内的文件!')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const httpRequest: UploadProps['httpRequest'] = async (options: UploadRequestOptions): Promise<any> => {
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '文件上传中 请等待',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
|
||||
try {
|
||||
const filePath = `${baseUrl}/${getFileHashName(options.file.name)}.${getFileExtension(options.file.name)}`
|
||||
|
||||
const data = new FormData()
|
||||
data.append('file', options.file)
|
||||
data.append('filePath', filePath)
|
||||
|
||||
await uploadFileApi(data)
|
||||
baseUrl = filePath
|
||||
ElMessage.success('上传成功')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
ElMessage.error('上传失败 请联系管理员')
|
||||
} finally {
|
||||
loading.close()
|
||||
}
|
||||
}
|
||||
|
||||
const handleExceed: UploadProps['onExceed'] = () => {
|
||||
ElMessage.warning('最多上传1个视频')
|
||||
}
|
||||
|
||||
const beforeRemove: UploadProps['beforeRemove'] = () => {
|
||||
return ElMessageBox.confirm('确认删除该文件?').then(() => true, () => false)
|
||||
}
|
||||
|
||||
const handleRemove: UploadProps['onRemove'] = () => {
|
||||
baseUrl = ''
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
# 三、工具类
|
||||
```ts
|
||||
import { hashSHA256 } from 'vue3-common/utils/cryptoUtil'
|
||||
|
||||
export const getFileHashName = (name: string) => {
|
||||
return hashSHA256(name)
|
||||
}
|
||||
|
||||
export const getFileExtension = (name: string) => {
|
||||
return name.substring(name.lastIndexOf('.') + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export const validateExcel = (fileType: string, fileExtension: string): boolean => {
|
||||
// 常见的 Excel MIME 类型
|
||||
const excelMimeTypes = [
|
||||
'application/vnd.ms-excel', // .xls
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12', // .xlsm
|
||||
'text/csv' // CSV
|
||||
]
|
||||
|
||||
// 常见的 Excel 文件扩展名
|
||||
const excelExtensions = ['xls', 'xlsx', 'xlsm', 'csv']
|
||||
|
||||
return excelMimeTypes.includes(fileType) || excelExtensions.includes(fileExtension.toLowerCase())
|
||||
}
|
||||
|
||||
export const validateImage = (fileType: string, fileExtension: string): boolean => {
|
||||
// 常见的图片 MIME 类型
|
||||
const imageMimeTypes = [
|
||||
'image/jpeg', // JPEG
|
||||
'image/jpg', // JPG
|
||||
'image/png', // PNG
|
||||
'image/gif', // GIF
|
||||
'image/bmp', // BMP
|
||||
'image/webp', // WebP
|
||||
'image/svg+xml' // SVG
|
||||
]
|
||||
|
||||
// 常见的图片文件扩展名
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
||||
|
||||
return imageMimeTypes.includes(fileType) || imageExtensions.includes(fileExtension.toLowerCase())
|
||||
}
|
||||
|
||||
export const validateVideo = (fileType: string, fileExtension: string): boolean => {
|
||||
// 常见的视频 MIME 类型
|
||||
const videoMimeTypes = [
|
||||
'video/mp4', // MP4
|
||||
'video/mpeg', // MPEG
|
||||
'video/quicktime', // MOV
|
||||
'video/x-msvideo', // AVI
|
||||
'video/x-matroska', // MKV
|
||||
'video/webm', // WebM
|
||||
'video/3gpp', // 3GP
|
||||
'video/3gpp2', // 3G2
|
||||
'video/x-flv', // FLV
|
||||
'video/mp2t', // TS (MPEG Transport Stream)
|
||||
'application/x-mpegURL', // M3U8 (HLS)
|
||||
'video/H264', // H264
|
||||
'video/H265' // H265/HEVC
|
||||
]
|
||||
|
||||
// 常见的视频文件扩展名
|
||||
const videoExtensions = [
|
||||
'mp4',
|
||||
'mpeg',
|
||||
'mpg',
|
||||
'mov',
|
||||
'avi',
|
||||
'mkv',
|
||||
'webm',
|
||||
'3gp',
|
||||
'3g2',
|
||||
'flv',
|
||||
'ts',
|
||||
'm3u8',
|
||||
'h264',
|
||||
'h265',
|
||||
'hevc'
|
||||
]
|
||||
|
||||
return videoMimeTypes.includes(fileType) || videoExtensions.includes(fileExtension.toLowerCase())
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 前端包管理器
|
||||
title: Vite核心原理
|
||||
date: 2025-12-13
|
||||
---
|
||||
|
||||
93
docs/Web/Vue/Vue3-DefineModel.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Vue3 defineModel
|
||||
date: 2026-06-05
|
||||
---
|
||||
|
||||
# 一、概念
|
||||
  defineModel是对 props + emit的语法糖,返回一个可写的 ref。Vu3.4开始支持。
|
||||
  之前的做法:
|
||||
```ts
|
||||
// 子组件
|
||||
const props = defineProps({
|
||||
modelValue: Number
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function increment() {
|
||||
emit('update:modelValue', props.modelValue + 1)
|
||||
}
|
||||
```
|
||||
|
||||
  现在做法:
|
||||
```ts
|
||||
const model = defineModel()
|
||||
```
|
||||
|
||||
# 二、简单示例
|
||||
  父组件:
|
||||
```vue
|
||||
<script setup>
|
||||
import Stepper from './Stepper.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const quantity = ref(1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p>购买数量:{{ quantity }}</p>
|
||||
<Stepper v-model="quantity" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
  子组件:
|
||||
```vue
|
||||
<script setup>
|
||||
const model = defineModel({
|
||||
type: Number,
|
||||
default: 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="quantity--">−</button>
|
||||
<span>{{ quantity }}</span>
|
||||
<button @click="quantity++">+</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
  和`defineProps`定义类似,可以指定类型和默认值。
|
||||
|
||||
# 三、多个v-model
|
||||
  父组件:
|
||||
```vue
|
||||
<UserForm
|
||||
v-model:name="form.name"
|
||||
v-model:age="form.age"
|
||||
/>
|
||||
```
|
||||
|
||||
  子组件:
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const name = defineModel<string>('name', {
|
||||
default: ''
|
||||
})
|
||||
|
||||
const age = defineModel<number>('age', {
|
||||
default: 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-model="name" placeholder="姓名" />
|
||||
<input v-model="age" type="number" placeholder="年龄" />
|
||||
</template>
|
||||
```
|
||||
|
||||
::: tip
|
||||
defineProps: 只读,不能改
|
||||
defineModel: 可写
|
||||
:::
|
||||
79
docs/Web/Vue/Vue3-Directive.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Vue3自定义指令
|
||||
date: 2026-06-05
|
||||
---
|
||||
|
||||
# 一、概念
|
||||
  自定义指令(Directive) 是 Vue 提供的一种机制,用于直接操作 DOM,封装可复用的底层 DOM 行为。它是对模板能力的补充,当现有模板语法(v-if / v-bind / v-on)不够用时,就可以用指令。
|
||||
|
||||
# 二、简单示例
|
||||
  注册:
|
||||
```ts
|
||||
app.directive('demo', {
|
||||
mounted(el, binding) {
|
||||
console.log(el, binding)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
  使用:
|
||||
```vue
|
||||
<div v-demo="'hello'"></div>
|
||||
```
|
||||
|
||||
  使用该指令,就会触发打印效果。
|
||||
|
||||
# 三、生命周期
|
||||
| 钩子 | 说明 |
|
||||
| ----- | ----- |
|
||||
| created | 绑定前 |
|
||||
| beforeMount | 元素挂载前 |
|
||||
| mounted | 元素已挂载 |
|
||||
| beforeUpdate | 更新前 |
|
||||
| updated | 更新后 |
|
||||
| beforeUnmount | 卸载前 |
|
||||
| unmounted | 卸载后 |
|
||||
|
||||
# 四、参数
|
||||
  mounted(el, binding, vnode, prevVnode):
|
||||
| 参数 | 说明 |
|
||||
| ----- | ----- |
|
||||
| el | 当前 DOM 元素 |
|
||||
| binding.value | 指令绑定的值 |
|
||||
| binding.arg | 参数 |
|
||||
| binding.modifiers | 修饰符 |
|
||||
| vnode | 虚拟节点 |
|
||||
|
||||
# 五、封装
|
||||
  在`src`目录新建`directives/**.ts`,定义指令:
|
||||
```ts
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export default {
|
||||
mounted(el, binding) {
|
||||
const authStore = useAuthStore()
|
||||
const permission = binding.value
|
||||
|
||||
const hasPermission = authStore.currentUser?.permission?.includes(permission)
|
||||
|
||||
if (!hasPermission) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
  该指令根据用户权限控制DOM是否显示。
|
||||
|
||||
  然后在`main.ts`中注册指令:
|
||||
```ts
|
||||
import permissionDirective from '@/directives/permission'
|
||||
|
||||
app.directive('permission', permissionDirective)
|
||||
```
|
||||
|
||||
  最后在需要的元素中加上`v-permission=""`即可。
|
||||
|
||||
::: tip
|
||||
自定义指令只做DOM相关的事情,比如隐藏DOM,禁止点击、修改样式等等。
|
||||
:::
|
||||
158
docs/Web/Vue/VueUse.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: VueUse简介和使用
|
||||
date: 2026-06-05
|
||||
---
|
||||
|
||||
# 一、安装
|
||||
```bash
|
||||
npm install @vueuse/core
|
||||
```
|
||||
|
||||
# 二、使用
|
||||
## 2.1 实时获取当前时间
|
||||
```ts
|
||||
<template>
|
||||
<div class="card">
|
||||
<p>当前时间: {{ formattedTime }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useNow, useDateFormat } from '@vueuse/core'
|
||||
|
||||
// 获取当前时间响应式对象
|
||||
const now = useNow()
|
||||
|
||||
// 格式化时间
|
||||
const formattedTime = useDateFormat(now, 'YYYY-MM-DD HH:mm:ss')
|
||||
</script>
|
||||
```
|
||||
|
||||
## 2.2 useWebSocket
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
iconst { status, send } = useWebSocket('wss://example.com/ws', {
|
||||
// 连接成功
|
||||
onConnected(ws) {
|
||||
console.log('WS connected')
|
||||
},
|
||||
// 收到消息
|
||||
onMessage(ws, event) {
|
||||
const msg = JSON.parse(event.data)
|
||||
console.log('receive:', msg)
|
||||
},
|
||||
// 断开
|
||||
onDisconnected(ws, event) {
|
||||
console.log('WS disconnected, code:', event.code)
|
||||
},
|
||||
onError(ws, event) {
|
||||
console.error('WS error', event)
|
||||
},
|
||||
autoReconnect: {
|
||||
retries: 3,
|
||||
delay: 1000
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## 2.3 防抖和节流
|
||||
| | 防抖 Debounce | 节流 Throttle |
|
||||
| :--- | :--- | :--- |
|
||||
| **执行时机** | 连续触发 **停下后** | **每隔一段时间** |
|
||||
| **执行次数** | 通常 1 次(最后) | 多次,但受限制 |
|
||||
| **典型场景** | 搜索联想、校验 | 滚动、拖拽 |
|
||||
|
||||
  防抖:
|
||||
```vue
|
||||
<template>
|
||||
<input @input="handleInput" placeholder="输入搜索关键字" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
const keyword = ref('')
|
||||
|
||||
// 输入停止 500ms 后执行搜索
|
||||
const debouncedSearch = useDebounceFn((val) => {
|
||||
console.log('发起搜索请求:', val)
|
||||
// api.search(val)
|
||||
}, 500)
|
||||
|
||||
const handleInput = (e) => {
|
||||
keyword.value = e.target.value
|
||||
debouncedSearch(keyword.value)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
  节流:
|
||||
```vue
|
||||
<template>
|
||||
<div style="padding: 20px">
|
||||
<p>点击次数(被节流):{{ count }}</p>
|
||||
<button @click="throttledClick">
|
||||
狂点我(节流 500ms)
|
||||
</button>
|
||||
<p style="color:#999;font-size:12px">
|
||||
快速连点也不会一直 +1,每 500ms 最多加一次
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
// 节流函数:500ms 内最多执行一次
|
||||
const throttledClick = useThrottleFn(() => {
|
||||
count.value++
|
||||
console.log('真正执行 +1')
|
||||
}, 500)
|
||||
</script>
|
||||
```
|
||||
|
||||
::: warning
|
||||
如果是表单提交,应该使用状态锁来限制,即当提交后,将按钮置为loading状态,提交成功后再重置。
|
||||
```vue
|
||||
<template>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? '提交中...' : '提交' }}
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 模拟接口请求
|
||||
await submitForm()
|
||||
ElMessage.success('提交成功')
|
||||
} catch (err) {
|
||||
ElMessage.error('提交失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
return new Promise(resolve => setTimeout(resolve, 1500))
|
||||
}
|
||||
</script>
|
||||
```
|
||||
:::
|
||||
BIN
docs/Web/images/jmeter.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: doc
|
||||
title: Web前端
|
||||
description: Web前端开发技术文档
|
||||
title: Web平台
|
||||
description: Web平台开发技术文档
|
||||
---
|
||||
|
||||
<script setup>
|
||||
@@ -10,14 +10,14 @@ hero:
|
||||
alt: VitePress
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Web前端
|
||||
link: /Web-Front/
|
||||
text: Web平台
|
||||
link: /Web/
|
||||
- theme: brand
|
||||
text: Web后端
|
||||
link: /Web-Backend/
|
||||
- theme: brand
|
||||
text: DevOps
|
||||
text: 运维部署
|
||||
link: /DevOps/
|
||||
- theme: brand
|
||||
text: 项目实战
|
||||
link: /Practice/
|
||||
|
||||
features:
|
||||
- title: 🚀 技术分享
|
||||
|
||||
BIN
docs/public/images/aixuexi/arch.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/public/images/blog/archive.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
docs/public/images/blog/article.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
docs/public/images/blog/category.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/public/images/blog/commit.png
Normal file
|
After Width: | Height: | Size: 119 KiB |