This commit is contained in:
2026-08-14 13:45:02 +08:00
parent 96348ce39b
commit fba2b60c2a
3 changed files with 712 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
package main
// Edge TTS 客户端:微软 Edge 朗读私有 WebSocket 协议。
// 实现对照 edge-tts 7.2.8drm.py / constants.py / communicate.py)逐行校验:
// Sec-MS-GEC 纯算法生成(无 HTTP 预请求),403 时读响应 Date 头校正时钟偏移重试。
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
trustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
winEpoch = 11644473600 // Unix 与 Windows 文件时间纪元的秒差(1601-01-01)
originHeader = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
audioURL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
audioFormat = "audio-24khz-48kbitrate-mono-mp3"
maxChunkBytes = 4096 // 单请求文本字节上限(与 edge-tts 一致,超长分块多轮连接)
)
type ttsClient struct {
voice string
rate string
version string
timeout time.Duration
retries int
clockSkew float64 // 403 时按服务端 Date 头累加校正
}
// permanentErr 协议类错误,重试无意义(不重试)。
type permanentErr struct{ err error }
func (e *permanentErr) Error() string { return e.err.Error() }
func (e *permanentErr) Unwrap() error { return e.err }
// synthesize 合成一段文本,返回 mp3 字节;超长文本按字节上限分块、逐块独立连接后拼接。
func (c *ttsClient) synthesize(ctx context.Context, text string) ([]byte, error) {
text = escapeXML(cleanText(text))
var buf []byte
for _, chunk := range splitChunks(text, maxChunkBytes) {
audio, err := c.synthesizeChunk(ctx, chunk)
if err != nil {
return nil, err
}
buf = append(buf, audio...)
}
if len(buf) == 0 {
return nil, gerror.New("合成结果为空")
}
return buf, nil
}
func (c *ttsClient) synthesizeChunk(ctx context.Context, text string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= c.retries; attempt++ {
if attempt > 0 {
select {
case <-time.After(time.Duration(attempt) * time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
}
audio, err := c.turn(ctx, text)
if err == nil {
return audio, nil
}
lastErr = err
var pe *permanentErr
if errors.As(err, &pe) {
return nil, err
}
}
return nil, fmt.Errorf("重试 %d 次仍失败: %w", c.retries, lastErr)
}
// turn 一次连接完成一帧文本的合成,返回 mp3 字节。
func (c *ttsClient) turn(ctx context.Context, text string) ([]byte, error) {
conn, err := c.dial(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
// 帧 1speech.config(输出格式配置)
configFrame := fmt.Sprintf(
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":"%s"}}}}`+"\r\n",
dateString(), audioFormat)
// 帧 2ssmlxml:lang 固定 en-US,与 edge-tts 一致;X-Timestamp 尾缀 Z 是微软端约定)
ssml := fmt.Sprintf(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
"<voice name='%s'><prosody pitch='+0Hz' rate='%s' volume='+0%%'>%s</prosody></voice></speak>",
c.voice, c.rate, text)
ssmlFrame := fmt.Sprintf(
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n%s",
strings.ReplaceAll(uuid.NewString(), "-", ""), dateString(), ssml)
conn.SetWriteDeadline(time.Now().Add(c.timeout))
if err := conn.WriteMessage(websocket.TextMessage, []byte(configFrame)); err != nil {
return nil, fmt.Errorf("发送 speech.config 失败: %w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, []byte(ssmlFrame)); err != nil {
return nil, fmt.Errorf("发送 ssml 失败: %w", err)
}
var audio []byte
for {
conn.SetReadDeadline(time.Now().Add(c.timeout))
mt, payload, err := conn.ReadMessage()
if err != nil {
return nil, fmt.Errorf("读帧失败: %w", err)
}
switch mt {
case websocket.TextMessage:
path, err := textFramePath(payload)
if err != nil {
return nil, &permanentErr{err}
}
switch path {
case "turn.end":
if len(audio) == 0 {
return nil, &permanentErr{gerror.New("无音频返回(文本或参数可能不被支持)")}
}
return audio, nil
case "turn.start", "response", "audio.metadata":
// 忽略
default:
return nil, &permanentErr{fmt.Errorf("未知响应 Path: %s", path)}
}
case websocket.BinaryMessage:
data, err := audioFrameData(payload)
if err != nil {
return nil, &permanentErr{err}
}
audio = append(audio, data...)
}
}
}
func (c *ttsClient) dial(ctx context.Context) (*websocket.Conn, error) {
connID := strings.ReplaceAll(uuid.NewString(), "-", "")
wsURL := fmt.Sprintf("%s?TrustedClientToken=%s&ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s",
audioURL, trustedClientToken, connID, c.secMsGec(), c.version)
header := http.Header{
"Pragma": {"no-cache"},
"Cache-Control": {"no-cache"},
"Origin": {originHeader},
"User-Agent": {userAgent},
"Accept-Language": {"en-US,en;q=0.9"},
"Cookie": {"muid=" + muid() + ";"},
}
dialer := websocket.Dialer{
EnableCompression: true,
HandshakeTimeout: c.timeout,
}
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusForbidden {
// 403 多为时钟偏差:读 Date 头校正偏移,下次握手即有效
if serverTS, ok := parseRFC2616Date(resp.Header.Get("Date")); ok {
c.clockSkew += serverTS - float64(time.Now().Unix())
}
}
return nil, fmt.Errorf("握手失败: %w", err)
}
return conn, nil
}
// secMsGec 生成 Sec-MS-GEC 令牌(edge-tts drm.py generate_sec_ms_gec):
// (UTC秒 + 时钟偏差 + 11644473600) 取整到 5 分钟窗 → ×1e9/100 转 Windows 100ns 刻度 →
// 拼接 TrustedClientToken → SHA256 大写 hex。
func (c *ttsClient) secMsGec() string {
ticks := float64(time.Now().Unix()) + c.clockSkew
ticks += winEpoch
ticks -= math.Mod(ticks, 300)
ticks *= 1e9 / 100
sum := sha256.Sum256([]byte(fmt.Sprintf("%.0f%s", ticks, trustedClientToken)))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
// muid 32 位大写 hex 随机串(Cookie: muid=...;
func muid() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return strings.ToUpper(hex.EncodeToString(b))
}
// dateString JavaScript 风格 UTC 时间串(speech.config / ssml 帧头)
func dateString() string {
return time.Now().UTC().Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
}
// parseRFC2616Date 解析 HTTP Date 头(如 Thu, 14 Aug 2026 12:34:56 GMT
func parseRFC2616Date(s string) (float64, bool) {
t, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", s)
if err != nil {
return 0, false
}
return float64(t.Unix()), true
}
// cleanText 剔除服务端不支持的 ASCII 控制字符(0-8、11-12、14-31 → 空格)
func cleanText(s string) string {
rs := []rune(s)
for i, r := range rs {
if (r >= 0 && r <= 8) || (r >= 11 && r <= 12) || (r >= 14 && r <= 31) {
rs[i] = ' '
}
}
return string(rs)
}
// escapeXML 转义 XML 特殊字符(与 saxutils.escape 默认一致:& < >
func escapeXML(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
// splitChunks 按字节上限在 rune 边界切分(超长文本分块,避免单请求过大)
func splitChunks(s string, maxBytes int) []string {
if len(s) <= maxBytes {
return []string{s}
}
var chunks []string
var buf []byte
for _, r := range s {
rb := string(r)
if len(buf) > 0 && len(buf)+len(rb) > maxBytes {
chunks = append(chunks, string(buf))
buf = nil
}
buf = append(buf, rb...)
}
if len(buf) > 0 {
chunks = append(chunks, string(buf))
}
return chunks
}
// textFramePath 取文本帧的 Path 头(帧结构:头\r\n\r\n载荷)
func textFramePath(payload []byte) (string, error) {
idx := bytes.Index(payload, []byte("\r\n\r\n"))
if idx < 0 {
return "", gerror.New("文本帧缺头尾分隔")
}
return framePath(payload[:idx])
}
// audioFrameData 解析二进制音频帧:前 2 字节大端头长度,头块(自身含尾部 \r\n)后即 mp3 载荷。
// 终止帧:无 Content-Type 且载荷为空(实测头文本含尾部 \r\n,与 Python 的 headerLen+2 偏移等价)。
func audioFrameData(payload []byte) ([]byte, error) {
if len(payload) < 2 {
return nil, gerror.New("音频帧缺头长度")
}
headerLen := int(binary.BigEndian.Uint16(payload[:2]))
if 2+headerLen > len(payload) {
return nil, gerror.New("音频帧头长度越界")
}
path, err := framePath(payload[2 : 2+headerLen])
if err != nil {
return nil, err
}
if path != "audio" {
return nil, fmt.Errorf("二进制帧 Path 非 audio: %s", path)
}
data := payload[2+headerLen:]
contentType, _ := frameHeader(payload[2:2+headerLen], "Content-Type")
switch {
case contentType == "":
if len(data) == 0 {
return nil, nil // 终止帧
}
return nil, gerror.New("无 Content-Type 却有数据")
case contentType != "audio/mpeg":
return nil, fmt.Errorf("意外的 Content-Type: %s", contentType)
case len(data) == 0:
return nil, gerror.New("audio/mpeg 帧缺音频数据")
}
return data, nil
}
func framePath(headers []byte) (string, error) {
return frameHeader(headers, "Path")
}
func frameHeader(headers []byte, key string) (string, error) {
for _, line := range bytes.Split(headers, []byte("\r\n")) {
k, v, ok := bytes.Cut(line, []byte(":"))
if ok && string(k) == key {
return string(v), nil
}
}
return "", fmt.Errorf("帧缺 %s 头", key)
}
+107
View File
@@ -0,0 +1,107 @@
package main
// gentss:离线生成 TTS 朗读音频(Edge TTS 神经网络语音)并回填各 audio 字段。
// 用法:
//
// go run ./cmd/gentss --list # 打印待生成清单
// go run ./cmd/gentss --strategy=1 # 只生成第 1 计(8 节点剧本已导入)
// go run ./cmd/gentss --only=script|content|scene|option|teach # 只生成指定内容源
// go run ./cmd/gentss --force # 覆盖已生成 mp3 重新合成
//
// 幂等:mp3 文件已存在且字段已回填 → 跳过(--force 覆盖);文件在但字段空 → 只补回填。
// 配置在 config.yml 的 tts 段;产物落 ui-src/static/generated/audio/URL /static/generated/audio/...(与 PNG 素材同挂载)。
import (
"flag"
"fmt"
"sync"
"time"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
)
var staticDir = "ui-src/static/generated" // 打包入客户端的音频目录
type genFlags struct {
listOnly bool
only string // script | content | scene | option | teach
strategy int // 0 = 全部计策
force bool
}
type ttsConfig struct {
voice string
rate string
version string
concurrency int
timeout int
retries int
}
func main() {
fl := parseFlags()
cfg := loadTTSConfig()
if fl.listOnly {
printInventory(fl)
return
}
client := &ttsClient{
voice: cfg.voice,
rate: cfg.rate,
version: cfg.version,
timeout: time.Duration(cfg.timeout) * time.Second,
retries: cfg.retries,
}
tasks := enumerateTasks(fl)
if len(tasks) == 0 {
fmt.Println("[gentss] 无待生成任务")
return
}
sem := make(chan struct{}, cfg.concurrency)
var wg sync.WaitGroup
for _, t := range tasks {
t.force = fl.force
wg.Add(1)
sem <- struct{}{}
go func(t task) {
defer wg.Done()
defer func() { <-sem }()
if err := runTask(client, t); err != nil {
glog.Errorf(ctx, "[gentss] %s 失败: %v", taskDesc(t), err)
}
}(t)
}
wg.Wait()
fmt.Println("[gentss] 完成")
}
func parseFlags() genFlags {
var fl genFlags
flag.BoolVar(&fl.listOnly, "list", false, "只打印待生成清单")
flag.StringVar(&fl.only, "only", "", "script | content | scene | option | teach,默认全部")
flag.IntVar(&fl.strategy, "strategy", 0, "只处理指定计策(strategy_id),0=全部")
flag.BoolVar(&fl.force, "force", false, "覆盖已生成音频重新合成")
flag.Parse()
return fl
}
func loadTTSConfig() ttsConfig {
return ttsConfig{
voice: g.Cfg().MustGet(ctx, "tts.voice", "zh-CN-XiaoxiaoNeural").String(),
rate: g.Cfg().MustGet(ctx, "tts.rate", "+0%").String(),
version: g.Cfg().MustGet(ctx, "tts.version", "1-143.0.3650.75").String(),
concurrency: g.Cfg().MustGet(ctx, "tts.concurrency", 2).Int(),
timeout: g.Cfg().MustGet(ctx, "tts.timeout", 60).Int(),
retries: g.Cfg().MustGet(ctx, "tts.retries", 3).Int(),
}
}
func taskDesc(t task) string {
if t.kind == "script" {
return fmt.Sprintf("node %d", t.nodeID)
}
return t.relPath
}
+296
View File
@@ -0,0 +1,296 @@
package main
// 任务枚举与回填:扫描待合成内容 → 逐任务合成 mp3 → 写 ui-src/static/generated/audio/ → 回填各 audio 字段。
// 幂等:文件已存在且字段已回填 → 跳过;--force 重生成覆盖;文件在但字段空(半途崩溃)→ 只补回填。
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"36wisdom/biz/consts"
)
var ctx = context.Background()
// task 一条合成任务。kind 决定文件路径与回填字段。
type task struct {
kind string // script | content | scene | option | teach
levelID int64 // scene/script/content/option 用
nodeID int64 // script/content/option 用
optionID int64
strategyID int64 // teach 用
text string
relPath string // staticDir 相对路径,如 audio/1/2/content.mp3
scriptJSON string // kind=script 时整列 script JSON
force bool
}
// scriptLine 台词行(回填 audio 时保持原字段)
type scriptLine struct {
Emotion string `json:"emotion"`
Pinyin string `json:"pinyin"`
Speaker string `json:"speaker"`
Text string `json:"text"`
Audio string `json:"audio,omitempty"`
}
// ---------- 枚举 ----------
func enumerateTasks(fl genFlags) []task {
var tasks []task
only := map[string]bool{}
if fl.only == "" {
only["script"], only["content"], only["scene"], only["option"], only["teach"] = true, true, true, true, true
} else {
only[fl.only] = true
}
levelIDs, nodeIDs := strategyScope(fl)
scopeLevel := func(m *gdb.Model) *gdb.Model {
if fl.strategy > 0 {
return m.WhereIn("level_id", levelIDs)
}
return m
}
if only["script"] {
m := g.DB().Model(consts.TableSceneNode).Fields("id, level_id, script").Where("script IS NOT NULL AND script != ''")
for _, r := range all(scopeLevel(m)) {
tasks = append(tasks, task{
kind: "script", levelID: r["level_id"].Int64(), nodeID: r["id"].Int64(),
scriptJSON: r["script"].String(),
})
}
}
if only["content"] {
m := g.DB().Model(consts.TableSceneNode).Fields("id, level_id, content").Where("content != '' AND (audio IS NULL OR audio = '')")
for _, r := range all(scopeLevel(m)) {
nid := r["id"].Int64()
tasks = append(tasks, task{
kind: "content", levelID: r["level_id"].Int64(), nodeID: nid, text: r["content"].String(),
relPath: fmt.Sprintf("audio/%d/%d/content.mp3", r["level_id"].Int64(), nid),
})
}
}
if only["scene"] {
m := g.DB().Model(consts.TableLevel).Fields("id, scene_content").Where("scene_content != '' AND (scene_audio IS NULL OR scene_audio = '')")
if fl.strategy > 0 {
m = m.Where("strategy_id", fl.strategy)
}
for _, r := range all(m) {
lid := r["id"].Int64()
tasks = append(tasks, task{
kind: "scene", levelID: lid, text: r["scene_content"].String(),
relPath: fmt.Sprintf("audio/%d/scene.mp3", lid),
})
}
}
if only["option"] {
m := g.DB().Model(consts.TableNodeOption).Fields("id, node_id, text").Where("text != '' AND (audio IS NULL OR audio = '')")
if fl.strategy > 0 {
m = m.WhereIn("node_id", nodeIDs)
}
rows := all(m)
levelOf := nodeLevelMap(rows)
for _, r := range rows {
oid, nid := r["id"].Int64(), r["node_id"].Int64()
tasks = append(tasks, task{
kind: "option", levelID: levelOf[nid], nodeID: nid, optionID: oid, text: r["text"].String(),
relPath: fmt.Sprintf("audio/%d/%d/opt_%d.mp3", levelOf[nid], nid, oid),
})
}
}
if only["teach"] {
m := g.DB().Model(consts.TableStrategy).Fields("id, teach_content").Where("teach_content != '' AND (teach_audio IS NULL OR teach_audio = '')")
if fl.strategy > 0 {
m = m.Where("id", fl.strategy)
}
for _, r := range all(m) {
sid := r["id"].Int64()
tasks = append(tasks, task{
kind: "teach", strategyID: sid, text: r["teach_content"].String(),
relPath: fmt.Sprintf("audio/teach/%d.mp3", sid),
})
}
}
return tasks
}
// strategyScope 返回某计策涉及的 level_ids 与 node_idsstrategy<=0 时均 nil
func strategyScope(fl genFlags) ([]int64, []int64) {
if fl.strategy <= 0 {
return nil, nil
}
var levelIDs []int64
for _, v := range arr(g.DB().Model(consts.TableLevel).Where("strategy_id", fl.strategy), "id") {
levelIDs = append(levelIDs, v.Int64())
}
var nodeIDs []int64
for _, v := range arr(g.DB().Model(consts.TableSceneNode).WhereIn("level_id", levelIDs), "id") {
nodeIDs = append(nodeIDs, v.Int64())
}
return levelIDs, nodeIDs
}
// nodeLevelMap 选项行 → 所属关卡(跨表组装:先取 node_ids 再对 scene_node 批量查)
func nodeLevelMap(optRows []gdb.Record) map[int64]int64 {
nodeIDs := make([]int64, 0, len(optRows))
for _, r := range optRows {
nodeIDs = append(nodeIDs, r["node_id"].Int64())
}
out := map[int64]int64{}
if len(nodeIDs) == 0 {
return out
}
for _, r := range all(g.DB().Model(consts.TableSceneNode).Fields("id, level_id").WhereIn("id", nodeIDs)) {
out[r["id"].Int64()] = r["level_id"].Int64()
}
return out
}
// ---------- 执行 ----------
func runTask(c *ttsClient, t task) error {
if t.kind == "script" {
return runScriptTask(c, t)
}
if strings.TrimSpace(t.text) == "" {
return nil
}
if !t.force && fileExists(filepath.Join(staticDir, t.relPath)) {
backfill(t) // 幂等补回填:防上次写文件后未回填
return nil
}
audio, err := c.synthesize(ctx, t.text)
if err != nil {
return err
}
if err := writeFile(filepath.Join(staticDir, t.relPath), audio); err != nil {
return err
}
backfill(t)
fmt.Printf("[gentss] ok %-7s %s\n", t.kind, t.relPath)
return nil
}
// runScriptTask 整节点台词:逐行合成(无 audio 的行),全部完成后整列重写 script JSON。
func runScriptTask(c *ttsClient, t task) error {
var lines []scriptLine
if err := json.Unmarshal([]byte(t.scriptJSON), &lines); err != nil {
return fmt.Errorf("script JSON 解析失败: %v", err)
}
changed := false
for i := range lines {
if strings.TrimSpace(lines[i].Text) == "" {
continue
}
rel := fmt.Sprintf("audio/%d/%d/%d.mp3", t.levelID, t.nodeID, i)
file := filepath.Join(staticDir, rel)
if t.force || !fileExists(file) {
audio, err := c.synthesize(ctx, lines[i].Text)
if err != nil {
return fmt.Errorf("第 %d 句合成失败: %w", i, err)
}
if err := writeFile(file, audio); err != nil {
return fmt.Errorf("第 %d 句写文件失败: %w", i, err)
}
}
url := "/static/generated/" + rel
if lines[i].Audio != url {
lines[i].Audio = url
changed = true
}
}
if changed {
b, err := json.Marshal(lines)
if err != nil {
return err
}
if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b)}).Where("id", t.nodeID).Update(); err != nil {
return err
}
}
fmt.Printf("[gentss] ok %-7s node %d%d 句)\n", t.kind, t.nodeID, len(lines))
return nil
}
// backfill 回填 audio 字段(URL 前缀 /static/generated/ 与 PNG 素材同挂载)
func backfill(t task) {
url := "/static/generated/" + t.relPath
var err error
switch t.kind {
case "content":
_, err = g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"audio": url}).Where("id", t.nodeID).Update()
case "scene":
_, err = g.DB().Model(consts.TableLevel).Ctx(ctx).Data(g.Map{"scene_audio": url}).Where("id", t.levelID).Update()
case "option":
_, err = g.DB().Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{"audio": url}).Where("id", t.optionID).Update()
case "teach":
_, err = g.DB().Model(consts.TableStrategy).Ctx(ctx).Data(g.Map{"teach_audio": url}).Where("id", t.strategyID).Update()
}
if err != nil {
glog.Errorf(ctx, "[gentss] 回填 %s 失败: %v", t.relPath, err)
}
}
// ---------- 清单 ----------
func printInventory(fl genFlags) {
tasks := enumerateTasks(fl)
if len(tasks) == 0 {
fmt.Println("[gentss] 无待生成任务")
return
}
counts := map[string]int{}
for _, t := range tasks {
counts[t.kind]++
}
fmt.Printf("待生成任务:script 节点 %d、内容 %d、开场 %d、选项 %d、学堂 %d\n",
counts["script"], counts["content"], counts["scene"], counts["option"], counts["teach"])
for _, t := range tasks {
if t.kind == "script" {
fmt.Printf(" script node %d (level %d)\n", t.nodeID, t.levelID)
} else {
fmt.Printf(" %-7s %s\n", t.kind, t.relPath)
}
}
}
// ---------- 工具 ----------
// all 查询并取全部行(出错即退出,与 genasset 一致)
func all(m *gdb.Model) gdb.Result {
rows, err := m.Ctx(ctx).All()
if err != nil {
glog.Fatal(ctx, err)
}
return rows
}
// arr 查询单列值
func arr(m *gdb.Model, field string) gdb.Array {
vals, err := m.Ctx(ctx).Array(field)
if err != nil {
glog.Fatal(ctx, err)
}
return vals
}
func writeFile(p string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return os.WriteFile(p, data, 0o644)
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}