一种利用免费CDN加速视频站点的思路
现有问题当你在做一个视频站点的时候，你是否遇到下列痛点：服务器带宽不够，人一多就爆炸卡顿？原视频画质高，文件大，CDN拒绝缓存大文件，又不想压缩画质？对资源的访问没有保护，任何人都可以直接观看视频？对于第三点鉴权，一般情况下，我们都是使用token的形式，因为浏览器访问视频资源的时候，是GET请求，如果需要鉴权，那么就需要在GET里带Query，类似：https://example.com/test.mp4?token=xxx 这种鉴权方式本来没有问题，但是一旦我们想要利用CDN的缓存机制来加速的时候，就出事了。对于CDN来讲，当一个用户第一次访问这个资源的时候，他就会把这个资源缓存到边缘节点，当下一次访问的时候，就不会回源，就例如用户刚刚访问里上面那个视频，CDN就会把视频缓存到边缘节点，然后标注，这是路径/test.mp4?token=xxx的资源，下次不用回源。但是如果另一个用户也需要访问这个视频，他的token会和第一个用户不同，他的访问路径类似：https://example.com/test.mp4?token=yyy CDN会检查路径/test.mp4?token=yyy，但是他和/test.mp4?token=xxx是不同的，因为token不同。这样的话，CDN会认为没有命中缓存，继续回源，这就造成了带宽和流量的浪费，尤其是带宽小的情况下尤为严重，会导致其他用户的卡顿。当然，在大部分主流CDN厂商的设置中，我们都可以设置忽略查询字符串来去掉后面的token部分来让第二个用户吃到缓存：但是，这样也让鉴权失去了意义。如果去除掉查询字符串，任何一个人访问/test.mp4都将可以访问到这个视频，因为由于直接访问了，而且服务器端将收不到任何信息，这样就无法审计观看情况了。那有没有什么好的解决方案呢？有的兄弟，有的，请继续往下看。视频分片首先需要解决的是视频太大的问题，这个非常好解决，视频分片技术已经出现很久了，我们通过把一个大视频拆分成一个m3u8文件和多个ts文件，这样就可以让CDN缓存每一个ts文件，完美解决了CDN缓存的文件不能太大的问题。我们可以使用高性能的开源套件FFmpeg来快速、高效率的实现。1.安装FFmpegFFmpeg可以很方便的使用包管理器安装：Ubuntu / Debian 系：sudo apt update sudo apt install ffmpegCentOS / RHEL / Fedora 系：sudo dnf install ffmpeg之后在控制台输入 ffmpeg -version可以看到如下界面，就说明成功了。2.安装显卡驱动（可选）如果你的服务器有GPU，那么你可以利用GPU来进行转码，接下来是英特尔N150的核显+Debian的安装教程，其他品牌的应该类似。执行命令：sudo apt update sudo apt full-upgrade -y sudo apt install -y \ linux-image-amd64 \ firmware-intel-graphics \ mesa-utils \ libgl1-mesa-dri \ mesa-vulkan-drivers \ vainfo \ intel-media-va-driver-non-free \ intel-gpu-tools sudo reboot安装完成后。输入 lsmod | grep i915 （其他显卡需要把i915修改成对应的），显示如下则说明已经成功读取到显卡驱动了。3.转码测试执行命令：ffmpeg \ -i input.mp4 \ -c:v libx264 \ -preset veryfast \ -crf 23 \ -threads 1 \ -c:a aac \ -b:a 128k \ -f hls \ -hls_time 10 \ -hls_list_size 0 \ -hls_segment_filename "output_hls/seg_%03d.ts" \ "output_hls/index.m3u8"输入的mp4文件和输出文件名需要根据真实情况修改，之后你应该在可以看到类似这样的目录结构：output_hls/ ├── index.m3u8 ├── seg_000.ts ├── seg_001.ts ├── seg_002.ts └── ...那么恭喜你，ffmpeg转码正常4.代码实施以下是一个完整的go语言代码，包含GPU加速失败回退、合法性校验等，最终输出一个HLS文件夹。package common import ( "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" ) // 限制视频重型任务并发，避免多个 ffmpeg 同时跑满 CPU / GPU。 var videoTaskChan = make(chan struct{}, 1) type VideoInfo struct { Codec string PixFmt string Profile string Width int Height int Duration int HWCompatible bool } type ffprobeOutput struct { Streams []struct { CodecName string `json:"codec_name"` PixFmt string `json:"pix_fmt"` Profile string `json:"profile"` Width int `json:"width"` Height int `json:"height"` } `json:"streams"` Format struct { Duration string `json:"duration"` } `json:"format"` } // ProcessVideoToHLS 异步将视频转为 HLS。 func ProcessVideoToHLS(filePath string, resourceID uint) { go func() { videoTaskChan <- struct{}{} defer func() { <-videoTaskChan }() startedAt := time.Now() updateTranscodingStatus(resourceID, "processing") info, err := ProbeVideoInfo(filePath) if err != nil { failTranscoding(resourceID, "读取视频信息失败", err) return } logVideoInfo(resourceID, info) inputPath, playlistPath, segmentPattern, err := prepareHLSPaths(filePath) if err != nil { failTranscoding(resourceID, "创建 HLS 输出目录失败", err) return } cmd := buildFFmpegCommand(inputPath, playlistPath, segmentPattern, info.HWCompatible) if err := runCommand(cmd); err != nil { failTranscoding(resourceID, "视频转码失败", err) return } hlsURL, err := toAPIURL(playlistPath) if err != nil { failTranscoding(resourceID, "生成 HLS 访问路径失败", err) return } updateResource(resourceID, map[string]interface{}{ "transcoding_status": "completed", "hls_url": hlsURL, "duration": info.Duration, "estimated_time": info.Duration, }) removeOriginalFile(filePath, resourceID) logTranscodingResult(resourceID, info.Duration, startedAt) }() } // ProbeVideoInfo 使用 ffprobe 获取视频基础信息。 func ProbeVideoInfo(filePath string) (*VideoInfo, error) { cmd := exec.Command( "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,pix_fmt,profile,width,height:format=duration", "-of", "json", filePath, ) output, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("ffprobe 执行失败: %w, 输出: %s", err, output) } var probe ffprobeOutput if err := json.Unmarshal(output, &probe); err != nil { return nil, fmt.Errorf("解析 ffprobe 输出失败: %w", err) } if len(probe.Streams) == 0 { return nil, fmt.Errorf("未找到视频流") } stream := probe.Streams[0] info := &VideoInfo{ Codec: stream.CodecName, PixFmt: stream.PixFmt, Profile: stream.Profile, Width: stream.Width, Height: stream.Height, } if probe.Format.Duration != "" { duration, err := strconv.ParseFloat(probe.Format.Duration, 64) if err == nil { info.Duration = int(duration) } } info.HWCompatible = isVAAPICompatible(info) return info, nil } // isVAAPICompatible 判断当前视频是否适合走 Linux VA-API 硬件加速。 func isVAAPICompatible(info *VideoInfo) bool { if runtime.GOOS != "linux" { return false } // 常见低功耗设备对 10-bit 视频支持不稳定，保守起见走软解。 if strings.Contains(info.PixFmt, "10") || strings.Contains(info.Profile, "10") { return false } switch info.Codec { case "h264", "hevc": return true default: return false } } func prepareHLSPaths(filePath string) (inputPath, playlistPath, segmentPattern string, err error) { baseDir := filepath.Dir(filePath) fileName := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) hlsDir := filepath.Join(baseDir, fileName+"_hls") if err := os.MkdirAll(hlsDir, 0755); err != nil { return "", "", "", err } inputPath, err = filepath.Abs(filePath) if err != nil { return "", "", "", err } playlistPath, err = filepath.Abs(filepath.Join(hlsDir, "index.m3u8")) if err != nil { return "", "", "", err } segmentPattern, err = filepath.Abs(filepath.Join(hlsDir, "seg_%03d.ts")) if err != nil { return "", "", "", err } return inputPath, playlistPath, segmentPattern, nil } func buildFFmpegCommand(inputPath, playlistPath, segmentPattern string, useVAAPI bool) *exec.Cmd { if useVAAPI { cmd := exec.Command( "ffmpeg", "-hide_banner", "-y", "-vaapi_device", "/dev/dri/renderD128", "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-i", inputPath, "-c:v", "h264_vaapi", "-qp", "24", "-c:a", "aac", "-b:a", "128k", "-f", "hls", "-hls_time", "10", "-hls_list_size", "0", "-hls_segment_filename", segmentPattern, playlistPath, ) // Intel 核显常用 iHD 驱动。 cmd.Env = append(os.Environ(), "LIBVA_DRIVER_NAME=iHD") return cmd } return exec.Command( "ffmpeg", "-hide_banner", "-y", "-i", inputPath, "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-threads", "1", "-c:a", "aac", "-b:a", "128k", "-f", "hls", "-hls_time", "10", "-hls_list_size", "0", "-hls_segment_filename", segmentPattern, playlistPath, ) } func runCommand(cmd *exec.Cmd) error { cmd.Stdout = log.Writer() cmd.Stderr = log.Writer() return cmd.Run() } func toAPIURL(playlistPath string) (string, error) { relPath, err := filepath.Rel(".", playlistPath) if err != nil { return "", err } return "/api/" + filepath.ToSlash(relPath), nil } func updateTranscodingStatus(resourceID uint, status string) { updateResource(resourceID, map[string]interface{}{ "transcoding_status": status, }) } func updateResource(resourceID uint, values map[string]interface{}) { if err := GetDB(). Model(&Resource{}). Where("id = ?", resourceID). Updates(values).Error; err != nil { log.Printf("[HLS] 更新资源失败: resourceID=%d, err=%v", resourceID, err) } } func failTranscoding(resourceID uint, message string, err error) { log.Printf("[HLS] %s: resourceID=%d, err=%v", message, resourceID, err) updateTranscodingStatus(resourceID, "failed") } func removeOriginalFile(filePath string, resourceID uint) { if err := os.Remove(filePath); err != nil { log.Printf("[HLS] 删除原始文件失败: resourceID=%d, err=%v", resourceID, err) return } log.Printf("[HLS] 已删除原始文件: resourceID=%d", resourceID) } func logVideoInfo(resourceID uint, info *VideoInfo) { acceleration := "CPU 软转码" if info.HWCompatible { acceleration = "VA-API 硬件转码" } log.Printf( "[HLS] 视频信息: resourceID=%d, codec=%s, pix_fmt=%s, profile=%s, size=%dx%d, duration=%ds, mode=%s", resourceID, info.Codec, info.PixFmt, info.Profile, info.Width, info.Height, info.Duration, acceleration, ) } func logTranscodingResult(resourceID uint, duration int, startedAt time.Time) { elapsed := time.Since(startedAt) if duration <= 0 { log.Printf("[HLS] 转码完成: resourceID=%d, elapsed=%.1f分钟", resourceID, elapsed.Minutes()) return } speed := float64(duration) / elapsed.Seconds() log.Printf( "[HLS] 转码完成: resourceID=%d, duration=%ds, elapsed=%.1f分钟, speed=%.2fx", resourceID, duration, elapsed.Minutes(), speed, ) }边缘鉴权在服务器上将大视频转化为一个个小文件后，接下来就是要实现CDN加速+鉴权的操作。这里我们可以借助CDN边缘鉴权的方式，当用户要求访问一个视频的时候，只有在访问m3u8文件的时候，通过鉴权token向服务器发出请求，服务器接受后，返回一个每个用户都不一样的独特m3u8文件，带有可以被CDN边缘鉴权接受的Query参数。我们使用Type-A鉴权方法。其中，你需要替换下面的PrivateKey替换为自己的密码。1.本地代码编写这段代码为每一个.ts文件生成一个可以被CDN边缘鉴权识别的鉴权参数。package common import ( "crypto/md5" "encoding/hex" "fmt" "math/rand" "os" "strconv" "strings" "time" ) // CDNSignConfig CDN签名配置 type CDNSignConfig struct { PrivateKey string // 签名密钥，与边缘函数中的privateKey一致 Expire int64 // 有效期（秒），默认3600秒 } // GetCDNSignConfig 获取CDN签名配置 func GetCDNSignConfig() CDNSignConfig { privateKey := os.Getenv("CDN_PRIVATE_KEY") if privateKey == "" { privateKey = "your_default_secret_key" // 默认密钥，生产环境必须配置 } return CDNSignConfig{ PrivateKey: privateKey, Expire: 14400, // 4小时有效期，确保长视频能完整播放 } } // GenerateCDNSignedURL 生成CDN签名URL (Type-A 鉴权，无 UID 绑定) // 格式: /path/to/resource?auth_key=timestamp-rand-signature // 签名算法: MD5(path-timestamp-rand-privateKey) func GenerateCDNSignedURL(resourcePath string, userID uint) string { config := GetCDNSignConfig() // 计算过期时间戳 timestamp := time.Now().Unix() + config.Expire // 生成随机数 randNum := rand.Int63() // 构建签名字符串: path-timestamp-rand-privateKey signString := fmt.Sprintf("%s-%d-%d-%s", resourcePath, timestamp, randNum, config.PrivateKey) // 计算MD5签名 hash := md5.Sum([]byte(signString)) signature := hex.EncodeToString(hash[:]) // 构建auth_key参数 authKey := fmt.Sprintf("%d-%d-%s", timestamp, randNum, signature) return fmt.Sprintf("%s?auth_key=%s", resourcePath, authKey) } // GenerateCDNSignedURLWithDomain 生成包含CDN域名的完整签名URL func GenerateCDNSignedURLWithDomain(resourcePath string, userID uint) string { cdnDomain := os.Getenv("CDN_DOMAIN") if cdnDomain == "" { // 如果没有配置CDN域名，返回相对路径 return GenerateCDNSignedURL(resourcePath, userID) } signedPath := GenerateCDNSignedURL(resourcePath, userID) return fmt.Sprintf("https://%s%s", cdnDomain, signedPath) } // VerifyLocalSign 验证本地 Type-A 签名 func VerifyLocalSign(resourcePath string, authKey string) (bool, uint) { config := GetCDNSignConfig() // 解析 authKey parts := strings.Split(authKey, "-") if len(parts) == 3 { timestampStr := parts[0] randNumStr := parts[1] signature := parts[2] timestamp, err := strconv.ParseInt(timestampStr, 10, 64) if err != nil || time.Now().Unix() > timestamp { return false, 0 } // 校验签名 signString := fmt.Sprintf("%s-%s-%s-%s", resourcePath, timestampStr, randNumStr, config.PrivateKey) hash := md5.Sum([]byte(signString)) if hex.EncodeToString(hash[:]) != signature { return false, 0 } return true, 0 } return false, 0 } func init() { rand.Seed(time.Now().UnixNano()) } 2.CDN边缘节点代码编写这里以阿里云ESA为例，其他CDN厂商一般也都支持边缘javascript代码执行。阿里云ESA的边缘函数一个月支持100万的调用，个人开发完全足够了。进入阿里云ESA后台，点击“函数与Pages”，再点击“创建”点击“函数模版”，“鉴权方式A”-创建之后进入代码修改页面。修改privateKey成上面在后端设置的privateKey。之后点击上方的“快速发布”即可成功发布。3.配置边缘CDN路由在“路由”中，配置视频资源的路由，让视频资源触发这个边缘鉴权函数。缓存设置在规则-缓存规则中，我们可以设置后缀为ts的文件，在节点边缘缓存中存储一年或更久。这样，我们就完美实现了CDN加速+鉴权，既可以留下观看视频的访问记录（访问m3u8时记录），也可以让资源不被他人随意获取。最重要的是，让无数个小的ts文件存储在边缘节点，大大降低了服务器的运行压力。本文以阿里云ESA为例，其他CDN厂家的配置思路类似。可自行探索
0.配置基本的Java环境详见以前的推文：VSCode配置java开发环境 - Yanlin's博客需要安装JDK17及以上的版本，推荐最新的JDK21。当配置成功后，在命令提示符中输入java -version应该会显示类似以下的内容：1.安装Mavenmaven对于Servlet的开发并不是一个必须安装的模块，但是maven可以在管理依赖、编译的时候提供便利，同时也为后续大项目做准备。所以还是推荐先安装上。注：以下所有安装均推荐在虚拟网卡加速下进行，否则下载速度较慢。1.1下载Maven打开Maven官网下载：Download Apache Maven – Maven 找到Windows版本并下载。下载后是一个zip格式的压缩包，将其解压到一个合适的位置。如果你想完全跟随本教程，那么你需要将其解压到C盘根目录。解压后应该显示如下：1.2配置环境变量接下来，我们需要在环境变量中加入Maven的路径来方便我们后续在VScode中的调用。打开设置-搜索栏搜索“环境”-编辑账户的环境变量。双击“Path”点击右上角的“新建”，之后将Maven的安装目录添加到环境变量中。如果你前面完全按照教程来操作，那么你应该添加：C:\apache-maven-3.9.11\bin。之后点击“确认”关闭所有窗口。1.3测试安装打开命令提示符并输入：mvn -version 如果出现类似下面的信息，那么恭喜你，安装成功！2.安装Tomcat2.1下载Tomcat打开Tomcat官网：Apache Tomcat® - Apache Tomcat 10 Software Downloads 即可下载GH老师推荐的Tomcat10。这里推荐下载exe格式的方便安装。2.2安装Tomcat下载后打开安装包，连续点击下一步直到这个界面：这里点击Full，为了防止某些功能之后会用到。下一步可以配置端口，无特殊要求默认8080即可。在这里你需要配置你的jdk路径，注意必须在17及以上的版本。接下来点击next安装即可。2.3测试首先打开Monitor Tomcat。接下来在右下角的状态栏里应该可以看到Monitor的图标。双击即可打开配置页面。如果Service Status状态不为Started，那么你需要点击Start来启动。之后就可以打开网页：http://localhost:端口打开Tomcat。如果你是默认的设置，那么就应该是 http://localhost:8080如果你打开显示下面类似的网页，那么恭喜你，配置成功！3.配置VScode完成了以上的安装后，我们就可以在vscode中配置了。3.1安装插件搜索Extension Pack for Java来一次性安装相应的Java插件。为了方便与Tomcat通信，还需要下载Community Server Connectors插件3.2创建新项目将一个方便的文件夹目录添加到VScode中。右键空白处——Maven——从Maven原型创建新项目。这里选择maven-archetypr-webapp正常情况下选择1.4版本即可在这里你可以自定义你的包名字，使用.隔开，当然你也可以直接使用默认的com.example在这里是你的项目名称，这个不能和你以后的其他项目名称重复。之后会弹出设置文件夹的窗口，我们直接在这个目录下即可。当提示输入版本是直接回车默认即可。这里输入y确认配置。出现绿色的BUILD SUCCESS 就代表配置成功了。3.3配置Servlet配置完成后，右侧会出现新文件夹，打开里面的pom.xml文件。将这里的1.7版本修改为21（或你安装的版本）之后再上面的搜索栏输入：>reload并选择Java 重新加载项目即可加载。接下来再上面的输入框中搜索：>maven并选择添加依赖选择对应的项目搜索：servlet选择第一个：servlet-api之后等待Java插件自动下载后就为安装完成。之后需要和上面一样重载java配置。3.3测试开发3.3.1测试Servlet在src\main下创建新的java文件夹，之后再次创建符合前面Maven里配置的包路径相应的文件夹。创建Test.java粘贴代码：package 你的包路径; import java.io.IOException; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @WebServlet("/time") public class Test extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { DateFormat dtFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String currentTime = dtFormat.format(new Date()); req.setAttribute("currentTime", currentTime); req.getRequestDispatcher("/WEB-INF/hello.jsp").forward(req, resp); } }之后再WEB-INF路径下创建hello.jsp粘贴代码：<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %> Hello JSP Hello JSP Current Time: ${currentTime} 接下来在搜索框输入：>maven 选择执行命令选择package 就开始打包文件。显示绿色的BUILD SUCCESS即为打包成功。3.3.2测试Tomcat在下方servers里的connection上右键——创建新服务器。选择NO，使用本地服务器。选择Tomcat安装的目录。这里也需要填写Tomcat目录，点击Finish即可配置成功。之后再target文件夹中找到刚刚编译出来的war文件，右键点击再服务器上运行。选择刚刚创建的服务器。下一步选择NO右键Tomcat，选择Server Actions点击 Show in browser，选择自己的项目名即可打开网页。显示Hello World就是说明服务器启动了。在网址后加/time就可以进入时间界面。出现如下界面就说明配置完全成功，祝贺你！
统一验证系统代码实现：2.初始化数据库
gorm是一个很方便的数据库连接器，使用这个包：gorm.io/gorm连接数据库首先我们需要根据配置文件组件数据库链接，如下：dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", AppConfig.DBUser, AppConfig.DBPassword, AppConfig.DBHost, AppConfig.DBPort, AppConfig.DBName)Sprintf与c语言的类似，相当于把print出来的内容赋值给一个变量。接下来就可以使用gorm.Open()连接数据库了db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logLevel), }) if err != nil { log.Fatalf("连接数据库失败: %v", err) }迁移数据库表如果连接成功，就可以开始迁移表了。gorm的迁移功能十分强大，它可以检测数据库中表的类型和对应的数据结构类型是否一致，如果不一致就会自动修改，并且不会影响到原有的数据。如果数据库中没有这个表，它也会按照数据结构来创建。例如InviteCode就会创建invitecodes表，即把大写字母去除并在最后加上一个s可以使用db.AutoMigrate(&InviteCode{})来完成迁移，这里的InviteCode是一个数据结构，如下：type InviteCode struct { gorm.Model Code string `gorm:"type:varchar(100);uniqueIndex" json:"code"` Count int `gorm:"type:int;default:0" json:"count"` MaxCount int `gorm:"type:int;default:1" json:"max_count"` CreatedBy uint `gorm:"type:int" json:"created_by"` ExpTime *time.Time `gorm:"type:datetime;default:null" json:"exp_time"` IsAvailable bool `gorm:"type:boolean;default:true" json:"is_available"` BindGroup uint `gorm:"type:int" json:"bind_group"` }gorm.Model会自动帮我们创建id，updatetime，createtime，deletetime等基础元素。值得注意的是，gorm会自动帮我们自动转换列名称，转换逻辑是大写字母全部转为小写，若是中间的大写字母则在大写字母前添加下划线。如上面的邀请码数据结构就会生成如下的表：创建默认管理员账户迁移完成后，很重要的一步是需要判断是否需要创建默认管理员账户。当用户第一次启动时，应该创建一个管理员账户。当用户设置了DEFAULT_ADMIN_USERNAME和DEFAULT_ADMIN_PASSWORD时会根据设置创建对应的账号，如果没有设置，那么就会使用默认密码：admin/admin123来创建。判断是否要创建的方法也很简单，只需要判断用户表是否为空即可。首先判断用户数量：var userCount int64 if err := db.Model(&User{}).Count(&userCount).Error; err != nil { log.Printf("检查用户数量失败: %v", err) return }接下来完成判断并写入数据库：if userCount == 0 { defaultUsername := os.Getenv("DEFAULT_ADMIN_USERNAME") if defaultUsername == "" { defaultUsername = "admin" } defaultPassword := os.Getenv("DEFAULT_ADMIN_PASSWORD") if defaultPassword == "" { defaultPassword = "admin123" } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(defaultPassword), bcrypt.DefaultCost) if err != nil { log.Printf("生成管理员密码哈希失败: %v", err) return } admin := User{ Username: defaultUsername, Password: string(hashedPassword), Role: "admin", IsBanned: false, } if err := db.Create(&admin).Error; err != nil { log.Printf("创建默认管理员失败: %v", err) } else { log.Printf("✅ 默认管理员创建成功 - 用户名: %s, 密码: %s", defaultUsername, defaultPassword) log.Println("⚠️ 请尽快登录并修改默认密码！") } } else { log.Printf("数据库中已存在 %d 个用户，跳过默认管理员创建", userCount) }
统一验证系统代码实现：1.获取配置文件
为了方便部署，我将数据库连接、jwt key和配置开发/部署数据结构首先我设计了Config的数据结构，用于读取环境配置文件。这里我使用了github.com/joho/godotenv包来进行导入。type Config struct { JWTSecret string DBHost string DBPort int DBUser string DBPassword string DBName string Port string Environment string CORSOrigins []string }读取配置文件之后试图读取配置文件，如果没有的话就使用环境变量，还没有的话就使用开发时的默认值。err := godotenv.Load() if err != nil { log.Println("警告: 无法加载 .env 文件，使用默认配置或环境变量") }godotenv.Load()指令可以把目录下的.env文件存储到环境变量中，这样后续的os.Getenv()就可以读取到文件中设置的环境变量。为了防止文件读取错误导致的报错，设计了一个函数来使未读取到文件时使用默认值。func getEnv(key, defaultValue string) string { value := os.Getenv(key) if value == "" { return defaultValue } return value }之后就可以把文件写入配置文件了AppConfig = &Config{ JWTSecret: getEnv("JWT_SECRET", "default-secret-key"), DBHost: getEnv("DB_HOST", "localhost"), DBPort: dbPort, DBUser: getEnv("DB_USER", "root"), DBPassword: getEnv("DB_PASSWORD", ""), DBName: getEnv("DB_NAME", "fastlogin"), Port: getEnv("PORT", "3000"), Environment: getEnv("ENV", "development"), CORSOrigins: corsOrigins, }写入后，就可以使用AppConfig.项目来获取到设置，十分方便。
记录一下自己的第一个Go项目——统一验证系统后端
首先祝贺一下网址备案成功啦！也是用上国内EdgeOne了。为什么会想用Go呢？24年寒假的时候用Flask写了一个图书管理系统，上线以后发现即使使用了多线程但是反应速度还是奇慢无比，人多的时候直接卡死。本来是想优化的，但是最后觉得一不做二不休，不如直接换一个高效一点的语言。本来看上的是java springboot，但是据说系统占用会很大。作为我们这种没米的大学生肯定不是首选。毕竟也租不起这么好的服务器。了解一圈以后觉得go语言简洁高效、运行速度快、可以直接编译成二进制文件，也有丰富的社区资源。所以最后选了go语言。在重写图书管理系统之前，我觉得先写一个统一验证系统练练手。因为如果每个系统都配备独立的鉴权系统的话，写的费力不说，还会浪费很多系统资源，既然要重写了，那还不如接入验证系统。系统的设计一开始看上了其他现有的统一验证系统，但是他们都太过于臃肿了，我们只需要一个可以鉴权、修改密码、登入登出的系统即可，为了遵循轻量化原则，我觉得我的所有系统都使用JWT鉴权。各个系统统一向验证系统鉴权，token存储在用户的浏览器中，这样可以实现同一个浏览器可以一次登录所有系统，避免了繁琐的登录过程。目前是各个系统主动发送登录请求，后面计划做成跳转至统一验证系统登录后，将token存储到当前网站的local storage中。今后会陆续更新有关统一验证系统的具体代码实现
《Java编程课程设计》设计报告书——《保卫JAVA》塔防游戏
题目 ：《保卫JAVA》塔防游戏程序设计一、基本信息设计时间2025.5.9-2025.6.10设计题目《保卫JAVA》塔防游戏程序设计Github开源地址https://github.com/LittleYanlin/Protect-Java团队协作方式Git+Github主要用到的库JFrame、Timer、Image、Toolkit等二、需求分析2.1 课题设计问题分析塔防游戏旨在让玩家通过布置防御塔，抵御一波又一波敌人的进攻，保护核心目标不被敌人摧毁。游戏需实现以下核心功能：地图与场景：创建可容纳防御塔和敌人移动的游戏地图，显示游戏场景。 防御塔系统：玩家可在地图指定位置建造、升级、出售防御塔，防御塔具备攻击范围、攻击力、等属性。 敌人系统：敌人按照预设路线移动，拥有生命值、移动速度、攻击目标等属性，成功抵达终点会对核心目标造成伤害。游戏进程控制：控制敌人的刷新波次，随着波次推进，敌人数量和强度增加，设定游戏胜利和失败条件。 用户交互：提供界面让玩家进行建造、升级、出售、使用道具操作，显示游戏状态信息，如金币数量、剩余生命值等。技能设置 ：玩家可以合理使用金钱，在适当的时候发动技能来帮助赢得游戏，增加了游戏的趣味性2.2 问题分解地图模块：负责地图的创建、显示，以及防御塔可建造区域的判定。 防御塔模块：管理防御塔的建造、升级、出售逻辑，计算防御塔的攻击行为。 敌人模块：控制敌人的生成、移动、攻击和死亡逻辑。 子弹模块：控制子弹的位置、移动、伤害。技能模块：设置技能的释放、伤害、范围。游戏控制模块：协调各模块工作，控制游戏波次，判定游戏胜负。 用户界面模块：实现用户交互功能，展示游戏相关信息。 图片设计模块： 负责地图样式，敌人，子弹，特效等方面的制作，展现游戏特色。三、概要设计3.1 类的设计Panel 类：是所有面板的超类，继承自JPanel类，实现定时器（每秒刷新62.5次界面），添加了鼠标点击、移动监听器以及组件监听器（通过面板的显示与否来开始或停止计时器）其子类有：GamePanel（游戏面板类）、MenuPanel（菜单面板类）、StartPanel（开始面板类）、IntroductionPanel（游戏介绍面板类）App类：是程序的主入口，负责管理卡片布局、切换卡片、删除卡片等操作。Tower类：是所有防御塔的超类，拥有攻击力、攻击范围、攻击速度、价格等属性，以及攻击敌人、升级、发射子弹等方法。 其子类有：ArrayTower（箭塔类）、MagicTower（法师塔类）Enemy类：表示敌人，包含生命值、移动速度、移动路线、攻击力等属性，以及移动、受到攻击、死亡等方法。 其子类有：PaoChe（炮车类）、XiaoBing（小兵类）、Boss（Boss类）Player类：存储玩家的金钱和生命值、有扣钱、加钱、扣血量等方法ImageGather类：存储所有图片的集合，供其他类调用Bullet类：表示子弹，包含子弹速度、移动路线、攻击力等属性，以及受到攻击等方法。3.2 类图（UML 图）图 1 程序UML图3.3 程序流程图与模块层次关系程序流程图图 2 主要流程图模块层次关系图 3 模块层次关系图四、详细设计4.1 数据类型具体实现GamePanel为主要逻辑类类 GamePanel 继承 Panel:属性:​ player: Player​ towers: Tower[]​ enemies: List​ bullets: List​ 关卡地图、敌人波次、金钱、状态等构造方法(cardSwitcher):​ 初始化各种游戏数据​ 设置定时器，每帧调用 updategame() 和 repaint()方法 paint(g):​ 绘制背景、塔、敌人、子弹、按钮、金钱、血量等方法 updategame():​ 如果游戏未开始，直接返回​ 处理敌人生成（按波次、计时器）​ 检查金钱是否足够发动技能​ 处理技能效果（灭霸、冰冻等）​ 敌人移动，若到达终点则扣血​ 塔自动攻击范围内敌人​ 子弹移动并处理命中​ 敌人死亡奖励金钱​ 判断胜利或失败，弹窗提示并结束本关方法 handleMouseClicked(e):​ 判断是否点击返回按钮，切换到菜单​ 判断是否点击塔位，进入建造/升级状态​ 判断是否点击建造/升级按钮，扣钱并建造/升级​ 判断是否点击开始按钮，开始新一波敌人​ 判断是否点击技能按钮，发动技能方法 handleMouseMoved(e):​ 处理鼠标悬停高亮、按钮状态等方法 enemiesNotSpawnSum():返回当前未生成敌人总数4.2 核心算法敌人移动算法图 4 敌人移动算法示意图防御塔攻击算法图 5 防御塔攻击算法示意图子弹追踪算法图 6 子弹追踪算法示意图五、调试分析5.1 问题解决与回顾讨论问题：在游戏初期，敌人移动出现路径偏移现象。 解决：检查发现是敌人移动速度计算和路径点更新逻辑存在问题，如果离转折点的距离小于速度的时候会导致敌人偏离设计的路线，之后我们重新优化路径计算算法，采用更精确的坐标计算和时间间隔控制，解决了偏移问题。问题：防御塔攻击范围判定不准确，存在误判。 解决：修改距离计算方式，分别进行两次循环，第一次循环筛选出防御塔攻击范围内血量最低的敌人，第二次循环筛选出离java距离最近的对象进行攻击。六、用户使用说明启动游戏：运行游戏程序，进入游戏主界面。建造防御塔：在界面中点击 “建造” 按钮，选择想要建造的防御塔类型（），然后在地图可建造区域点击鼠标左键完成建造。建造需要消耗金币，若金币不足则无法建造。升级防御塔：选中已建造的防御塔，点击 “升级” 按钮，若金币足够则可提升防御塔属性与外观。开始游戏：点击 “开始” 按钮，敌人开始刷新并按照预设路线移动，用户可建造防御塔，防御塔自动攻击进入攻击范围的敌人。游戏道具：点击“冰冻”，“灭霸”按钮，敌人会按照预设受到相应效果，用户可根据实际情况，合理运用道具。查看游戏状态：界面上会实时显示金币数量、剩余生命值、当前波次等信息。游戏结束：当核心目标（java）生命值降为 0 时，游戏失败；成功抵御所有波次敌人进攻，则游戏胜利。七、测试结果编号测试场景输入操作预期输出实际输出测试结果1初始游戏界面启动游戏显示完整游戏界面，金币、生命值等初始化显示完整游戏界面，金币、生命值等初始化通过2建造防御塔点击建造按钮，选择防御塔，在可建造区域点击成功建造防御塔，金币减少成功建造防御塔，金币减少通过3升级防御塔选中已建造防御塔，点击升级按钮防御塔属性提升，金币减少防御塔属性提升，金币减少通过4敌人移动与攻击开始游戏，等待敌人刷新和移动敌人按路线移动，攻击核心目标，防御塔攻击敌人敌人按路线移动，攻击核心目标，防御塔攻击敌人通过5道具使用选择右下角道具，并选择释放位置敌人按预期受到相应效果，减速或扣除一定血量敌人按预期受到相应效果，减速或扣除一定血量通过6游戏胜利成功抵御所有波次敌人进攻显示游戏胜利界面显示游戏胜利界面通过7游戏失败核心目标生命值降为 0显示游戏失败界面显示游戏失败界面通过八、总结与收获通过本次塔防游戏项目开发，对 Java 语言的面向对象编程特性有了更深刻的理解，学会了如何设计和实现类之间的关系，以及如何运用封装、继承、多态等特性优化代码结构。在项目开发过程中，掌握了从需求分析、设计到实现和测试的完整流程，提升了解决实际问题的能力。同时，认识到团队协作的重要性，通过合理分工和沟通，提高了项目开发效率。也意识到算法优化和代码质量的重要性，为今后的软件开发积累了宝贵经验。九、附录代码目录：\>BulletBullet.java\>EnemyEnemy.javaBoss.javaPaoChe.javaXiaoBing.java\>GameApp.javaCardSwicher.java\>PanelGamePanel.javaIntroductionPanel.javaMenuPanel.javaMission1.javaMission2.javaMission3.javaPanel.javaStartPanel.java\>PlayerImageGather.javaPlayer.java\>TowerArrayTower.javaMagicTower.javaTower.java
后端接口文档后端提供各种api接口共同性所有返回值均为json格式返回值说明举例类型备注http code返回token验证情况成功：200 失败：422或其他int必有status表明程序运行是否正确0：有错误，1：正确int必有message输出程序运行结果或错误登录成功！string如果status为0，必有传入值：加入headers传输传入值说明举例类型备注Authorization传入access token，验证身份Bearer +你的tokenstring必须在其他模块里，共同性的东西不在赘述若无特殊说明，均默认string类型，默认使用表单传输刷新令牌URL：/refresh方法：POST用于访问令牌失效了，但是刷新令牌未失效的情况自动获取新的访问令牌加入headers传输：传入值说明举例类型备注Authorization传入refresh tokenBearer +你的refresh tokenstring必须返回值说明举例类型备注access_token返回新的访问令牌无string必有登录URL：/login方法：POST注意：本接口不需要访问令牌用于用户登录传入值说明举例备注role验证登录角色1必须 1为学生，2为收书员，3为管理员username用户名302024334011必须password密码无必须返回值说明举例类型备注access_token访问令牌无stringstatus为1时refresh_token刷新令牌无stringstatus为1时注册URL：/reg方法：POST注意：本接口不需要访问令牌传入值说明举例备注role设置注册角色1必须 1为学生，2为收书员username用户名302024334011必须password密码无必须place校区1必须 校区，1为朝晖，2为屏峰，3为莫干山phone电话无必须shoukuan收款方式支付宝role为1时必须address地址尚6role为1时必须name姓名测试学生role为2时必须返回值说明举例类型备注access_token访问令牌无stringstatus为1时且role为1refresh_token刷新令牌无stringstatus为1时且role为1检验首次登录URL：/check_first_login方法：POST注意：本接口不需要访问令牌用于检查学生是否为第一次登录传入值说明举例备注role验证登录角色1必须 1为学生，2为收书员，3为管理员username用户名302024334011必须password密码无必须返回值说明举例类型备注result返回结果Trueboolstatus为1时获取旧书再利用市场信息URL：/get_uploaded_books方法：POST注：管理员使用本接口会返回所有订单，包括已卖出的传入值说明举例备注action区分市场和本人订单all可选 all代表本校区所有还未卖出的订单，own或无代表本人订单返回值说明举例类型备注result返回结果略jsonstatus为1时result中的子项目说明name书的名称place校区，1为朝晖，2为屏峰，3为莫干山print_edition出版社publisher作者img_url图片链接uploader上传者available是否被卖出 True：未卖出 False：已卖出id订单id号time上传时间显示旧书回收订单URL：/get_user_upload_books方法：POST传入值说明举例备注action区分市场和本人订单all可选 all代表本校区所有还没有被接单的订单，stu_check 为学生查询，booker_check为收书员查询返回值说明举例类型备注result返回结果略jsonstatus为1时result中的子项目说明uploader上传者kg_real收书员上传的重量place校区，1为朝晖，2为屏峰，3为莫干山money_uploader学生获得的奖励kg_upload学生上传的重量img_url图片链接uploader上传者available是否被接单 True：未接单 False：已接单id订单id号time上传时间isend是否完成订单 True：已完成 False：未完成booker收书员学号money_booker收书员获得的奖励我要卖书模块上传URL：/upload_book方法：POST传入值说明举例备注kg_upload上传的大概重量5必须img_url照片的地址无必须收书员完成收书URL：/shoushu方法：POST传入值说明举例备注img_url照片地址无必须kg_real真实的重量5必须id接单订单的id1必须收书员获得订单URL：/get_order方法：POST传入值说明举例备注id要获得的订单id5必须收书员获得学生信息URL：/get_order_detail方法：POST传入值说明举例备注id要获得的订单id5必须返回值说明举例类型备注result返回结果略jsonstatus为1时result中的子项目说明phone学生电话address学生地址上传旧书再利用模块书籍URL：/upload_book_hub方法：POST传入值说明举例备注name书籍名称高等数学必须print_edition书籍版本第五版必须publisher作者宋浩必须img_url图片链接无必须完成旧书再利用模块订单URL：/book_end方法：POST传入值说明举例备注id要完成的订单id5必须管理员学生管理获取学生信息URL：/admin/user/stu方法：POST修改学生信息URL：/admin/user/stu_manage方法：POST传入值说明举例备注new_pass新密码无可选，若不更改既为空score信誉分80必须money余额10必须管理员信誉分管理查看举报信息URL：/admin/score方法：POST返回值说明举例类型备注data返回结果略jsonstatus为1时data中的子项目说明report_user举报者学号reported_user被举报者学号message举报内容time举报时间id举报iddata举报订单idavailable管理员是否完成处理score扣除的分数处理举报信息URL：/admin/score_manage方法：POST传入值说明举例备注stu_username被学生学号302024334011必须score扣除的分数5必须id涉及的举报id10必须取消扣分URL：/admin/score_cancel方法：POST传入值说明举例备注id涉及的举报id10必须理由库管理URL：/admin/reason方法1：GET获取已经存储的理由返回值说明举例类型备注id理由id5intstatus为1时reason理由内容欺诈行为stringstatus为1时方法2：POST添加新的理由传入值说明举例备注reason新的理由欺诈行为必须方法3：DELETE删除理由使用header传输：传入值说明举例备注id删除的理由id10必须管理员内容管理URL：/admin/book_manage方法1：GET获取市场内容使用header传输：传入值说明举例备注id查看的订单id10必须返回值说明举例类型备注result返回结果略jsonstatus为1时result中的子项目说明name书的名称place校区，1为朝晖，2为屏峰，3为莫干山print_edition出版社publisher作者img_url图片链接uploader上传者available是否被卖出 True：未卖出 False：已卖出id订单id号time上传时间方法2：POST修改内容传入值说明name书的名称place校区，1为朝晖，2为屏峰，3为莫干山print_edition出版社publisher作者img_url图片链接uploader上传者available是否被卖出 True：未卖出 False：已卖出id订单id号time上传时间方法3：DELETE删除理由使用header传输：传入值说明举例备注id封禁的订单id10必须超级管理员管理URL：/admin/admin方法1：GET获取管理员信息返回值说明举例类型备注result返回结果略jsonstatus为1时result中的子项目说明user_manage用户管理权限score_manage信誉分管理权限content_manage内容管理权限super_admin超级管理员权限id管理员id方法2：POST添加新管理员或修改管理员信息传入值说明举例备注id修改的管理员id10可选，若为空则为添加管理员username管理员用户名niubige可选，注册时必须password密码无可选，注册时必须，修改时为空默认不更改密码user_manage用户管理权限1必须 1为有权限，0为无权限score_manage信誉分管理权限1必须 1为有权限，0为无权限content_manage内容管理权限1必须 1为有权限，0为无权限super_admin超级管理员权限1必须 1为有权限，0为无权限方法3：DELETE删除管理员使用header传输：传入值说明举例备注id删除管理员的id10必须举报URL：/report方法1：GET获取自己的举报信息返回值说明举例类型备注result返回内容无jsonstatus为1时result中的子项目说明reported_user被举报者学号message举报内容score扣除的分数data举报的订单idavailable举报是否处理完成id举报id号time举报时间方法2：POST添加新的举报内容传入值说明举例备注reported_user被举报者学号302024334011必须message举报内容欺诈行为必须data举报的订单id5必须方法3：DELETE删除举报内容使用header传输：传入值说明举例备注id删除的举报id10必须全局设置管理方法1：GET获取全局设置返回值说明举例类型备注result返回内容无jsonstatus为1时result中的子项目说明money_per_kg单价rate收书员获得奖励的比例score最低信誉分要求方法2：POST修改全局设置传入值说明举例备注money_per_kg单价10必须rate收书员获得奖励的比例0.2必须score最低信誉分要求10必须用户信息维护学生URL：/manage/1方法：POST传入值说明举例备注action说明操作check必须 check代表查看信息，manage为更改信息place校区1当action为manage时必须 1为朝晖，2为屏峰，3为莫干山phone手机号码无当action为manage时必须address地址尚6当action为manage时必须shoukuan收款地址支付宝当action为manage时必须new_pass新密码无可选，若不更改既为空返回值说明举例类型备注data返回结果略jsonstatus为1时data中的子项目说明phone学生电话address学生地址place1为朝晖，2为屏峰，3为莫干山score信誉分shoukuan收款方式money余额收书员URL：/manage/2方法：POST传入值说明举例备注action说明操作check必须 check代表查看信息，manage为更改信息place校区1当action为manage时必须 1为朝晖，2为屏峰，3为莫干山phone手机号码无当action为manage时必须new_pass新密码无可选，若不更改既为空返回值说明举例类型备注data返回结果略jsonstatus为1时data中的子项目说明phone电话name姓名place1为朝晖，2为屏峰，3为莫干山money余额管理员URL：/manage/3方法：POST传入值说明举例备注action说明操作check必须 check代表查看信息，manage为更改信息new_pass新密码无可选，若不更改既为空返回值说明举例类型备注data返回结果略jsonstatus为1时data中的子项目说明user_manage用户管理权限score_manage信誉分管理权限content_manage内容管理权限super_admin超级管理员权限注：返回均为布尔值聊天获取聊天简介URL：/chat/get/all方法：POST获取和每一个用户聊天的最新三条对方发来的信息返回值说明举例类型备注data返回结果略jsonstatus为1时data中的子项目说明poster发送者学号data三条内容time三条内容的发送时间获取聊天信息详情URL：/chat/get/detail方法：POST传入值说明举例备注req_user要查看和谁的聊天记录302024334011必须返回值说明举例类型备注data返回结果略jsonstatus为1时data中的子项目说明poster这条信息的发送者receiver这条信息的接收者time内容的发送时间data聊天内容发送信息URL：/chat/post方法：POST传入值说明举例备注receiver发给谁302024334011必须data聊天内容你好啊！必须上传图片URL：/upload_img方法：POST传入值说明举例备注file上传的图片无必须 为file类型返回值说明举例类型备注url返回图片链接略stringstatus为1时
Pinia是集中式状态管理工具，可以实现多个组件共用数据。引入pinia在main.ts中进行引用import './assets/main.css' import { createApp } from 'vue' import App from './App.vue' import router from './router' import { createPinia } from 'pinia' const app=createApp(App) //创建 const pinia=createPinia() app.use(router) app.use(pinia) app.mount('#app')引入 createPinia 即可存储+读取数据新建一个.ts文件来读取数据。love.tsimport { defineStore } from "pinia"; export const useCountLove=defineStore('love',{ state(){ return{ talkList:[{id:'',content:''}] } } })在vue文件中这样引用： 获取情话 {{ t.content }} <count></count> 这样就可以完成读取和写入数据了actions我们可以把一些常用的函数封装到pinia中。例如上面的代码：love.tsimport { defineStore } from "pinia"; import axios from "axios"; import {nanoid} from 'nanoid' export const useCountLove=defineStore('love',{ actions:{ async getATalk(){ let result=await axios.get('/api') let obj={id:nanoid(),content:result.data.content} this.talkList.unshift(obj) } }, state(){ return{ talkList:[{id:'',content:''}] } } })设置了action后就可以在vue里引用。 获取情话 {{ t.content }} <count></count> 也可以获得相同的效果。storeToRefs与toRefs相似，当我们 const {sum,school.address} = countStore这类代码是会丢失响应式。使用 const {sum,school.address} = storeToRefs(countStore)即可。getters如果我们有数据需要预处理，那么我们可以使用getterslove.tsimport { defineStore } from "pinia"; import axios from "axios"; import {nanoid} from 'nanoid' export const useCountLove=defineStore('love',{ actions:{ async getATalk(){ let result=await axios.get('/api') let obj={id:nanoid(),content:result.data.content} this.talkList.unshift(obj) } }, state(){ return{ talkList:[{id:'',content:''}] } }, getters:{ leng(state){ return state.talkList.length } } })$subscribe$subscribe有点像watch，他会在数据发生变化时执行函数。就可以做成自动保存。love.vue 获取情话 {{ t.content }} <count></count> love.tsimport { defineStore } from "pinia"; import axios from "axios"; import {nanoid} from 'nanoid' export const useCountLove=defineStore('love',{ actions:{ async getATalk(){ let result=await axios.get('/api') let obj={id:nanoid(),content:result.data.content} this.talkList.unshift(obj) } }, state(){ return{ talkList:JSON.parse(localStorage.getItem('talklist') as string) ||[] } }, getters:{ leng(state){ return state.talkList.length } } })这样就做到了本地存储。刷新后依然可以读取。
基本路由切换步骤：设计好导航区和展示区请来路由器制定路由的基本规则写一个一个的vue/router/index.ts首先要创建一个路由器import { createRouter, createWebHashHistory } from "vue-router"; //引入要呈现的组件 import dog from "@/components/dog.vue"; import news from "@/components/news.vue"; import home from "@/components/home.vue"; //创建一个路由器 const router=createRouter({ history:createWebHashHistory(), routes:[ { name:"Home", path:'/home',//路径 component:home }, { name:"Dog", path:'/dog',//路径 component:dog }, { name:"news", path:'/news',//路径 component:news }, ] }) //暴露出去 export default routermain.ts其次要在main.ts中使用这个路由器import './assets/main.css' import { createApp } from 'vue' import App from './App.vue' import router from './router' const app=createApp(App) //使用路由器 app.use(router) app.mount('#app')app.vue在app.vue中使用router-link来表明更改页面的按钮使用router-view来确定子页面输出的位置 Vue路由测试 <router-link :to="{name:'Home'}">首页</router-link> <router-link :to="{name:'Dog'}">狗</router-link> <router-link :to="{name:'news'}">新闻</router-link> <router-view></router-view> 这时运行后就可以看到实现了基本的切换效果路由器的工作模式history模式写法：history:createWebHistory()优点：URL更加美观，不带有#缺点：后期项目上线需要服务器端进行配置，否则有404Hash模式写法：history:createWebHashHistory()优点：兼容性好，不需要服务端处理路径缺点：URL带有#不美观，SEO较差嵌套路由在index.ts中需要添加嵌套路由的地方添加以下配置{ name:"news", path:'/news',//路径 component:news, children:[{ name:"detail",//嵌套路由路径 path:'deatil', component:Deatil }在news.vue里按照上文一样的写法添加路由即可。 <RouterLink :to="{name:'detail'}">{{ t.title }}</RouterLink> <RouterView></RouterView> 这时就完成了基本的嵌套路由，但是还需要更多配置来使新闻显示生效。传参qerey在进行路由跳转的时候，有时候我们需要传递参数，这个时候我们就需要用到传参。qerey传参比较简单，只需要在to参数中传递即可。<RouterLink :to="{name:'detail',query:{id:t.id,title:t.title,content:t.content}}">{{ t.title }}</RouterLink>detail.vue之后再引用即可 编号：{{ route.query.id }} 标题：{{ route.query.title }} 内容：{{ route.query.content }} paramsparams不是使用？的形式传参，而是使用/要使用params，需要先在路由表里定义变量index.ts{ name:"news", path:'/news',//路径 component:news, children:[{ name:"detail", path:'deatil/:id/:title/:content', component:Deatil }]news.vue<RouterLink :to="{name:'detail',params:{id:t.id,title:t.title,content:t.content}}">{{ t.title }</RouterLink>detail.vue之后直接引用即可 编号：{{ route.params.id }} 标题：{{ route.params.title }} 内容：{{ route.params.content }} props先是需要再index.ts中添加一项配置{ name:"news", path:'/news',//路径 component:news, children:[{ name:"detail", path:'deatil/:id/:title/:content', component:Deatil, props:true }]之后再detail.vue中读取数据 编号：{{ id }} 标题：{{ title }} 内容：{{ content }} 这样可以方便的读取数据，避免了过多的.跳转方式replacereplace就是不可以回退，没有历史记录的模式<router-link replace :to="{name:'Home'}">首页</router-link> <router-link replace :to="{name:'Dog'}">狗</router-link> <router-link replace :to="{name:'news'}">新闻</router-link>push默认就是push，可以回退编程式导航我们也可以在脚本里进行路由跳转，多用于注册登录等场景news.vue 显示新闻 <RouterLink :to="{ name:'detail', params:{ id:t.id, title:t.title, content:t.content } }">{{ t.title }}</RouterLink> <RouterView></RouterView> router.push()中括号里的参数就是RouterLink中的to参数重定向我们的根目录可能并不是主页，这时就需要重定向。只需要在index.ts中编写即可import { createRouter, createWebHashHistory, createWebHistory } from "vue-router"; //引入要呈现的组件 import dog from "@/components/dog.vue"; import news from "@/components/news.vue"; import home from "@/components/home.vue"; import Deatil from "@/components/deatil.vue"; //创建一个路由器 const router=createRouter({ history:createWebHistory(), routes:[ { name:"Home", path:'/home',//路径 component:home }, { name:"Dog", path:'/dog',//路径 component:dog }, { name:"news", path:'/news',//路径 component:news, children:[{ name:"detail", path:'deatil/:id/:title/:content', component:Deatil, props:true }], }, { path:'/', redirect:'/home' } ] }) //暴露出去 export default router加入redirect即可。
我们也可以自定义钩子来让我们的代码更易于维护和调用。test.vue 当前有{{ sum }}张照片 点击获取新图片 useDog.tsimport axios from 'axios'; import { onMounted, reactive, ref } from 'vue'; export default function(){ let sum=ref(0) let dogList = reactive(['']) async function add(){ try{ sum.value+=1 let result = await axios.get('https://dog.ceo/api/breed/pembroke/images/random') dogList.push(result.data.message) } catch(error){ alert(error) } } onMounted(()=>{ add() }) return {dogList,add,sum} }在ts文件中，除了import，其他代码都需要包在一个函数里，同时要有返回值，暴露给vue文件。这样就可以做到一个ts被多个vue文件调用，更方便运行示例：